Close remaining web gaps: rich profile, login history, lightbox/slider, flash client, admin chatlog/DJ/chart/WYSIWYG, niche API
- Rich profile (/u/[username]): wallet (credits/duckets/diamonds), friends grid (messenger_friendships), and owned rooms sections. - Login history: new website_login_logs table (model + migration 0007), recorded on every successful sign-in (ip + user-agent), surfaced on a new /settings/sessions page (with failed-attempt list from failed_logins). - Photos lightbox + home article slider (client components, no Swiper dep). - /client/flash launcher (SSO ticket like the Nitro page). - Admin: private chatlogs section in /admin/logs, /admin/radio/moderation (shout moderation), a "users by rank" inline bar chart on the dashboard, and a TinyMCE rich-text editor on the article admin forms. - Niche API: /api/values/[id], /api/guilds(+/[id]), /api/radio/auto-play. Verified live (prod, amx_test): login recorded → /settings/sessions shows it with device; profile renders wallet/friends/rooms; dashboard chart + private-chat logs + /client/flash + /api/guilds all OK. Reverted test data. tsc 0, vitest 49/49, next build 0.
This commit is contained in:
1 parent
f7b3845131
commit
4dfe698009
25 files changed
+1463
-64
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
-- Successful-login history (the AtomCMS "session logs" equivalent; our auth is
|
||||||
|
-- stateless JWT so there's no sessions table). Idempotent (MariaDB).
|
||||||
|
CREATE TABLE IF NOT EXISTS website_login_logs (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
ip VARCHAR(45) NOT NULL,
|
||||||
|
user_agent VARCHAR(512) NULL,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY website_login_logs_user_id_idx (user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -1687,6 +1687,17 @@ model WebsiteWriteableBoxes {
|
|||||||
@@map("website_writeable_boxes")
|
@@map("website_writeable_boxes")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model WebsiteLoginLogs {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
userId Int @map("user_id")
|
||||||
|
ip String @db.VarChar(45)
|
||||||
|
userAgent String? @map("user_agent") @db.VarChar(512)
|
||||||
|
createdAt DateTime? @map("created_at") @db.Timestamp(0)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@map("website_login_logs")
|
||||||
|
}
|
||||||
|
|
||||||
model WebsiteBetaCodes {
|
model WebsiteBetaCodes {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
code String @unique @db.VarChar(255)
|
code String @unique @db.VarChar(255)
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { requireStaff } from '@/lib/admin/guard';
|
||||||
|
import { logStaffActivity } from '@/lib/services/staff-activity';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
|
||||||
|
function parseId(raw: FormDataEntryValue | null): bigint | null {
|
||||||
|
if (typeof raw !== 'string' || raw.trim() === '') return null;
|
||||||
|
try {
|
||||||
|
const id = BigInt(raw.trim());
|
||||||
|
return id > 0n ? id : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a radio shout from the DJ moderation page. Re-reads auth via
|
||||||
|
* requireStaff, writes a staff-activity audit entry and revalidates the
|
||||||
|
* moderation route. Fails soft if the row is already gone.
|
||||||
|
*/
|
||||||
|
export async function deleteShout(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requireStaff();
|
||||||
|
const id = parseId(formData.get('id'));
|
||||||
|
if (id === null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.radioShouts.delete({ where: { id } });
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: 'radio.shout.delete',
|
||||||
|
description: `Deleted radio shout #${id}`,
|
||||||
|
targetType: 'radio_shout',
|
||||||
|
targetId: Number(id),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Row may already be gone; ignore so the action does not throw.
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/admin/radio/moderation');
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { deleteArticle, updateArticle } from "@/actions/admin-articles";
|
import { deleteArticle, updateArticle } from "@/actions/admin-articles";
|
||||||
|
import { RichText } from "@/components/admin/rich-text";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -30,7 +31,7 @@ export default async function EditArticle({
|
|||||||
<input name="title" defaultValue={article.title} required />
|
<input name="title" defaultValue={article.title} required />
|
||||||
<input name="image" defaultValue={article.image} placeholder="Image URL" />
|
<input name="image" defaultValue={article.image} placeholder="Image URL" />
|
||||||
<textarea name="shortStory" defaultValue={article.shortStory} rows={2} />
|
<textarea name="shortStory" defaultValue={article.shortStory} rows={2} />
|
||||||
<textarea name="fullStory" defaultValue={article.fullStory} rows={10} />
|
<RichText name="fullStory" defaultValue={article.fullStory} rows={10} />
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
</form>
|
</form>
|
||||||
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
|
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { createArticle } from "@/actions/admin-articles";
|
import { createArticle } from "@/actions/admin-articles";
|
||||||
|
import { RichText } from "@/components/admin/rich-text";
|
||||||
|
|
||||||
export default function NewArticle() {
|
export default function NewArticle() {
|
||||||
return (
|
return (
|
||||||
@@ -12,7 +13,7 @@ export default function NewArticle() {
|
|||||||
<input name="title" placeholder="Title" required />
|
<input name="title" placeholder="Title" required />
|
||||||
<input name="image" placeholder="Image URL" />
|
<input name="image" placeholder="Image URL" />
|
||||||
<textarea name="shortStory" placeholder="Short story (summary)" rows={2} />
|
<textarea name="shortStory" placeholder="Short story (summary)" rows={2} />
|
||||||
<textarea name="fullStory" placeholder="Full story" rows={10} />
|
<RichText name="fullStory" placeholder="Full story" rows={10} />
|
||||||
<button type="submit">Create</button>
|
<button type="submit">Create</button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ const CRITICAL = new Set(["critical", "error", "danger"]);
|
|||||||
|
|
||||||
// chatlogs_room and commandlogs are @@ignore'd in the schema (no PK), so Prisma
|
// chatlogs_room and commandlogs are @@ignore'd in the schema (no PK), so Prisma
|
||||||
// generates no delegate for them — they are read via $queryRaw against the real
|
// generates no delegate for them — they are read via $queryRaw against the real
|
||||||
// emulator columns. alert_logs has a proper delegate (prisma.alertLogs).
|
// emulator columns. chatlogs_private has a delegate (prisma.chatlogsPrivate) but
|
||||||
|
// is read here via $queryRaw too, mirroring the room-chat section and selecting
|
||||||
|
// defensively so a column mismatch degrades to an empty list rather than a 500.
|
||||||
|
// alert_logs has a proper delegate (prisma.alertLogs).
|
||||||
|
|
||||||
type ChatlogRoomRow = {
|
type ChatlogRoomRow = {
|
||||||
room_id: number;
|
room_id: number;
|
||||||
@@ -17,6 +20,13 @@ type ChatlogRoomRow = {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ChatlogPrivateRow = {
|
||||||
|
user_from_id: number;
|
||||||
|
user_to_id: number;
|
||||||
|
message: string;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
type CommandlogRow = {
|
type CommandlogRow = {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
@@ -50,6 +60,21 @@ export default async function AdminLogs() {
|
|||||||
chatlogs = [];
|
chatlogs = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Private (whisper) chat. Defensive SELECT + try/catch — the table may be
|
||||||
|
// absent or have differing columns on some emulators, in which case we show
|
||||||
|
// an empty section instead of crashing the page.
|
||||||
|
let privateChatlogs: ChatlogPrivateRow[] = [];
|
||||||
|
try {
|
||||||
|
privateChatlogs = await prisma.$queryRaw<ChatlogPrivateRow[]>`
|
||||||
|
SELECT user_from_id, user_to_id, message, timestamp
|
||||||
|
FROM chatlogs_private
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 50
|
||||||
|
`;
|
||||||
|
} catch {
|
||||||
|
privateChatlogs = [];
|
||||||
|
}
|
||||||
|
|
||||||
let commandlogs: CommandlogRow[] = [];
|
let commandlogs: CommandlogRow[] = [];
|
||||||
try {
|
try {
|
||||||
commandlogs = await prisma.$queryRaw<CommandlogRow[]>`
|
commandlogs = await prisma.$queryRaw<CommandlogRow[]>`
|
||||||
@@ -81,6 +106,7 @@ export default async function AdminLogs() {
|
|||||||
|
|
||||||
<div className="admin-stats">
|
<div className="admin-stats">
|
||||||
<StatusCard label="Room chat" value={chatlogs.length} icon="💬" />
|
<StatusCard label="Room chat" value={chatlogs.length} icon="💬" />
|
||||||
|
<StatusCard label="Private chat" value={privateChatlogs.length} icon="🔒" />
|
||||||
<StatusCard label="Commands" value={commandlogs.length} icon="⌨️" />
|
<StatusCard label="Commands" value={commandlogs.length} icon="⌨️" />
|
||||||
<StatusCard label="Alerts" value={alertLogs.length} icon="🔔" />
|
<StatusCard label="Alerts" value={alertLogs.length} icon="🔔" />
|
||||||
</div>
|
</div>
|
||||||
@@ -117,6 +143,36 @@ export default async function AdminLogs() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="card" style={{ marginBottom: "1.5rem" }}>
|
||||||
|
<h3 style={{ marginTop: 0 }}>Private chat ({privateChatlogs.length})</h3>
|
||||||
|
{privateChatlogs.length === 0 ? (
|
||||||
|
<p className="muted">No private chat logs.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>From</th>
|
||||||
|
<th>To</th>
|
||||||
|
<th>Message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{privateChatlogs.map((c, i) => (
|
||||||
|
<tr key={`${c.timestamp}-${c.user_from_id}-${c.user_to_id}-${i}`}>
|
||||||
|
<td className="muted">{fromUnix(c.timestamp)}</td>
|
||||||
|
<td>{String(c.user_from_id)}</td>
|
||||||
|
<td>{c.user_to_id ? String(c.user_to_id) : "—"}</td>
|
||||||
|
<td>{c.message}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="card" style={{ marginBottom: "1.5rem" }}>
|
<section className="card" style={{ marginBottom: "1.5rem" }}>
|
||||||
<h3 style={{ marginTop: 0 }}>Commands ({commandlogs.length})</h3>
|
<h3 style={{ marginTop: 0 }}>Commands ({commandlogs.length})</h3>
|
||||||
{commandlogs.length === 0 ? (
|
{commandlogs.length === 0 ? (
|
||||||
|
|||||||
+60
-4
@@ -8,6 +8,8 @@ function fromDate(d: Date | null | undefined): string {
|
|||||||
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
|
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RankCount = { rank: number; count: number };
|
||||||
|
|
||||||
export default async function AdminDashboard() {
|
export default async function AdminDashboard() {
|
||||||
let users = -1;
|
let users = -1;
|
||||||
let online = 0;
|
let online = 0;
|
||||||
@@ -25,6 +27,15 @@ export default async function AdminDashboard() {
|
|||||||
}
|
}
|
||||||
const dbOk = users >= 0;
|
const dbOk = users >= 0;
|
||||||
|
|
||||||
|
// Users-by-rank breakdown (small inline bar chart, no chart library).
|
||||||
|
const rankGroups = await prisma.user
|
||||||
|
.groupBy({ by: ["rank"], _count: { _all: true }, orderBy: { rank: "desc" } })
|
||||||
|
.catch(() => [] as Array<{ rank: number; _count: { _all: number } }>);
|
||||||
|
const rankCounts: RankCount[] = rankGroups
|
||||||
|
.map((g) => ({ rank: g.rank, count: g._count._all }))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
const maxRankCount = rankCounts.reduce((m, r) => Math.max(m, r.count), 0);
|
||||||
|
|
||||||
const activity = await prisma.staffActivities
|
const activity = await prisma.staffActivities
|
||||||
.findMany({ orderBy: { createdAt: "desc" }, take: 8 })
|
.findMany({ orderBy: { createdAt: "desc" }, take: 8 })
|
||||||
.catch(() => [] as Awaited<ReturnType<typeof prisma.staffActivities.findMany>>);
|
.catch(() => [] as Awaited<ReturnType<typeof prisma.staffActivities.findMany>>);
|
||||||
@@ -47,23 +58,68 @@ export default async function AdminDashboard() {
|
|||||||
value={online}
|
value={online}
|
||||||
hint={dbOk ? `${users} accounts` : "DB unavailable"}
|
hint={dbOk ? `${users} accounts` : "DB unavailable"}
|
||||||
state={dbOk ? "neutral" : "danger"}
|
state={dbOk ? "neutral" : "danger"}
|
||||||
icon="👤"
|
icon="\u{1F464}"
|
||||||
/>
|
/>
|
||||||
<StatusCard label="Articles" value={dbOk ? articles : "—"} icon="📰" />
|
<StatusCard label="Articles" value={dbOk ? articles : "—"} icon="\u{1F4F0}" />
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Active bans"
|
label="Active bans"
|
||||||
value={dbOk ? bans : "—"}
|
value={dbOk ? bans : "—"}
|
||||||
state={bans > 0 ? "warn" : "neutral"}
|
state={bans > 0 ? "warn" : "neutral"}
|
||||||
icon="🔨"
|
icon="\u{1F528}"
|
||||||
/>
|
/>
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Database"
|
label="Database"
|
||||||
value={dbOk ? "Connected" : "Down"}
|
value={dbOk ? "Connected" : "Down"}
|
||||||
state={dbOk ? "ok" : "danger"}
|
state={dbOk ? "ok" : "danger"}
|
||||||
icon="🗄️"
|
icon="\u{1F5C4}️"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section className="admin-section">
|
||||||
|
<h2 style={{ margin: 0 }}>Users by rank</h2>
|
||||||
|
{rankCounts.length === 0 ? (
|
||||||
|
<p className="muted" style={{ marginTop: "0.7rem" }}>
|
||||||
|
{dbOk ? "No accounts to break down yet." : "DB unavailable."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "grid", gap: "0.5rem", marginTop: "0.8rem" }}>
|
||||||
|
{rankCounts.map((r) => {
|
||||||
|
const pct = maxRankCount > 0 ? Math.round((r.count / maxRankCount) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={r.rank}
|
||||||
|
style={{ display: "grid", gridTemplateColumns: "5.5rem 1fr 3rem", alignItems: "center", gap: "0.6rem" }}
|
||||||
|
>
|
||||||
|
<span className="muted" style={{ fontSize: "0.82rem", whiteSpace: "nowrap" }}>
|
||||||
|
Rank {r.rank}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
height: "0.85rem",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
role="img"
|
||||||
|
aria-label={`Rank ${r.rank}: ${r.count} accounts`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${Math.max(pct, r.count > 0 ? 4 : 0)}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: "var(--color-primary, #eeb425)",
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<strong style={{ fontSize: "0.85rem", textAlign: "right" }}>{r.count}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="admin-section">
|
<section className="admin-section">
|
||||||
<div style={{ display: "flex", alignItems: "baseline", gap: "0.75rem" }}>
|
<div style={{ display: "flex", alignItems: "baseline", gap: "0.75rem" }}>
|
||||||
<h2 style={{ margin: 0 }}>Recent staff activity</h2>
|
<h2 style={{ margin: 0 }}>Recent staff activity</h2>
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { deleteShout } from '@/actions/admin-radio-moderation';
|
||||||
|
import { StatusCard } from '@/components/admin/dashboard';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
type Shout = {
|
||||||
|
id: bigint;
|
||||||
|
userId: bigint;
|
||||||
|
message: string;
|
||||||
|
createdAt: Date | null;
|
||||||
|
updatedAt: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(d: Date | null): string {
|
||||||
|
return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminRadioModerationPage() {
|
||||||
|
let shouts: Shout[] = [];
|
||||||
|
try {
|
||||||
|
shouts = await prisma.radioShouts.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
message: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
shouts = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
|
||||||
|
<Link href="/admin/radio">Radio</Link> ·{' '}
|
||||||
|
<Link href="/admin/radio/moderation">Moderation</Link> ·{' '}
|
||||||
|
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
|
||||||
|
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
|
||||||
|
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
|
||||||
|
<Link href="/admin/radio/history">History</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1>DJ Moderation</h1>
|
||||||
|
<p className="muted" style={{ marginTop: '-0.4rem' }}>
|
||||||
|
Review and remove radio shouts (50 most recent).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="admin-stats">
|
||||||
|
<StatusCard label="Recent shouts" value={shouts.length} icon="📣" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="admin-section">
|
||||||
|
<h2>Recent Shouts ({shouts.length})</h2>
|
||||||
|
{shouts.length === 0 ? (
|
||||||
|
<p className="muted">No shouts to moderate.</p>
|
||||||
|
) : (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Message</th>
|
||||||
|
<th>Posted</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{shouts.map((s) => (
|
||||||
|
<tr key={String(s.id)}>
|
||||||
|
<td>{String(s.id)}</td>
|
||||||
|
<td>{String(s.userId)}</td>
|
||||||
|
<td>{s.message}</td>
|
||||||
|
<td className="muted">{formatDate(s.createdAt)}</td>
|
||||||
|
<td>
|
||||||
|
<form action={deleteShout}>
|
||||||
|
<input type="hidden" name="id" value={String(s.id)} />
|
||||||
|
<button type="submit" className="btn btn-danger">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Public REST: a single guild (guilds) with its member count
|
||||||
|
// (guilds_members). AtomCMS JSON API parity. Returns { error } (404) when the
|
||||||
|
// id is unknown. The owner is the user_id column (accessor userId), surfaced
|
||||||
|
// here as ownerId.
|
||||||
|
import { apiJson } from "@/lib/api";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
if (!/^\d+$/.test(id)) {
|
||||||
|
return apiJson({ error: "Guild not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
const guildId = Number(id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const guild = await prisma.guilds.findUnique({
|
||||||
|
where: { id: guildId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
userId: true,
|
||||||
|
roomId: true,
|
||||||
|
badge: true,
|
||||||
|
dateCreated: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!guild) {
|
||||||
|
return apiJson({ error: "Guild not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Member count is a separate guarded query (no relation is modelled).
|
||||||
|
let memberCount = 0;
|
||||||
|
try {
|
||||||
|
memberCount = await prisma.guildsMembers.count({
|
||||||
|
where: { guildId: guild.id },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
memberCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId, ...rest } = guild;
|
||||||
|
return apiJson({ data: { ...rest, ownerId: userId, memberCount } });
|
||||||
|
} catch {
|
||||||
|
// DB unavailable — treat as not found rather than a 500.
|
||||||
|
return apiJson({ error: "Guild not found" }, { status: 200 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Public REST: hotel guilds (guilds). AtomCMS JSON API parity — read-only,
|
||||||
|
// paginated list ordered newest first (by id), exposing the basic public
|
||||||
|
// fields. The owner is stored as the user_id column (accessor userId), surfaced
|
||||||
|
// here as ownerId for API clarity.
|
||||||
|
import { apiJson, pagination } from "@/lib/api";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const sp = new URL(req.url).searchParams;
|
||||||
|
const { page, perPage, skip, take } = pagination(sp);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [total, rows] = await Promise.all([
|
||||||
|
prisma.guilds.count(),
|
||||||
|
prisma.guilds.findMany({
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Rename userId → ownerId for the public payload.
|
||||||
|
const data = rows.map(({ userId, ...rest }) => ({
|
||||||
|
...rest,
|
||||||
|
ownerId: userId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return apiJson({
|
||||||
|
data,
|
||||||
|
meta: { page, perPage, total, lastPage: Math.max(1, Math.ceil(total / perPage)) },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// DB unreachable — never 500; return an empty, well-formed payload.
|
||||||
|
return apiJson(
|
||||||
|
{ data: [], meta: { page, perPage, total: 0, lastPage: 1 } },
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// Public REST: whether the radio player should auto-play on load. Reads the
|
||||||
|
// radio_auto_play website setting (the key the admin radio settings page
|
||||||
|
// writes), falling back to the legacy radio_autoplay alias if present. Booleans
|
||||||
|
// are stored as the strings '1' / '0'. Returns { autoplay: boolean }.
|
||||||
|
import { apiJson } from "@/lib/api";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const AUTOPLAY_KEYS = ["radio_auto_play", "radio_autoplay"];
|
||||||
|
|
||||||
|
function truthy(value: string | undefined): boolean {
|
||||||
|
if (value === undefined) return false;
|
||||||
|
const s = value.trim().toLowerCase();
|
||||||
|
return s === "1" || s === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_req: Request) {
|
||||||
|
try {
|
||||||
|
const rows = await prisma.websiteSetting.findMany({
|
||||||
|
where: { key: { in: AUTOPLAY_KEYS } },
|
||||||
|
select: { key: true, value: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
map[row.key] = row.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer the canonical key the settings page uses; fall back to the alias.
|
||||||
|
const autoplay = truthy(map.radio_auto_play ?? map.radio_autoplay);
|
||||||
|
|
||||||
|
return apiJson({ autoplay });
|
||||||
|
} catch {
|
||||||
|
// DB unavailable — default to no autoplay rather than a 500.
|
||||||
|
return apiJson({ autoplay: false }, { status: 200 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Public REST: a single rare furni trade value by id (website_rare_values),
|
||||||
|
// together with its parent category. AtomCMS JSON API parity. Returns { error }
|
||||||
|
// (404) when the id is unknown.
|
||||||
|
import { apiJson } from "@/lib/api";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// The primary key is a BigInt; reject non-numeric ids up front.
|
||||||
|
if (!/^\d+$/.test(id)) {
|
||||||
|
return apiJson({ error: "Rare value not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let valueId: bigint;
|
||||||
|
try {
|
||||||
|
valueId = BigInt(id);
|
||||||
|
} catch {
|
||||||
|
return apiJson({ error: "Rare value not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const value = await prisma.websiteRareValues.findUnique({
|
||||||
|
where: { id: valueId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
categoryId: true,
|
||||||
|
itemId: true,
|
||||||
|
name: true,
|
||||||
|
creditValue: true,
|
||||||
|
currencyValue: true,
|
||||||
|
currencyType: true,
|
||||||
|
furnitureIcon: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return apiJson({ error: "Rare value not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// No relation is modelled between the value and its category, so resolve the
|
||||||
|
// category in a second guarded query.
|
||||||
|
let category: {
|
||||||
|
id: bigint;
|
||||||
|
name: string;
|
||||||
|
badge: string;
|
||||||
|
priority: number;
|
||||||
|
} | null = null;
|
||||||
|
try {
|
||||||
|
category = await prisma.websiteRareValueCategories.findUnique({
|
||||||
|
where: { id: value.categoryId },
|
||||||
|
select: { id: true, name: true, badge: true, priority: true },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
category = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiJson({ data: { ...value, category } });
|
||||||
|
} catch {
|
||||||
|
// DB unavailable — treat as not found rather than a 500.
|
||||||
|
return apiJson({ error: "Rare value not found" }, { status: 200 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
|
import { issueSsoTicket } from "@/lib/auth/sso-ticket";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function FlashClientPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) redirect("/login");
|
||||||
|
|
||||||
|
const userId = Number(session.user.id);
|
||||||
|
// The Flash client URL may be stored under either key depending on the
|
||||||
|
// AtomCMS settings seed — try both, then fall back to the Nitro URL so the
|
||||||
|
// launcher still issues a working SSO ticket.
|
||||||
|
const [hotelName, flashUrl, flashUrlAlt, nitroUrl] = await Promise.all([
|
||||||
|
siteSettings.get("hotel_name", "Atom").catch(() => "Atom"),
|
||||||
|
siteSettings.get("flash_client_url", "").catch(() => ""),
|
||||||
|
siteSettings.get("client_flash_url", "").catch(() => ""),
|
||||||
|
siteSettings.get("nitro_client_url", "").catch(() => ""),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const clientUrl = flashUrl || flashUrlAlt || nitroUrl || "";
|
||||||
|
|
||||||
|
const hdrs = await headers();
|
||||||
|
const ip =
|
||||||
|
hdrs.get("x-forwarded-for")?.split(",")[0]?.trim() ?? hdrs.get("x-real-ip") ?? "0.0.0.0";
|
||||||
|
|
||||||
|
// Faithful to AtomCMS's client controllers: issue (and persist) the SSO ticket
|
||||||
|
// on the launcher render, then hand it to the Flash client.
|
||||||
|
const ticket = await issueSsoTicket(prisma, userId, hotelName ?? "Atom", ip);
|
||||||
|
|
||||||
|
const src = clientUrl
|
||||||
|
? `${clientUrl}${clientUrl.includes("?") ? "&" : "?"}sso=${encodeURIComponent(ticket)}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||||
|
<ContentCard
|
||||||
|
icon="🎮"
|
||||||
|
title={`Launching ${hotelName ?? "Atom"} (Flash)…`}
|
||||||
|
subtitle="Your single sign-on ticket is ready"
|
||||||
|
action={
|
||||||
|
<Link href="/client" className="btn btn-outline">
|
||||||
|
Use Nitro client
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{src ? (
|
||||||
|
<>
|
||||||
|
<ContentCard
|
||||||
|
icon="🚀"
|
||||||
|
title="Open the Flash client"
|
||||||
|
subtitle="Flash works best in its own tab"
|
||||||
|
action={
|
||||||
|
<a
|
||||||
|
href={src}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
Launch in a new tab
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ContentCard padded={false}>
|
||||||
|
<iframe
|
||||||
|
title="Flash hotel client"
|
||||||
|
src={src}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "80vh",
|
||||||
|
border: 0,
|
||||||
|
borderRadius: 10,
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ContentCard>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<ContentCard
|
||||||
|
icon="🔌"
|
||||||
|
title="No Flash client configured"
|
||||||
|
subtitle="Set flash_client_url in settings"
|
||||||
|
>
|
||||||
|
<EmptyState icon="🔌">
|
||||||
|
No Flash client URL configured (set <code>flash_client_url</code> in settings). SSO
|
||||||
|
ticket issued for this session: <code>{ticket}</code>
|
||||||
|
</EmptyState>
|
||||||
|
</ContentCard>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
@@ -36,6 +37,11 @@ export default async function ClientPage() {
|
|||||||
icon="🏨"
|
icon="🏨"
|
||||||
title={`Launching ${hotelName ?? "Atom"}…`}
|
title={`Launching ${hotelName ?? "Atom"}…`}
|
||||||
subtitle="Your single sign-on ticket is ready"
|
subtitle="Your single sign-on ticket is ready"
|
||||||
|
action={
|
||||||
|
<Link href="/client/flash" className="btn btn-outline">
|
||||||
|
Use Flash client
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{src ? (
|
{src ? (
|
||||||
|
|||||||
+12
-25
@@ -1,6 +1,7 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
|
import { ArticleSlider, type SliderArticle } from "@/components/public/article-slider";
|
||||||
import { excerpt } from "@/lib/format";
|
import { excerpt } from "@/lib/format";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { sanitize } from "@/lib/sanitize";
|
import { sanitize } from "@/lib/sanitize";
|
||||||
@@ -23,6 +24,15 @@ export default async function HomePage() {
|
|||||||
// DB unavailable — show the shell without articles.
|
// DB unavailable — show the shell without articles.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-shape for the client slider: compute excerpts server-side so the client
|
||||||
|
// component carries no prisma/i18n dependency.
|
||||||
|
const slides: SliderArticle[] = articles.map((a) => ({
|
||||||
|
slug: a.slug,
|
||||||
|
title: a.title,
|
||||||
|
image: a.image,
|
||||||
|
excerpt: excerpt(a.shortStory, 110),
|
||||||
|
}));
|
||||||
|
|
||||||
// Staff-authored content boxes (managed at /admin/writeable-boxes).
|
// Staff-authored content boxes (managed at /admin/writeable-boxes).
|
||||||
const boxes = await prisma.websiteWriteableBoxes
|
const boxes = await prisma.websiteWriteableBoxes
|
||||||
.findMany({
|
.findMany({
|
||||||
@@ -62,33 +72,10 @@ export default async function HomePage() {
|
|||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{articles.length === 0 ? (
|
{slides.length === 0 ? (
|
||||||
<EmptyState icon="📰">{t("noArticles")}</EmptyState>
|
<EmptyState icon="📰">{t("noArticles")}</EmptyState>
|
||||||
) : (
|
) : (
|
||||||
<div className="card-grid sm-2 lg-4">
|
<ArticleSlider articles={slides} />
|
||||||
{articles.map((a) => (
|
|
||||||
<article
|
|
||||||
key={a.slug}
|
|
||||||
className="card hover"
|
|
||||||
style={{ padding: 0, overflow: "hidden" }}
|
|
||||||
>
|
|
||||||
{a.image ? (
|
|
||||||
// biome-ignore lint/performance/noImgElement: external article image
|
|
||||||
<img className="article-img" src={a.image} alt="" />
|
|
||||||
) : (
|
|
||||||
<div className="article-img" />
|
|
||||||
)}
|
|
||||||
<div style={{ padding: "0.85rem" }}>
|
|
||||||
<h3 style={{ margin: "0 0 0.3rem", fontSize: "1rem" }}>
|
|
||||||
<Link href={`/news/${a.slug}`}>{a.title}</Link>
|
|
||||||
</h3>
|
|
||||||
<p className="muted" style={{ margin: 0 }}>
|
|
||||||
{excerpt(a.shortStory, 110)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</ContentCard>
|
</ContentCard>
|
||||||
|
|
||||||
|
|||||||
+14
-26
@@ -1,5 +1,6 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
|
import { PhotoLightbox, type LightboxPhoto } from "@/components/public/photo-lightbox";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -34,6 +35,16 @@ export default async function PhotosPage() {
|
|||||||
photos = [];
|
photos = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-shape for the client lightbox: translate captions server-side so the
|
||||||
|
// client component stays free of i18n/prisma dependencies.
|
||||||
|
const items: LightboxPhoto[] = photos.map((p) => ({
|
||||||
|
id: String(p.id),
|
||||||
|
url: p.url,
|
||||||
|
alt: t("photoAlt", { id: p.userId }),
|
||||||
|
date: formatDate(p.timestamp),
|
||||||
|
user: t("byUser", { id: p.userId }),
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||||
<ContentCard
|
<ContentCard
|
||||||
@@ -42,34 +53,11 @@ export default async function PhotosPage() {
|
|||||||
subtitle={t("subtitle")}
|
subtitle={t("subtitle")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ContentCard padded={photos.length === 0}>
|
<ContentCard padded={items.length === 0}>
|
||||||
{photos.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<EmptyState icon="📸">{t("noPhotos")}</EmptyState>
|
<EmptyState icon="📸">{t("noPhotos")}</EmptyState>
|
||||||
) : (
|
) : (
|
||||||
<div className="card-grid sm-2 md-3 lg-4" style={{ padding: "1rem" }}>
|
<PhotoLightbox photos={items} />
|
||||||
{photos.map((p) => (
|
|
||||||
<article key={String(p.id)} className="card hover" style={{ padding: 0, overflow: "hidden" }}>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={p.url}
|
|
||||||
alt={t("photoAlt", { id: p.userId })}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "auto",
|
|
||||||
display: "block",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div style={{ padding: "0.7rem 0.85rem" }}>
|
|
||||||
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
|
|
||||||
{formatDate(p.timestamp)}
|
|
||||||
</p>
|
|
||||||
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
|
|
||||||
{t("byUser", { id: p.userId })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</ContentCard>
|
</ContentCard>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const metadata = { title: "Login activity" };
|
||||||
|
|
||||||
|
function fromDate(d: Date | null | undefined): string {
|
||||||
|
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tiny user-agent → "Browser on OS" summary (no dependency). */
|
||||||
|
function device(ua: string | null): string {
|
||||||
|
if (!ua) return "Unknown device";
|
||||||
|
const browser = /Edg/.test(ua)
|
||||||
|
? "Edge"
|
||||||
|
: /OPR|Opera/.test(ua)
|
||||||
|
? "Opera"
|
||||||
|
: /Chrome/.test(ua)
|
||||||
|
? "Chrome"
|
||||||
|
: /Firefox/.test(ua)
|
||||||
|
? "Firefox"
|
||||||
|
: /Safari/.test(ua)
|
||||||
|
? "Safari"
|
||||||
|
: "Browser";
|
||||||
|
const os = /Windows/.test(ua)
|
||||||
|
? "Windows"
|
||||||
|
: /Android/.test(ua)
|
||||||
|
? "Android"
|
||||||
|
: /iPhone|iPad|iOS/.test(ua)
|
||||||
|
? "iOS"
|
||||||
|
: /Mac OS X|Macintosh/.test(ua)
|
||||||
|
? "macOS"
|
||||||
|
: /Linux/.test(ua)
|
||||||
|
? "Linux"
|
||||||
|
: "";
|
||||||
|
return os ? `${browser} on ${os}` : browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SessionsPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) redirect("/login");
|
||||||
|
const userId = Number(session.user.id);
|
||||||
|
|
||||||
|
const logins = await prisma.websiteLoginLogs
|
||||||
|
.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
take: 30,
|
||||||
|
select: { id: true, ip: true, userAgent: true, createdAt: true },
|
||||||
|
})
|
||||||
|
.catch(() => []);
|
||||||
|
|
||||||
|
// Failed attempts on this username (failed_logins is keyless → raw query).
|
||||||
|
type Failed = { ip_address: string; attempted_at: Date | string };
|
||||||
|
const failed = await prisma.$queryRaw<Failed[]>`
|
||||||
|
SELECT ip_address, attempted_at FROM failed_logins
|
||||||
|
WHERE username = ${session.user.name} ORDER BY id DESC LIMIT 15
|
||||||
|
`.catch(() => [] as Failed[]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||||
|
<ContentCard
|
||||||
|
icon="🔐"
|
||||||
|
title="Login activity"
|
||||||
|
subtitle="Recent sign-ins and failed attempts on your account"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ContentCard icon="✅" title="Recent sign-ins" padded={logins.length === 0}>
|
||||||
|
{logins.length === 0 ? (
|
||||||
|
<EmptyState icon="🔐">No sign-ins recorded yet.</EmptyState>
|
||||||
|
) : (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>Device</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{logins.map((l) => (
|
||||||
|
<tr key={String(l.id)}>
|
||||||
|
<td className="muted" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{fromDate(l.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="muted">{l.ip}</td>
|
||||||
|
<td>{device(l.userAgent)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</ContentCard>
|
||||||
|
|
||||||
|
{failed.length > 0 ? (
|
||||||
|
<ContentCard icon="⚠️" title="Failed attempts" subtitle="Wrong password on your username">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{failed.map((f, i) => (
|
||||||
|
<tr key={`${f.ip_address}-${i}`}>
|
||||||
|
<td className="muted" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{typeof f.attempted_at === "string"
|
||||||
|
? f.attempted_at.slice(0, 16).replace("T", " ")
|
||||||
|
: fromDate(f.attempted_at)}
|
||||||
|
</td>
|
||||||
|
<td className="muted">{f.ip_address}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</ContentCard>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { ContentCard, EmptyState, OnlineBadge, StatBlock } from "@/components/public/ui";
|
import { ContentCard, EmptyState, OnlineBadge, StatBlock } from "@/components/public/ui";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
@@ -26,6 +27,10 @@ export async function generateMetadata({
|
|||||||
// Canonical Habbo badge image CDN. Badge codes (e.g. "ADM") map to a .gif here.
|
// Canonical Habbo badge image CDN. Badge codes (e.g. "ADM") map to a .gif here.
|
||||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||||
|
|
||||||
|
// Emulator currency type ids (see UsersCurrency model): Duckets=0, Diamonds=5.
|
||||||
|
const CURRENCY_DUCKETS = 0;
|
||||||
|
const CURRENCY_DIAMONDS = 5;
|
||||||
|
|
||||||
function formatTimestamp(ts: number | null | undefined): string {
|
function formatTimestamp(ts: number | null | undefined): string {
|
||||||
if (!ts) return "";
|
if (!ts) return "";
|
||||||
return new Date(ts * 1000).toISOString().slice(0, 10);
|
return new Date(ts * 1000).toISOString().slice(0, 10);
|
||||||
@@ -69,6 +74,77 @@ export default async function ProfilePage({
|
|||||||
|
|
||||||
// Profile sub-sections. Each query is isolated so a DB hiccup degrades that
|
// Profile sub-sections. Each query is isolated so a DB hiccup degrades that
|
||||||
// single section to empty rather than 500-ing the whole profile.
|
// single section to empty rather than 500-ing the whole profile.
|
||||||
|
|
||||||
|
// Currency ledger rows (duckets/diamonds). Credits live on the users row
|
||||||
|
// itself; the alt-currencies sit in the users_currency ledger keyed by type.
|
||||||
|
const currencyRows = await prisma.usersCurrency
|
||||||
|
.findMany({
|
||||||
|
where: { userId: user.id, type: { in: [CURRENCY_DUCKETS, CURRENCY_DIAMONDS] } },
|
||||||
|
select: { type: true, amount: true },
|
||||||
|
})
|
||||||
|
.catch(() => []);
|
||||||
|
const ducketsAmount =
|
||||||
|
currencyRows.find((c) => c.type === CURRENCY_DUCKETS)?.amount ?? 0;
|
||||||
|
const diamondsAmount =
|
||||||
|
currencyRows.find((c) => c.type === CURRENCY_DIAMONDS)?.amount ?? 0;
|
||||||
|
const wallet = [
|
||||||
|
{
|
||||||
|
key: "credits",
|
||||||
|
icon: "💰",
|
||||||
|
label: t("statCredits"),
|
||||||
|
value: user.credits,
|
||||||
|
color: "var(--color-primary)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "duckets",
|
||||||
|
icon: "🪙",
|
||||||
|
label: t("statDuckets"),
|
||||||
|
value: ducketsAmount,
|
||||||
|
color: "var(--color-accent)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "diamonds",
|
||||||
|
icon: "💎",
|
||||||
|
label: t("statDiamonds"),
|
||||||
|
value: diamondsAmount,
|
||||||
|
color: "#38bdf8",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Friends. messenger_friendships stores one directed row per pair keyed on
|
||||||
|
// user_one_id; resolve the user_two_id side to usernames/looks for the grid.
|
||||||
|
const friendships = await prisma.messengerFriendships
|
||||||
|
.findMany({
|
||||||
|
where: { userOneId: user.id },
|
||||||
|
orderBy: { friendsSince: "desc" },
|
||||||
|
take: 18,
|
||||||
|
select: { userTwoId: true },
|
||||||
|
})
|
||||||
|
.catch(() => []);
|
||||||
|
const friendIds = Array.from(new Set(friendships.map((f) => f.userTwoId)));
|
||||||
|
const friendUsers = friendIds.length
|
||||||
|
? await prisma.user
|
||||||
|
.findMany({
|
||||||
|
where: { id: { in: friendIds } },
|
||||||
|
select: { id: true, username: true, look: true },
|
||||||
|
})
|
||||||
|
.catch(() => [])
|
||||||
|
: [];
|
||||||
|
const friendById = new Map(friendUsers.map((f) => [f.id, f]));
|
||||||
|
const friends = friendships
|
||||||
|
.map((f) => friendById.get(f.userTwoId))
|
||||||
|
.filter((f): f is NonNullable<typeof f> => Boolean(f));
|
||||||
|
|
||||||
|
// Rooms owned by this user.
|
||||||
|
const rooms = await prisma.rooms
|
||||||
|
.findMany({
|
||||||
|
where: { ownerId: user.id },
|
||||||
|
orderBy: { users: "desc" },
|
||||||
|
take: 8,
|
||||||
|
select: { id: true, name: true, users: true, usersMax: true, state: true },
|
||||||
|
})
|
||||||
|
.catch(() => []);
|
||||||
|
|
||||||
const badges = await prisma.usersBadges
|
const badges = await prisma.usersBadges
|
||||||
.findMany({
|
.findMany({
|
||||||
where: { userId: user.id },
|
where: { userId: user.id },
|
||||||
@@ -106,7 +182,7 @@ export default async function ProfilePage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||||
{/* ── Profile header ───────────────────────────────────── */}
|
{/* ── Profile header ──────────────────── */}
|
||||||
<ContentCard padded={false}>
|
<ContentCard padded={false}>
|
||||||
<div
|
<div
|
||||||
className="content-card-body"
|
className="content-card-body"
|
||||||
@@ -129,7 +205,161 @@ export default async function ProfilePage({
|
|||||||
</div>
|
</div>
|
||||||
</ContentCard>
|
</ContentCard>
|
||||||
|
|
||||||
{/* ── Badges ───────────────────────────────────────────── */}
|
{/* ── Wallet / currencies ─────────────── */}
|
||||||
|
<ContentCard icon="💰" title={t("walletTitle")}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))",
|
||||||
|
gap: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{wallet.map((w) => (
|
||||||
|
<div
|
||||||
|
key={w.key}
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
borderLeft: `4px solid ${w.color}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
fontSize: "1.6rem",
|
||||||
|
lineHeight: 1,
|
||||||
|
display: "flex",
|
||||||
|
width: "2.4rem",
|
||||||
|
height: "2.4rem",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: "10px",
|
||||||
|
background: `color-mix(in srgb, ${w.color} 16%, transparent)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{w.icon}
|
||||||
|
</span>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: "1.35rem", fontWeight: 700, color: w.color }}>
|
||||||
|
{w.value.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="muted" style={{ fontSize: "0.85rem" }}>
|
||||||
|
{w.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ContentCard>
|
||||||
|
|
||||||
|
{/* ── Friends ──────────────────────── */}
|
||||||
|
<ContentCard
|
||||||
|
icon="👥"
|
||||||
|
title={t("friendsTitle")}
|
||||||
|
subtitle={t("friendsSubtitle", { count: friends.length })}
|
||||||
|
padded={friends.length === 0}
|
||||||
|
>
|
||||||
|
{friends.length === 0 ? (
|
||||||
|
<EmptyState icon="👥">{t("friendsEmpty")}</EmptyState>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(72px, 1fr))",
|
||||||
|
gap: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{friends.map((f) => (
|
||||||
|
<Link
|
||||||
|
key={f.id}
|
||||||
|
href={`/u/${encodeURIComponent(f.username)}`}
|
||||||
|
className="card"
|
||||||
|
title={f.username}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.35rem",
|
||||||
|
padding: "0.6rem 0.4rem",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={avatarImageUrl(imagerBase ?? "", f.look, { size: "m", headOnly: true })}
|
||||||
|
alt={f.username}
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
style={{ display: "block" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
maxWidth: "100%",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.username}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ContentCard>
|
||||||
|
|
||||||
|
{/* ── Rooms ────────────────────────── */}
|
||||||
|
<ContentCard
|
||||||
|
icon="🏠"
|
||||||
|
title={t("roomsTitle")}
|
||||||
|
subtitle={t("roomsSubtitle", { username: user.username })}
|
||||||
|
padded={rooms.length === 0}
|
||||||
|
>
|
||||||
|
{rooms.length === 0 ? (
|
||||||
|
<EmptyState icon="🏠">{t("roomsEmpty")}</EmptyState>
|
||||||
|
) : (
|
||||||
|
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||||
|
{rooms.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
className="card"
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
|
<span aria-hidden style={{ fontSize: "1.2rem" }}>
|
||||||
|
{"🏠"}
|
||||||
|
</span>
|
||||||
|
<strong
|
||||||
|
style={{
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.name || t("roomUntitled")}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="muted"
|
||||||
|
style={{ display: "flex", justifyContent: "space-between", fontSize: "0.85rem" }}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{"👤"} {r.users}/{r.usersMax}
|
||||||
|
</span>
|
||||||
|
<span style={{ textTransform: "capitalize" }}>{r.state}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ContentCard>
|
||||||
|
|
||||||
|
{/* ── Badges ────────────────────── */}
|
||||||
<ContentCard icon="🏅" title={t("badgesTitle")} padded={badges.length === 0}>
|
<ContentCard icon="🏅" title={t("badgesTitle")} padded={badges.length === 0}>
|
||||||
{badges.length === 0 ? (
|
{badges.length === 0 ? (
|
||||||
<EmptyState icon="🏅">{t("badgesEmpty")}</EmptyState>
|
<EmptyState icon="🏅">{t("badgesEmpty")}</EmptyState>
|
||||||
@@ -158,7 +388,7 @@ export default async function ProfilePage({
|
|||||||
)}
|
)}
|
||||||
</ContentCard>
|
</ContentCard>
|
||||||
|
|
||||||
{/* ── Photos ───────────────────────────────────────────── */}
|
{/* ── Photos ────────────────────── */}
|
||||||
<ContentCard icon="📸" title={t("photosTitle")} padded={photos.length === 0}>
|
<ContentCard icon="📸" title={t("photosTitle")} padded={photos.length === 0}>
|
||||||
{photos.length === 0 ? (
|
{photos.length === 0 ? (
|
||||||
<EmptyState icon="📸">{t("photosEmpty")}</EmptyState>
|
<EmptyState icon="📸">{t("photosEmpty")}</EmptyState>
|
||||||
@@ -181,7 +411,7 @@ export default async function ProfilePage({
|
|||||||
)}
|
)}
|
||||||
</ContentCard>
|
</ContentCard>
|
||||||
|
|
||||||
{/* ── Guestbook ────────────────────────────────────────── */}
|
{/* ── Guestbook ───────────────────── */}
|
||||||
<ContentCard icon="📝" title={t("guestbookTitle")} subtitle={t("guestbookSubtitle", { username: user.username })}>
|
<ContentCard icon="📝" title={t("guestbookTitle")} subtitle={t("guestbookSubtitle", { username: user.username })}>
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<form action={postGuestbook} className="card" style={{ marginBottom: "1rem" }}>
|
<form action={postGuestbook} className="card" style={{ marginBottom: "1rem" }}>
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
|
|
||||||
|
// TinyMCE community build, loaded from the public jsDelivr CDN. `no-api-key`
|
||||||
|
// is the documented sentinel for the free self-hosted/CDN build and silences
|
||||||
|
// the "register for an API key" notification.
|
||||||
|
const TINYMCE_SRC =
|
||||||
|
"https://cdn.jsdelivr.net/npm/tinymce@6/tinymce.min.js";
|
||||||
|
|
||||||
|
type TinyMCEGlobal = {
|
||||||
|
init: (opts: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
remove: (selector: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
tinymce?: TinyMCEGlobal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let loaderPromise: Promise<TinyMCEGlobal | null> | null = null;
|
||||||
|
|
||||||
|
/** Inject the TinyMCE script once; resolves null if it fails to load. */
|
||||||
|
function loadTinyMce(): Promise<TinyMCEGlobal | null> {
|
||||||
|
if (typeof window === "undefined") return Promise.resolve(null);
|
||||||
|
if (window.tinymce) return Promise.resolve(window.tinymce);
|
||||||
|
if (loaderPromise) return loaderPromise;
|
||||||
|
|
||||||
|
loaderPromise = new Promise((resolve) => {
|
||||||
|
const existing = document.querySelector<HTMLScriptElement>("script[data-tinymce]");
|
||||||
|
const finish = () => resolve(window.tinymce ?? null);
|
||||||
|
if (existing) {
|
||||||
|
existing.addEventListener("load", finish, { once: true });
|
||||||
|
existing.addEventListener("error", () => resolve(null), { once: true });
|
||||||
|
if (window.tinymce) resolve(window.tinymce);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = TINYMCE_SRC;
|
||||||
|
script.async = true;
|
||||||
|
script.referrerPolicy = "origin";
|
||||||
|
script.dataset.tinymce = "1";
|
||||||
|
script.addEventListener("load", finish, { once: true });
|
||||||
|
script.addEventListener("error", () => resolve(null), { once: true });
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
return loaderPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WYSIWYG editor over a plain <textarea>. Keeps the original `name` so the
|
||||||
|
* surrounding server-action form still receives the HTML on submit. If TinyMCE
|
||||||
|
* fails to load (offline, CDN blocked) it silently degrades to the textarea.
|
||||||
|
*/
|
||||||
|
export function RichText({
|
||||||
|
name,
|
||||||
|
defaultValue = "",
|
||||||
|
placeholder,
|
||||||
|
rows = 10,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
rows?: number;
|
||||||
|
}) {
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const reactId = useId();
|
||||||
|
// Stable, querySelector-safe id (useId() contains ':').
|
||||||
|
const editorId = `rt-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const selector = `#${editorId}`;
|
||||||
|
|
||||||
|
loadTinyMce().then((tinymce) => {
|
||||||
|
if (cancelled || !tinymce || !textareaRef.current) return;
|
||||||
|
tinymce
|
||||||
|
.init({
|
||||||
|
selector,
|
||||||
|
menubar: false,
|
||||||
|
height: Math.max(rows * 22, 240),
|
||||||
|
plugins: "link lists code autolink",
|
||||||
|
toolbar:
|
||||||
|
"undo redo | bold italic underline | bullist numlist | link | removeformat | code",
|
||||||
|
branding: false,
|
||||||
|
promotion: false,
|
||||||
|
license_key: "gpl",
|
||||||
|
skin: "oxide",
|
||||||
|
content_style: "body{font-family:inherit;font-size:14px}",
|
||||||
|
// Mirror typed HTML back into the underlying textarea so the form
|
||||||
|
// submit (server action) always sees the current value.
|
||||||
|
setup: (editor: {
|
||||||
|
on: (ev: string, cb: () => void) => void;
|
||||||
|
getContent: () => string;
|
||||||
|
}) => {
|
||||||
|
const sync = () => {
|
||||||
|
if (textareaRef.current) textareaRef.current.value = editor.getContent();
|
||||||
|
};
|
||||||
|
editor.on("change keyup undo redo SetContent", sync);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
if (!cancelled) setReady(true);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* leave the plain textarea in place */
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
// Detach the editor instance on unmount; ignore if TinyMCE never loaded.
|
||||||
|
try {
|
||||||
|
window.tinymce?.remove(selector);
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [editorId, rows]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
id={editorId}
|
||||||
|
ref={textareaRef}
|
||||||
|
name={name}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={rows}
|
||||||
|
// Hide the raw textarea only once TinyMCE has mounted (it replaces it with
|
||||||
|
// its own iframe); until then the plain field is the visible fallback.
|
||||||
|
style={ready ? { display: "none" } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontal, scroll-snap slider for the home page "latest news" row. Keeps the
|
||||||
|
* exact article-card markup from the server page; the cards are passed in as a
|
||||||
|
* pre-shaped list so this client component carries no prisma/i18n dependency.
|
||||||
|
* Prev/next buttons scroll the snap track by one viewport-ish step and disable
|
||||||
|
* themselves at the ends.
|
||||||
|
*/
|
||||||
|
export type SliderArticle = {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
image: string;
|
||||||
|
excerpt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ArticleSlider({ articles }: { articles: SliderArticle[] }) {
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [atStart, setAtStart] = useState(true);
|
||||||
|
const [atEnd, setAtEnd] = useState(false);
|
||||||
|
|
||||||
|
const updateEdges = useCallback(() => {
|
||||||
|
const el = trackRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const maxScroll = el.scrollWidth - el.clientWidth;
|
||||||
|
setAtStart(el.scrollLeft <= 1);
|
||||||
|
setAtEnd(el.scrollLeft >= maxScroll - 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateEdges();
|
||||||
|
window.addEventListener("resize", updateEdges);
|
||||||
|
return () => window.removeEventListener("resize", updateEdges);
|
||||||
|
}, [updateEdges]);
|
||||||
|
|
||||||
|
const scrollByDir = useCallback((dir: 1 | -1) => {
|
||||||
|
const el = trackRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
// Step by roughly one card width (incl. gap) so snapping lands cleanly.
|
||||||
|
const step = Math.max(el.clientWidth * 0.8, 260);
|
||||||
|
el.scrollBy({ left: dir * step, behavior: "smooth" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Previous"
|
||||||
|
onClick={() => scrollByDir(-1)}
|
||||||
|
disabled={atStart}
|
||||||
|
className="article-slider-nav"
|
||||||
|
style={{ left: "-0.6rem", opacity: atStart ? 0.35 : 1 }}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
onScroll={updateEdges}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "1rem",
|
||||||
|
overflowX: "auto",
|
||||||
|
scrollSnapType: "x mandatory",
|
||||||
|
scrollbarWidth: "none",
|
||||||
|
paddingBottom: "0.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{articles.map((a) => (
|
||||||
|
<article
|
||||||
|
key={a.slug}
|
||||||
|
className="card hover"
|
||||||
|
style={{
|
||||||
|
padding: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
width: "clamp(220px, 70vw, 280px)",
|
||||||
|
scrollSnapAlign: "start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{a.image ? (
|
||||||
|
// biome-ignore lint/performance/noImgElement: external article image
|
||||||
|
<img className="article-img" src={a.image} alt="" />
|
||||||
|
) : (
|
||||||
|
<div className="article-img" />
|
||||||
|
)}
|
||||||
|
<div style={{ padding: "0.85rem" }}>
|
||||||
|
<h3 style={{ margin: "0 0 0.3rem", fontSize: "1rem" }}>
|
||||||
|
<Link href={`/news/${a.slug}`}>{a.title}</Link>
|
||||||
|
</h3>
|
||||||
|
<p className="muted" style={{ margin: 0 }}>
|
||||||
|
{a.excerpt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Next"
|
||||||
|
onClick={() => scrollByDir(1)}
|
||||||
|
disabled={atEnd}
|
||||||
|
className="article-slider-nav"
|
||||||
|
style={{ right: "-0.6rem", opacity: atEnd ? 0.35 : 1 }}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.article-slider-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 2;
|
||||||
|
width: 2.4rem;
|
||||||
|
height: 2.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: opacity 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.article-slider-nav:hover:not(:disabled) {
|
||||||
|
box-shadow: var(--shadow-card-hover);
|
||||||
|
}
|
||||||
|
.article-slider-nav:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side photo gallery for the public Photos page. Renders the same
|
||||||
|
* card grid as before, but each tile is now a button that opens the photo in
|
||||||
|
* a full-screen lightbox overlay. The overlay is a fixed, full-viewport layer
|
||||||
|
* toggled by state — closes on backdrop click, on the ✕ button, or with Esc.
|
||||||
|
*
|
||||||
|
* Photos are pre-shaped on the server (caption strings already translated) so
|
||||||
|
* this component carries no prisma/i18n dependency.
|
||||||
|
*/
|
||||||
|
export type LightboxPhoto = {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
alt: string;
|
||||||
|
date: string;
|
||||||
|
user: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
|
||||||
|
// Index of the photo shown in the lightbox, or null when closed.
|
||||||
|
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||||
|
const active = openIndex !== null ? photos[openIndex] : null;
|
||||||
|
|
||||||
|
const close = useCallback(() => setOpenIndex(null), []);
|
||||||
|
|
||||||
|
// Esc-to-close + lock background scroll while the overlay is open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (active == null) return;
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") close();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
const prevOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
document.body.style.overflow = prevOverflow;
|
||||||
|
};
|
||||||
|
}, [active, close]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="card-grid sm-2 md-3 lg-4" style={{ padding: "1rem" }}>
|
||||||
|
{photos.map((p, i) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpenIndex(i)}
|
||||||
|
className="card hover"
|
||||||
|
style={{
|
||||||
|
padding: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
border: 0,
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
cursor: "zoom-in",
|
||||||
|
textAlign: "left",
|
||||||
|
font: "inherit",
|
||||||
|
color: "inherit",
|
||||||
|
display: "block",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
aria-label={p.alt}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={p.url}
|
||||||
|
alt={p.alt}
|
||||||
|
style={{ width: "100%", height: "auto", display: "block" }}
|
||||||
|
/>
|
||||||
|
<div style={{ padding: "0.7rem 0.85rem" }}>
|
||||||
|
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
|
||||||
|
{p.date}
|
||||||
|
</p>
|
||||||
|
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
|
||||||
|
{p.user}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{active ? (
|
||||||
|
<div
|
||||||
|
onClick={close}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={active.alt}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "1.5rem",
|
||||||
|
background: "rgba(0, 0, 0, 0.82)",
|
||||||
|
backdropFilter: "blur(2px)",
|
||||||
|
cursor: "zoom-out",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={close}
|
||||||
|
aria-label="Close"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "1rem",
|
||||||
|
right: "1rem",
|
||||||
|
width: "2.5rem",
|
||||||
|
height: "2.5rem",
|
||||||
|
borderRadius: "999px",
|
||||||
|
border: 0,
|
||||||
|
background: "rgba(255, 255, 255, 0.16)",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: "1.4rem",
|
||||||
|
lineHeight: 1,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
{/* Stop propagation so clicking the image itself doesn't close. */}
|
||||||
|
<figure
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
maxWidth: "min(92vw, 1100px)",
|
||||||
|
maxHeight: "90vh",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
cursor: "default",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={active.url}
|
||||||
|
alt={active.alt}
|
||||||
|
style={{
|
||||||
|
maxWidth: "100%",
|
||||||
|
maxHeight: "78vh",
|
||||||
|
width: "auto",
|
||||||
|
height: "auto",
|
||||||
|
display: "block",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
boxShadow: "var(--shadow-card-hover)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<figcaption
|
||||||
|
style={{
|
||||||
|
color: "rgba(255, 255, 255, 0.85)",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{active.date} · {active.user}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -105,6 +105,9 @@ export async function TopHeader() {
|
|||||||
<Link href="/settings/2fa" className="dropdown-item">
|
<Link href="/settings/2fa" className="dropdown-item">
|
||||||
Two-factor
|
Two-factor
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/settings/sessions" className="dropdown-item">
|
||||||
|
Login activity
|
||||||
|
</Link>
|
||||||
<form
|
<form
|
||||||
action={async () => {
|
action={async () => {
|
||||||
"use server";
|
"use server";
|
||||||
|
|||||||
@@ -57,6 +57,18 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record the successful login for the user's "session logs" page.
|
||||||
|
// Best-effort — never let logging block or fail the sign-in.
|
||||||
|
try {
|
||||||
|
const { headers } = await import("next/headers");
|
||||||
|
const ua = (await headers()).get("user-agent")?.slice(0, 512) ?? null;
|
||||||
|
await prisma.websiteLoginLogs.create({
|
||||||
|
data: { userId: user.id, ip: await clientIp(), userAgent: ua, createdAt: new Date() },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
return { id: String(user.id), name: user.username, rank: user.rank };
|
return { id: String(user.id), name: user.username, rank: user.rank };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
+11
-1
@@ -99,7 +99,17 @@
|
|||||||
"post": "Post",
|
"post": "Post",
|
||||||
"loginToPost": "Log in to leave a guestbook message.",
|
"loginToPost": "Log in to leave a guestbook message.",
|
||||||
"guestbookEmpty": "No guestbook entries yet.",
|
"guestbookEmpty": "No guestbook entries yet.",
|
||||||
"unknownUser": "User #{id}"
|
"unknownUser": "User #{id}",
|
||||||
|
"statDuckets": "Duckets",
|
||||||
|
"statDiamonds": "Diamonds",
|
||||||
|
"walletTitle": "Wallet",
|
||||||
|
"friendsTitle": "Friends",
|
||||||
|
"friendsSubtitle": "People in the hotel they hang out with",
|
||||||
|
"friendsEmpty": "No friends to show.",
|
||||||
|
"roomsTitle": "Rooms",
|
||||||
|
"roomsSubtitle": "Rooms they own",
|
||||||
|
"roomsEmpty": "No rooms to show.",
|
||||||
|
"roomUntitled": "Untitled room"
|
||||||
},
|
},
|
||||||
"rankings": {
|
"rankings": {
|
||||||
"title": "Rankings",
|
"title": "Rankings",
|
||||||
|
|||||||
+11
-1
@@ -99,7 +99,17 @@
|
|||||||
"post": "Pubblica",
|
"post": "Pubblica",
|
||||||
"loginToPost": "Accedi per lasciare un messaggio sulla bacheca.",
|
"loginToPost": "Accedi per lasciare un messaggio sulla bacheca.",
|
||||||
"guestbookEmpty": "Ancora nessun messaggio in bacheca.",
|
"guestbookEmpty": "Ancora nessun messaggio in bacheca.",
|
||||||
"unknownUser": "Utente #{id}"
|
"unknownUser": "Utente #{id}",
|
||||||
|
"statDuckets": "Duckets",
|
||||||
|
"statDiamonds": "Diamanti",
|
||||||
|
"walletTitle": "Portafoglio",
|
||||||
|
"friendsTitle": "Amici",
|
||||||
|
"friendsSubtitle": "Le persone con cui passa il tempo nell’hotel",
|
||||||
|
"friendsEmpty": "Nessun amico da mostrare.",
|
||||||
|
"roomsTitle": "Stanze",
|
||||||
|
"roomsSubtitle": "Le stanze che possiede",
|
||||||
|
"roomsEmpty": "Nessuna stanza da mostrare.",
|
||||||
|
"roomUntitled": "Stanza senza nome"
|
||||||
},
|
},
|
||||||
"rankings": {
|
"rankings": {
|
||||||
"title": "Classifica",
|
"title": "Classifica",
|
||||||
|
|||||||
Reference in new issue
Block a user