stage 0 database schema ready
This commit is contained in:
parent
f463e71c28
commit
7720522a32
18 changed files with 5577 additions and 4 deletions
|
|
@ -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!")
|
||||
}
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
182
backend/server/main.go
Normal 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!")
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue