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 Image from "next/image";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
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 { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||||
import { SurfaceCard } from "@/components/surface-card";
|
import { SurfaceCard } from "@/components/surface-card";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import {
|
|
||||||
db,
|
|
||||||
MessengerFriendships,
|
|
||||||
MessengerOffline,
|
|
||||||
Rooms,
|
|
||||||
User,
|
|
||||||
UserReferrals,
|
|
||||||
UsersBadges,
|
|
||||||
UsersSettings,
|
|
||||||
} from "@/lib/db";
|
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
import { avatarImageUrl } from "@/lib/format";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { loadUserDashboard } from "@/lib/services/user-dashboard";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
|
||||||
import CopyReferralButton from "./CopyReferralButton";
|
import CopyReferralButton from "./CopyReferralButton";
|
||||||
|
|
||||||
export const metadata = { title: "My dashboard" };
|
export const metadata = { title: "My dashboard" };
|
||||||
@@ -78,7 +66,7 @@ export default async function MePage({
|
|||||||
try {
|
try {
|
||||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||||
|
|
||||||
const [
|
const {
|
||||||
userRows,
|
userRows,
|
||||||
hotelName,
|
hotelName,
|
||||||
neededRaw,
|
neededRaw,
|
||||||
@@ -92,112 +80,7 @@ export default async function MePage({
|
|||||||
unreadCountRows,
|
unreadCountRows,
|
||||||
referralsRows,
|
referralsRows,
|
||||||
friends,
|
friends,
|
||||||
] = await Promise.all([
|
} = await loadUserDashboard(userId);
|
||||||
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(() => []),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const user = userRows[0] ?? null;
|
const user = userRows[0] ?? null;
|
||||||
if (!user) throw new Error("user-not-found");
|
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 { useServerAction } from "@/hooks/use-server-action";
|
||||||
import { getAvatarUrl } from "@/lib/imager";
|
import { getAvatarUrl } from "@/lib/imager";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { categorizePerms, PermToggle } from "./permission-controls";
|
||||||
|
import type { RankEditorProps } from "./rank-editor-types";
|
||||||
/* ─── 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 ─────────────────────────────────────── */
|
/* ─── Main Component ─────────────────────────────────────── */
|
||||||
|
|
||||||
export function RankEditClient({
|
export function RankEditClient({
|
||||||
@@ -137,7 +43,7 @@ export function RankEditClient({
|
|||||||
totalUsers,
|
totalUsers,
|
||||||
cmsRole: initialCmsRole,
|
cmsRole: initialCmsRole,
|
||||||
allCmsPermissions,
|
allCmsPermissions,
|
||||||
}: Props) {
|
}: RankEditorProps) {
|
||||||
const { run, isPending } = useServerAction();
|
const { run, isPending } = useServerAction();
|
||||||
|
|
||||||
const [general, setGeneral] = useState({
|
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";
|
"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 { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
addBlacklistWord as addBlacklistWordAction,
|
addBlacklistWord as addBlacklistWordAction,
|
||||||
@@ -25,296 +25,14 @@ import {
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { useServerAction } from "@/hooks/use-server-action";
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
|
|
||||||
interface UserPrefix {
|
import { PrefixDialog } from "./prefix-dialog";
|
||||||
id: number;
|
import {
|
||||||
user_id: number;
|
type BlacklistWord,
|
||||||
text: string;
|
PRESET_EFFECTS,
|
||||||
color: string;
|
type PrefixFormData,
|
||||||
icon: string;
|
type PrefixSettings,
|
||||||
effect: string;
|
type UserPrefix,
|
||||||
active: boolean;
|
} from "./prefix-model";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
|
export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
|
||||||
const { run, isPending } = useServerAction();
|
const { run, isPending } = useServerAction();
|
||||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useFormStatus } from "react-dom";
|
||||||
import {
|
import {
|
||||||
deleteScope,
|
deleteScope,
|
||||||
duplicateScope,
|
duplicateScope,
|
||||||
saveScopeValues,
|
saveScopeValues,
|
||||||
} from "@/actions/theme-builder";
|
} from "@/actions/theme-builder";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { ScopeInfo, ScopeValues } from "./theme-editor-fields";
|
||||||
import {
|
import {
|
||||||
BLOCK_KEY_PREFIX,
|
BlocksTab,
|
||||||
type EffectKey,
|
ColorsTab,
|
||||||
getBlocksByCategory,
|
CustomTab,
|
||||||
type LayoutKey,
|
EffectsTab,
|
||||||
type MediaKey,
|
LayoutTab,
|
||||||
} from "@/lib/theme-blocks";
|
MediaTab,
|
||||||
import type { ThemeColorKey } from "@/lib/theme-presets";
|
} from "./theme-editor-tabs";
|
||||||
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] ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const TYPE_LABELS: Record<string, string> = {
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
global: "🌍 Global",
|
global: "🌍 Global",
|
||||||
@@ -449,6 +110,7 @@ export function ThemeEditor({
|
|||||||
<div className="flex rounded-lg border border-[var(--admin-border)] overflow-hidden">
|
<div className="flex rounded-lg border border-[var(--admin-border)] overflow-hidden">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-pressed={mode === "light"}
|
||||||
onClick={() => setMode("light")}
|
onClick={() => setMode("light")}
|
||||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
mode === "light"
|
mode === "light"
|
||||||
@@ -460,6 +122,7 @@ export function ThemeEditor({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-pressed={mode === "dark"}
|
||||||
onClick={() => setMode("dark")}
|
onClick={() => setMode("dark")}
|
||||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
mode === "dark"
|
mode === "dark"
|
||||||
@@ -479,6 +142,7 @@ export function ThemeEditor({
|
|||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-pressed={tab === t.id}
|
||||||
onClick={() => setTab(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 ${
|
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium whitespace-nowrap transition-colors ${
|
||||||
tab === t.id
|
tab === t.id
|
||||||
@@ -504,274 +168,18 @@ export function ThemeEditor({
|
|||||||
{tab === "custom" && <CustomTab values={values} />}
|
{tab === "custom" && <CustomTab values={values} />}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 mt-4">
|
<div className="flex justify-end gap-2 mt-4">
|
||||||
<Button type="submit" variant="default">
|
<ThemeSaveButton />
|
||||||
Save Theme Values
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Colors Tab ──────────────────────────────────────────────────────────────
|
function ThemeSaveButton() {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
function ColorsTab({
|
|
||||||
values,
|
|
||||||
mode,
|
|
||||||
}: {
|
|
||||||
values: ScopeValues;
|
|
||||||
mode: "light" | "dark";
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-card p-4">
|
<Button type="submit" disabled={pending} aria-busy={pending}>
|
||||||
<h3 className="text-sm font-semibold text-[var(--admin-text)] mb-4">
|
{pending ? "Saving theme…" : "Save Theme Values"}
|
||||||
Colors ({mode} mode)
|
</Button>
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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