diff --git a/api/src/db.ts b/api/src/db.ts index 4a38f36..dd4e790 100644 --- a/api/src/db.ts +++ b/api/src/db.ts @@ -1,7 +1,7 @@ import Database from "better-sqlite3"; import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; -import { BoardState, DEFAULT_STATE } from "./types.js"; +import { Background, BoardAnimation, BoardState, DEFAULT_FONT, DEFAULT_STATE, Line, Texture } from "./types.js"; const DB_PATH = process.env.DB_PATH ?? "./data/messageboard.db"; @@ -26,7 +26,7 @@ const setStmt = db.prepare<[string, number]>( export function getState(): BoardState { const row = getStmt.get(); if (!row) return DEFAULT_STATE; - return JSON.parse(row.json) as BoardState; + return migrate(JSON.parse(row.json)); } export function setState(state: BoardState): BoardState { @@ -34,3 +34,52 @@ export function setState(state: BoardState): BoardState { setStmt.run(JSON.stringify(stamped), stamped.updatedAt); return stamped; } + +const TEXTURES: Texture[] = ["none", "dots", "stripes", "grid", "noise"]; +const ANIMATIONS: BoardAnimation[] = ["none", "pan", "pulse", "shimmer"]; + +function migrate(raw: unknown): BoardState { + const r = (raw ?? {}) as Record; + const lines: Line[] = Array.isArray(r.lines) + ? (r.lines as unknown[]).map((l, i) => migrateLine(l, i)) + : DEFAULT_STATE.lines; + return { + lines, + background: migrateBackground(r), + texture: TEXTURES.includes(r.texture as Texture) ? (r.texture as Texture) : "none", + animation: ANIMATIONS.includes(r.animation as BoardAnimation) ? (r.animation as BoardAnimation) : "none", + defaultFont: typeof r.defaultFont === "string" && r.defaultFont ? r.defaultFont : DEFAULT_FONT, + photoMode: r.photoMode === true, + imageName: typeof r.imageName === "string" ? r.imageName : null, + updatedAt: typeof r.updatedAt === "number" ? r.updatedAt : 0, + }; +} + +function migrateLine(raw: unknown, i: number): Line { + const r = (raw ?? {}) as Record; + return { + id: typeof r.id === "string" ? r.id : `l${i + 1}`, + text: typeof r.text === "string" ? r.text : "", + color: typeof r.color === "string" ? r.color : null, + font: typeof r.font === "string" ? r.font : null, + }; +} + +function migrateBackground(r: Record): Background { + const bg = r.background as Record | undefined; + if (bg && typeof bg === "object") { + if (bg.type === "linear" && typeof bg.from === "string" && typeof bg.to === "string") { + return { type: "linear", from: bg.from, to: bg.to, angle: typeof bg.angle === "number" ? bg.angle : 90 }; + } + if (bg.type === "radial" && typeof bg.from === "string" && typeof bg.to === "string") { + return { type: "radial", from: bg.from, to: bg.to }; + } + if (bg.type === "solid" && typeof bg.color === "string") { + return { type: "solid", color: bg.color }; + } + } + if (typeof r.backgroundColor === "string") { + return { type: "solid", color: r.backgroundColor }; + } + return DEFAULT_STATE.background; +} diff --git a/api/src/routes/state.ts b/api/src/routes/state.ts index 8305abe..0318ce9 100644 --- a/api/src/routes/state.ts +++ b/api/src/routes/state.ts @@ -3,10 +3,13 @@ import { streamSSE } from "hono/streaming"; import { getState, setState } from "../db.js"; import { publish, subscribe } from "../events.js"; import { requireAuth } from "../auth.js"; -import { BoardState, Line } from "../types.js"; +import { Background, BoardAnimation, BoardState, Line, Texture } from "../types.js"; const app = new Hono(); +const TEXTURES: Texture[] = ["none", "dots", "stripes", "grid", "noise"]; +const ANIMATIONS: BoardAnimation[] = ["none", "pan", "pulse", "shimmer"]; + app.get("/", (c) => c.json(getState())); app.post("/", requireAuth, async (c) => { @@ -14,7 +17,10 @@ app.post("/", requireAuth, async (c) => { const current = getState(); const next: BoardState = { lines: validateLines(body.lines) ?? current.lines, - backgroundColor: typeof body.backgroundColor === "string" ? body.backgroundColor : current.backgroundColor, + background: validateBackground(body.background) ?? current.background, + texture: TEXTURES.includes(body.texture as Texture) ? (body.texture as Texture) : current.texture, + animation: ANIMATIONS.includes(body.animation as BoardAnimation) ? (body.animation as BoardAnimation) : current.animation, + defaultFont: typeof body.defaultFont === "string" && body.defaultFont ? body.defaultFont : current.defaultFont, photoMode: typeof body.photoMode === "boolean" ? body.photoMode : current.photoMode, imageName: body.imageName === undefined ? current.imageName : body.imageName, updatedAt: 0, @@ -51,9 +57,29 @@ function validateLines(input: unknown): Line[] | null { const r = raw as Record; if (typeof r.id !== "string" || typeof r.text !== "string") return null; if (r.color !== null && typeof r.color !== "string") return null; - lines.push({ id: r.id, text: r.text.slice(0, 64), color: r.color }); + if (r.font !== undefined && r.font !== null && typeof r.font !== "string") return null; + lines.push({ + id: r.id, + text: r.text.slice(0, 64), + color: typeof r.color === "string" ? r.color : null, + font: typeof r.font === "string" ? r.font : null, + }); } return lines; } +function validateBackground(input: unknown): Background | null { + if (!input || typeof input !== "object") return null; + const b = input as Record; + if (b.type === "solid" && typeof b.color === "string") return { type: "solid", color: b.color }; + if (b.type === "linear" && typeof b.from === "string" && typeof b.to === "string") { + const angle = typeof b.angle === "number" ? b.angle : 90; + return { type: "linear", from: b.from, to: b.to, angle }; + } + if (b.type === "radial" && typeof b.from === "string" && typeof b.to === "string") { + return { type: "radial", from: b.from, to: b.to }; + } + return null; +} + export default app; diff --git a/api/src/types.ts b/api/src/types.ts index f424aef..96f655e 100644 --- a/api/src/types.ts +++ b/api/src/types.ts @@ -1,24 +1,41 @@ +export type SolidBackground = { type: "solid"; color: string }; +export type LinearBackground = { type: "linear"; from: string; to: string; angle: number }; +export type RadialBackground = { type: "radial"; from: string; to: string }; +export type Background = SolidBackground | LinearBackground | RadialBackground; + +export type Texture = "none" | "dots" | "stripes" | "grid" | "noise"; +export type BoardAnimation = "none" | "pan" | "pulse" | "shimmer"; + export type Line = { id: string; text: string; color: string | null; + font: string | null; }; export type BoardState = { lines: Line[]; - backgroundColor: string; + background: Background; + texture: Texture; + animation: BoardAnimation; + defaultFont: string; photoMode: boolean; imageName: string | null; updatedAt: number; }; +export const DEFAULT_FONT = "Bebas Neue"; + export const DEFAULT_STATE: BoardState = { lines: [ - { id: "l1", text: "", color: null }, - { id: "l2", text: "", color: null }, - { id: "l3", text: "", color: null }, + { id: "l1", text: "", color: null, font: null }, + { id: "l2", text: "", color: null, font: null }, + { id: "l3", text: "", color: null, font: null }, ], - backgroundColor: "#000000", + background: { type: "solid", color: "#000000" }, + texture: "none", + animation: "none", + defaultFont: DEFAULT_FONT, photoMode: false, imageName: null, updatedAt: 0, diff --git a/web/index.html b/web/index.html index 01370db..97cc6f3 100644 --- a/web/index.html +++ b/web/index.html @@ -7,7 +7,7 @@ - + Message Board diff --git a/web/src/components/BackgroundEditor.tsx b/web/src/components/BackgroundEditor.tsx new file mode 100644 index 0000000..b998055 --- /dev/null +++ b/web/src/components/BackgroundEditor.tsx @@ -0,0 +1,186 @@ +import styled from "styled-components"; +import { Background, BoardAnimation, Texture } from "../types"; +import { ColorSwatch } from "./ColorSwatch"; + +type Props = { + background: Background; + texture: Texture; + animation: BoardAnimation; + onBackground: (bg: Background) => void; + onTexture: (t: Texture) => void; + onAnimation: (a: BoardAnimation) => void; +}; + +const TYPES: Background["type"][] = ["solid", "linear", "radial"]; +const TYPE_LABEL: Record = { + solid: "Solid", + linear: "Linear gradient", + radial: "Radial gradient", +}; + +const TEXTURES: Texture[] = ["none", "dots", "stripes", "grid", "noise"]; +const ANIMATIONS: BoardAnimation[] = ["none", "pan", "pulse", "shimmer"]; + +export function BackgroundEditor({ background, texture, animation, onBackground, onTexture, onAnimation }: Props) { + function setType(type: Background["type"]) { + if (type === background.type) return; + const baseColor = background.type === "solid" ? background.color : background.from; + if (type === "solid") { + onBackground({ type: "solid", color: baseColor }); + } else if (type === "linear") { + const to = background.type === "solid" ? "#ffffff" : background.to; + const angle = background.type === "linear" ? background.angle : 90; + onBackground({ type: "linear", from: baseColor, to, angle }); + } else { + const to = background.type === "solid" ? "#ffffff" : background.to; + onBackground({ type: "radial", from: baseColor, to }); + } + } + + return ( + + + + + + + {background.type === "solid" && ( + + + onBackground({ ...background, color })} + size={56} + label="Background color" + /> + + )} + + {(background.type === "linear" || background.type === "radial") && ( + <> + + + onBackground({ ...background, from })} + size={56} + label="Gradient start" + /> + + + + onBackground({ ...background, to })} + size={56} + label="Gradient end" + /> + + + )} + + {background.type === "linear" && ( + + + onBackground({ ...background, angle: Number(e.target.value) })} + /> + + )} + + Per-line colors override the background. + + + + + + + + + + + + + {animation !== "none" && animation !== "shimmer" && texture === "none" && ( + Pick a texture to see the {animation} animation. + )} + + ); +} + +function cap(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +const Body = styled.div` + display: flex; + flex-direction: column; + gap: 14px; +`; + +const Field = styled.div` + display: flex; + align-items: center; + gap: 16px; +`; + +const Label = styled.span` + width: 90px; + flex-shrink: 0; + color: #aaa; + font-size: 0.95rem; +`; + +const Select = styled.select` + flex: 1; + height: 40px; + padding: 0 8px; + background: #1a1a1a; + color: #f0f0f0; + border: 1px solid #444; + border-radius: 4px; + cursor: pointer; + &:hover { border-color: #666; } +`; + +const Slider = styled.input` + flex: 1; + accent-color: #4a90e2; +`; + +const Caption = styled.div` + color: #777; + font-size: 0.85rem; +`; + +const SubGrid = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + margin-top: 4px; + padding-top: 14px; + border-top: 1px solid #333; + ${Field} { gap: 10px; } + ${Label} { width: auto; } +`; + +const Hint = styled.div` + color: #d4a04a; + font-size: 0.85rem; +`; diff --git a/web/src/components/BigPicture.tsx b/web/src/components/BigPicture.tsx index 7e07496..f55f4dd 100644 --- a/web/src/components/BigPicture.tsx +++ b/web/src/components/BigPicture.tsx @@ -1,16 +1,26 @@ import styled from "styled-components"; +import { Background } from "../types"; import { api } from "../api"; -export function BigPicture({ backgroundColor, imageName }: { backgroundColor: string; imageName: string | null }) { - return ; +export function BigPicture({ background, imageName }: { background: Background; imageName: string | null }) { + return ; +} + +function backgroundCSS(bg: Background): string { + switch (bg.type) { + case "solid": + return bg.color; + case "linear": + return `linear-gradient(${bg.angle}deg, ${bg.from}, ${bg.to})`; + case "radial": + return `radial-gradient(circle at center, ${bg.from}, ${bg.to})`; + } } const Image = styled.div<{ $bg: string; $url: string | null }>` width: 100%; height: 100%; - background-color: ${(p) => p.$bg}; - background-image: ${(p) => (p.$url ? `url(${p.$url})` : "none")}; - background-size: contain; - background-repeat: no-repeat; - background-position: center; + background-color: transparent; + background: ${(p) => p.$bg}; + ${(p) => p.$url && `background-image: url(${p.$url}); background-size: contain; background-repeat: no-repeat; background-position: center;`} `; diff --git a/web/src/components/Board.tsx b/web/src/components/Board.tsx index c2ab544..0a7fa98 100644 --- a/web/src/components/Board.tsx +++ b/web/src/components/Board.tsx @@ -1,25 +1,134 @@ -import styled from "styled-components"; -import { BoardState } from "../types"; +import styled, { css, keyframes } from "styled-components"; +import { Background, BoardAnimation, BoardState, Texture } from "../types"; +import { fontFamily } from "../fonts"; export function Board({ state }: { state: BoardState }) { return ( - + + {state.animation === "shimmer" && } {state.lines.map((line, i) => ( - - {line.text} + + + {line.text} + ))} ); } -const Stack = styled.div<{ $bg: string }>` +function backgroundCSS(bg: Background): string { + switch (bg.type) { + case "solid": + return bg.color; + case "linear": + return `linear-gradient(${bg.angle}deg, ${bg.from}, ${bg.to})`; + case "radial": + return `radial-gradient(circle at center, ${bg.from}, ${bg.to})`; + } +} + +function backgroundFallback(bg: Background): string { + return bg.type === "solid" ? bg.color : bg.from; +} + +const NOISE_SVG = + "url(\"data:image/svg+xml;utf8,\")"; + +function textureCSS(t: Texture) { + switch (t) { + case "none": + return css`background-image: none;`; + case "dots": + return css` + background-image: radial-gradient(rgba(0, 0, 0, 0.18) 1.5px, transparent 1.6px); + background-size: 18px 18px; + `; + case "stripes": + return css` + background-image: repeating-linear-gradient( + 45deg, + rgba(0, 0, 0, 0.12) 0 6px, + transparent 6px 14px + ); + `; + case "grid": + return css` + background-image: + linear-gradient(rgba(0, 0, 0, 0.12) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 0, 0, 0.12) 1px, transparent 1px); + background-size: 24px 24px; + `; + case "noise": + return css` + background-image: ${NOISE_SVG}; + background-size: 160px 160px; + `; + } +} + +const panKf = keyframes` + from { background-position: 0 0; } + to { background-position: 200px 200px; } +`; + +const pulseKf = keyframes` + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } +`; + +const shimmerKf = keyframes` + 0% { transform: translateX(-110%); } + 100% { transform: translateX(110%); } +`; + +function textureAnimation(a: BoardAnimation, t: Texture) { + if (t === "none") return css``; + if (a === "pan") return css`animation: ${panKf} 18s linear infinite;`; + if (a === "pulse") return css`animation: ${pulseKf} 4s ease-in-out infinite;`; + return css``; +} + +const Stack = styled.div<{ $bg: Background; $texture: Texture; $animation: BoardAnimation }>` width: 100%; height: 100%; display: flex; flex-direction: column; - background: ${(p) => p.$bg}; + position: relative; + isolation: isolate; overflow: hidden; + background: ${(p) => backgroundCSS(p.$bg)}; + + &::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + z-index: 0; + ${(p) => textureCSS(p.$texture)}; + ${(p) => textureAnimation(p.$animation, p.$texture)}; + } +`; + +const Shimmer = styled.div` + position: absolute; + top: 0; + bottom: 0; + width: 40%; + pointer-events: none; + z-index: 4; + background: linear-gradient( + 100deg, + transparent 0%, + rgba(255, 255, 255, 0.18) 50%, + transparent 100% + ); + animation: ${shimmerKf} 6s linear infinite; + mix-blend-mode: overlay; `; const Row = styled.div<{ $bg: string; $first: boolean }>` @@ -31,18 +140,29 @@ const Row = styled.div<{ $bg: string; $first: boolean }>` justify-content: center; background: ${(p) => p.$bg}; position: relative; + z-index: 1; container-type: size; ${(p) => !p.$first && ` - &::before { + &::after { content: ""; position: absolute; top: 0; left: 0; right: 0; - height: 0.5rem; - background: rgba(0, 0, 0, 0.18); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); + height: 0.6cqh; + min-height: 3px; + pointer-events: none; + z-index: 3; + background: linear-gradient( + to bottom, + rgba(0, 0, 0, 0.45), + rgba(0, 0, 0, 0.25) 60%, + rgba(0, 0, 0, 0.1) + ); + box-shadow: + inset 0 1px 1px rgba(0, 0, 0, 0.35), + 0 1px 0 rgba(255, 255, 255, 0.18); } `} `; @@ -57,5 +177,7 @@ const Text = styled.span` overflow: hidden; text-overflow: ellipsis; max-width: 100%; + position: relative; + z-index: 2; transform: translateY(0.09em); `; diff --git a/web/src/components/FontSelect.tsx b/web/src/components/FontSelect.tsx new file mode 100644 index 0000000..b2123de --- /dev/null +++ b/web/src/components/FontSelect.tsx @@ -0,0 +1,46 @@ +import styled from "styled-components"; +import { FONTS, fontFamily } from "../fonts"; + +type Props = { + value: string | null; + fallback?: string; + onChange: (name: string | null) => void; + allowInherit?: boolean; + inheritLabel?: string; + compact?: boolean; +}; + +export function FontSelect({ value, fallback, onChange, allowInherit, inheritLabel, compact }: Props) { + const display = value ?? fallback ?? FONTS[0]!.name; + return ( + + ); +} + +const Select = styled.select<{ $compact: boolean }>` + height: ${(p) => (p.$compact ? "32px" : "40px")}; + padding: 0 8px; + font-size: ${(p) => (p.$compact ? "0.85rem" : "1rem")}; + background: #1a1a1a; + color: #f0f0f0; + border: 1px solid #444; + border-radius: 4px; + cursor: pointer; + &:hover { border-color: #666; } +`; diff --git a/web/src/components/LineEditor.tsx b/web/src/components/LineEditor.tsx index d296b7d..8e448bc 100644 --- a/web/src/components/LineEditor.tsx +++ b/web/src/components/LineEditor.tsx @@ -1,11 +1,14 @@ import styled from "styled-components"; import { Line } from "../types"; import { ColorSwatch } from "./ColorSwatch"; +import { FontSelect } from "./FontSelect"; +import { fontFamily } from "../fonts"; type Props = { line: Line; index: number; defaultColor: string; + defaultFont: string; onChange: (line: Line) => void; onRemove: () => void; canRemove: boolean; @@ -15,8 +18,21 @@ type Props = { canMoveDown: boolean; }; -export function LineEditor({ line, index, defaultColor, onChange, onRemove, canRemove, onMoveUp, onMoveDown, canMoveUp, canMoveDown }: Props) { +export function LineEditor({ + line, + index, + defaultColor, + defaultFont, + onChange, + onRemove, + canRemove, + onMoveUp, + onMoveDown, + canMoveUp, + canMoveDown, +}: Props) { const effectiveColor = line.color ?? defaultColor; + const effectiveFont = line.font ?? defaultFont; return ( @@ -25,11 +41,20 @@ export function LineEditor({ line, index, defaultColor, onChange, onRemove, canR onChange({ ...line, text: e.target.value })} /> + onChange({ ...line, font })} + allowInherit + inheritLabel="↳ default" + /> onChange({ ...line, color: hex })} @@ -68,6 +93,7 @@ const IconBtn = styled.button` const TextField = styled.input<{ $bg: string }>` flex: 1; + min-width: 0; height: 56px; padding: 0 16px; font-size: 1.6rem; diff --git a/web/src/components/Preview.tsx b/web/src/components/Preview.tsx index 3ee6175..996dc4c 100644 --- a/web/src/components/Preview.tsx +++ b/web/src/components/Preview.tsx @@ -8,7 +8,7 @@ export function Preview({ state }: { state: BoardState }) { {state.photoMode ? ( - + ) : ( )} diff --git a/web/src/fonts.ts b/web/src/fonts.ts new file mode 100644 index 0000000..1786112 --- /dev/null +++ b/web/src/fonts.ts @@ -0,0 +1,19 @@ +export type FontOption = { + name: string; + family: string; +}; + +export const FONTS: FontOption[] = [ + { name: "Bebas Neue", family: "'Bebas Neue', sans-serif" }, + { name: "Anton", family: "'Anton', sans-serif" }, + { name: "Oswald", family: "'Oswald', sans-serif" }, + { name: "Bungee", family: "'Bungee', sans-serif" }, + { name: "Permanent Marker", family: "'Permanent Marker', cursive" }, + { name: "Lobster", family: "'Lobster', cursive" }, +]; + +export const DEFAULT_FONT_NAME = "Bebas Neue"; + +export function fontFamily(name: string): string { + return FONTS.find((f) => f.name === name)?.family ?? FONTS[0]!.family; +} diff --git a/web/src/screens/Admin.tsx b/web/src/screens/Admin.tsx index 9bb67a3..b71d7c1 100644 --- a/web/src/screens/Admin.tsx +++ b/web/src/screens/Admin.tsx @@ -2,14 +2,19 @@ import { ChangeEvent, useEffect, useMemo, useState } from "react"; import styled from "styled-components"; import { api } from "../api"; import { clearToken, getToken } from "../auth"; -import { BoardState, Line } from "../types"; -import { ColorSwatch } from "../components/ColorSwatch"; +import { Background, BoardAnimation, BoardState, Line, Texture } from "../types"; import { LineEditor } from "../components/LineEditor"; import { Toggle } from "../components/Toggle"; import { Preview } from "../components/Preview"; +import { BackgroundEditor } from "../components/BackgroundEditor"; +import { FontSelect } from "../components/FontSelect"; import { Login } from "./Login"; -const newLine = (): Line => ({ id: crypto.randomUUID(), text: "", color: null }); +const newLine = (): Line => ({ id: crypto.randomUUID(), text: "", color: null, font: null }); + +function bgFallback(bg: Background): string { + return bg.type === "solid" ? bg.color : bg.from; +} export function Admin() { const [authed, setAuthed] = useState(!!getToken()); @@ -102,7 +107,8 @@ export function Admin() { key={line.id} line={line} index={i} - defaultColor={draft.backgroundColor} + defaultColor={bgFallback(draft.background)} + defaultFont={draft.defaultFont} onChange={(updated) => updateLines((ls) => ls.map((l) => (l.id === line.id ? updated : l)))} onRemove={() => updateLines((ls) => ls.filter((l) => l.id !== line.id))} canRemove={draft.lines.length > 1} @@ -118,18 +124,28 @@ export function Admin() {
- Background + Typography - update({ backgroundColor: hex })} - size={72} - label="Background color" + Default font + update({ defaultFont: name ?? draft.defaultFont })} /> - Per-line colors override this.
+
+ Background & effects + update({ background })} + onTexture={(texture: Texture) => update({ texture })} + onAnimation={(animation: BoardAnimation) => update({ animation })} + /> +
+
Photo mode {state.photoMode ? ( - + ) : ( )} diff --git a/web/src/types.ts b/web/src/types.ts index 045f365..12a099f 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -1,12 +1,24 @@ +export type SolidBackground = { type: "solid"; color: string }; +export type LinearBackground = { type: "linear"; from: string; to: string; angle: number }; +export type RadialBackground = { type: "radial"; from: string; to: string }; +export type Background = SolidBackground | LinearBackground | RadialBackground; + +export type Texture = "none" | "dots" | "stripes" | "grid" | "noise"; +export type BoardAnimation = "none" | "pan" | "pulse" | "shimmer"; + export type Line = { id: string; text: string; color: string | null; + font: string | null; }; export type BoardState = { lines: Line[]; - backgroundColor: string; + background: Background; + texture: Texture; + animation: BoardAnimation; + defaultFont: string; photoMode: boolean; imageName: string | null; updatedAt: number;