diff --git a/.env.example b/.env.example index 8da27d1..6e3dd1f 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,10 @@ MINEGEO_RENDER_IDLE_SECONDS=600 # Allow Chromium to use the GPU; software forces SwiftShader for headless hosts. MINEGEO_RENDER_BACKEND=auto MINEGEO_MAX_UPLOAD_BYTES=268435456 + +# Player bug reports. Keep secrets server-only; configure Production on Vercel. +LINEAR_API_KEY= +LINEAR_WEBHOOK_SECRET= +LINEAR_TEAM_ID= +# Optional UUID or project identifier (MineGeo: P-DUN-3). +LINEAR_PROJECT_ID= diff --git a/app/api/support/linear/route.ts b/app/api/support/linear/route.ts new file mode 100644 index 0000000..c79f685 --- /dev/null +++ b/app/api/support/linear/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { admin, required } from "@/lib/supabase"; +import { verifySupportWebhook } from "@/lib/support-signature"; +import { supportCommand } from "@/shared/bug-reports"; + +export const runtime = "nodejs"; +export async function POST(request: NextRequest) { + const secret = process.env.LINEAR_WEBHOOK_SECRET; + if (!secret) + return NextResponse.json( + { error: "Support integration is not configured" }, + { status: 503 }, + ); + const reader = request.body?.getReader(); + if (!reader) return new NextResponse(null, { status: 400 }); + const chunks: Uint8Array[] = []; + let size = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.length; + if (size > 256 * 1024) { + await reader.cancel(); + return new NextResponse(null, { status: 413 }); + } + chunks.push(value); + } + const raw = Buffer.concat(chunks).toString("utf8"); + if ( + !verifySupportWebhook(raw, request.headers.get("linear-signature"), secret) + ) + return new NextResponse(null, { status: 401 }); + let decoded: unknown; + try { + decoded = JSON.parse(raw); + } catch { + return new NextResponse(null, { status: 400 }); + } + const payload = z + .object({ + action: z.string(), + type: z.string(), + actor: z + .object({ + id: z.string(), + type: z.string(), + name: z.string().optional(), + }) + .nullable() + .optional(), + data: z.object({ + id: z.string().uuid(), + issueId: z.string().uuid().optional(), + body: z.string().optional(), + userId: z.string().optional(), + }), + }) + .safeParse(decoded); + if (!payload.success) return new NextResponse(null, { status: 400 }); + const event = payload.data; + // Only explicit new staff comments are public. Internal comments and user echoes stay private. + if ( + event.action !== "create" || + event.type !== "Comment" || + event.actor?.type.toLowerCase() !== "user" || + event.data.userId !== event.actor.id || + !event.data.issueId + ) + return NextResponse.json({ ok: true }); + const command = supportCommand(event.data.body ?? ""); + if (!command) return NextResponse.json({ ok: true }); + try { + required( + await admin().rpc("receive_bug_reply", { + p_report: event.data.issueId, + p_external: event.data.id, + p_author: event.actor.name ?? "Support", + p_body: command.kind === "reply" ? command.text : "", + p_glow: command.kind === "glow" ? command.enabled : null, + }), + ); + return NextResponse.json({ ok: true }); + } catch { + return NextResponse.json( + { error: "Support delivery failed" }, + { status: 503 }, + ); + } +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 3bd917d..3831959 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -5,6 +5,7 @@ import { Dashboard } from "@/components/Dashboard"; import { admin, user, required } from "@/lib/supabase"; import Link from "next/link"; import { accountProfile } from "@/lib/profile"; +import { Username } from "@/components/Username"; export const dynamic = "force-dynamic"; export default async function DashboardPage() { const { t, formatDate } = await getI18n(); @@ -33,7 +34,12 @@ export default async function DashboardPage() { {t("THE CARTOGRAPHER’S DESK")}

{t("My expedition.")}

- {t("Welcome back,")} {profile?.display_name}. + {t("Welcome back,")}{" "} + + .

{history.length > 0 && ( diff --git a/app/layout.tsx b/app/layout.tsx index c1327e4..0a4a101 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -15,6 +15,7 @@ import { getI18n } from "@/lib/i18n"; import "./polish.css"; import "./mobile-game.css"; import "./multiplayer.css"; +import "./support.css"; import { SiteSettingsProvider } from "@/components/SiteSettingsProvider"; import { initialSiteSettings } from "@/lib/site-settings"; import { user } from "@/lib/supabase"; diff --git a/app/multiplayer.css b/app/multiplayer.css index 66a028c..27631bd 100644 --- a/app/multiplayer.css +++ b/app/multiplayer.css @@ -286,7 +286,7 @@ font-size: 14px; } .room-chat-log strong, -.room-chat-log span { +.room-chat-log p > span { display: block; } .room-chat-log strong { diff --git a/app/servers/[id]/page.tsx b/app/servers/[id]/page.tsx index 025ce39..34e106c 100644 --- a/app/servers/[id]/page.tsx +++ b/app/servers/[id]/page.tsx @@ -1,4 +1,5 @@ import { getI18n } from "@/lib/i18n"; +import { Username } from "@/components/Username"; import { notFound } from "next/navigation"; import Image from "next/image"; import { Header, Footer } from "@/components/Shell"; @@ -31,7 +32,8 @@ export default async function ServerPage({

{map.name}

{map.description}

- {t("Created by")} {map.credit} + {t("Created by")}{" "} +

{account?.id === map.owner_id && } diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 4c57e8b..ed163a2 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -5,6 +5,7 @@ import { Header, Footer } from "@/components/Shell"; import { AccountSettings } from "@/components/AccountSettings"; import { user } from "@/lib/supabase"; import { accountProfile } from "@/lib/profile"; +import { Username } from "@/components/Username"; export async function generateMetadata() { const { t } = await getI18n(); return { title: t("Account settings") }; @@ -32,12 +33,14 @@ export default async function Settings({ {t("YOUR CARTOGRAPHER ACCOUNT")}

{t("Account settings.")}

+ ·{" "} {t("Your name, your sign-in, your next expedition.")}

diff --git a/app/support.css b/app/support.css new file mode 100644 index 0000000..e4538ef --- /dev/null +++ b/app/support.css @@ -0,0 +1,264 @@ +.header-actions { + display: flex; + align-items: center; + gap: 12px; +} +.header-report { + display: inline-flex; + gap: 7px; + align-items: center; + justify-content: center; + min-height: 44px; + color: var(--text); + font-size: 12px; + white-space: nowrap; +} +.support-layout { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(220px, 1fr); + gap: 50px; +} +.support-layout h1, +.support-conversation h1 { + margin-top: 14px; + overflow-wrap: anywhere; +} +.support-form form, +.support-conversation form { + display: grid; + gap: 12px; +} +.support-form label, +.support-conversation label { + font-weight: 650; + display: flex; + align-items: center; + gap: 8px; +} +.support-form input, +.support-form textarea, +.support-conversation textarea { + width: 100%; + min-width: 0; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel); + color: var(--text); + padding: 12px; + font: 16px var(--font-body, sans-serif); +} +.support-form textarea, +.support-conversation textarea { + resize: vertical; +} +.support-form small { + color: var(--muted); +} +.support-form form .button, +.support-conversation form .button { + justify-self: start; +} +.support-inbox { + border-left: 1px solid var(--line); + padding-left: 28px; +} +.support-inbox ul { + list-style: none; + margin: 0; + padding: 0; +} +.support-inbox li { + padding: 16px 0; + border-bottom: 1px solid var(--line); + overflow-wrap: anywhere; +} +.support-inbox strong, +.support-inbox small { + display: block; +} +.support-inbox small { + margin-top: 6px; + color: var(--muted); +} +.support-conversation { + max-width: 850px; + margin: 0 auto; +} +.support-conversation > .eyebrow { + display: block; + margin-top: 28px; +} +.support-screenshot { + display: block; + max-width: 100%; + max-height: 400px; + object-fit: contain; + border: 1px solid var(--line); + border-radius: 12px; + margin: 24px 0; +} +.support-messages { + display: grid; + gap: 16px; + margin: 24px 0; +} +.support-message { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 12px; + padding: 18px; + overflow-wrap: anywhere; +} +.support-message-staff { + border-color: var(--accent, #8dce66); +} +.support-message-system { + border-style: dashed; +} +.support-message header { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; +} +.support-message small { + color: var(--muted); +} +.support-message p { + white-space: pre-wrap; + margin: 12px 0; +} +.support-reward { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; + padding: 16px; + border: 1px solid #dac880; + border-radius: 12px; +} +.support-reward small { + flex-basis: 100%; +} +.username-glow { + color: #ffe6a1; + text-shadow: + 0 0 8px #ffca6090, + 0 0 18px #ffca6060; +} +.username { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 5px; + max-width: 100%; + vertical-align: middle; + overflow-wrap: anywhere; + white-space: normal; +} +.username > span { + min-width: 0; + max-width: 100%; +} +.username-prefix { + display: inline-flex; + align-items: center; + gap: 4px; + font: + 600 11px/1.4 Arial, + sans-serif; + padding: 2px 6px; + border: 1px solid currentColor; + border-radius: 5px; + text-shadow: none; +} +.username-prefix svg { + flex-shrink: 0; +} +.username-moderator { + color: #91d3ff; +} +.username-bug-finder { + color: #ffe6a1; +} +.username-custom-prefix { + color: #cdb8ff; +} +.username-preview { + padding: 18px; + border: 1px solid var(--line); + border-radius: 10px; + margin: 16px 0; +} +.glow-settings input[type="color"] { + width: 90px; + height: 48px; + cursor: pointer; +} +.prefix-search { + display: flex; + align-items: end; + flex-wrap: wrap; + gap: 12px; +} +.prefix-search .field { + flex: 1; + min-width: 200px; +} +.prefix-player-list { + list-style: none; + padding: 0; + display: grid; + gap: 8px; + max-height: 300px; + overflow-y: auto; +} +.prefix-player-list button { + width: 100%; + text-align: left; + background: var(--panel); + color: var(--text); + border: 1px solid var(--line); + border-radius: 8px; + padding: 12px; +} +.prefix-player-list button[aria-pressed="true"] { + border-color: #91d3ff; +} +.prefix-player-list small { + display: block; + color: var(--muted); + margin-top: 5px; + overflow-wrap: anywhere; +} +.prefix-editor { + display: grid; + gap: 12px; + margin-top: 20px; +} +@media (max-width: 1000px) { + .header-report span { + display: none; + } + .header-report { + width: 44px; + } + .header-actions { + gap: 6px; + } +} +@media (max-width: 700px) { + .support-layout { + grid-template-columns: 1fr; + gap: 32px; + } + .support-inbox { + border-left: 0; + padding-left: 0; + border-top: 1px solid var(--line); + padding-top: 20px; + } + .header-actions { + margin-left: auto; + } +} diff --git a/app/support/[id]/page.tsx b/app/support/[id]/page.tsx new file mode 100644 index 0000000..103e246 --- /dev/null +++ b/app/support/[id]/page.tsx @@ -0,0 +1,22 @@ +import { notFound } from "next/navigation"; +import { z } from "zod"; +import { Header, Footer } from "@/components/Shell"; +import { SupportConversation } from "@/components/Support"; +export const metadata = { robots: { index: false, follow: false } }; +export default async function Report({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + if (!z.string().uuid().safeParse(id).success) notFound(); + return ( + <> +
+
+ +
+