Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions api/src/db.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -26,11 +26,60 @@ 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 {
const stamped = { ...state, updatedAt: Date.now() };
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<string, unknown>;
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<string, unknown>;
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<string, unknown>): Background {
const bg = r.background as Record<string, unknown> | 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;
}
32 changes: 29 additions & 3 deletions api/src/routes/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,24 @@ 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) => {
const body = await c.req.json<Partial<BoardState>>();
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,
Expand Down Expand Up @@ -51,9 +57,29 @@ function validateLines(input: unknown): Line[] | null {
const r = raw as Record<string, unknown>;
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<string, unknown>;
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;
27 changes: 22 additions & 5 deletions api/src/types.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 1 addition & 1 deletion web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<link rel="icon" href="/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Anton&family=Bebas+Neue&family=Bungee&family=Lobster&family=Oswald:wght@500&family=Permanent+Marker&display=swap" rel="stylesheet" />
<title>Message Board</title>
</head>
<body>
Expand Down
186 changes: 186 additions & 0 deletions web/src/components/BackgroundEditor.tsx
Original file line number Diff line number Diff line change
@@ -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<Background["type"], string> = {
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 (
<Body>
<Field>
<Label>Type</Label>
<Select value={background.type} onChange={(e) => setType(e.target.value as Background["type"])}>
{TYPES.map((t) => (
<option key={t} value={t}>{TYPE_LABEL[t]}</option>
))}
</Select>
</Field>

{background.type === "solid" && (
<Field>
<Label>Color</Label>
<ColorSwatch
value={background.color}
onChange={(color) => onBackground({ ...background, color })}
size={56}
label="Background color"
/>
</Field>
)}

{(background.type === "linear" || background.type === "radial") && (
<>
<Field>
<Label>From</Label>
<ColorSwatch
value={background.from}
onChange={(from) => onBackground({ ...background, from })}
size={56}
label="Gradient start"
/>
</Field>
<Field>
<Label>To</Label>
<ColorSwatch
value={background.to}
onChange={(to) => onBackground({ ...background, to })}
size={56}
label="Gradient end"
/>
</Field>
</>
)}

{background.type === "linear" && (
<Field>
<Label>Angle ({background.angle}°)</Label>
<Slider
type="range"
min={0}
max={360}
value={background.angle}
onChange={(e) => onBackground({ ...background, angle: Number(e.target.value) })}
/>
</Field>
)}

<Caption>Per-line colors override the background.</Caption>

<SubGrid>
<Field>
<Label>Texture</Label>
<Select value={texture} onChange={(e) => onTexture(e.target.value as Texture)}>
{TEXTURES.map((t) => (
<option key={t} value={t}>{cap(t)}</option>
))}
</Select>
</Field>
<Field>
<Label>Animation</Label>
<Select value={animation} onChange={(e) => onAnimation(e.target.value as BoardAnimation)}>
{ANIMATIONS.map((a) => (
<option key={a} value={a}>{cap(a)}</option>
))}
</Select>
</Field>
</SubGrid>

{animation !== "none" && animation !== "shimmer" && texture === "none" && (
<Hint>Pick a texture to see the {animation} animation.</Hint>
)}
</Body>
);
}

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;
`;
Loading
Loading