refactor(cms): separate dashboard data and editor components with focused UX fixes
This commit is contained in:
1 parent
3ae4f21217
commit
e17346ab9f
11 files changed
+1212
-1118
No files matched your search
+3
-120
@@ -1,4 +1,3 @@
|
||||
import { and, asc, count, desc, eq, gt, or } from "drizzle-orm";
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
@@ -9,19 +8,8 @@ import { Reveal } from "@/components/motion-reveal";
|
||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { SurfaceCard } from "@/components/surface-card";
|
||||
import { auth } from "@/lib/auth";
|
||||
import {
|
||||
db,
|
||||
MessengerFriendships,
|
||||
MessengerOffline,
|
||||
Rooms,
|
||||
User,
|
||||
UserReferrals,
|
||||
UsersBadges,
|
||||
UsersSettings,
|
||||
} from "@/lib/db";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { loadUserDashboard } from "@/lib/services/user-dashboard";
|
||||
import CopyReferralButton from "./CopyReferralButton";
|
||||
|
||||
export const metadata = { title: "My dashboard" };
|
||||
@@ -78,7 +66,7 @@ export default async function MePage({
|
||||
try {
|
||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
|
||||
const [
|
||||
const {
|
||||
userRows,
|
||||
hotelName,
|
||||
neededRaw,
|
||||
@@ -92,112 +80,7 @@ export default async function MePage({
|
||||
unreadCountRows,
|
||||
referralsRows,
|
||||
friends,
|
||||
] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: User.id,
|
||||
username: User.username,
|
||||
look: User.look,
|
||||
motto: User.motto,
|
||||
rank: User.rank,
|
||||
credits: User.credits,
|
||||
accountCreated: User.accountCreated,
|
||||
lastLogin: User.lastLogin,
|
||||
lastOnline: User.lastOnline,
|
||||
})
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1),
|
||||
resolveHotelName(),
|
||||
siteSettings.get("referrals_needed", "5"),
|
||||
siteSettings.get("referral_reward_amount", "30"),
|
||||
siteSettings.getMany([
|
||||
"referral_reward_currency_type",
|
||||
"referral_reward_currency",
|
||||
]),
|
||||
siteSettings.get("hotel_alert_banner", ""),
|
||||
db
|
||||
.select({
|
||||
id: Rooms.id,
|
||||
name: Rooms.name,
|
||||
ownerName: Rooms.ownerName,
|
||||
users: Rooms.users,
|
||||
usersMax: Rooms.usersMax,
|
||||
})
|
||||
.from(Rooms)
|
||||
.orderBy(desc(Rooms.id))
|
||||
.limit(6)
|
||||
.catch(() => []),
|
||||
db
|
||||
.select({
|
||||
badgeCode: UsersBadges.badgeCode,
|
||||
slotId: UsersBadges.slotId,
|
||||
})
|
||||
.from(UsersBadges)
|
||||
.where(and(eq(UsersBadges.userId, userId), gt(UsersBadges.slotId, 0)))
|
||||
.orderBy(asc(UsersBadges.slotId))
|
||||
.catch(() => []),
|
||||
db
|
||||
.select({
|
||||
achievementScore: UsersSettings.achievementScore,
|
||||
respectsReceived: UsersSettings.respectsReceived,
|
||||
})
|
||||
.from(UsersSettings)
|
||||
.where(eq(UsersSettings.userId, userId))
|
||||
.limit(1)
|
||||
.catch(() => []),
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(MessengerFriendships)
|
||||
.where(
|
||||
or(
|
||||
eq(MessengerFriendships.userOneId, userId),
|
||||
eq(MessengerFriendships.userTwoId, userId),
|
||||
),
|
||||
)
|
||||
.catch(() => [{ value: 0 }]),
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(MessengerOffline)
|
||||
.where(eq(MessengerOffline.userId, userId))
|
||||
.catch(() => [{ value: 0 }]),
|
||||
db
|
||||
.select({ referralsTotal: UserReferrals.referralsTotal })
|
||||
.from(UserReferrals)
|
||||
.where(eq(UserReferrals.userId, userId))
|
||||
.orderBy(desc(UserReferrals.id))
|
||||
.limit(1)
|
||||
.catch(() => []),
|
||||
db
|
||||
.select({
|
||||
id: User.id,
|
||||
username: User.username,
|
||||
look: User.look,
|
||||
motto: User.motto,
|
||||
online: User.online,
|
||||
})
|
||||
.from(MessengerFriendships)
|
||||
.innerJoin(
|
||||
User,
|
||||
or(
|
||||
and(
|
||||
eq(MessengerFriendships.userOneId, userId),
|
||||
eq(User.id, MessengerFriendships.userTwoId),
|
||||
),
|
||||
and(
|
||||
eq(MessengerFriendships.userTwoId, userId),
|
||||
eq(User.id, MessengerFriendships.userOneId),
|
||||
),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
or(
|
||||
eq(MessengerFriendships.userOneId, userId),
|
||||
eq(MessengerFriendships.userTwoId, userId),
|
||||
),
|
||||
)
|
||||
.catch(() => []),
|
||||
]);
|
||||
} = await loadUserDashboard(userId);
|
||||
|
||||
const user = userRows[0] ?? null;
|
||||
if (!user) throw new Error("user-not-found");
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
import { cn } from "@/lib/utils";
|
||||
/* ─── Helpers ────────────────────────────────────────────── */
|
||||
|
||||
export function categorizePerms(keys: string[]) {
|
||||
const commands: string[] = [];
|
||||
const account: string[] = [];
|
||||
const other: string[] = [];
|
||||
for (const key of keys.sort()) {
|
||||
if (key.startsWith("cmd_") || key === "kiss_cmd") commands.push(key);
|
||||
else if (key.startsWith("acc_")) account.push(key);
|
||||
else other.push(key);
|
||||
}
|
||||
return { commands, account, other };
|
||||
}
|
||||
|
||||
/* ─── Tri-state Toggle ───────────────────────────────────── */
|
||||
|
||||
export function PermToggle({
|
||||
value,
|
||||
maxValue,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
maxValue: number;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const values = Array.from({ length: Math.max(1, maxValue) + 1 }, (_, i) =>
|
||||
String(i),
|
||||
);
|
||||
return (
|
||||
<div className="flex gap-0.5 shrink-0">
|
||||
{values.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
aria-pressed={v === value}
|
||||
aria-label={`Permission value ${v}`}
|
||||
onClick={() => onChange(v)}
|
||||
className={cn(
|
||||
"w-7 h-6 text-[11px] font-mono rounded transition-all font-medium",
|
||||
v === value
|
||||
? v === "0"
|
||||
? "bg-[var(--admin-text-muted)] text-[var(--color-surface-text-readable)] shadow-sm"
|
||||
: v === "1"
|
||||
? "bg-[var(--admin-success)] text-[var(--admin-success-foreground)] shadow-sm"
|
||||
: "bg-[var(--admin-warning)] text-[var(--admin-warning-foreground)] shadow-sm"
|
||||
: "bg-muted/60 text-muted-foreground/40 hover:bg-muted hover:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,104 +31,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { getAvatarUrl } from "@/lib/imager";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { categorizePerms, PermToggle } from "./permission-controls";
|
||||
import type { RankEditorProps } from "./rank-editor-types";
|
||||
/* ─── Types ──────────────────────────────────────────────── */
|
||||
|
||||
interface RankData {
|
||||
id: number;
|
||||
rank_name: string;
|
||||
badge: string;
|
||||
level: number;
|
||||
prefix: string;
|
||||
prefix_color: string;
|
||||
hidden_rank: string;
|
||||
log_commands: string;
|
||||
room_effect: number;
|
||||
auto_credits_amount: number;
|
||||
auto_pixels_amount: number;
|
||||
auto_gotw_amount: number;
|
||||
auto_points_amount: number;
|
||||
permissions: Record<string, string>;
|
||||
permissionMaxValues: Record<string, number>;
|
||||
}
|
||||
|
||||
interface RankUser {
|
||||
id: number;
|
||||
username: string;
|
||||
look: string;
|
||||
}
|
||||
interface CmsRoleData {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
permissionSlugs: string[];
|
||||
}
|
||||
interface CmsPermission {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
}
|
||||
interface Props {
|
||||
rank: RankData;
|
||||
users: RankUser[];
|
||||
totalUsers: number;
|
||||
cmsRole: CmsRoleData | null;
|
||||
allCmsPermissions: CmsPermission[];
|
||||
}
|
||||
|
||||
/* ─── Helpers ────────────────────────────────────────────── */
|
||||
|
||||
function categorizePerms(keys: string[]) {
|
||||
const commands: string[] = [];
|
||||
const account: string[] = [];
|
||||
const other: string[] = [];
|
||||
for (const key of keys.sort()) {
|
||||
if (key.startsWith("cmd_") || key === "kiss_cmd") commands.push(key);
|
||||
else if (key.startsWith("acc_")) account.push(key);
|
||||
else other.push(key);
|
||||
}
|
||||
return { commands, account, other };
|
||||
}
|
||||
|
||||
/* ─── Tri-state Toggle ───────────────────────────────────── */
|
||||
|
||||
function PermToggle({
|
||||
value,
|
||||
maxValue,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
maxValue: number;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const values = Array.from({ length: Math.max(1, maxValue) + 1 }, (_, i) =>
|
||||
String(i),
|
||||
);
|
||||
return (
|
||||
<div className="flex gap-0.5 shrink-0">
|
||||
{values.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => onChange(v)}
|
||||
className={cn(
|
||||
"w-7 h-6 text-[11px] font-mono rounded transition-all font-medium",
|
||||
v === value
|
||||
? v === "0"
|
||||
? "bg-[var(--admin-text-muted)] text-[var(--color-surface-text-readable)] shadow-sm"
|
||||
: v === "1"
|
||||
? "bg-[var(--admin-success)] text-[var(--admin-success-foreground)] shadow-sm"
|
||||
: "bg-[var(--admin-warning)] text-[var(--admin-warning-foreground)] shadow-sm"
|
||||
: "bg-muted/60 text-muted-foreground/40 hover:bg-muted hover:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Main Component ─────────────────────────────────────── */
|
||||
|
||||
export function RankEditClient({
|
||||
@@ -137,7 +43,7 @@ export function RankEditClient({
|
||||
totalUsers,
|
||||
cmsRole: initialCmsRole,
|
||||
allCmsPermissions,
|
||||
}: Props) {
|
||||
}: RankEditorProps) {
|
||||
const { run, isPending } = useServerAction();
|
||||
|
||||
const [general, setGeneral] = useState({
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
interface RankData {
|
||||
id: number;
|
||||
rank_name: string;
|
||||
badge: string;
|
||||
level: number;
|
||||
prefix: string;
|
||||
prefix_color: string;
|
||||
hidden_rank: string;
|
||||
log_commands: string;
|
||||
room_effect: number;
|
||||
auto_credits_amount: number;
|
||||
auto_pixels_amount: number;
|
||||
auto_gotw_amount: number;
|
||||
auto_points_amount: number;
|
||||
permissions: Record<string, string>;
|
||||
permissionMaxValues: Record<string, number>;
|
||||
}
|
||||
|
||||
interface RankUser {
|
||||
id: number;
|
||||
username: string;
|
||||
look: string;
|
||||
}
|
||||
interface CmsRoleData {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
permissionSlugs: string[];
|
||||
}
|
||||
interface CmsPermission {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
}
|
||||
export interface RankEditorProps {
|
||||
rank: RankData;
|
||||
users: RankUser[];
|
||||
totalUsers: number;
|
||||
cmsRole: CmsRoleData | null;
|
||||
allCmsPermissions: CmsPermission[];
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
import { Save, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
PRESET_EFFECTS,
|
||||
PRESET_ICONS,
|
||||
type PrefixFormData,
|
||||
type UserPrefix,
|
||||
} from "./prefix-model";
|
||||
export function PrefixDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
editPrefix,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (data: PrefixFormData, editId?: number) => Promise<void>;
|
||||
editPrefix: UserPrefix | null;
|
||||
}) {
|
||||
const [form, setForm] = useState<PrefixFormData>({
|
||||
username: "",
|
||||
text: "",
|
||||
color: "#FFFFFF",
|
||||
icon: "",
|
||||
effect: "",
|
||||
active: false,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
if (editPrefix) {
|
||||
setForm({
|
||||
username: editPrefix.username || "",
|
||||
text: editPrefix.text,
|
||||
color: editPrefix.color,
|
||||
icon: editPrefix.icon || "",
|
||||
effect: editPrefix.effect || "",
|
||||
active: editPrefix.active,
|
||||
});
|
||||
} else {
|
||||
setForm({
|
||||
username: "",
|
||||
text: "",
|
||||
color: "#FFFFFF",
|
||||
icon: "",
|
||||
effect: "",
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
}, [editPrefix, isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(form, editPrefix?.id);
|
||||
onClose();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Parse multi-color for preview
|
||||
const colors = form.color.split(",");
|
||||
const hasMultiColor = colors.length > 1 && new Set(colors).size > 1;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !saving) onClose();
|
||||
}}
|
||||
disablePointerDismissal
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="block max-h-[90dvh] overflow-y-auto sm:max-w-md space-y-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="text-lg font-semibold">
|
||||
{editPrefix ? "Edit Prefix" : "Add Prefix"}
|
||||
</DialogTitle>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<span className="sr-only">Close prefix editor</span>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="flex items-center justify-center p-3 rounded-lg bg-muted min-h-[40px]">
|
||||
<span className="text-lg font-bold">
|
||||
{form.icon && <span className="mr-0.5">{form.icon}</span>}
|
||||
{hasMultiColor ? (
|
||||
<span>
|
||||
{"{"}
|
||||
{[...(form.text || "...")]
|
||||
.map((char, i) => ({ char, n: i }))
|
||||
.map(({ char, n }) => (
|
||||
<span
|
||||
key={`char-${n}`}
|
||||
style={{ color: colors[Math.min(n, colors.length - 1)] }}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
{"}"}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: colors[0] || "#FFFFFF" }}>
|
||||
{"{"}
|
||||
{form.text || "..."}
|
||||
{"}"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="ml-2 text-lg text-muted-foreground">
|
||||
{form.username || "Username"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!editPrefix && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">Username</span>
|
||||
<Input
|
||||
placeholder="Username..."
|
||||
aria-label="Username"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">Text</span>
|
||||
<Input
|
||||
maxLength={15}
|
||||
placeholder="Prefix text..."
|
||||
aria-label="Prefix text"
|
||||
value={form.text}
|
||||
onChange={(e) => setForm({ ...form, text: e.target.value })}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{form.text.length}/15
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">
|
||||
Color (HEX or multi: #FF0000,#00FF00)
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="#FFFFFF o #FF0000,#00FF00,..."
|
||||
aria-label="Prefix color"
|
||||
value={form.color}
|
||||
onChange={(e) => setForm({ ...form, color: e.target.value })}
|
||||
/>
|
||||
<div
|
||||
className="w-8 h-8 rounded border shrink-0"
|
||||
style={{ backgroundColor: colors[0] || "#FFFFFF" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">Icon</span>
|
||||
<select
|
||||
className="w-full px-3 py-2 rounded border bg-background text-sm"
|
||||
aria-label="Prefix icon"
|
||||
value={form.icon}
|
||||
onChange={(e) => setForm({ ...form, icon: e.target.value })}
|
||||
>
|
||||
{PRESET_ICONS.map((icon) => (
|
||||
<option key={icon.label} value={icon.emoji}>
|
||||
{icon.emoji ? `${icon.emoji} ${icon.label}` : icon.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">Effect</span>
|
||||
<select
|
||||
className="w-full px-3 py-2 rounded border bg-background text-sm"
|
||||
aria-label="Prefix effect"
|
||||
value={form.effect}
|
||||
onChange={(e) => setForm({ ...form, effect: e.target.value })}
|
||||
>
|
||||
{PRESET_EFFECTS.map((fx) => (
|
||||
<option key={fx.id} value={fx.id}>
|
||||
{fx.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
checked={form.active}
|
||||
id="prefix-active"
|
||||
type="checkbox"
|
||||
onChange={(e) => setForm({ ...form, active: e.target.checked })}
|
||||
/>
|
||||
<label className="text-sm" htmlFor="prefix-active">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
saving ||
|
||||
!form.text.trim() ||
|
||||
(!editPrefix && !form.username.trim())
|
||||
}
|
||||
onClick={handleSave}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
export interface UserPrefix {
|
||||
id: number;
|
||||
user_id: number;
|
||||
text: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
effect: string;
|
||||
active: boolean;
|
||||
created_at: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface BlacklistWord {
|
||||
id: number;
|
||||
word: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PrefixSettings {
|
||||
max_length: string;
|
||||
min_rank_to_buy: string;
|
||||
price_credits: string;
|
||||
price_points: string;
|
||||
points_type: string;
|
||||
}
|
||||
|
||||
export interface PrefixFormData {
|
||||
username: string;
|
||||
text: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
effect: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export const PRESET_EFFECTS = [
|
||||
{ id: "", label: "None" },
|
||||
{ id: "glow", label: "✨ Glow" },
|
||||
{ id: "shadow", label: "🌑 Shadow" },
|
||||
{ id: "italic", label: "𝑰 Italic" },
|
||||
{ id: "outline", label: "🔲 Outline" },
|
||||
{ id: "pulse", label: "💫 Pulse" },
|
||||
{ id: "bold-glow", label: "💡 Neon" },
|
||||
];
|
||||
|
||||
export const PRESET_ICONS = [
|
||||
{ emoji: "", label: "None" },
|
||||
{ emoji: "👑", label: "Crown" },
|
||||
{ emoji: "⭐", label: "Star" },
|
||||
{ emoji: "❤️", label: "Heart" },
|
||||
{ emoji: "💎", label: "Diamond" },
|
||||
{ emoji: "🔥", label: "Fire" },
|
||||
{ emoji: "⚡", label: "Lightning" },
|
||||
{ emoji: "🛡️", label: "Shield" },
|
||||
{ emoji: "💀", label: "Skull" },
|
||||
{ emoji: "🎵", label: "Music" },
|
||||
{ emoji: "💠", label: "Gem" },
|
||||
{ emoji: "⚔️", label: "Swords" },
|
||||
{ emoji: "🏆", label: "Trophy" },
|
||||
{ emoji: "🚀", label: "Rocket" },
|
||||
{ emoji: "👻", label: "Ghost" },
|
||||
{ emoji: "🐾", label: "Paw" },
|
||||
{ emoji: "🍃", label: "Leaf" },
|
||||
{ emoji: "😎", label: "Cool" },
|
||||
{ emoji: "🐺", label: "Wolf" },
|
||||
{ emoji: "🦁", label: "Lion" },
|
||||
{ emoji: "🐉", label: "Dragon" },
|
||||
{ emoji: "🦄", label: "Unicorn" },
|
||||
{ emoji: "🌙", label: "Moon" },
|
||||
{ emoji: "☀️", label: "Sun" },
|
||||
{ emoji: "❄️", label: "Snow" },
|
||||
{ emoji: "🌹", label: "Rose" },
|
||||
{ emoji: "🎮", label: "Controller" },
|
||||
{ emoji: "💰", label: "Money" },
|
||||
{ emoji: "🔮", label: "Orb" },
|
||||
{ emoji: "🏴☠️", label: "Pirate" },
|
||||
];
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil, Plus, Save, Search, Trash2, X } from "lucide-react";
|
||||
import { Pencil, Plus, Save, Search, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
addBlacklistWord as addBlacklistWordAction,
|
||||
@@ -25,296 +25,14 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
interface UserPrefix {
|
||||
id: number;
|
||||
user_id: number;
|
||||
text: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
effect: string;
|
||||
active: boolean;
|
||||
created_at: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface BlacklistWord {
|
||||
id: number;
|
||||
word: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface PrefixSettings {
|
||||
max_length: string;
|
||||
min_rank_to_buy: string;
|
||||
price_credits: string;
|
||||
price_points: string;
|
||||
points_type: string;
|
||||
}
|
||||
|
||||
interface PrefixFormData {
|
||||
username: string;
|
||||
text: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
effect: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const PRESET_EFFECTS = [
|
||||
{ id: "", label: "None" },
|
||||
{ id: "glow", label: "✨ Glow" },
|
||||
{ id: "shadow", label: "🌑 Shadow" },
|
||||
{ id: "italic", label: "𝑰 Italic" },
|
||||
{ id: "outline", label: "🔲 Outline" },
|
||||
{ id: "pulse", label: "💫 Pulse" },
|
||||
{ id: "bold-glow", label: "💡 Neon" },
|
||||
];
|
||||
|
||||
const PRESET_ICONS = [
|
||||
{ emoji: "", label: "None" },
|
||||
{ emoji: "👑", label: "Crown" },
|
||||
{ emoji: "⭐", label: "Star" },
|
||||
{ emoji: "❤️", label: "Heart" },
|
||||
{ emoji: "💎", label: "Diamond" },
|
||||
{ emoji: "🔥", label: "Fire" },
|
||||
{ emoji: "⚡", label: "Lightning" },
|
||||
{ emoji: "🛡️", label: "Shield" },
|
||||
{ emoji: "💀", label: "Skull" },
|
||||
{ emoji: "🎵", label: "Music" },
|
||||
{ emoji: "💠", label: "Gem" },
|
||||
{ emoji: "⚔️", label: "Swords" },
|
||||
{ emoji: "🏆", label: "Trophy" },
|
||||
{ emoji: "🚀", label: "Rocket" },
|
||||
{ emoji: "👻", label: "Ghost" },
|
||||
{ emoji: "🐾", label: "Paw" },
|
||||
{ emoji: "🍃", label: "Leaf" },
|
||||
{ emoji: "😎", label: "Cool" },
|
||||
{ emoji: "🐺", label: "Wolf" },
|
||||
{ emoji: "🦁", label: "Lion" },
|
||||
{ emoji: "🐉", label: "Dragon" },
|
||||
{ emoji: "🦄", label: "Unicorn" },
|
||||
{ emoji: "🌙", label: "Moon" },
|
||||
{ emoji: "☀️", label: "Sun" },
|
||||
{ emoji: "❄️", label: "Snow" },
|
||||
{ emoji: "🌹", label: "Rose" },
|
||||
{ emoji: "🎮", label: "Controller" },
|
||||
{ emoji: "💰", label: "Money" },
|
||||
{ emoji: "🔮", label: "Orb" },
|
||||
{ emoji: "🏴☠️", label: "Pirate" },
|
||||
];
|
||||
|
||||
function PrefixDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
editPrefix,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (data: PrefixFormData, editId?: number) => Promise<void>;
|
||||
editPrefix: UserPrefix | null;
|
||||
}) {
|
||||
const [form, setForm] = useState<PrefixFormData>({
|
||||
username: "",
|
||||
text: "",
|
||||
color: "#FFFFFF",
|
||||
icon: "",
|
||||
effect: "",
|
||||
active: false,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
if (editPrefix) {
|
||||
setForm({
|
||||
username: editPrefix.username || "",
|
||||
text: editPrefix.text,
|
||||
color: editPrefix.color,
|
||||
icon: editPrefix.icon || "",
|
||||
effect: editPrefix.effect || "",
|
||||
active: editPrefix.active,
|
||||
});
|
||||
} else {
|
||||
setForm({
|
||||
username: "",
|
||||
text: "",
|
||||
color: "#FFFFFF",
|
||||
icon: "",
|
||||
effect: "",
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
}, [editPrefix, isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(form, editPrefix?.id);
|
||||
onClose();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Parse multi-color for preview
|
||||
const colors = form.color.split(",");
|
||||
const hasMultiColor = colors.length > 1 && new Set(colors).size > 1;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-foreground/50">
|
||||
<div className="bg-background rounded-lg shadow-lg w-full max-w-md p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{editPrefix ? "Edit Prefix" : "Add Prefix"}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="flex items-center justify-center p-3 rounded-lg bg-muted min-h-[40px]">
|
||||
<span className="text-lg font-bold">
|
||||
{form.icon && <span className="mr-0.5">{form.icon}</span>}
|
||||
{hasMultiColor ? (
|
||||
<span>
|
||||
{"{"}
|
||||
{[...(form.text || "...")]
|
||||
.map((char, i) => ({ char, n: i }))
|
||||
.map(({ char, n }) => (
|
||||
<span
|
||||
key={`char-${n}`}
|
||||
style={{ color: colors[Math.min(n, colors.length - 1)] }}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
{"}"}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: colors[0] || "#FFFFFF" }}>
|
||||
{"{"}
|
||||
{form.text || "..."}
|
||||
{"}"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="ml-2 text-lg text-muted-foreground">
|
||||
{form.username || "Username"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!editPrefix && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">Username</span>
|
||||
<Input
|
||||
placeholder="Username..."
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">Text</span>
|
||||
<Input
|
||||
maxLength={15}
|
||||
placeholder="Prefix text..."
|
||||
value={form.text}
|
||||
onChange={(e) => setForm({ ...form, text: e.target.value })}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{form.text.length}/15
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">
|
||||
Color (HEX or multi: #FF0000,#00FF00)
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="#FFFFFF o #FF0000,#00FF00,..."
|
||||
value={form.color}
|
||||
onChange={(e) => setForm({ ...form, color: e.target.value })}
|
||||
/>
|
||||
<div
|
||||
className="w-8 h-8 rounded border shrink-0"
|
||||
style={{ backgroundColor: colors[0] || "#FFFFFF" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">Icon</span>
|
||||
<select
|
||||
className="w-full px-3 py-2 rounded border bg-background text-sm"
|
||||
value={form.icon}
|
||||
onChange={(e) => setForm({ ...form, icon: e.target.value })}
|
||||
>
|
||||
{PRESET_ICONS.map((icon) => (
|
||||
<option key={icon.label} value={icon.emoji}>
|
||||
{icon.emoji ? `${icon.emoji} ${icon.label}` : icon.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">Effect</span>
|
||||
<select
|
||||
className="w-full px-3 py-2 rounded border bg-background text-sm"
|
||||
value={form.effect}
|
||||
onChange={(e) => setForm({ ...form, effect: e.target.value })}
|
||||
>
|
||||
{PRESET_EFFECTS.map((fx) => (
|
||||
<option key={fx.id} value={fx.id}>
|
||||
{fx.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
checked={form.active}
|
||||
id="prefix-active"
|
||||
type="checkbox"
|
||||
onChange={(e) => setForm({ ...form, active: e.target.checked })}
|
||||
/>
|
||||
<label className="text-sm" htmlFor="prefix-active">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
saving ||
|
||||
!form.text.trim() ||
|
||||
(!editPrefix && !form.username.trim())
|
||||
}
|
||||
onClick={handleSave}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
import { PrefixDialog } from "./prefix-dialog";
|
||||
import {
|
||||
type BlacklistWord,
|
||||
PRESET_EFFECTS,
|
||||
type PrefixFormData,
|
||||
type PrefixSettings,
|
||||
type UserPrefix,
|
||||
} from "./prefix-model";
|
||||
export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
|
||||
const { run, isPending } = useServerAction();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import type { EffectKey, LayoutKey, MediaKey } from "@/lib/theme-blocks";
|
||||
import type { ThemeColorKey } from "@/lib/theme-presets";
|
||||
export interface ScopeInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
parentId: number | null;
|
||||
siteDomain: string | null;
|
||||
routePath: string | null;
|
||||
moduleId: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface ScopeValues {
|
||||
light: Record<string, string>;
|
||||
dark: Record<string, string>;
|
||||
blocks: Record<string, string>;
|
||||
layout: Record<string, string | undefined>;
|
||||
effects: Record<string, string | undefined>;
|
||||
media: Record<string, string | undefined>;
|
||||
custom: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
// ─── Color fields ────────────────────────────────────────────────────────────
|
||||
|
||||
export const COLOR_FIELDS: Array<{
|
||||
key: ThemeColorKey;
|
||||
label: string;
|
||||
def: string;
|
||||
desc: string;
|
||||
bgKey?: string;
|
||||
}> = [
|
||||
{
|
||||
key: "color_background",
|
||||
label: "Page background",
|
||||
def: "#f8fafc",
|
||||
desc: "Main page background",
|
||||
},
|
||||
{
|
||||
key: "color_surface",
|
||||
label: "Card / surface",
|
||||
def: "#ffffff",
|
||||
desc: "Cards, panels, elevated surfaces",
|
||||
},
|
||||
{
|
||||
key: "color_dropdown",
|
||||
label: "Dropdown",
|
||||
def: "#ffffff",
|
||||
desc: "Dropdown menus",
|
||||
},
|
||||
{
|
||||
key: "color_navbar",
|
||||
label: "Navbar",
|
||||
def: "#ffffff",
|
||||
desc: "Navigation bar background",
|
||||
},
|
||||
{
|
||||
key: "color_navbar_text",
|
||||
label: "Navbar text",
|
||||
def: "#1e293b",
|
||||
desc: "Nav text color",
|
||||
bgKey: "color_navbar",
|
||||
},
|
||||
{
|
||||
key: "color_text",
|
||||
label: "Body text",
|
||||
def: "#0f172a",
|
||||
desc: "Main text color",
|
||||
bgKey: "color_surface",
|
||||
},
|
||||
{
|
||||
key: "color_text_muted",
|
||||
label: "Muted text",
|
||||
def: "#64748b",
|
||||
desc: "Secondary text",
|
||||
bgKey: "color_surface",
|
||||
},
|
||||
{
|
||||
key: "color_primary",
|
||||
label: "Primary accent",
|
||||
def: "#f59e0b",
|
||||
desc: "Main brand color",
|
||||
},
|
||||
{
|
||||
key: "color_accent",
|
||||
label: "Secondary accent",
|
||||
def: "#10b981",
|
||||
desc: "Secondary brand color",
|
||||
},
|
||||
{
|
||||
key: "color_success",
|
||||
label: "Success",
|
||||
def: "#16a34a",
|
||||
desc: "Success states",
|
||||
},
|
||||
{
|
||||
key: "color_warning",
|
||||
label: "Warning",
|
||||
def: "#eab308",
|
||||
desc: "Warning states",
|
||||
},
|
||||
{ key: "color_error", label: "Error", def: "#ef4444", desc: "Error states" },
|
||||
{
|
||||
key: "color_info",
|
||||
label: "Info",
|
||||
def: "#0ea5e9",
|
||||
desc: "Information states",
|
||||
},
|
||||
{
|
||||
key: "button_primary_color",
|
||||
label: "Button primary",
|
||||
def: "#f59e0b",
|
||||
desc: "Primary button background",
|
||||
},
|
||||
{
|
||||
key: "button_text_color",
|
||||
label: "Button primary text",
|
||||
def: "#1e293b",
|
||||
desc: "Primary button text",
|
||||
},
|
||||
{
|
||||
key: "button_secondary_color",
|
||||
label: "Button secondary",
|
||||
def: "#22c55e",
|
||||
desc: "Secondary button background",
|
||||
},
|
||||
{
|
||||
key: "button_secondary_text_color",
|
||||
label: "Button secondary text",
|
||||
def: "#ffffff",
|
||||
desc: "Secondary button text",
|
||||
},
|
||||
{
|
||||
key: "button_danger_color",
|
||||
label: "Button danger",
|
||||
def: "#ef4444",
|
||||
desc: "Danger button background",
|
||||
},
|
||||
{
|
||||
key: "button_danger_text_color",
|
||||
label: "Button danger text",
|
||||
def: "#ffffff",
|
||||
desc: "Danger button text",
|
||||
},
|
||||
{
|
||||
key: "button_outline_color",
|
||||
label: "Button outline",
|
||||
def: "#eeb425",
|
||||
desc: "Outline button border",
|
||||
},
|
||||
{
|
||||
key: "button_outline_text_color",
|
||||
label: "Button outline text",
|
||||
def: "#1a1a2e",
|
||||
desc: "Outline button text",
|
||||
},
|
||||
{
|
||||
key: "link_color",
|
||||
label: "Link color",
|
||||
def: "#b77900",
|
||||
desc: "Link text color",
|
||||
},
|
||||
{
|
||||
key: "link_hover_color",
|
||||
label: "Link hover",
|
||||
def: "#8a5b00",
|
||||
desc: "Link hover color",
|
||||
},
|
||||
{
|
||||
key: "border_color",
|
||||
label: "Border color",
|
||||
def: "#eeb425",
|
||||
desc: "General borders",
|
||||
},
|
||||
{
|
||||
key: "panel_border_color",
|
||||
label: "Panel border",
|
||||
def: "#e9b124",
|
||||
desc: "Panel borders",
|
||||
},
|
||||
{
|
||||
key: "gradient_from",
|
||||
label: "Gradient start",
|
||||
def: "#f59e0b",
|
||||
desc: "Gradient from color",
|
||||
},
|
||||
{
|
||||
key: "gradient_to",
|
||||
label: "Gradient end",
|
||||
def: "#10b981",
|
||||
desc: "Gradient to color",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Layout fields ───────────────────────────────────────────────────────────
|
||||
|
||||
export const LAYOUT_FIELDS: Array<{
|
||||
key: LayoutKey;
|
||||
label: string;
|
||||
def: string;
|
||||
desc: string;
|
||||
type?: string;
|
||||
}> = [
|
||||
{
|
||||
key: "layout:max_width",
|
||||
label: "Max Width",
|
||||
def: "1280px",
|
||||
desc: "Maximum content width",
|
||||
},
|
||||
{
|
||||
key: "layout:spacing",
|
||||
label: "Grid Spacing",
|
||||
def: "1.5rem",
|
||||
desc: "Gap between grid items",
|
||||
},
|
||||
{
|
||||
key: "layout:grid_columns",
|
||||
label: "Grid Columns",
|
||||
def: "12",
|
||||
desc: "Number of grid columns",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
key: "layout:content_padding",
|
||||
label: "Content Padding",
|
||||
def: "0.75rem",
|
||||
desc: "Horizontal padding on content",
|
||||
},
|
||||
{
|
||||
key: "layout:navbar_height",
|
||||
label: "Navbar Height",
|
||||
def: "56px",
|
||||
desc: "Height of the navigation bar",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Effect fields ───────────────────────────────────────────────────────────
|
||||
|
||||
export const EFFECT_FIELDS: Array<{
|
||||
key: EffectKey;
|
||||
label: string;
|
||||
def: string;
|
||||
desc: string;
|
||||
type?: string;
|
||||
}> = [
|
||||
{
|
||||
key: "effect:card_shadow",
|
||||
label: "Card Shadow",
|
||||
def: "0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)",
|
||||
desc: "Default card box shadow",
|
||||
},
|
||||
{
|
||||
key: "effect:card_shadow_hover",
|
||||
label: "Card Shadow (hover)",
|
||||
def: "0 8px 24px rgba(0,0,0,0.1), 0 2px 6px rgba(0,0,0,0.06)",
|
||||
desc: "Card box shadow on hover",
|
||||
},
|
||||
{
|
||||
key: "effect:border_style",
|
||||
label: "Border Style",
|
||||
def: "solid",
|
||||
desc: "CSS border-style (solid, dashed, dotted, etc.)",
|
||||
},
|
||||
{
|
||||
key: "effect:border_width",
|
||||
label: "Border Width",
|
||||
def: "1px",
|
||||
desc: "Default border width",
|
||||
},
|
||||
{
|
||||
key: "effect:transition_duration",
|
||||
label: "Transition Duration",
|
||||
def: "200ms",
|
||||
desc: "Duration for hover/transition effects",
|
||||
},
|
||||
{
|
||||
key: "effect:animation_scale",
|
||||
label: "Animation Scale",
|
||||
def: "1",
|
||||
desc: "Scale factor for animations (1 = normal)",
|
||||
type: "number",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Media fields ────────────────────────────────────────────────────────────
|
||||
|
||||
export const MEDIA_FIELDS: Array<{
|
||||
key: MediaKey;
|
||||
label: string;
|
||||
def: string;
|
||||
desc: string;
|
||||
type?: string;
|
||||
}> = [
|
||||
{
|
||||
key: "media:logo_url",
|
||||
label: "Logo URL",
|
||||
def: "",
|
||||
desc: "Custom logo image URL (overrides default)",
|
||||
},
|
||||
{
|
||||
key: "media:background_image",
|
||||
label: "Background Image",
|
||||
def: "",
|
||||
desc: "Page background image URL",
|
||||
},
|
||||
{
|
||||
key: "media:background_position",
|
||||
label: "Background Position",
|
||||
def: "center",
|
||||
desc: "CSS background-position",
|
||||
},
|
||||
{
|
||||
key: "media:background_size",
|
||||
label: "Background Size",
|
||||
def: "cover",
|
||||
desc: "CSS background-size (cover, contain, auto)",
|
||||
},
|
||||
{
|
||||
key: "media:background_repeat",
|
||||
label: "Background Repeat",
|
||||
def: "no-repeat",
|
||||
desc: "CSS background-repeat",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getVal(
|
||||
values: ScopeValues | null,
|
||||
key: string,
|
||||
mode: "light" | "dark",
|
||||
): string {
|
||||
if (!values) return "";
|
||||
if (key.startsWith("block:")) return values.blocks[key] ?? "";
|
||||
if (key.startsWith("layout:")) return values.layout[key] ?? "";
|
||||
if (key.startsWith("effect:")) return values.effects[key] ?? "";
|
||||
if (key.startsWith("media:")) return values.media[key] ?? "";
|
||||
if (key.startsWith("custom:")) return values.custom[key] ?? "";
|
||||
if (mode === "dark") return values.dark[key] ?? "";
|
||||
return values.light[key] ?? "";
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
import { BLOCK_KEY_PREFIX, getBlocksByCategory } from "@/lib/theme-blocks";
|
||||
import { ColorField } from "./color-field";
|
||||
import {
|
||||
COLOR_FIELDS,
|
||||
EFFECT_FIELDS,
|
||||
getVal,
|
||||
LAYOUT_FIELDS,
|
||||
MEDIA_FIELDS,
|
||||
type ScopeValues,
|
||||
} from "./theme-editor-fields";
|
||||
// ─── Colors Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function ColorsTab({
|
||||
values,
|
||||
mode,
|
||||
}: {
|
||||
values: ScopeValues;
|
||||
mode: "light" | "dark";
|
||||
}) {
|
||||
return (
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Colors ({mode} mode)
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Only values you set here override the parent scope. Leave empty to
|
||||
inherit.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{COLOR_FIELDS.map((field) => (
|
||||
<ColorField
|
||||
key={`${field.key}-${mode}`}
|
||||
name={`${field.key}_${mode}`}
|
||||
label={field.label}
|
||||
description={field.desc}
|
||||
defaultValue={getVal(values, field.key, mode) || field.def}
|
||||
backgroundColorKey={
|
||||
field.bgKey ? `${field.bgKey}_${mode}` : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Blocks Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlocksTab({ values }: { values: ScopeValues }) {
|
||||
const grouped = getBlocksByCategory();
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
chrome: "Site Chrome",
|
||||
layout: "Layout",
|
||||
components: "Components",
|
||||
effects: "Effects",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(grouped).map(([category, blocks]) => (
|
||||
<div key={category} className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-1">
|
||||
{categoryLabels[category] ?? category}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Toggle visibility of blocks in this scope. Hidden blocks use{" "}
|
||||
<code className="bg-[var(--admin-surface-elevated)] px-1 rounded">
|
||||
display: none
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{blocks.map((block) => {
|
||||
const key = `${BLOCK_KEY_PREFIX}${block.id}`;
|
||||
const currentVal = values.blocks[key];
|
||||
const isChecked =
|
||||
currentVal === "true" ||
|
||||
(currentVal === undefined && block.defaultVisible);
|
||||
|
||||
return (
|
||||
<label
|
||||
key={block.id}
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--admin-surface-elevated)] cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={key}
|
||||
defaultChecked={isChecked}
|
||||
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-[var(--admin-accent)]"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm text-[var(--admin-text)]">
|
||||
{block.label}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] ml-2">
|
||||
{block.description}
|
||||
</span>
|
||||
</div>
|
||||
<code className="text-[10px] text-[var(--admin-text-muted)] font-mono">
|
||||
{block.selector}
|
||||
</code>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Layout Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function LayoutTab({ values }: { values: ScopeValues }) {
|
||||
return (
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Layout Settings
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Control spacing, width, and grid settings for this scope. Values are
|
||||
applied as CSS custom properties.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{LAYOUT_FIELDS.map((field) => (
|
||||
<div key={field.key} className="rounded-lg p-2">
|
||||
<label className="block text-sm font-semibold text-[var(--admin-text)] mb-1">
|
||||
{field.label}
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-normal">
|
||||
{field.desc}
|
||||
</p>
|
||||
<input
|
||||
type={field.type || "text"}
|
||||
name={field.key}
|
||||
defaultValue={getVal(values, field.key, "light") || field.def}
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Effects Tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function EffectsTab({ values }: { values: ScopeValues }) {
|
||||
return (
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Effects & Transitions
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Control shadows, borders, and animation timing for this scope.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{EFFECT_FIELDS.map((field) => (
|
||||
<div key={field.key} className="rounded-lg p-2">
|
||||
<label className="block text-sm font-semibold text-[var(--admin-text)] mb-1">
|
||||
{field.label}
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-normal">
|
||||
{field.desc}
|
||||
</p>
|
||||
<input
|
||||
type={field.type || "text"}
|
||||
name={field.key}
|
||||
defaultValue={getVal(values, field.key, "light") || field.def}
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Media Tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function MediaTab({ values }: { values: ScopeValues }) {
|
||||
return (
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Media & Backgrounds
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Set custom logos, background images, and background properties for this
|
||||
scope.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{MEDIA_FIELDS.map((field) => (
|
||||
<div key={field.key} className="rounded-lg p-2">
|
||||
<label className="block text-sm font-semibold text-[var(--admin-text)] mb-1">
|
||||
{field.label}
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-normal">
|
||||
{field.desc}
|
||||
</p>
|
||||
<input
|
||||
type={field.type || "text"}
|
||||
name={field.key}
|
||||
defaultValue={getVal(values, field.key, "light") || field.def}
|
||||
placeholder={field.def}
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Custom Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function CustomTab({ values }: { values: ScopeValues }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Custom CSS
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Add custom CSS that applies within this scope. Use{" "}
|
||||
<code className="bg-[var(--admin-surface-elevated)] px-1 rounded">
|
||||
&{" "}
|
||||
</code>{" "}
|
||||
as a placeholder for the scope selector (e.g.{" "}
|
||||
<code className="bg-[var(--admin-surface-elevated)] px-1 rounded">
|
||||
& .content-card {"{"} border: 2px solid red; {"}"}
|
||||
</code>
|
||||
).
|
||||
</p>
|
||||
<textarea
|
||||
name="custom:css"
|
||||
defaultValue={getVal(values, "custom:css", "light")}
|
||||
rows={8}
|
||||
placeholder={".content-card { border-radius: 20px; }"}
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono resize-y"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Custom HTML
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Add custom HTML that injects into the page for this scope. Use for
|
||||
custom banners, scripts, or content blocks.
|
||||
</p>
|
||||
<textarea
|
||||
name="custom:html"
|
||||
defaultValue={getVal(values, "custom:html", "light")}
|
||||
rows={6}
|
||||
placeholder='<div class="my-banner">Welcome!</div>'
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono resize-y"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,361 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
import {
|
||||
deleteScope,
|
||||
duplicateScope,
|
||||
saveScopeValues,
|
||||
} from "@/actions/theme-builder";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ScopeInfo, ScopeValues } from "./theme-editor-fields";
|
||||
import {
|
||||
BLOCK_KEY_PREFIX,
|
||||
type EffectKey,
|
||||
getBlocksByCategory,
|
||||
type LayoutKey,
|
||||
type MediaKey,
|
||||
} from "@/lib/theme-blocks";
|
||||
import type { ThemeColorKey } from "@/lib/theme-presets";
|
||||
import { ColorField } from "./color-field";
|
||||
|
||||
interface ScopeInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
parentId: number | null;
|
||||
siteDomain: string | null;
|
||||
routePath: string | null;
|
||||
moduleId: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface ScopeValues {
|
||||
light: Record<string, string>;
|
||||
dark: Record<string, string>;
|
||||
blocks: Record<string, string>;
|
||||
layout: Record<string, string | undefined>;
|
||||
effects: Record<string, string | undefined>;
|
||||
media: Record<string, string | undefined>;
|
||||
custom: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
// ─── Color fields ────────────────────────────────────────────────────────────
|
||||
|
||||
const COLOR_FIELDS: Array<{
|
||||
key: ThemeColorKey;
|
||||
label: string;
|
||||
def: string;
|
||||
desc: string;
|
||||
bgKey?: string;
|
||||
}> = [
|
||||
{
|
||||
key: "color_background",
|
||||
label: "Page background",
|
||||
def: "#f8fafc",
|
||||
desc: "Main page background",
|
||||
},
|
||||
{
|
||||
key: "color_surface",
|
||||
label: "Card / surface",
|
||||
def: "#ffffff",
|
||||
desc: "Cards, panels, elevated surfaces",
|
||||
},
|
||||
{
|
||||
key: "color_dropdown",
|
||||
label: "Dropdown",
|
||||
def: "#ffffff",
|
||||
desc: "Dropdown menus",
|
||||
},
|
||||
{
|
||||
key: "color_navbar",
|
||||
label: "Navbar",
|
||||
def: "#ffffff",
|
||||
desc: "Navigation bar background",
|
||||
},
|
||||
{
|
||||
key: "color_navbar_text",
|
||||
label: "Navbar text",
|
||||
def: "#1e293b",
|
||||
desc: "Nav text color",
|
||||
bgKey: "color_navbar",
|
||||
},
|
||||
{
|
||||
key: "color_text",
|
||||
label: "Body text",
|
||||
def: "#0f172a",
|
||||
desc: "Main text color",
|
||||
bgKey: "color_surface",
|
||||
},
|
||||
{
|
||||
key: "color_text_muted",
|
||||
label: "Muted text",
|
||||
def: "#64748b",
|
||||
desc: "Secondary text",
|
||||
bgKey: "color_surface",
|
||||
},
|
||||
{
|
||||
key: "color_primary",
|
||||
label: "Primary accent",
|
||||
def: "#f59e0b",
|
||||
desc: "Main brand color",
|
||||
},
|
||||
{
|
||||
key: "color_accent",
|
||||
label: "Secondary accent",
|
||||
def: "#10b981",
|
||||
desc: "Secondary brand color",
|
||||
},
|
||||
{
|
||||
key: "color_success",
|
||||
label: "Success",
|
||||
def: "#16a34a",
|
||||
desc: "Success states",
|
||||
},
|
||||
{
|
||||
key: "color_warning",
|
||||
label: "Warning",
|
||||
def: "#eab308",
|
||||
desc: "Warning states",
|
||||
},
|
||||
{ key: "color_error", label: "Error", def: "#ef4444", desc: "Error states" },
|
||||
{
|
||||
key: "color_info",
|
||||
label: "Info",
|
||||
def: "#0ea5e9",
|
||||
desc: "Information states",
|
||||
},
|
||||
{
|
||||
key: "button_primary_color",
|
||||
label: "Button primary",
|
||||
def: "#f59e0b",
|
||||
desc: "Primary button background",
|
||||
},
|
||||
{
|
||||
key: "button_text_color",
|
||||
label: "Button primary text",
|
||||
def: "#1e293b",
|
||||
desc: "Primary button text",
|
||||
},
|
||||
{
|
||||
key: "button_secondary_color",
|
||||
label: "Button secondary",
|
||||
def: "#22c55e",
|
||||
desc: "Secondary button background",
|
||||
},
|
||||
{
|
||||
key: "button_secondary_text_color",
|
||||
label: "Button secondary text",
|
||||
def: "#ffffff",
|
||||
desc: "Secondary button text",
|
||||
},
|
||||
{
|
||||
key: "button_danger_color",
|
||||
label: "Button danger",
|
||||
def: "#ef4444",
|
||||
desc: "Danger button background",
|
||||
},
|
||||
{
|
||||
key: "button_danger_text_color",
|
||||
label: "Button danger text",
|
||||
def: "#ffffff",
|
||||
desc: "Danger button text",
|
||||
},
|
||||
{
|
||||
key: "button_outline_color",
|
||||
label: "Button outline",
|
||||
def: "#eeb425",
|
||||
desc: "Outline button border",
|
||||
},
|
||||
{
|
||||
key: "button_outline_text_color",
|
||||
label: "Button outline text",
|
||||
def: "#1a1a2e",
|
||||
desc: "Outline button text",
|
||||
},
|
||||
{
|
||||
key: "link_color",
|
||||
label: "Link color",
|
||||
def: "#b77900",
|
||||
desc: "Link text color",
|
||||
},
|
||||
{
|
||||
key: "link_hover_color",
|
||||
label: "Link hover",
|
||||
def: "#8a5b00",
|
||||
desc: "Link hover color",
|
||||
},
|
||||
{
|
||||
key: "border_color",
|
||||
label: "Border color",
|
||||
def: "#eeb425",
|
||||
desc: "General borders",
|
||||
},
|
||||
{
|
||||
key: "panel_border_color",
|
||||
label: "Panel border",
|
||||
def: "#e9b124",
|
||||
desc: "Panel borders",
|
||||
},
|
||||
{
|
||||
key: "gradient_from",
|
||||
label: "Gradient start",
|
||||
def: "#f59e0b",
|
||||
desc: "Gradient from color",
|
||||
},
|
||||
{
|
||||
key: "gradient_to",
|
||||
label: "Gradient end",
|
||||
def: "#10b981",
|
||||
desc: "Gradient to color",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Layout fields ───────────────────────────────────────────────────────────
|
||||
|
||||
const LAYOUT_FIELDS: Array<{
|
||||
key: LayoutKey;
|
||||
label: string;
|
||||
def: string;
|
||||
desc: string;
|
||||
type?: string;
|
||||
}> = [
|
||||
{
|
||||
key: "layout:max_width",
|
||||
label: "Max Width",
|
||||
def: "1280px",
|
||||
desc: "Maximum content width",
|
||||
},
|
||||
{
|
||||
key: "layout:spacing",
|
||||
label: "Grid Spacing",
|
||||
def: "1.5rem",
|
||||
desc: "Gap between grid items",
|
||||
},
|
||||
{
|
||||
key: "layout:grid_columns",
|
||||
label: "Grid Columns",
|
||||
def: "12",
|
||||
desc: "Number of grid columns",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
key: "layout:content_padding",
|
||||
label: "Content Padding",
|
||||
def: "0.75rem",
|
||||
desc: "Horizontal padding on content",
|
||||
},
|
||||
{
|
||||
key: "layout:navbar_height",
|
||||
label: "Navbar Height",
|
||||
def: "56px",
|
||||
desc: "Height of the navigation bar",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Effect fields ───────────────────────────────────────────────────────────
|
||||
|
||||
const EFFECT_FIELDS: Array<{
|
||||
key: EffectKey;
|
||||
label: string;
|
||||
def: string;
|
||||
desc: string;
|
||||
type?: string;
|
||||
}> = [
|
||||
{
|
||||
key: "effect:card_shadow",
|
||||
label: "Card Shadow",
|
||||
def: "0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)",
|
||||
desc: "Default card box shadow",
|
||||
},
|
||||
{
|
||||
key: "effect:card_shadow_hover",
|
||||
label: "Card Shadow (hover)",
|
||||
def: "0 8px 24px rgba(0,0,0,0.1), 0 2px 6px rgba(0,0,0,0.06)",
|
||||
desc: "Card box shadow on hover",
|
||||
},
|
||||
{
|
||||
key: "effect:border_style",
|
||||
label: "Border Style",
|
||||
def: "solid",
|
||||
desc: "CSS border-style (solid, dashed, dotted, etc.)",
|
||||
},
|
||||
{
|
||||
key: "effect:border_width",
|
||||
label: "Border Width",
|
||||
def: "1px",
|
||||
desc: "Default border width",
|
||||
},
|
||||
{
|
||||
key: "effect:transition_duration",
|
||||
label: "Transition Duration",
|
||||
def: "200ms",
|
||||
desc: "Duration for hover/transition effects",
|
||||
},
|
||||
{
|
||||
key: "effect:animation_scale",
|
||||
label: "Animation Scale",
|
||||
def: "1",
|
||||
desc: "Scale factor for animations (1 = normal)",
|
||||
type: "number",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Media fields ────────────────────────────────────────────────────────────
|
||||
|
||||
const MEDIA_FIELDS: Array<{
|
||||
key: MediaKey;
|
||||
label: string;
|
||||
def: string;
|
||||
desc: string;
|
||||
type?: string;
|
||||
}> = [
|
||||
{
|
||||
key: "media:logo_url",
|
||||
label: "Logo URL",
|
||||
def: "",
|
||||
desc: "Custom logo image URL (overrides default)",
|
||||
},
|
||||
{
|
||||
key: "media:background_image",
|
||||
label: "Background Image",
|
||||
def: "",
|
||||
desc: "Page background image URL",
|
||||
},
|
||||
{
|
||||
key: "media:background_position",
|
||||
label: "Background Position",
|
||||
def: "center",
|
||||
desc: "CSS background-position",
|
||||
},
|
||||
{
|
||||
key: "media:background_size",
|
||||
label: "Background Size",
|
||||
def: "cover",
|
||||
desc: "CSS background-size (cover, contain, auto)",
|
||||
},
|
||||
{
|
||||
key: "media:background_repeat",
|
||||
label: "Background Repeat",
|
||||
def: "no-repeat",
|
||||
desc: "CSS background-repeat",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function getVal(
|
||||
values: ScopeValues | null,
|
||||
key: string,
|
||||
mode: "light" | "dark",
|
||||
): string {
|
||||
if (!values) return "";
|
||||
if (key.startsWith("block:")) return values.blocks[key] ?? "";
|
||||
if (key.startsWith("layout:")) return values.layout[key] ?? "";
|
||||
if (key.startsWith("effect:")) return values.effects[key] ?? "";
|
||||
if (key.startsWith("media:")) return values.media[key] ?? "";
|
||||
if (key.startsWith("custom:")) return values.custom[key] ?? "";
|
||||
if (mode === "dark") return values.dark[key] ?? "";
|
||||
return values.light[key] ?? "";
|
||||
}
|
||||
BlocksTab,
|
||||
ColorsTab,
|
||||
CustomTab,
|
||||
EffectsTab,
|
||||
LayoutTab,
|
||||
MediaTab,
|
||||
} from "./theme-editor-tabs";
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
global: "🌍 Global",
|
||||
@@ -449,6 +110,7 @@ export function ThemeEditor({
|
||||
<div className="flex rounded-lg border border-[var(--admin-border)] overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "light"}
|
||||
onClick={() => setMode("light")}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
mode === "light"
|
||||
@@ -460,6 +122,7 @@ export function ThemeEditor({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "dark"}
|
||||
onClick={() => setMode("dark")}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
mode === "dark"
|
||||
@@ -479,6 +142,7 @@ export function ThemeEditor({
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
aria-pressed={tab === t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium whitespace-nowrap transition-colors ${
|
||||
tab === t.id
|
||||
@@ -504,274 +168,18 @@ export function ThemeEditor({
|
||||
{tab === "custom" && <CustomTab values={values} />}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button type="submit" variant="default">
|
||||
Save Theme Values
|
||||
</Button>
|
||||
<ThemeSaveButton />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Colors Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ColorsTab({
|
||||
values,
|
||||
mode,
|
||||
}: {
|
||||
values: ScopeValues;
|
||||
mode: "light" | "dark";
|
||||
}) {
|
||||
function ThemeSaveButton() {
|
||||
const { pending } = useFormStatus();
|
||||
return (
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Colors ({mode} mode)
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Only values you set here override the parent scope. Leave empty to
|
||||
inherit.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{COLOR_FIELDS.map((field) => (
|
||||
<ColorField
|
||||
key={`${field.key}-${mode}`}
|
||||
name={`${field.key}_${mode}`}
|
||||
label={field.label}
|
||||
description={field.desc}
|
||||
defaultValue={getVal(values, field.key, mode) || field.def}
|
||||
backgroundColorKey={
|
||||
field.bgKey ? `${field.bgKey}_${mode}` : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Blocks Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function BlocksTab({ values }: { values: ScopeValues }) {
|
||||
const grouped = getBlocksByCategory();
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
chrome: "Site Chrome",
|
||||
layout: "Layout",
|
||||
components: "Components",
|
||||
effects: "Effects",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(grouped).map(([category, blocks]) => (
|
||||
<div key={category} className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-1">
|
||||
{categoryLabels[category] ?? category}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Toggle visibility of blocks in this scope. Hidden blocks use{" "}
|
||||
<code className="bg-[var(--admin-surface-elevated)] px-1 rounded">
|
||||
display: none
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{blocks.map((block) => {
|
||||
const key = `${BLOCK_KEY_PREFIX}${block.id}`;
|
||||
const currentVal = values.blocks[key];
|
||||
const isChecked =
|
||||
currentVal === "true" ||
|
||||
(currentVal === undefined && block.defaultVisible);
|
||||
|
||||
return (
|
||||
<label
|
||||
key={block.id}
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--admin-surface-elevated)] cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={key}
|
||||
defaultChecked={isChecked}
|
||||
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-[var(--admin-accent)]"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm text-[var(--admin-text)]">
|
||||
{block.label}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] ml-2">
|
||||
{block.description}
|
||||
</span>
|
||||
</div>
|
||||
<code className="text-[10px] text-[var(--admin-text-muted)] font-mono">
|
||||
{block.selector}
|
||||
</code>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Layout Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function LayoutTab({ values }: { values: ScopeValues }) {
|
||||
return (
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Layout Settings
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Control spacing, width, and grid settings for this scope. Values are
|
||||
applied as CSS custom properties.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{LAYOUT_FIELDS.map((field) => (
|
||||
<div key={field.key} className="rounded-lg p-2">
|
||||
<label className="block text-sm font-semibold text-[var(--admin-text)] mb-1">
|
||||
{field.label}
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-normal">
|
||||
{field.desc}
|
||||
</p>
|
||||
<input
|
||||
type={field.type || "text"}
|
||||
name={field.key}
|
||||
defaultValue={getVal(values, field.key, "light") || field.def}
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Effects Tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
function EffectsTab({ values }: { values: ScopeValues }) {
|
||||
return (
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Effects & Transitions
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Control shadows, borders, and animation timing for this scope.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{EFFECT_FIELDS.map((field) => (
|
||||
<div key={field.key} className="rounded-lg p-2">
|
||||
<label className="block text-sm font-semibold text-[var(--admin-text)] mb-1">
|
||||
{field.label}
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-normal">
|
||||
{field.desc}
|
||||
</p>
|
||||
<input
|
||||
type={field.type || "text"}
|
||||
name={field.key}
|
||||
defaultValue={getVal(values, field.key, "light") || field.def}
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Media Tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
function MediaTab({ values }: { values: ScopeValues }) {
|
||||
return (
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Media & Backgrounds
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Set custom logos, background images, and background properties for this
|
||||
scope.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{MEDIA_FIELDS.map((field) => (
|
||||
<div key={field.key} className="rounded-lg p-2">
|
||||
<label className="block text-sm font-semibold text-[var(--admin-text)] mb-1">
|
||||
{field.label}
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-normal">
|
||||
{field.desc}
|
||||
</p>
|
||||
<input
|
||||
type={field.type || "text"}
|
||||
name={field.key}
|
||||
defaultValue={getVal(values, field.key, "light") || field.def}
|
||||
placeholder={field.def}
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Custom Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function CustomTab({ values }: { values: ScopeValues }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Custom CSS
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Add custom CSS that applies within this scope. Use{" "}
|
||||
<code className="bg-[var(--admin-surface-elevated)] px-1 rounded">
|
||||
&{" "}
|
||||
</code>{" "}
|
||||
as a placeholder for the scope selector (e.g.{" "}
|
||||
<code className="bg-[var(--admin-surface-elevated)] px-1 rounded">
|
||||
& .content-card {"{"} border: 2px solid red; {"}"}
|
||||
</code>
|
||||
).
|
||||
</p>
|
||||
<textarea
|
||||
name="custom:css"
|
||||
defaultValue={getVal(values, "custom:css", "light")}
|
||||
rows={8}
|
||||
placeholder={".content-card { border-radius: 20px; }"}
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono resize-y"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-card p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
||||
Custom HTML
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
Add custom HTML that injects into the page for this scope. Use for
|
||||
custom banners, scripts, or content blocks.
|
||||
</p>
|
||||
<textarea
|
||||
name="custom:html"
|
||||
defaultValue={getVal(values, "custom:html", "light")}
|
||||
rows={6}
|
||||
placeholder='<div class="my-banner">Welcome!</div>'
|
||||
className="w-full px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm font-mono resize-y"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={pending} aria-busy={pending}>
|
||||
{pending ? "Saving theme…" : "Save Theme Values"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import "server-only";
|
||||
import { and, asc, count, desc, eq, gt, or } from "drizzle-orm";
|
||||
import {
|
||||
db,
|
||||
MessengerFriendships,
|
||||
MessengerOffline,
|
||||
Rooms,
|
||||
User,
|
||||
UserReferrals,
|
||||
UsersBadges,
|
||||
UsersSettings,
|
||||
} from "@/lib/db";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { siteSettings } from "./site-settings";
|
||||
export async function loadUserDashboard(userId: number) {
|
||||
const [
|
||||
userRows,
|
||||
hotelName,
|
||||
neededRaw,
|
||||
rewardAmountRaw,
|
||||
currencySettings,
|
||||
alertRaw,
|
||||
recentRooms,
|
||||
badges,
|
||||
userSettingsRows,
|
||||
friendCountRows,
|
||||
unreadCountRows,
|
||||
referralsRows,
|
||||
friends,
|
||||
] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: User.id,
|
||||
username: User.username,
|
||||
look: User.look,
|
||||
motto: User.motto,
|
||||
rank: User.rank,
|
||||
credits: User.credits,
|
||||
accountCreated: User.accountCreated,
|
||||
lastLogin: User.lastLogin,
|
||||
lastOnline: User.lastOnline,
|
||||
})
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1),
|
||||
resolveHotelName(),
|
||||
siteSettings.get("referrals_needed", "5"),
|
||||
siteSettings.get("referral_reward_amount", "30"),
|
||||
siteSettings.getMany([
|
||||
"referral_reward_currency_type",
|
||||
"referral_reward_currency",
|
||||
]),
|
||||
siteSettings.get("hotel_alert_banner", ""),
|
||||
db
|
||||
.select({
|
||||
id: Rooms.id,
|
||||
name: Rooms.name,
|
||||
ownerName: Rooms.ownerName,
|
||||
users: Rooms.users,
|
||||
usersMax: Rooms.usersMax,
|
||||
})
|
||||
.from(Rooms)
|
||||
.orderBy(desc(Rooms.id))
|
||||
.limit(6)
|
||||
.catch(() => []),
|
||||
db
|
||||
.select({
|
||||
badgeCode: UsersBadges.badgeCode,
|
||||
slotId: UsersBadges.slotId,
|
||||
})
|
||||
.from(UsersBadges)
|
||||
.where(and(eq(UsersBadges.userId, userId), gt(UsersBadges.slotId, 0)))
|
||||
.orderBy(asc(UsersBadges.slotId))
|
||||
.catch(() => []),
|
||||
db
|
||||
.select({
|
||||
achievementScore: UsersSettings.achievementScore,
|
||||
respectsReceived: UsersSettings.respectsReceived,
|
||||
})
|
||||
.from(UsersSettings)
|
||||
.where(eq(UsersSettings.userId, userId))
|
||||
.limit(1)
|
||||
.catch(() => []),
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(MessengerFriendships)
|
||||
.where(
|
||||
or(
|
||||
eq(MessengerFriendships.userOneId, userId),
|
||||
eq(MessengerFriendships.userTwoId, userId),
|
||||
),
|
||||
)
|
||||
.catch(() => [{ value: 0 }]),
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(MessengerOffline)
|
||||
.where(eq(MessengerOffline.userId, userId))
|
||||
.catch(() => [{ value: 0 }]),
|
||||
db
|
||||
.select({ referralsTotal: UserReferrals.referralsTotal })
|
||||
.from(UserReferrals)
|
||||
.where(eq(UserReferrals.userId, userId))
|
||||
.orderBy(desc(UserReferrals.id))
|
||||
.limit(1)
|
||||
.catch(() => []),
|
||||
db
|
||||
.select({
|
||||
id: User.id,
|
||||
username: User.username,
|
||||
look: User.look,
|
||||
motto: User.motto,
|
||||
online: User.online,
|
||||
})
|
||||
.from(MessengerFriendships)
|
||||
.innerJoin(
|
||||
User,
|
||||
or(
|
||||
and(
|
||||
eq(MessengerFriendships.userOneId, userId),
|
||||
eq(User.id, MessengerFriendships.userTwoId),
|
||||
),
|
||||
and(
|
||||
eq(MessengerFriendships.userTwoId, userId),
|
||||
eq(User.id, MessengerFriendships.userOneId),
|
||||
),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
or(
|
||||
eq(MessengerFriendships.userOneId, userId),
|
||||
eq(MessengerFriendships.userTwoId, userId),
|
||||
),
|
||||
)
|
||||
.catch(() => []),
|
||||
]);
|
||||
|
||||
return {
|
||||
userRows,
|
||||
hotelName,
|
||||
neededRaw,
|
||||
rewardAmountRaw,
|
||||
currencySettings,
|
||||
alertRaw,
|
||||
recentRooms,
|
||||
badges,
|
||||
userSettingsRows,
|
||||
friendCountRows,
|
||||
unreadCountRows,
|
||||
referralsRows,
|
||||
friends,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user