stage 0 database schema ready

This commit is contained in:
portakal 2026-05-25 20:07:14 +03:00
commit 7720522a32
18 changed files with 5577 additions and 4 deletions

View file

@ -1 +1,205 @@
package main
import (
"fmt"
"os"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// ============================================================================
// STYLING DEFINITIONS (Lipgloss)
// ============================================================================
var (
// Colors
skyBlue = lipgloss.Color("117")
neonPink = lipgloss.Color("205")
moneyGreen = lipgloss.Color("85")
gray = lipgloss.Color("240")
// Component Styles
titleStyle = lipgloss.NewStyle().
Foreground(skyBlue).
Bold(true).
MarginTop(1).
MarginBottom(1)
headerBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(skyBlue).
Padding(0, 2).
MarginBottom(1)
listStyle = lipgloss.NewStyle().MarginLeft(2)
itemStyle = lipgloss.NewStyle().
PaddingLeft(2).
Foreground(lipgloss.Color("252"))
selectedItemStyle = lipgloss.NewStyle().
PaddingLeft(2).
Foreground(neonPink).
Bold(true).
Background(lipgloss.Color("236")) // Slight background highlight
footerStyle = lipgloss.NewStyle().
Foreground(gray).
MarginTop(2)
)
// ============================================================================
// 1. THE MODEL
// ============================================================================
type homepageModel struct {
cursor int
choices []string
selected string
// Mock Player Data (In a real app, pass this in via initialModel)
playerName string
cash int
fleetCount int
}
func initialHomepageModel() homepageModel {
return homepageModel{
// Our interactive menu options
choices: []string{
"🗄️ Manage Hangar Fleet",
"🏢 View Airport Infrastructure",
"🏁 Active Runways & Service Loop",
"🚪 Logout / Quit",
},
playerName: "CaptainGo",
cash: 14500,
fleetCount: 12,
}
}
// ============================================================================
// 2. THE INIT FUNCTION
// ============================================================================
func (m homepageModel) Init() tea.Cmd {
return nil // No background tasks to start immediately
}
// ============================================================================
// 3. THE UPDATE FUNCTION (Handles inputs)
// ============================================================================
func (m homepageModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
// Handle keyboard events
case tea.KeyMsg:
switch msg.String() {
// Quit commands
case "ctrl+c", "q", "esc":
m.selected = "quit"
return m, tea.Quit
// Move up the menu
case "up", "k":
if m.cursor > 0 {
m.cursor--
} else {
m.cursor = len(m.choices) - 1 // Wrap around to bottom
}
// Move down the menu
case "down", "j":
if m.cursor < len(m.choices)-1 {
m.cursor++
} else {
m.cursor = 0 // Wrap around to top
}
// Select an option
case "enter", " ":
m.selected = m.choices[m.cursor]
return m, tea.Quit
}
}
return m, nil
}
// ============================================================================
// 4. THE VIEW FUNCTION (Renders the UI)
// ============================================================================
func (m homepageModel) View() string {
var b strings.Builder
// --- 1. TITLE ---
b.WriteString(titleStyle.Render("✈️ SKYRAMA COMMAND CENTER"))
b.WriteString("\n")
// --- 2. HEADER BAR (Player Stats) ---
statsLine := fmt.Sprintf("[ 🔑 Player: %s ] [ 💰 Cash: %s ] [ 🛩️ Fleet: %d ]",
lipgloss.NewStyle().Foreground(lipgloss.Color("252")).Bold(true).Render(m.playerName),
lipgloss.NewStyle().Foreground(moneyGreen).Render(fmt.Sprintf("%d €", m.cash)),
m.fleetCount,
)
b.WriteString(headerBoxStyle.Render(statsLine))
b.WriteString("\n\n")
// --- 3. INTERACTIVE MENU ---
b.WriteString(" Main Operations Menu:\n\n")
for i, choice := range m.choices {
// Is the cursor pointing at this choice?
cursorStr := " " // no cursor
if m.cursor == i {
cursorStr = lipgloss.NewStyle().Foreground(neonPink).Render(">")
b.WriteString(fmt.Sprintf("%s %s\n", cursorStr, selectedItemStyle.Render(choice)))
} else {
b.WriteString(fmt.Sprintf("%s %s\n", cursorStr, itemStyle.Render(choice)))
}
}
// --- 4. FOOTER ---
b.WriteString(footerStyle.Render("\n ↑/↓: Navigate • Enter: Select • q/esc: Quit"))
// Return the final drawn screen with a little left margin padding
return lipgloss.NewStyle().MarginLeft(2).Render(b.String()) + "\n"
}
// ============================================================================
// THE WRAPPER FUNCTION
// ============================================================================
// RunHomepageScreen starts the TUI and returns the menu string the user selected.
func RunHomepageScreen() string {
p := tea.NewProgram(initialHomepageModel(), tea.WithAltScreen()) // AltScreen creates a full-screen view
finalModel, err := p.Run()
if err != nil {
fmt.Printf("Error running dashboard: %v", err)
os.Exit(1)
}
if m, ok := finalModel.(homepageModel); ok {
return m.selected
}
return "quit"
}
/*
// Optional Main function to test it directly
func main() {
selection := RunHomepageScreen()
// Clear the screen on exit to print standard output
fmt.Print("\033[H\033[2J")
if selection == "quit" || selection == "🚪 Logout / Quit" {
fmt.Println("Gracefully disconnected from SkyRama servers. Goodbye!")
os.Exit(0)
}
fmt.Printf("You selected: %s\n", selection)
fmt.Println("Here is where you would load the next Bubble Tea model for that screen!")
}
*/

View file

@ -1 +1,206 @@
package main
import (
"fmt"
"os"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// ============================================================================
// STYLING DEFINITIONS (Lipgloss)
// ============================================================================
var (
focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
cursorStyle = focusedStyle.Copy()
noStyle = lipgloss.NewStyle()
// A nice box around the login screen
dialogBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("87")).
Padding(1, 4).
MarginTop(1).
MarginLeft(2)
)
// ============================================================================
// 1. THE MODEL
// ============================================================================
type loginModel struct {
focusIndex int
inputs []textinput.Model
cursorMode textinput.CursorMode
// Flags to return back to the main application
isSubmitted bool
isCanceled bool
errorMessage string
}
func initialModell() loginModel {
m := loginModel{
inputs: make([]textinput.Model, 2),
}
var t textinput.Model
for i := range m.inputs {
t = textinput.New()
t.Cursor.Style = cursorStyle
t.CharLimit = 32
switch i {
case 0:
t.Placeholder = "Username"
t.Focus()
t.PromptStyle = focusedStyle
t.TextStyle = focusedStyle
case 1:
t.Placeholder = "Password"
t.EchoMode = textinput.EchoPassword // Masks the password with *
t.EchoCharacter = '•'
}
m.inputs[i] = t
}
return m
}
// ============================================================================
// 2. THE INIT FUNCTION (Runs once on startup)
// ============================================================================
func (m loginModel) Init() tea.Cmd {
return textinput.Blink
}
// ============================================================================
// 3. THE UPDATE FUNCTION (Handles keypresses)
// ============================================================================
func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc":
m.isCanceled = true
return m, tea.Quit
// Handle switching between username and password fields
case "tab", "shift+tab", "enter", "up", "down":
s := msg.String()
// If they press Enter on the password field, submit the form
if s == "enter" && m.focusIndex == len(m.inputs)-1 {
m.isSubmitted = true
return m, tea.Quit
}
// Cycle through inputs
if s == "up" || s == "shift+tab" {
m.focusIndex--
} else {
m.focusIndex++
}
if m.focusIndex > len(m.inputs)-1 {
m.focusIndex = 0
} else if m.focusIndex < 0 {
m.focusIndex = len(m.inputs) - 1
}
// Apply focus states
cmds := make([]tea.Cmd, len(m.inputs))
for i := 0; i <= len(m.inputs)-1; i++ {
if i == m.focusIndex {
cmds[i] = m.inputs[i].Focus()
m.inputs[i].PromptStyle = focusedStyle
m.inputs[i].TextStyle = focusedStyle
continue
}
m.inputs[i].Blur()
m.inputs[i].PromptStyle = noStyle
m.inputs[i].TextStyle = noStyle
}
return m, tea.Batch(cmds...)
}
}
// Handle character inputs and blinking
cmd := m.updateInputs(msg)
return m, cmd
}
func (m *loginModel) updateInputs(msg tea.Msg) tea.Cmd {
cmds := make([]tea.Cmd, len(m.inputs))
for i := range m.inputs {
m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
}
return tea.Batch(cmds...)
}
// ============================================================================
// 4. THE VIEW FUNCTION (Renders the UI)
// ============================================================================
func (m loginModel) View() string {
// Build the UI string
var b strings.Builder
b.WriteString(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("78")).Render("✈️ SKYRAMA TERMINAL LOGIN\n\n"))
for i := range m.inputs {
b.WriteString(m.inputs[i].View())
if i < len(m.inputs)-1 {
b.WriteRune('\n')
}
}
button := &lipgloss.Style{}
if m.focusIndex == len(m.inputs) {
button = &focusedStyle
} else {
button = &blurredStyle
}
b.WriteString("\n")
if m.errorMessage != "" {
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render("⚠️ " + m.errorMessage + "\n"))
} else {
b.WriteString("\n")
}
b.WriteString(button.Render("[ Submit ]"))
b.WriteString(blurredStyle.Render("\n\n(esc to quit)"))
// Wrap the whole thing in our dialog box
return dialogBoxStyle.Render(b.String()) + "\n"
}
// ============================================================================
// THE WRAPPER FUNCTION (Call this from your app)
// ============================================================================
// RunLoginScreen starts the TUI and returns the username, password, and a boolean
// indicating if the user completed the form (true) or pressed escape (false).
func RunLoginScreen() (username string, password string, submitted bool) {
p := tea.NewProgram(initialModell())
// Run the Bubble Tea program synchronously
finalModel, err := p.Run()
if err != nil {
fmt.Printf("Error running login screen: %v", err)
os.Exit(1)
}
// Cast the returned tea.Model back into our specific loginModel
if m, ok := finalModel.(loginModel); ok {
if m.isSubmitted {
return m.inputs[0].Value(), m.inputs[1].Value(), true
}
}
return "", "", false
}

182
backend/server/main.go Normal file
View file

@ -0,0 +1,182 @@
package main
import (
"context"
"database/sql"
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
_ "github.com/go-sql-driver/mysql" // MySQL Driver
"golang.org/x/crypto/bcrypt"
// CHANGE THIS to match your go.mod name + /dba
"skyrama_clone_backend/backend/db"
)
// ============================================================================
// 1. STATE DEFINITIONS
// ============================================================================
type sessionState int
const (
stateLogin sessionState = iota
stateHomepage
)
// ============================================================================
// 2. THE MASTER MODEL (The Router)
// ============================================================================
type mainModel struct {
state sessionState
queries *db.Queries // <-- NEW: Holds our database queries
login loginModel
homepage homepageModel
}
func initialModel(q *db.Queries) mainModel {
return mainModel{
state: stateLogin,
queries: q,
login: initialModell(),
homepage: initialHomepageModel(),
}
}
func (m mainModel) Init() tea.Cmd {
return m.login.Init()
}
// ============================================================================
// 3. THE MASTER UPDATE (Traffic Cop & Authentication)
// ============================================================================
func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "ctrl+c" {
return m, tea.Quit
}
//var cmd tea.Cmd
var cmds []tea.Cmd
ctx := context.Background()
switch m.state {
case stateLogin:
newLoginModel, loginCmd := m.login.Update(msg)
m.login = newLoginModel.(loginModel)
cmds = append(cmds, loginCmd)
// Did the user press Enter on the Submit button?
if m.login.isSubmitted {
username := m.login.inputs[0].Value()
password := m.login.inputs[1].Value()
// 1. Fetch the user from the database
user, err := m.queries.GetUserByUsername(ctx, username)
if err != nil {
m.login.errorMessage = "User not found."
m.login.isSubmitted = false // Reset so they can try again
return m, nil
}
// 2. Verify the password hash
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password))
if err != nil {
m.login.errorMessage = "Invalid password."
m.login.isSubmitted = false // Reset so they can try again
return m, nil
}
// 3. Fetch their Airport Data
// Note: user.ID is int32 in sqlc (based on schema)
airport, err := m.queries.GetAirportByUserId(ctx, user.ID)
if err != nil {
m.login.errorMessage = "No airport found for this user."
m.login.isSubmitted = false
return m, nil
}
// 4. Fetch their Fleet Count
// Note: airport.ID is uint32, GetPlayerFleet expects the correct type
fleet, _ := m.queries.GetPlayerFleet(ctx, airport.ID)
// 5. SUCCESS! Push real data to the homepage and switch screens
m.homepage.playerName = user.Username
m.homepage.cash = int(airport.Cash)
m.homepage.fleetCount = len(fleet)
m.state = stateHomepage
cmds = append(cmds, m.homepage.Init())
} else if m.login.isCanceled {
return m, tea.Quit
}
case stateHomepage:
newHomepageModel, homeCmd := m.homepage.Update(msg)
m.homepage = newHomepageModel.(homepageModel)
cmds = append(cmds, homeCmd)
if m.homepage.selected != "" {
if m.homepage.selected == "🚪 Logout / Quit" {
return m, tea.Quit
}
// Placeholder for other menus
return m, tea.Quit
}
}
return m, tea.Batch(cmds...)
}
func (m mainModel) View() string {
switch m.state {
case stateLogin:
return m.login.View()
case stateHomepage:
return m.homepage.View()
default:
return "Error: Unknown state"
}
}
// ============================================================================
// MAIN EXECUTION WITH DATABASE POOL
// ============================================================================
func main() {
// 1. Connect to MySQL Database
// Ensure ?parseTime=true is present!
dsn := "skyramaUser:Skyrama1234.@tcp(127.0.0.1:3306)/skyrama_clone?parseTime=true"
dbConn, err := sql.Open("mysql", dsn)
if err != nil {
fmt.Printf("Error opening database pool: %v\n", err)
os.Exit(1)
}
defer dbConn.Close()
if err := dbConn.Ping(); err != nil {
fmt.Printf("Database unreachable: %v\n", err)
os.Exit(1)
}
// 2. Initialize sqlc queries
queries := db.New(dbConn)
// 3. Start the Bubble Tea UI, passing the database into the model
p := tea.NewProgram(initialModel(queries), tea.WithAltScreen())
finalModel, err := p.Run()
if err != nil {
fmt.Printf("Fatal error starting SkyRama: %v", err)
os.Exit(1)
}
fmt.Print("\033[H\033[2J") // Clear terminal on exit
if m, ok := finalModel.(mainModel); ok {
if m.state == stateHomepage && m.homepage.selected != "" && m.homepage.selected != "🚪 Logout / Quit" {
fmt.Printf("You clicked '%s', which we will build next!\n", m.homepage.selected)
} else {
fmt.Println("Gracefully disconnected from SkyRama servers. Goodbye!")
}
}
}