diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 65218b08..3b517ed1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import { getLocale, getMessages } from "next-intl/server"; import { Nunito } from "next/font/google"; import type { ReactNode } from "react"; import { Navigation } from "@/components/navigation"; +import RadioPlayerGate from "@/components/public/radio-player-gate"; import { SiteFooter } from "@/components/site-footer"; import { SiteHeader } from "@/components/site-header"; import { ThemeVars } from "@/components/theme-vars"; @@ -57,6 +58,7 @@ export default async function RootLayout({ children }: { children: ReactNode }) + diff --git a/src/app/logo/page.tsx b/src/app/logo/page.tsx new file mode 100644 index 00000000..2aa93486 --- /dev/null +++ b/src/app/logo/page.tsx @@ -0,0 +1,24 @@ +import { ContentCard } from "@/components/public/ui"; +import LogoGenerator from "@/components/public/logo-generator"; + +export const metadata = { title: "Logo generator" }; + +/** + * Public logo generator (AtomCMS logo-generator.blade). Server wrapper that + * renders the atom-styled ContentCard header and hands off to the fully + * client-side , which does all styling, live preview, and PNG + * export in the browser (no server data, no DB). + */ +export default function LogoPage() { + return ( +
+ + + +
+ ); +} diff --git a/src/app/room/[id]/page.tsx b/src/app/room/[id]/page.tsx new file mode 100644 index 00000000..a02d7771 --- /dev/null +++ b/src/app/room/[id]/page.tsx @@ -0,0 +1,158 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +// Arcturus stores room access as a short string on rooms.state. Map each known +// value to a friendly label + lock icon; unknown values fall back to the raw +// string so we never hide a real (future) state from the visitor. +const STATE_LABELS: Record = { + open: { label: "Open", icon: "🔓" }, + locked: { label: "Locked (doorbell)", icon: "🔒" }, + password: { label: "Password", icon: "🔑" }, + invisible: { label: "Invisible", icon: "đŸ‘ģ" }, +}; + +function describeState(state: string): { label: string; icon: string } { + return STATE_LABELS[state] ?? { label: state || "Unknown", icon: "đŸšĒ" }; +} + +/** Parse a route id to a positive integer, or null when it isn't numeric. */ +function parseRoomId(raw: string): number | null { + if (!/^\d+$/.test(raw)) return null; + const id = Number(raw); + return Number.isSafeInteger(id) && id > 0 ? id : null; +} + +async function getRoom(raw: string) { + const id = parseRoomId(raw); + if (id === null) return null; + return prisma.rooms + .findUnique({ + where: { id }, + select: { + id: true, + ownerId: true, + ownerName: true, + name: true, + description: true, + state: true, + users: true, + usersMax: true, + category: true, + score: true, + tags: true, + }, + }) + .catch(() => null); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ id: string }>; +}): Promise { + const { id } = await params; + const room = await getRoom(id); + if (!room) return { title: "Room" }; + return { + title: room.name || `Room #${room.id}`, + description: room.description || `Visit ${room.name || "this room"} in the hotel.`, + }; +} + +export default async function RoomPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + // Guard non-numeric / absent ids before touching the DB. + if (parseRoomId(id) === null) notFound(); + + const room = await getRoom(id); + if (!room) notFound(); + + // Resolve the live owner username by id; fall back to the room's denormalised + // owner_name column if the account row is missing or the query degrades. + const owner = + room.ownerId > 0 + ? await prisma.user + .findUnique({ where: { id: room.ownerId }, select: { username: true } }) + .catch(() => null) + : null; + const ownerName = owner?.username || room.ownerName || "Unknown"; + + const state = describeState(room.state); + const tags = room.tags + .split(/[,;\s]+/) + .map((t) => t.trim()) + .filter(Boolean); + + return ( +
+

+ + ← Back to the hotel + +

+ + + Owned by{" "} + {ownerName} + + } + > + {room.description ? ( +

{room.description}

+ ) : ( +

+ This room has no description. +

+ )} + +
+ + + + +
+ + {tags.length > 0 ? ( +
+ {tags.map((tag) => ( + + #{tag} + + ))} +
+ ) : null} +
+ + + + Enter the hotel to visit {room.name || `room #${room.id}`} in 3D. + + +
+ ); +} diff --git a/src/components/public/logo-generator.tsx b/src/components/public/logo-generator.tsx new file mode 100644 index 00000000..d53b1f83 --- /dev/null +++ b/src/components/public/logo-generator.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { useCallback, useMemo, useRef, useState } from "react"; +import { ContentCard } from "@/components/public/ui"; + +/** + * Client-side logo generator. Lets the visitor type a hotel name, pick a fun + * font stack, text + (optional) background colour, and a size, with a live + * preview. "Download PNG" paints the styled text onto an offscreen + * and triggers a download via canvas.toBlob -> object URL -> . + * + * Pure browser code — no server modules (prisma/siteSettings) are imported. + */ + +type FontOption = { label: string; stack: string }; + +// ~6 fun, broadly-available stacks (web-safe + a Google-ish first pick). +const FONTS: FontOption[] = [ + { label: "Poppins (rounded)", stack: "'Poppins', 'Trebuchet MS', sans-serif" }, + { label: "Comic Sans (playful)", stack: "'Comic Sans MS', 'Comic Sans', cursive" }, + { label: "Georgia (classic serif)", stack: "Georgia, 'Times New Roman', serif" }, + { label: "Impact (bold poster)", stack: "Impact, 'Haettenschweiler', sans-serif" }, + { label: "Trebuchet (clean)", stack: "'Trebuchet MS', Tahoma, sans-serif" }, + { label: "Monospace (retro)", stack: "'Courier New', monospace" }, +]; + +const MIN_SIZE = 16; +const MAX_SIZE = 160; + +export default function LogoGenerator() { + const [text, setText] = useState("Atom"); + const [fontStack, setFontStack] = useState(FONTS[0]!.stack); + const [textColor, setTextColor] = useState("#f59e0b"); + const [bgEnabled, setBgEnabled] = useState(false); + const [bgColor, setBgColor] = useState("#0f172a"); + const [size, setSize] = useState(72); + const [bold, setBold] = useState(true); + + const linkRef = useRef(null); + + const safeText = text.trim().length > 0 ? text : "Your hotel"; + const fontWeight = bold ? "bold" : "normal"; + + // Inline style for the live preview text, mirroring what the canvas will draw. + const previewTextStyle = useMemo( + () => ({ + fontFamily: fontStack, + fontSize: `${size}px`, + fontWeight, + color: textColor, + lineHeight: 1.1, + margin: 0, + padding: "0.1em 0.15em", + whiteSpace: "nowrap", + letterSpacing: "0.5px", + }), + [fontStack, size, fontWeight, textColor], + ); + + const handleDownload = useCallback(() => { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const fontSpec = `${fontWeight} ${size}px ${fontStack}`; + // Measure with the chosen font so the canvas fits the text snugly. + ctx.font = fontSpec; + const metrics = ctx.measureText(safeText); + const padX = Math.round(size * 0.4); + const padY = Math.round(size * 0.45); + const textWidth = Math.max(1, Math.ceil(metrics.width)); + const width = textWidth + padX * 2; + const height = Math.ceil(size * 1.35) + padY * 2; + + // Render crisply on high-DPI screens, then keep CSS-pixel coordinates below. + const dpr = Math.min(typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1, 3); + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + ctx.scale(dpr, dpr); + + if (bgEnabled) { + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, width, height); + } + // else: leave transparent (the default for a fresh canvas). + + ctx.font = fontSpec; + ctx.fillStyle = textColor; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(safeText, width / 2, height / 2); + + const fileBase = + safeText + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "logo"; + + canvas.toBlob((blob) => { + if (!blob) return; + const url = URL.createObjectURL(blob); + const a = linkRef.current ?? document.createElement("a"); + a.href = url; + a.download = `${fileBase}-logo.png`; + a.click(); + // Release the object URL once the download has been kicked off. + setTimeout(() => URL.revokeObjectURL(url), 1000); + }, "image/png"); + }, [bgColor, bgEnabled, fontStack, fontWeight, safeText, size, textColor]); + + return ( +
+ {/* ── Controls ──────────────────────────────── */} + +
+ + + + +
+ + + +
+ + + + + + + {/* Reused, hidden anchor used to trigger the file download. */} +
+ download + +
+ + + {/* ── Live preview ──────────────────────────── */} + +
+

{safeText}

+
+

+ Everything runs in your browser — nothing is uploaded. The PNG keeps a + {" "} + transparent background unless you enable one. +

+
+
+ ); +}