Replace PicoCSS with DaisyUI + Tailwind v4

Use gotailwind (standalone Tailwind v4 via Go tool) with DaisyUI
plugin files — no npm needed. CSS is compiled at build time and
embedded via a Via Plugin that serves it as a static file.

Custom "connect4" theme: light, warm, playful palette with red/yellow
accents matching game pieces and board blue accent.
This commit is contained in:
Ryan Hamamura
2026-01-31 07:31:29 -10:00
parent dcab4343e5
commit f590a2444a
11 changed files with 2897 additions and 471 deletions

File diff suppressed because one or more lines are too long

1026
assets/css/daisyui.mjs Normal file

File diff suppressed because one or more lines are too long

63
assets/css/input.css Normal file
View File

@@ -0,0 +1,63 @@
@import 'tailwindcss';
@source not "./daisyui{,*}.mjs";
@plugin "./daisyui.mjs";
@plugin "./daisyui-theme.mjs" {
name: "connect4";
default: true;
color-scheme: light;
/* Playful & bright — warm, casual board game feel */
--color-base-100: oklch(98% 0.005 90); /* warm white */
--color-base-200: oklch(95% 0.01 90); /* light cream */
--color-base-300: oklch(90% 0.015 85); /* warm gray border */
--color-base-content: oklch(25% 0.02 50); /* dark warm text */
--color-primary: oklch(60% 0.22 30); /* warm red — matches game pieces */
--color-primary-content: oklch(98% 0.005 90);
--color-secondary: oklch(82% 0.17 85); /* golden yellow — matches game pieces */
--color-secondary-content: oklch(25% 0.02 50);
--color-accent: oklch(65% 0.19 250); /* board blue */
--color-accent-content: oklch(98% 0.005 90);
--color-neutral: oklch(35% 0.03 260);
--color-neutral-content: oklch(95% 0.01 90);
--color-success: oklch(72% 0.19 155);
--color-success-content: oklch(20% 0.04 155);
--color-warning: oklch(82% 0.17 85);
--color-warning-content: oklch(25% 0.04 85);
--color-error: oklch(60% 0.22 30);
--color-error-content: oklch(97% 0.01 30);
--color-info: oklch(70% 0.15 240);
--color-info-content: oklch(20% 0.04 240);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 0.75rem;
--border: 1px;
--depth: 1;
--noise: 0;
};
/* Game-specific styles that can't be Tailwind utilities */
.board {
display: flex;
gap: 8px;
background: #2563eb;
padding: 16px;
border-radius: 12px;
}
.column { display: flex; flex-direction: column; gap: 8px; padding: 4px; border-radius: 8px; }
.column.clickable { cursor: pointer; }
.column.clickable:hover { background: rgba(255,255,255,0.15); }
.cell { width: 48px; height: 48px; border-radius: 50%; background: #1e40af; transition: background 0.2s; }
.cell.red { background: #dc2626; }
.cell.yellow { background: #facc15; }
.cell.winning { animation: pulse 0.5s ease-in-out infinite alternate; }
@keyframes pulse {
from { transform: scale(1); box-shadow: 0 0 10px rgba(255,255,255,0.5); }
to { transform: scale(1.1); box-shadow: 0 0 20px rgba(255,255,255,0.8); }
}
.player-chip { width: 20px; height: 20px; border-radius: 50%; background: #666; }
.player-chip.red { background: #dc2626; }
.player-chip.yellow { background: #facc15; }

1523
assets/css/output.css Normal file

File diff suppressed because it is too large Load Diff

3
go.mod
View File

@@ -16,6 +16,7 @@ require (
github.com/alexedwards/scs/v2 v2.9.0 // indirect github.com/alexedwards/scs/v2 v2.9.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/hookenz/gotailwind/v4 v4.1.18 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect github.com/mfridman/interpolate v0.0.2 // indirect
@@ -33,3 +34,5 @@ require (
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
) )
tool github.com/hookenz/gotailwind/v4

2
go.sum
View File

@@ -20,6 +20,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hookenz/gotailwind/v4 v4.1.18 h1:1h3XwTVx1dEBm6A0bcosAplNCde+DCmVJG0arLy5fBE=
github.com/hookenz/gotailwind/v4 v4.1.18/go.mod h1:IfiJtdp8ExV9HV2XUiVjRBvB3QewVXVKWoAGEcpjfNE=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=

318
main.go
View File

@@ -3,7 +3,9 @@ package main
import ( import (
"context" "context"
"database/sql" "database/sql"
_ "embed"
"log" "log"
"net/http"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/ryanhamamura/c4/auth" "github.com/ryanhamamura/c4/auth"
@@ -18,6 +20,17 @@ import (
var store = game.NewGameStore() var store = game.NewGameStore()
var queries *gen.Queries var queries *gen.Queries
//go:embed assets/css/output.css
var daisyUICSS []byte
func DaisyUIPlugin(v *via.V) {
v.HTTPServeMux().HandleFunc("GET /_plugins/daisyui/style.css", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css")
_, _ = w.Write(daisyUICSS)
})
v.AppendToHead(h.Link(h.Rel("stylesheet"), h.Href("/_plugins/daisyui/style.css")))
}
func main() { func main() {
if err := db.Init("c4.db"); err != nil { if err := db.Init("c4.db"); err != nil {
log.Fatal(err) log.Fatal(err)
@@ -36,13 +49,9 @@ func main() {
DocumentTitle: "Connect 4", DocumentTitle: "Connect 4",
ServerAddress: ":7331", ServerAddress: ":7331",
SessionManager: sessionManager, SessionManager: sessionManager,
Plugins: []via.Plugin{DaisyUIPlugin},
}) })
v.AppendToHead(
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")),
h.StyleEl(h.Raw(gameCSS)),
)
// Home page - enter nickname and create game // Home page - enter nickname and create game
v.Page("/", func(c *via.Context) { v.Page("/", func(c *via.Context) {
userID := c.Session().GetString("user_id") userID := c.Session().GetString("user_id")
@@ -380,7 +389,7 @@ func main() {
var content []h.H var content []h.H
content = append(content, content = append(content,
h.H1(h.Text("Connect 4")), h.H1(h.Class("text-3xl font-bold"), h.Text("Connect 4")),
ui.PlayerInfo(g, myColor), ui.PlayerInfo(g, myColor),
ui.StatusBanner(g, myColor, createRematch.OnClick()), ui.StatusBanner(g, myColor, createRematch.OnClick()),
ui.BoardComponent(g, columnClick, myColor), ui.BoardComponent(g, columnClick, myColor),
@@ -391,7 +400,7 @@ func main() {
content = append(content, ui.InviteLink(g.ID)) content = append(content, ui.InviteLink(g.ID))
} }
mainAttrs := []h.H{h.Class("container game-container")} mainAttrs := []h.H{h.Class("flex flex-col items-center gap-4 p-4")}
mainAttrs = append(mainAttrs, content...) mainAttrs = append(mainAttrs, content...)
return h.Main(mainAttrs...) return h.Main(mainAttrs...)
}) })
@@ -399,298 +408,3 @@ func main() {
v.Start() v.Start()
} }
const gameCSS = `
body { margin: 0; }
.lobby {
max-width: 400px;
margin: 2rem auto;
text-align: center;
}
.game-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 1rem;
}
.board {
display: flex;
gap: 8px;
background: #2563eb;
padding: 16px;
border-radius: 12px;
}
.column {
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px;
border-radius: 8px;
}
.column.clickable {
cursor: pointer;
}
.column.clickable:hover {
background: rgba(255,255,255,0.15);
}
.cell {
width: 48px;
height: 48px;
border-radius: 50%;
background: #1e40af;
transition: background 0.2s;
}
.cell.red {
background: #dc2626;
}
.cell.yellow {
background: #facc15;
}
.cell.winning {
animation: pulse 0.5s ease-in-out infinite alternate;
}
@keyframes pulse {
from { transform: scale(1); box-shadow: 0 0 10px rgba(255,255,255,0.5); }
to { transform: scale(1.1); box-shadow: 0 0 20px rgba(255,255,255,0.8); }
}
.status {
font-size: 1.25rem;
font-weight: bold;
padding: 0.5rem 1rem;
border-radius: 8px;
}
.status.waiting {
background: var(--pico-muted-background);
}
.status.your-turn {
background: #22c55e;
color: white;
}
.status.opponent-turn {
background: var(--pico-muted-background);
}
.status.winner {
background: #22c55e;
color: white;
}
.status.loser {
background: #dc2626;
color: white;
}
.status.draw {
background: #f59e0b;
color: white;
}
.play-again-btn, .rematch-link {
margin-left: 1rem;
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
background: white;
color: #333;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
}
.play-again-btn:hover, .rematch-link:hover {
background: #eee;
}
.player-info {
display: flex;
gap: 2rem;
margin-bottom: 0.5rem;
}
.player {
display: flex;
align-items: center;
gap: 0.5rem;
}
.player-chip {
width: 20px;
height: 20px;
border-radius: 50%;
background: #666;
}
.player-chip.red {
background: #dc2626;
}
.player-chip.yellow {
background: #facc15;
}
.invite-section {
margin-top: 1rem;
text-align: center;
}
.invite-link {
background: var(--pico-muted-background);
padding: 1rem;
border-radius: 8px;
font-family: monospace;
word-break: break-all;
margin: 0.5rem 0;
}
.copy-btn {
margin-top: 0.5rem;
}
.auth-header {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
padding: 0.5rem;
background: var(--pico-muted-background);
border-radius: 8px;
}
.auth-header button {
margin: 0;
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
.guest-banner {
margin-bottom: 1rem;
padding: 0.5rem;
background: var(--pico-muted-background);
border-radius: 8px;
font-size: 0.875rem;
}
.error {
color: #dc2626;
background: #fef2f2;
padding: 0.5rem 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.game-list {
margin-top: 2rem;
text-align: left;
}
.game-list h3 {
margin-bottom: 1rem;
text-align: center;
}
.game-list-items {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.game-entry {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: var(--pico-muted-background);
border-radius: 8px;
transition: background 0.2s;
}
.game-entry:hover {
background: var(--pico-secondary-background);
}
.game-entry-link {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0.5rem;
text-decoration: none;
color: inherit;
}
.game-entry-main {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.game-delete-btn {
width: 2rem;
height: 2rem;
padding: 0;
margin: 0;
border: none;
background: transparent;
color: var(--pico-muted-color);
font-size: 1.25rem;
cursor: pointer;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
}
.game-delete-btn:hover {
background: #dc2626;
color: white;
}
.opponent-name {
font-weight: bold;
}
.game-status {
font-size: 0.875rem;
}
.game-status.your-turn {
color: #22c55e;
font-weight: bold;
}
.game-status.waiting {
color: var(--pico-muted-color);
}
.time-ago {
font-size: 0.75rem;
color: var(--pico-muted-color);
}
.join-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 1rem 0;
}
.register-link {
font-size: 0.875rem;
color: var(--pico-muted-color);
}
`

View File

@@ -7,109 +7,112 @@ import (
func LoginView(usernameBind, passwordBind, loginKeyDown, loginClick h.H, errorMsg string) h.H { func LoginView(usernameBind, passwordBind, loginKeyDown, loginClick h.H, errorMsg string) h.H {
var errorEl h.H var errorEl h.H
if errorMsg != "" { if errorMsg != "" {
errorEl = h.P(h.Class("error"), h.Text(errorMsg)) errorEl = h.P(h.Class("alert alert-error mb-4"), h.Text(errorMsg))
} }
return h.Main(h.Class("container"), return h.Main(h.Class("max-w-sm mx-auto mt-8 text-center"),
h.Div(h.Class("lobby"), h.H1(h.Class("text-3xl font-bold"), h.Text("Login")),
h.H1(h.Text("Login")), h.P(h.Class("mb-4"), h.Text("Sign in to your account")),
h.P(h.Text("Sign in to your account")), errorEl,
errorEl, h.Form(
h.Form( h.FieldSet(h.Class("fieldset"),
h.FieldSet( h.Label(h.Class("label"), h.Text("Username"), h.Attr("for", "username")),
h.Label(h.Text("Username"), h.Attr("for", "username")), h.Input(
h.Input( h.Class("input input-bordered w-full"),
h.ID("username"), h.ID("username"),
h.Type("text"), h.Type("text"),
h.Placeholder("Enter your username"), h.Placeholder("Enter your username"),
usernameBind, usernameBind,
h.Attr("required"), h.Attr("required"),
h.Attr("autofocus"), h.Attr("autofocus"),
),
h.Label(h.Text("Password"), h.Attr("for", "password")),
h.Input(
h.ID("password"),
h.Type("password"),
h.Placeholder("Enter your password"),
passwordBind,
h.Attr("required"),
loginKeyDown,
),
), ),
h.Button( h.Label(h.Class("label"), h.Text("Password"), h.Attr("for", "password")),
h.Type("button"), h.Input(
h.Text("Login"), h.Class("input input-bordered w-full"),
loginClick, h.ID("password"),
h.Type("password"),
h.Placeholder("Enter your password"),
passwordBind,
h.Attr("required"),
loginKeyDown,
), ),
), ),
h.P( h.Button(
h.Text("Don't have an account? "), h.Class("btn btn-primary w-full"),
h.A(h.Href("/register"), h.Text("Register")), h.Type("button"),
h.Text("Login"),
loginClick,
), ),
), ),
h.P(
h.Text("Don't have an account? "),
h.A(h.Class("link"), h.Href("/register"), h.Text("Register")),
),
) )
} }
func RegisterView(usernameBind, passwordBind, confirmBind, registerKeyDown, registerClick h.H, errorMsg string) h.H { func RegisterView(usernameBind, passwordBind, confirmBind, registerKeyDown, registerClick h.H, errorMsg string) h.H {
var errorEl h.H var errorEl h.H
if errorMsg != "" { if errorMsg != "" {
errorEl = h.P(h.Class("error"), h.Text(errorMsg)) errorEl = h.P(h.Class("alert alert-error mb-4"), h.Text(errorMsg))
} }
return h.Main(h.Class("container"), return h.Main(h.Class("max-w-sm mx-auto mt-8 text-center"),
h.Div(h.Class("lobby"), h.H1(h.Class("text-3xl font-bold"), h.Text("Register")),
h.H1(h.Text("Register")), h.P(h.Class("mb-4"), h.Text("Create a new account")),
h.P(h.Text("Create a new account")), errorEl,
errorEl, h.Form(
h.Form( h.FieldSet(h.Class("fieldset"),
h.FieldSet( h.Label(h.Class("label"), h.Text("Username"), h.Attr("for", "username")),
h.Label(h.Text("Username"), h.Attr("for", "username")), h.Input(
h.Input( h.Class("input input-bordered w-full"),
h.ID("username"), h.ID("username"),
h.Type("text"), h.Type("text"),
h.Placeholder("Choose a username"), h.Placeholder("Choose a username"),
usernameBind, usernameBind,
h.Attr("required"), h.Attr("required"),
h.Attr("autofocus"), h.Attr("autofocus"),
),
h.Label(h.Text("Password"), h.Attr("for", "password")),
h.Input(
h.ID("password"),
h.Type("password"),
h.Placeholder("Choose a password (min 8 chars)"),
passwordBind,
h.Attr("required"),
),
h.Label(h.Text("Confirm Password"), h.Attr("for", "confirm")),
h.Input(
h.ID("confirm"),
h.Type("password"),
h.Placeholder("Confirm your password"),
confirmBind,
h.Attr("required"),
registerKeyDown,
),
), ),
h.Button( h.Label(h.Class("label"), h.Text("Password"), h.Attr("for", "password")),
h.Type("button"), h.Input(
h.Text("Register"), h.Class("input input-bordered w-full"),
registerClick, h.ID("password"),
h.Type("password"),
h.Placeholder("Choose a password (min 8 chars)"),
passwordBind,
h.Attr("required"),
),
h.Label(h.Class("label"), h.Text("Confirm Password"), h.Attr("for", "confirm")),
h.Input(
h.Class("input input-bordered w-full"),
h.ID("confirm"),
h.Type("password"),
h.Placeholder("Confirm your password"),
confirmBind,
h.Attr("required"),
registerKeyDown,
), ),
), ),
h.P( h.Button(
h.Text("Already have an account? "), h.Class("btn btn-primary w-full"),
h.A(h.Href("/login"), h.Text("Login")), h.Type("button"),
h.Text("Register"),
registerClick,
), ),
), ),
h.P(
h.Text("Already have an account? "),
h.A(h.Class("link"), h.Href("/login"), h.Text("Login")),
),
) )
} }
func AuthHeader(username string, logoutClick h.H) h.H { func AuthHeader(username string, logoutClick h.H) h.H {
return h.Div(h.Class("auth-header"), return h.Div(h.Class("flex justify-center items-center gap-4 mb-4 p-2 bg-base-200 rounded-lg"),
h.Span(h.Text("Logged in as "), h.Strong(h.Text(username))), h.Span(h.Text("Logged in as "), h.Strong(h.Text(username))),
h.Button( h.Button(
h.Type("button"), h.Type("button"),
h.Class("secondary outline small"), h.Class("btn btn-ghost btn-sm"),
h.Text("Logout"), h.Text("Logout"),
logoutClick, logoutClick,
), ),
@@ -117,11 +120,11 @@ func AuthHeader(username string, logoutClick h.H) h.H {
} }
func GuestBanner() h.H { func GuestBanner() h.H {
return h.Div(h.Class("guest-banner"), return h.Div(h.Class("alert text-sm mb-4"),
h.Text("Playing as guest. "), h.Text("Playing as guest. "),
h.A(h.Href("/login"), h.Text("Login")), h.A(h.Class("link"), h.Href("/login"), h.Text("Login")),
h.Text(" or "), h.Text(" or "),
h.A(h.Href("/register"), h.Text("Register")), h.A(h.Class("link"), h.Href("/register"), h.Text("Register")),
h.Text(" to save your games."), h.Text(" to save your games."),
) )
} }

View File

@@ -26,11 +26,11 @@ func GameList(games []GameListItem, deleteClick func(id string) h.H) h.H {
items = append(items, gameListEntry(g, deleteClick)) items = append(items, gameListEntry(g, deleteClick))
} }
listItems := []h.H{h.Class("game-list-items")} listItems := []h.H{h.Class("flex flex-col gap-2")}
listItems = append(listItems, items...) listItems = append(listItems, items...)
return h.Div(h.Class("game-list"), return h.Div(h.Class("mt-8 text-left"),
h.H3(h.Text("Your Games")), h.H3(h.Class("mb-4 text-center text-lg font-bold"), h.Text("Your Games")),
h.Div(listItems...), h.Div(listItems...),
) )
} }
@@ -38,21 +38,21 @@ func GameList(games []GameListItem, deleteClick func(id string) h.H) h.H {
func gameListEntry(g GameListItem, deleteClick func(id string) h.H) h.H { func gameListEntry(g GameListItem, deleteClick func(id string) h.H) h.H {
statusText, statusClass := getStatusDisplay(g) statusText, statusClass := getStatusDisplay(g)
return h.Div(h.Class("game-entry"), return h.Div(h.Class("flex items-center gap-2 p-2 bg-base-200 rounded-lg transition-colors hover:bg-base-300"),
h.A( h.A(
h.Href("/game/"+g.ID), h.Href("/game/"+g.ID),
h.Class("game-entry-link"), h.Class("flex-1 flex justify-between items-center px-2 py-1 no-underline text-base-content"),
h.Div(h.Class("game-entry-main"), h.Div(h.Class("flex flex-col gap-1"),
h.Span(h.Class("opponent-name"), h.Text(getOpponentDisplay(g))), h.Span(h.Class("font-bold"), h.Text(getOpponentDisplay(g))),
h.Span(h.Class("game-status "+statusClass), h.Text(statusText)), h.Span(h.Class(statusClass), h.Text(statusText)),
), ),
h.Div(h.Class("game-entry-meta"), h.Div(
h.Span(h.Class("time-ago"), h.Text(formatTimeAgo(g.LastPlayed))), h.Span(h.Class("text-xs opacity-60"), h.Text(formatTimeAgo(g.LastPlayed))),
), ),
), ),
h.Button( h.Button(
h.Type("button"), h.Type("button"),
h.Class("game-delete-btn"), h.Class("btn btn-ghost btn-sm btn-square hover:btn-error"),
h.Text("\u00d7"), h.Text("\u00d7"),
deleteClick(g.ID), deleteClick(g.ID),
), ),
@@ -62,12 +62,12 @@ func gameListEntry(g GameListItem, deleteClick func(id string) h.H) h.H {
func getStatusDisplay(g GameListItem) (string, string) { func getStatusDisplay(g GameListItem) (string, string) {
switch game.GameStatus(g.Status) { switch game.GameStatus(g.Status) {
case game.StatusWaitingForPlayer: case game.StatusWaitingForPlayer:
return "Waiting for opponent", "waiting" return "Waiting for opponent", "text-sm opacity-60"
case game.StatusInProgress: case game.StatusInProgress:
if g.IsMyTurn { if g.IsMyTurn {
return "Your turn!", "your-turn" return "Your turn!", "text-sm text-success font-bold"
} }
return "Opponent's turn", "opponent-turn" return "Opponent's turn", "text-sm"
} }
return "", "" return "", ""
} }

View File

@@ -12,84 +12,83 @@ func LobbyView(nicknameBind, createGameKeyDown, createGameClick h.H, isLoggedIn
authSection = GuestBanner() authSection = GuestBanner()
} }
return h.Main(h.Class("container"), return h.Main(h.Class("max-w-sm mx-auto mt-8 text-center"),
h.Div(h.Class("lobby"), authSection,
authSection, h.H1(h.Class("text-3xl font-bold"), h.Text("Connect 4")),
h.H1(h.Text("Connect 4")), h.P(h.Class("mb-4"), h.Text("Challenge a friend to a game of Connect 4!")),
h.P(h.Text("Challenge a friend to a game of Connect 4!")), h.Form(
h.Form( h.FieldSet(h.Class("fieldset"),
h.FieldSet( h.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "nickname")),
h.Label(h.Text("Your Nickname"), h.Attr("for", "nickname")), h.Input(
h.Input( h.Class("input input-bordered w-full"),
h.ID("nickname"), h.ID("nickname"),
h.Type("text"), h.Type("text"),
h.Placeholder("Enter your nickname"), h.Placeholder("Enter your nickname"),
nicknameBind, nicknameBind,
h.Attr("required"), h.Attr("required"),
createGameKeyDown, createGameKeyDown,
),
),
h.Button(
h.Type("button"),
h.Text("Create Game"),
createGameClick,
), ),
), ),
GameList(userGames, deleteGameClick), h.Button(
h.Class("btn btn-primary w-full"),
h.Type("button"),
h.Text("Create Game"),
createGameClick,
),
), ),
GameList(userGames, deleteGameClick),
) )
} }
func NicknamePrompt(nicknameBind, setNicknameKeyDown, setNicknameClick h.H) h.H { func NicknamePrompt(nicknameBind, setNicknameKeyDown, setNicknameClick h.H) h.H {
return h.Main(h.Class("container"), return h.Main(h.Class("max-w-sm mx-auto mt-8 text-center"),
h.Div(h.Class("lobby"), h.H1(h.Class("text-3xl font-bold"), h.Text("Join Game")),
h.H1(h.Text("Join Game")), h.P(h.Class("mb-4"), h.Text("Enter your nickname to join the game.")),
h.P(h.Text("Enter your nickname to join the game.")), h.Form(
h.Form( h.FieldSet(h.Class("fieldset"),
h.FieldSet( h.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "nickname")),
h.Label(h.Text("Your Nickname"), h.Attr("for", "nickname")), h.Input(
h.Input( h.Class("input input-bordered w-full"),
h.ID("nickname"), h.ID("nickname"),
h.Type("text"), h.Type("text"),
h.Placeholder("Enter your nickname"), h.Placeholder("Enter your nickname"),
nicknameBind, nicknameBind,
h.Attr("required"), h.Attr("required"),
h.Attr("autofocus"), h.Attr("autofocus"),
setNicknameKeyDown, setNicknameKeyDown,
),
),
h.Button(
h.Type("button"),
h.Text("Join"),
setNicknameClick,
), ),
), ),
h.Button(
h.Class("btn btn-primary w-full"),
h.Type("button"),
h.Text("Join"),
setNicknameClick,
),
), ),
) )
} }
func GameJoinPrompt(loginClick, guestClick, registerClick h.H) h.H { func GameJoinPrompt(loginClick, guestClick, registerClick h.H) h.H {
return h.Main(h.Class("container"), return h.Main(h.Class("max-w-sm mx-auto mt-8 text-center"),
h.Div(h.Class("lobby"), h.H1(h.Class("text-3xl font-bold"), h.Text("Join Game")),
h.H1(h.Text("Join Game")), h.P(h.Class("mb-4"), h.Text("Log in to track your game history, or continue as a guest.")),
h.P(h.Text("Log in to track your game history, or continue as a guest.")), h.Div(h.Class("flex flex-col gap-2 my-4"),
h.Div(h.Class("join-options"), h.Button(
h.Button( h.Class("btn btn-primary w-full"),
h.Type("button"), h.Type("button"),
h.Text("Login"), h.Text("Login"),
loginClick, loginClick,
),
h.Button(
h.Type("button"),
h.Class("secondary"),
h.Text("Continue as Guest"),
guestClick,
),
), ),
h.P(h.Class("register-link"), h.Button(
h.Text("Don't have an account? "), h.Class("btn btn-secondary w-full"),
h.A(h.Href("#"), h.Text("Register"), registerClick), h.Type("button"),
h.Text("Continue as Guest"),
guestClick,
), ),
), ),
h.P(h.Class("text-sm opacity-60"),
h.Text("Don't have an account? "),
h.A(h.Class("link"), h.Href("#"), h.Text("Register"), registerClick),
),
) )
} }

View File

@@ -12,27 +12,27 @@ func StatusBanner(g *game.Game, myColor int, playAgainClick h.H) h.H {
switch g.Status { switch g.Status {
case game.StatusWaitingForPlayer: case game.StatusWaitingForPlayer:
message = "Waiting for opponent..." message = "Waiting for opponent..."
class = "status waiting" class = "alert bg-base-200 text-xl font-bold"
case game.StatusInProgress: case game.StatusInProgress:
if g.CurrentTurn == myColor { if g.CurrentTurn == myColor {
message = "Your turn!" message = "Your turn!"
class = "status your-turn" class = "alert alert-success text-xl font-bold"
} else { } else {
opponentName := getOpponentName(g, myColor) opponentName := getOpponentName(g, myColor)
message = opponentName + "'s turn" message = opponentName + "'s turn"
class = "status opponent-turn" class = "alert bg-base-200 text-xl font-bold"
} }
case game.StatusWon: case game.StatusWon:
if g.Winner != nil && g.Winner.Color == myColor { if g.Winner != nil && g.Winner.Color == myColor {
message = "You win!" message = "You win!"
class = "status winner" class = "alert alert-success text-xl font-bold"
} else if g.Winner != nil { } else if g.Winner != nil {
message = g.Winner.Nickname + " wins!" message = g.Winner.Nickname + " wins!"
class = "status loser" class = "alert alert-error text-xl font-bold"
} }
case game.StatusDraw: case game.StatusDraw:
message = "It's a draw!" message = "It's a draw!"
class = "status draw" class = "alert alert-warning text-xl font-bold"
} }
content := []h.H{ content := []h.H{
@@ -45,7 +45,7 @@ func StatusBanner(g *game.Game, myColor int, playAgainClick h.H) h.H {
if g.RematchGameID != nil { if g.RematchGameID != nil {
content = append(content, content = append(content,
h.A( h.A(
h.Class("rematch-link"), h.Class("btn btn-sm bg-white text-gray-800 border-none ml-4"),
h.Href("/game/"+*g.RematchGameID), h.Href("/game/"+*g.RematchGameID),
h.Text("Join Rematch"), h.Text("Join Rematch"),
), ),
@@ -53,7 +53,7 @@ func StatusBanner(g *game.Game, myColor int, playAgainClick h.H) h.H {
} else if playAgainClick != nil { } else if playAgainClick != nil {
content = append(content, content = append(content,
h.Button( h.Button(
h.Class("play-again-btn"), h.Class("btn btn-sm bg-white text-gray-800 border-none ml-4"),
h.Type("button"), h.Type("button"),
h.Text("Play again"), h.Text("Play again"),
playAgainClick, playAgainClick,
@@ -103,12 +103,12 @@ func PlayerInfo(g *game.Game, myColor int) h.H {
opponentName = "Waiting..." opponentName = "Waiting..."
} }
return h.Div(h.Class("player-info"), return h.Div(h.Class("flex gap-8 mb-2"),
h.Div(h.Class("player you"), h.Div(h.Class("flex items-center gap-2"),
h.Span(h.Class("player-chip "+myColorClass)), h.Span(h.Class("player-chip "+myColorClass)),
h.Span(h.Text(myName+" (You)")), h.Span(h.Text(myName+" (You)")),
), ),
h.Div(h.Class("player opponent"), h.Div(h.Class("flex items-center gap-2"),
h.Span(h.Class("player-chip "+opponentColorClass)), h.Span(h.Class("player-chip "+opponentColorClass)),
h.Span(h.Text(opponentName)), h.Span(h.Text(opponentName)),
), ),
@@ -119,13 +119,13 @@ const baseURL = "https://demo.adriatica.io"
func InviteLink(gameID string) h.H { func InviteLink(gameID string) h.H {
fullURL := baseURL + "/game/" + gameID fullURL := baseURL + "/game/" + gameID
return h.Div(h.Class("invite-section"), return h.Div(h.Class("mt-4 text-center"),
h.P(h.Text("Share this link with your opponent:")), h.P(h.Text("Share this link with your opponent:")),
h.Div(h.Class("invite-link"), h.Div(h.Class("bg-base-200 p-4 rounded-lg font-mono break-all my-2"),
h.Text(fullURL), h.Text(fullURL),
), ),
h.Button( h.Button(
h.Class("copy-btn"), h.Class("btn btn-sm mt-2"),
h.Type("button"), h.Type("button"),
h.Text("Copy Link"), h.Text("Copy Link"),
h.Attr("onclick", "navigator.clipboard.writeText('"+fullURL+"')"), h.Attr("onclick", "navigator.clipboard.writeText('"+fullURL+"')"),