diff --git a/src/app/(site)/me/page.tsx b/src/app/(site)/me/page.tsx index 04a3d7d930..6fe78861d2 100644 --- a/src/app/(site)/me/page.tsx +++ b/src/app/(site)/me/page.tsx @@ -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"); diff --git a/src/app/admin/permissions/[id]/permission-controls.tsx b/src/app/admin/permissions/[id]/permission-controls.tsx new file mode 100644 index 0000000000..7ec96be8cb --- /dev/null +++ b/src/app/admin/permissions/[id]/permission-controls.tsx @@ -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 ( +
+ {values.map((v) => ( + + ))} +
+ ); +} diff --git a/src/app/admin/permissions/[id]/rank-edit-client.tsx b/src/app/admin/permissions/[id]/rank-edit-client.tsx index 6e77327dc3..125245c2fe 100644 --- a/src/app/admin/permissions/[id]/rank-edit-client.tsx +++ b/src/app/admin/permissions/[id]/rank-edit-client.tsx @@ -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; - permissionMaxValues: Record; -} - -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 ( -
- {values.map((v) => ( - - ))} -
- ); -} - /* ─── 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({ diff --git a/src/app/admin/permissions/[id]/rank-editor-types.ts b/src/app/admin/permissions/[id]/rank-editor-types.ts new file mode 100644 index 0000000000..2d41e12403 --- /dev/null +++ b/src/app/admin/permissions/[id]/rank-editor-types.ts @@ -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; + permissionMaxValues: Record; +} + +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[]; +} diff --git a/src/app/admin/prefixes/prefix-dialog.tsx b/src/app/admin/prefixes/prefix-dialog.tsx new file mode 100644 index 0000000000..8efc2f9ccd --- /dev/null +++ b/src/app/admin/prefixes/prefix-dialog.tsx @@ -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; + editPrefix: UserPrefix | null; +}) { + const [form, setForm] = useState({ + 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 ( + { + if (!open && !saving) onClose(); + }} + disablePointerDismissal + > + +
+ + {editPrefix ? "Edit Prefix" : "Add Prefix"} + + +
+ + {/* Preview */} +
+ + {form.icon && {form.icon}} + {hasMultiColor ? ( + + {"{"} + {[...(form.text || "...")] + .map((char, i) => ({ char, n: i })) + .map(({ char, n }) => ( + + {char} + + ))} + {"}"} + + ) : ( + + {"{"} + {form.text || "..."} + {"}"} + + )} + + + {form.username || "Username"} + +
+ + {!editPrefix && ( +
+ Username + setForm({ ...form, username: e.target.value })} + /> +
+ )} + +
+ Text + setForm({ ...form, text: e.target.value })} + /> + + {form.text.length}/15 + +
+ +
+ + Color (HEX or multi: #FF0000,#00FF00) + +
+ setForm({ ...form, color: e.target.value })} + /> +
+
+
+ +
+ Icon + +
+ +
+ Effect + +
+ +
+ setForm({ ...form, active: e.target.checked })} + /> + +
+ +
+ + +
+ +
+ ); +} diff --git a/src/app/admin/prefixes/prefix-model.ts b/src/app/admin/prefixes/prefix-model.ts new file mode 100644 index 0000000000..ad5a37db62 --- /dev/null +++ b/src/app/admin/prefixes/prefix-model.ts @@ -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" }, +]; diff --git a/src/app/admin/prefixes/prefixes-client.tsx b/src/app/admin/prefixes/prefixes-client.tsx index aff2410c01..aad2e8a584 100644 --- a/src/app/admin/prefixes/prefixes-client.tsx +++ b/src/app/admin/prefixes/prefixes-client.tsx @@ -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; - editPrefix: UserPrefix | null; -}) { - const [form, setForm] = useState({ - 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 ( -
-
-
-

- {editPrefix ? "Edit Prefix" : "Add Prefix"} -

- -
- - {/* Preview */} -
- - {form.icon && {form.icon}} - {hasMultiColor ? ( - - {"{"} - {[...(form.text || "...")] - .map((char, i) => ({ char, n: i })) - .map(({ char, n }) => ( - - {char} - - ))} - {"}"} - - ) : ( - - {"{"} - {form.text || "..."} - {"}"} - - )} - - - {form.username || "Username"} - -
- - {!editPrefix && ( -
- Username - setForm({ ...form, username: e.target.value })} - /> -
- )} - -
- Text - setForm({ ...form, text: e.target.value })} - /> - - {form.text.length}/15 - -
- -
- - Color (HEX or multi: #FF0000,#00FF00) - -
- setForm({ ...form, color: e.target.value })} - /> -
-
-
- -
- Icon - -
- -
- Effect - -
- -
- setForm({ ...form, active: e.target.checked })} - /> - -
- -
- - -
-
-
- ); -} - +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(); diff --git a/src/app/admin/theme-builder/theme-editor-fields.ts b/src/app/admin/theme-builder/theme-editor-fields.ts new file mode 100644 index 0000000000..b3b588e246 --- /dev/null +++ b/src/app/admin/theme-builder/theme-editor-fields.ts @@ -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; + dark: Record; + blocks: Record; + layout: Record; + effects: Record; + media: Record; + custom: Record; +} + +// ─── 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] ?? ""; +} diff --git a/src/app/admin/theme-builder/theme-editor-tabs.tsx b/src/app/admin/theme-builder/theme-editor-tabs.tsx new file mode 100644 index 0000000000..ef5b7e17ee --- /dev/null +++ b/src/app/admin/theme-builder/theme-editor-tabs.tsx @@ -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 ( +
+

+ Colors ({mode} mode) +

+

+ Only values you set here override the parent scope. Leave empty to + inherit. +

+ +
+ {COLOR_FIELDS.map((field) => ( + + ))} +
+
+ ); +} + +// ─── Blocks Tab ────────────────────────────────────────────────────────────── + +export function BlocksTab({ values }: { values: ScopeValues }) { + const grouped = getBlocksByCategory(); + + const categoryLabels: Record = { + chrome: "Site Chrome", + layout: "Layout", + components: "Components", + effects: "Effects", + }; + + return ( +
+ {Object.entries(grouped).map(([category, blocks]) => ( +
+

+ {categoryLabels[category] ?? category} +

+

+ Toggle visibility of blocks in this scope. Hidden blocks use{" "} + + display: none + + . +

+ +
+ {blocks.map((block) => { + const key = `${BLOCK_KEY_PREFIX}${block.id}`; + const currentVal = values.blocks[key]; + const isChecked = + currentVal === "true" || + (currentVal === undefined && block.defaultVisible); + + return ( + + ); + })} +
+
+ ))} +
+ ); +} + +// ─── Layout Tab ────────────────────────────────────────────────────────────── + +export function LayoutTab({ values }: { values: ScopeValues }) { + return ( +
+

+ Layout Settings +

+

+ Control spacing, width, and grid settings for this scope. Values are + applied as CSS custom properties. +

+ +
+ {LAYOUT_FIELDS.map((field) => ( +
+ +
+ ))} +
+
+ ); +} + +// ─── Effects Tab ───────────────────────────────────────────────────────────── + +export function EffectsTab({ values }: { values: ScopeValues }) { + return ( +
+

+ Effects & Transitions +

+

+ Control shadows, borders, and animation timing for this scope. +

+ +
+ {EFFECT_FIELDS.map((field) => ( +
+ +
+ ))} +
+
+ ); +} + +// ─── Media Tab ─────────────────────────────────────────────────────────────── + +export function MediaTab({ values }: { values: ScopeValues }) { + return ( +
+

+ Media & Backgrounds +

+

+ Set custom logos, background images, and background properties for this + scope. +

+ +
+ {MEDIA_FIELDS.map((field) => ( +
+ +
+ ))} +
+
+ ); +} + +// ─── Custom Tab ────────────────────────────────────────────────────────────── + +export function CustomTab({ values }: { values: ScopeValues }) { + return ( +
+
+

+ Custom CSS +

+

+ Add custom CSS that applies within this scope. Use{" "} + + &{" "} + {" "} + as a placeholder for the scope selector (e.g.{" "} + + & .content-card {"{"} border: 2px solid red; {"}"} + + ). +

+