Polish register, forgot password, settings, community pages with shadcn components; update admin articles with modern table
Local Build and Deploy / deploy (push) Successful in 1m0s
Local Build and Deploy / deploy (push) Successful in 1m0s
This commit is contained in:
1 parent
cb4cdc4a17
commit
d4ee1dc4a3
5 files changed
+479
-487
No files matched your search
@@ -1,6 +1,8 @@
|
||||
import { ArrowRight, Compass, Globe, Trophy, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export const metadata = { title: "Community" };
|
||||
|
||||
@@ -9,31 +11,43 @@ const LINKS = [
|
||||
key: "rankings",
|
||||
href: "/rankings",
|
||||
icon: "🏆",
|
||||
color: "from-yellow-500/20 to-yellow-500/5",
|
||||
iconColor: "text-yellow-500",
|
||||
},
|
||||
{
|
||||
key: "staff",
|
||||
href: "/staff",
|
||||
icon: "🛡️",
|
||||
color: "from-blue-500/20 to-blue-500/5",
|
||||
iconColor: "text-blue-500",
|
||||
},
|
||||
{
|
||||
key: "news",
|
||||
href: "/news",
|
||||
icon: "📰",
|
||||
color: "from-green-500/20 to-green-500/5",
|
||||
iconColor: "text-green-500",
|
||||
},
|
||||
{
|
||||
key: "photos",
|
||||
href: "/photos",
|
||||
icon: "📸",
|
||||
color: "from-purple-500/20 to-purple-500/5",
|
||||
iconColor: "text-purple-500",
|
||||
},
|
||||
{
|
||||
key: "guilds",
|
||||
href: "/guilds",
|
||||
icon: "🚪",
|
||||
color: "from-orange-500/20 to-orange-500/5",
|
||||
iconColor: "text-orange-500",
|
||||
},
|
||||
{
|
||||
key: "leaderboards",
|
||||
href: "/leaderboard",
|
||||
icon: "📊",
|
||||
color: "from-cyan-500/20 to-cyan-500/5",
|
||||
iconColor: "text-cyan-500",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -41,36 +55,57 @@ export default function CommunityPage() {
|
||||
const t = useTranslations("pages.community");
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard icon="🌍" title={t("title")} subtitle={t("subtitle")} />
|
||||
<div className="space-y-6 py-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-[var(--color-primary)]/20 to-[var(--color-primary)]/5 shadow-lg">
|
||||
<Globe className="h-7 w-7 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-2xl font-extrabold tracking-tight">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="m-0 mt-0.5 text-sm text-[var(--color-text-muted-readable)]">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-grid sm-2 lg-3">
|
||||
{LINKS.map((l) => (
|
||||
{/* Community Cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{LINKS.map((l, i) => (
|
||||
<Link
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
className="content-card hover-lift"
|
||||
style={{ color: "inherit", textDecoration: "none" }}
|
||||
className="group block"
|
||||
style={{ animationDelay: `${i * 80}ms` }}
|
||||
>
|
||||
<div className="content-card-head">
|
||||
<span className="content-card-icon" aria-hidden>
|
||||
{l.icon}
|
||||
</span>
|
||||
<div className="content-card-head-text">
|
||||
<p className="content-card-title">
|
||||
{t(`links.${l.key}.title`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="content-card-body">
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
|
||||
{t(`links.${l.key}.text`)}
|
||||
</p>
|
||||
<span className="btn btn-outline">{t("open")}</span>
|
||||
</div>
|
||||
<Card className="border-[var(--border-subtle)] transition-all duration-200 hover:shadow-card-hover hover:-translate-y-1 h-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br ${l.color}`}>
|
||||
<span className="text-xl">{l.icon}</span>
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">
|
||||
{t(`links.${l.key}.title`)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(`links.${l.key}.text`)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-[var(--color-primary)] transition-all group-hover:gap-3">
|
||||
{t("open")}
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { KeyRound, Lock } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { requestReset } from "@/actions/password-reset";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default async function ForgotPage({
|
||||
searchParams,
|
||||
@@ -12,36 +13,64 @@ export default async function ForgotPage({
|
||||
const { sent } = await searchParams;
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
|
||||
<ContentCard icon="🔑" title={t("title")} subtitle={t("subtitle")}>
|
||||
{sent ? (
|
||||
<p className="muted" style={{ textAlign: "center", margin: 0 }}>
|
||||
{t("sentNotice")}
|
||||
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
||||
<div className="w-full max-w-md animate-fade-in">
|
||||
<div className="text-center mb-8">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-[var(--color-primary)]/20 to-[var(--color-primary)]/5 shadow-lg">
|
||||
<KeyRound className="h-8 w-8 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-extrabold tracking-tight m-0">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="mt-1.5 text-sm text-[var(--color-text-muted-readable)]">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
) : (
|
||||
<form
|
||||
action={requestReset}
|
||||
style={{ display: "grid", gap: "0.7rem" }}
|
||||
>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder={t("emailPlaceholder")}
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("sendResetLink")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<p
|
||||
className="muted"
|
||||
style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}
|
||||
>
|
||||
<Link href="/login">{t("backToLogin")}</Link>
|
||||
</p>
|
||||
</ContentCard>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Card className="border-[var(--border-subtle)] shadow-elevated">
|
||||
<CardHeader className="px-6 pt-6 pb-0">
|
||||
<CardTitle className="text-base font-bold">
|
||||
{sent ? "Email sent" : "Reset your password"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{sent ? t("sentNotice") : "Enter your email to receive a reset link"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
{sent ? (
|
||||
<div className="rounded-xl bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-900 px-4 py-6 text-center">
|
||||
<p className="text-sm font-medium text-green-600 dark:text-green-400 m-0">
|
||||
{t("sentNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form action={requestReset} className="space-y-4">
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--color-text-muted)]" />
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder={t("emailPlaceholder")}
|
||||
autoComplete="email"
|
||||
required
|
||||
className="w-full rounded-xl border-2 border-[var(--input-border-color)] bg-[var(--color-background)] pl-10 pr-4 py-2.5 text-sm transition-all duration-200 focus:border-[var(--color-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary w-full justify-center py-3 text-sm font-bold rounded-xl">
|
||||
{t("sendResetLink")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-[var(--color-text-muted-readable)]">
|
||||
<Link href="/login" className="font-bold text-[var(--color-primary)] hover:underline">
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { KeyRound, Link2, Pencil, Shield, Smile, User } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { updateMotto } from "@/actions/user-settings";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -38,108 +40,125 @@ export default async function SettingsPage() {
|
||||
try {
|
||||
imagerBase =
|
||||
(await siteSettings.get("habbo_imaging_url", imagerBase)) ?? imagerBase;
|
||||
} catch {
|
||||
// fall back to the default imager base
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const avatar = avatarImageUrl(imagerBase, user.look, { size: "l" });
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="⚙️"
|
||||
title={t("accountSettings")}
|
||||
subtitle={t("accountSettingsSubtitle")}
|
||||
/>
|
||||
|
||||
<ContentCard
|
||||
icon="🙂"
|
||||
title={user.username}
|
||||
subtitle={t("publicProfile")}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "1.5rem",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
className="avatar"
|
||||
src={avatar}
|
||||
alt={`${user.username} avatar`}
|
||||
width={100}
|
||||
height={150}
|
||||
/>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<p style={{ margin: "0 0 0.5rem", fontSize: "1.05rem" }}>
|
||||
{user.motto || <span className="muted">{t("noMotto")}</span>}
|
||||
</p>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{user.mail || t("noEmail")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mx-auto max-w-2xl space-y-6 py-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-[var(--color-primary)]/20 to-[var(--color-primary)]/5 shadow-lg">
|
||||
<User className="h-6 w-6 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</ContentCard>
|
||||
|
||||
<ContentCard
|
||||
icon="✏️"
|
||||
title={t("changeMotto")}
|
||||
subtitle={t("changeMottoSubtitle")}
|
||||
>
|
||||
<form
|
||||
action={updateMotto}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
maxWidth: 520,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
name="motto"
|
||||
defaultValue={user.motto}
|
||||
maxLength={127}
|
||||
placeholder={t("mottoPlaceholder")}
|
||||
aria-label="Motto"
|
||||
/>
|
||||
<div>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("saveMotto")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</ContentCard>
|
||||
|
||||
<div className="card-grid sm-2">
|
||||
<ContentCard
|
||||
icon="🔒"
|
||||
title={t("securityTitle")}
|
||||
subtitle={t("securitySubtitle")}
|
||||
>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
{t("securityDescription")}
|
||||
<div>
|
||||
<h1 className="m-0 text-2xl font-extrabold tracking-tight">
|
||||
{t("accountSettings")}
|
||||
</h1>
|
||||
<p className="m-0 mt-0.5 text-sm text-[var(--color-text-muted-readable)]">
|
||||
{t("accountSettingsSubtitle")}
|
||||
</p>
|
||||
<Link href="/settings/2fa" className="btn btn-outline">
|
||||
{t("twoFactorLink")}
|
||||
</Link>
|
||||
</ContentCard>
|
||||
|
||||
<ContentCard
|
||||
icon="🔗"
|
||||
title={t("connectionsTitle")}
|
||||
subtitle={t("connectionsSubtitle")}
|
||||
>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
{t("connectionsDescription")}
|
||||
</p>
|
||||
<Link href="/settings/connections" className="btn btn-outline">
|
||||
{t("manageConnections")}
|
||||
</Link>
|
||||
</ContentCard>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Profile Card */}
|
||||
<Card className="border-[var(--border-subtle)]">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="shrink-0 overflow-hidden rounded-2xl bg-gradient-to-br from-[var(--color-primary)]/20 to-[var(--color-primary)]/5 p-1 shadow-md">
|
||||
<img
|
||||
className="block rounded-xl"
|
||||
src={avatar}
|
||||
alt={`${user.username} avatar`}
|
||||
width={80}
|
||||
height={120}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg">{user.username}</CardTitle>
|
||||
<CardDescription>
|
||||
{user.motto || <span className="italic">{t("noMotto")}</span>}
|
||||
</CardDescription>
|
||||
<p className="mt-1 text-xs text-[var(--color-text-muted-readable)]">
|
||||
{user.mail || t("noEmail")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={updateMotto} className="flex items-end gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="mb-1.5 block text-xs font-bold uppercase tracking-wider text-[var(--color-text-muted-readable)]">
|
||||
{t("changeMotto")}
|
||||
</label>
|
||||
<input
|
||||
name="motto"
|
||||
defaultValue={user.motto}
|
||||
maxLength={127}
|
||||
placeholder={t("mottoPlaceholder")}
|
||||
aria-label="Motto"
|
||||
className="w-full rounded-xl border-2 border-[var(--input-border-color)] bg-[var(--color-background)] px-4 py-2.5 text-sm transition-all duration-200 focus:border-[var(--color-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="shrink-0 gap-2 rounded-xl">
|
||||
<Pencil className="h-4 w-4" />
|
||||
{t("saveMotto")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Security & Connections */}
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<Card className="border-[var(--border-subtle)]">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-[var(--color-primary)]/20 to-[var(--color-primary)]/5">
|
||||
<Shield className="h-5 w-5 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-sm">{t("securityTitle")}</CardTitle>
|
||||
<CardDescription>{t("securitySubtitle")}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-4 text-sm text-[var(--color-text-muted-readable)]">
|
||||
{t("securityDescription")}
|
||||
</p>
|
||||
<Link href="/settings/2fa">
|
||||
<Button variant="outline" className="w-full gap-2 rounded-xl">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
{t("twoFactorLink")}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-[var(--border-subtle)]">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-[var(--color-primary)]/20 to-[var(--color-primary)]/5">
|
||||
<Link2 className="h-5 w-5 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-sm">{t("connectionsTitle")}</CardTitle>
|
||||
<CardDescription>{t("connectionsSubtitle")}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-4 text-sm text-[var(--color-text-muted-readable)]">
|
||||
{t("connectionsDescription")}
|
||||
</p>
|
||||
<Link href="/settings/connections">
|
||||
<Button variant="outline" className="w-full gap-2 rounded-xl">
|
||||
{t("manageConnections")}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Newspaper } from "lucide-react";
|
||||
import { Newspaper, Plus, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { deleteArticle } from "@/actions/admin-articles";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -34,90 +35,113 @@ export default async function AdminArticles({
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Newspaper size={20} className="text-[var(--admin-accent)]" />
|
||||
<main className="animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 shadow-sm ring-1 ring-[var(--admin-accent)]/10">
|
||||
<Newspaper size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold tracking-tight text-[var(--admin-text)]">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="m-0 mt-0.5 text-xs text-[var(--admin-text-muted)]">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin/articles/new" className="btn btn-primary">
|
||||
+ {t("newArticle")}
|
||||
<Link href="/admin/articles/new">
|
||||
<Button className="gap-2 rounded-xl">
|
||||
<Plus size={16} />
|
||||
{t("newArticle")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
|
||||
<div className="mb-4 rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-xs font-medium text-red-500">
|
||||
Error: {error}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
{/* Stats */}
|
||||
<div className="mb-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<StatusCard
|
||||
label={t("articlesShown")}
|
||||
value={articles.length}
|
||||
icon="📰"
|
||||
icon={<Newspaper size={16} />}
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("latest")}
|
||||
value={latest}
|
||||
hint={t("mostRecentPost")}
|
||||
icon="🕒"
|
||||
icon={<Newspaper size={16} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("recentArticles")}</h2>
|
||||
{/* Articles Table */}
|
||||
<section className="admin-card">
|
||||
<h2 className="mb-4 text-sm font-bold text-[var(--admin-text)]">
|
||||
{t("recentArticles")}
|
||||
</h2>
|
||||
{articles.length === 0 ? (
|
||||
<div className="admin-empty">{t("noArticles")}</div>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colTitle")}</th>
|
||||
<th>{t("colSlug")}</th>
|
||||
<th>{t("colCreated")}</th>
|
||||
<th>{t("colActions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{articles.map((a) => (
|
||||
<tr key={String(a.id)}>
|
||||
<td>
|
||||
<Link href={`/admin/articles/${a.id}`}>{a.title}</Link>
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
<code>{a.slug}</code>
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{a.createdAt ? a.createdAt.toISOString().slice(0, 10) : "—"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex gap-2">
|
||||
<div className="overflow-hidden rounded-xl border border-[var(--admin-border)]">
|
||||
<table className="admin-page-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-full">{t("colTitle")}</th>
|
||||
<th className="whitespace-nowrap">{t("colSlug")}</th>
|
||||
<th className="whitespace-nowrap">{t("colCreated")}</th>
|
||||
<th className="whitespace-nowrap text-right">{t("colActions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{articles.map((a) => (
|
||||
<tr key={String(a.id)} className="group">
|
||||
<td>
|
||||
<Link
|
||||
href={`/admin/articles/${a.id}`}
|
||||
className="btn btn-primary"
|
||||
className="font-medium text-[var(--admin-text)] hover:text-[var(--admin-accent)]"
|
||||
>
|
||||
{t("edit")}
|
||||
{a.title}
|
||||
</Link>
|
||||
<form action={deleteArticle} className="m-0">
|
||||
<input type="hidden" name="id" value={String(a.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
{t("delete")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
<td className="whitespace-nowrap">
|
||||
<code className="rounded-md bg-[var(--admin-accent)]/8 px-2 py-0.5 text-xs text-[var(--admin-text-muted)]">
|
||||
{a.slug}
|
||||
</code>
|
||||
</td>
|
||||
<td className="whitespace-nowrap text-xs text-[var(--admin-text-muted)]">
|
||||
{a.createdAt ? a.createdAt.toISOString().slice(0, 10) : "—"}
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<div className="flex items-center justify-end gap-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Link href={`/admin/articles/${a.id}`}>
|
||||
<Button variant="outline" size="sm" className="rounded-lg text-xs">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</Link>
|
||||
<form action={deleteArticle} className="m-0">
|
||||
<input type="hidden" name="id" value={String(a.id)} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Eye, EyeOff, Lock, Mail, User, UserPlus } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useState } from "react";
|
||||
import { register } from "@/actions/register";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface RegisterFormProps {
|
||||
hotelName: string;
|
||||
@@ -17,292 +20,174 @@ export function RegisterForm({ hotelName, captcha, error }: RegisterFormProps) {
|
||||
const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey;
|
||||
const [serverError, formAction, isPending] = useActionState(register, null);
|
||||
const [termsAccepted, setTermsAccepted] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[800px]">
|
||||
{/* Header Banner */}
|
||||
<div
|
||||
className="relative overflow-hidden bg-center bg-cover rounded-t-lg"
|
||||
style={{
|
||||
backgroundImage: "url(/assets/images/mybobba_banner.png)",
|
||||
height: "180px",
|
||||
maxHeight: "150px",
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 z-10 bg-black/30" />
|
||||
<div className="relative flex items-center justify-center w-full h-full z-20">
|
||||
<div className="relative" style={{ width: "140px", height: "140px" }}>
|
||||
<div
|
||||
className="absolute inset-0 rounded-full overflow-hidden"
|
||||
style={{ zIndex: -1 }}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
background: "black",
|
||||
filter: "blur(8px)",
|
||||
transform: "scale(1.2)",
|
||||
opacity: 0.6,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Image
|
||||
src="/assets/images/FrankwithBag.gif"
|
||||
alt=""
|
||||
fill
|
||||
className="!static"
|
||||
style={{
|
||||
objectFit: "contain",
|
||||
objectPosition: "center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx-auto w-full max-w-lg animate-fade-in">
|
||||
<div className="text-center mb-8">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-[var(--color-primary)]/20 to-[var(--color-primary)]/5 shadow-lg">
|
||||
<UserPlus className="h-8 w-8 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-extrabold tracking-tight m-0">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="mt-1.5 text-sm text-[var(--color-text-muted-readable)]">
|
||||
{t("subtitle", { hotel: hotelName })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
action={formAction}
|
||||
className="p-4 flex flex-col gap-6"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--color-background) 50%, var(--color-surface))",
|
||||
borderRadius: "0 0 8px 8px",
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<div
|
||||
className="p-3 rounded-md text-sm"
|
||||
style={{ backgroundColor: "#ef4444", color: "white" }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{serverError && (
|
||||
<div
|
||||
className="p-3 rounded-md text-sm"
|
||||
style={{ backgroundColor: "#ef4444", color: "white" }}
|
||||
>
|
||||
{serverError}
|
||||
</div>
|
||||
)}
|
||||
<Card className="border-[var(--border-subtle)] shadow-elevated">
|
||||
<CardHeader className="px-6 pt-6 pb-0">
|
||||
<CardTitle className="text-base font-bold">
|
||||
Create your account
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Fill in the details below to join {hotelName}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<form action={formAction} className="space-y-5">
|
||||
{(error || serverError) && (
|
||||
<div className="rounded-xl bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 px-4 py-3">
|
||||
<p className="text-sm font-medium text-red-600 dark:text-red-400 text-center m-0">
|
||||
{error || serverError}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Username & Email */}
|
||||
<div className="w-full flex flex-col md:flex-row gap-4">
|
||||
<div className="w-full md:w-1/2">
|
||||
<fieldset className="flex flex-col w-full gap-1">
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block font-semibold text-sm"
|
||||
style={{
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
}}
|
||||
>
|
||||
{t("username")}
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
autoComplete="username"
|
||||
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="w-full md:w-1/2">
|
||||
<fieldset className="flex flex-col w-full gap-1">
|
||||
<label
|
||||
htmlFor="mail"
|
||||
className="block font-semibold text-sm"
|
||||
style={{
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
}}
|
||||
>
|
||||
{t("email")}
|
||||
</label>
|
||||
<input
|
||||
id="mail"
|
||||
name="mail"
|
||||
type="email"
|
||||
placeholder={t("emailPlaceholder")}
|
||||
autoComplete="email"
|
||||
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
<p
|
||||
className="text-xs mt-1"
|
||||
style={{
|
||||
color:
|
||||
"var(--color-text-muted-readable, var(--color-text-muted))",
|
||||
}}
|
||||
>
|
||||
Geen e-mail? Je kunt later via Discord verifiëren.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--color-text-muted)]" />
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
autoComplete="username"
|
||||
className="w-full rounded-xl border-2 border-[var(--input-border-color)] bg-[var(--color-background)] pl-10 pr-4 py-2.5 text-sm transition-all duration-200 focus:border-[var(--color-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--color-text-muted)]" />
|
||||
<input
|
||||
id="mail"
|
||||
name="mail"
|
||||
type="email"
|
||||
placeholder={t("emailPlaceholder")}
|
||||
autoComplete="email"
|
||||
className="w-full rounded-xl border-2 border-[var(--input-border-color)] bg-[var(--color-background)] pl-10 pr-4 py-2.5 text-sm transition-all duration-200 focus:border-[var(--color-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password & Confirm */}
|
||||
<div className="w-full flex flex-col md:flex-row gap-4">
|
||||
<div className="w-full md:w-1/2">
|
||||
<fieldset className="flex flex-col w-full gap-1">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block font-semibold text-sm"
|
||||
style={{
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
}}
|
||||
>
|
||||
{t("password")}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="w-full md:w-1/2">
|
||||
<fieldset className="flex flex-col w-full gap-1">
|
||||
<label
|
||||
htmlFor="password_confirmation"
|
||||
className="block font-semibold text-sm"
|
||||
style={{
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
}}
|
||||
>
|
||||
{t("confirmPassword")}
|
||||
</label>
|
||||
<input
|
||||
id="password_confirmation"
|
||||
name="password_confirmation"
|
||||
type="password"
|
||||
placeholder={t("confirmPasswordPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terms */}
|
||||
<div
|
||||
className="rounded-md p-3 flex flex-col gap-3"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--color-background) 50%, var(--color-surface))",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={termsAccepted}
|
||||
onClick={() => setTermsAccepted(!termsAccepted)}
|
||||
className="w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors"
|
||||
style={{
|
||||
borderColor: termsAccepted
|
||||
? "#27a44d"
|
||||
: "color-mix(in srgb, var(--color-text-muted) 30%, transparent)",
|
||||
backgroundColor: termsAccepted ? "#27a44d" : "transparent",
|
||||
}}
|
||||
>
|
||||
{termsAccepted && (
|
||||
<svg
|
||||
className="w-3 h-3 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--color-text-muted)]" />
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
className="w-full rounded-xl border-2 border-[var(--input-border-color)] bg-[var(--color-background)] pl-10 pr-10 py-2.5 text-sm transition-all duration-200 focus:border-[var(--color-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] hover:text-[var(--color-text-readable)]"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="hidden"
|
||||
name="terms"
|
||||
value={termsAccepted ? "1" : ""}
|
||||
/>
|
||||
<span
|
||||
className="font-semibold cursor-pointer"
|
||||
style={{ color: "var(--color-text-readable, var(--color-text))" }}
|
||||
onClick={() => setTermsAccepted(!termsAccepted)}
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--color-text-muted)]" />
|
||||
<input
|
||||
id="password_confirmation"
|
||||
name="password_confirmation"
|
||||
type="password"
|
||||
placeholder={t("confirmPasswordPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
className="w-full rounded-xl border-2 border-[var(--input-border-color)] bg-[var(--color-background)] pl-10 pr-4 py-2.5 text-sm transition-all duration-200 focus:border-[var(--color-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terms */}
|
||||
<div className="flex items-start gap-3 rounded-xl border border-[var(--border-subtle)] bg-[var(--color-background)] p-4">
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={termsAccepted}
|
||||
onClick={() => setTermsAccepted(!termsAccepted)}
|
||||
className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 transition-all"
|
||||
style={{
|
||||
borderColor: termsAccepted
|
||||
? "var(--color-primary)"
|
||||
: "var(--input-border-color)",
|
||||
backgroundColor: termsAccepted
|
||||
? "var(--color-primary)"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
{termsAccepted && (
|
||||
<svg className="h-3 w-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<input type="hidden" name="terms" value={termsAccepted ? "1" : ""} />
|
||||
<label
|
||||
className="text-sm cursor-pointer select-none"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
onClick={() => setTermsAccepted(!termsAccepted)}
|
||||
>
|
||||
{t("termsAccept", { hotel: hotelName })}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Captcha */}
|
||||
{showCaptcha && captcha.provider === "turnstile" && (
|
||||
<div className="flex justify-center">
|
||||
<div className="cf-turnstile" data-sitekey={captcha.siteKey} />
|
||||
</div>
|
||||
)}
|
||||
{showCaptcha && captcha.provider === "recaptcha" && (
|
||||
<div className="flex justify-center">
|
||||
<div className="g-recaptcha" data-sitekey={captcha.siteKey} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
size="lg"
|
||||
className="w-full font-bold text-base h-12 rounded-xl"
|
||||
>
|
||||
{t("termsAccept", { hotel: hotelName })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isPending ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{t("creatingAccount")}
|
||||
</span>
|
||||
) : t("createAccount")}
|
||||
</Button>
|
||||
|
||||
{/* Captcha */}
|
||||
{showCaptcha && captcha.provider === "turnstile" && (
|
||||
<div className="cf-turnstile" data-sitekey={captcha.siteKey} />
|
||||
)}
|
||||
{showCaptcha && captcha.provider === "recaptcha" && (
|
||||
<div className="g-recaptcha" data-sitekey={captcha.siteKey} />
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full rounded p-2 text-white font-semibold transition-all duration-200 text-base"
|
||||
style={{
|
||||
backgroundColor: "#27a44d",
|
||||
border: "2px solid #34c661",
|
||||
}}
|
||||
>
|
||||
{isPending ? t("creatingAccount") : t("createAccount")}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm font-semibold hover:underline"
|
||||
style={{
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
}}
|
||||
>
|
||||
{t("alreadyHaveAccount")}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-[var(--color-text-muted-readable)]">
|
||||
{t("alreadyHaveAccount")}{" "}
|
||||
<Link href="/login" className="font-bold text-[var(--color-primary)] hover:underline">
|
||||
{t("signIn")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user