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 (
+
+ );
+}
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; {"}"}
+
+ ).
+
+
+
+
+
+
+ Custom HTML
+
+
+ Add custom HTML that injects into the page for this scope. Use for
+ custom banners, scripts, or content blocks.
+
+
+
+
+ );
+}
diff --git a/src/app/admin/theme-builder/theme-editor.tsx b/src/app/admin/theme-builder/theme-editor.tsx
index 463f66bf19..9fd2014299 100644
--- a/src/app/admin/theme-builder/theme-editor.tsx
+++ b/src/app/admin/theme-builder/theme-editor.tsx
@@ -1,361 +1,22 @@
"use client";
import { useState } from "react";
+import { useFormStatus } from "react-dom";
import {
deleteScope,
duplicateScope,
saveScopeValues,
} from "@/actions/theme-builder";
import { Button } from "@/components/ui/button";
+import type { ScopeInfo, ScopeValues } from "./theme-editor-fields";
import {
- BLOCK_KEY_PREFIX,
- type EffectKey,
- getBlocksByCategory,
- type LayoutKey,
- type MediaKey,
-} from "@/lib/theme-blocks";
-import type { ThemeColorKey } from "@/lib/theme-presets";
-import { ColorField } from "./color-field";
-
-interface ScopeInfo {
- id: number;
- name: string;
- type: string;
- parentId: number | null;
- siteDomain: string | null;
- routePath: string | null;
- moduleId: string | null;
- isActive: boolean;
-}
-
-interface ScopeValues {
- light: Record;
- dark: Record;
- blocks: Record;
- layout: Record;
- effects: Record;
- media: Record;
- custom: Record;
-}
-
-// ─── Color fields ────────────────────────────────────────────────────────────
-
-const COLOR_FIELDS: Array<{
- key: ThemeColorKey;
- label: string;
- def: string;
- desc: string;
- bgKey?: string;
-}> = [
- {
- key: "color_background",
- label: "Page background",
- def: "#f8fafc",
- desc: "Main page background",
- },
- {
- key: "color_surface",
- label: "Card / surface",
- def: "#ffffff",
- desc: "Cards, panels, elevated surfaces",
- },
- {
- key: "color_dropdown",
- label: "Dropdown",
- def: "#ffffff",
- desc: "Dropdown menus",
- },
- {
- key: "color_navbar",
- label: "Navbar",
- def: "#ffffff",
- desc: "Navigation bar background",
- },
- {
- key: "color_navbar_text",
- label: "Navbar text",
- def: "#1e293b",
- desc: "Nav text color",
- bgKey: "color_navbar",
- },
- {
- key: "color_text",
- label: "Body text",
- def: "#0f172a",
- desc: "Main text color",
- bgKey: "color_surface",
- },
- {
- key: "color_text_muted",
- label: "Muted text",
- def: "#64748b",
- desc: "Secondary text",
- bgKey: "color_surface",
- },
- {
- key: "color_primary",
- label: "Primary accent",
- def: "#f59e0b",
- desc: "Main brand color",
- },
- {
- key: "color_accent",
- label: "Secondary accent",
- def: "#10b981",
- desc: "Secondary brand color",
- },
- {
- key: "color_success",
- label: "Success",
- def: "#16a34a",
- desc: "Success states",
- },
- {
- key: "color_warning",
- label: "Warning",
- def: "#eab308",
- desc: "Warning states",
- },
- { key: "color_error", label: "Error", def: "#ef4444", desc: "Error states" },
- {
- key: "color_info",
- label: "Info",
- def: "#0ea5e9",
- desc: "Information states",
- },
- {
- key: "button_primary_color",
- label: "Button primary",
- def: "#f59e0b",
- desc: "Primary button background",
- },
- {
- key: "button_text_color",
- label: "Button primary text",
- def: "#1e293b",
- desc: "Primary button text",
- },
- {
- key: "button_secondary_color",
- label: "Button secondary",
- def: "#22c55e",
- desc: "Secondary button background",
- },
- {
- key: "button_secondary_text_color",
- label: "Button secondary text",
- def: "#ffffff",
- desc: "Secondary button text",
- },
- {
- key: "button_danger_color",
- label: "Button danger",
- def: "#ef4444",
- desc: "Danger button background",
- },
- {
- key: "button_danger_text_color",
- label: "Button danger text",
- def: "#ffffff",
- desc: "Danger button text",
- },
- {
- key: "button_outline_color",
- label: "Button outline",
- def: "#eeb425",
- desc: "Outline button border",
- },
- {
- key: "button_outline_text_color",
- label: "Button outline text",
- def: "#1a1a2e",
- desc: "Outline button text",
- },
- {
- key: "link_color",
- label: "Link color",
- def: "#b77900",
- desc: "Link text color",
- },
- {
- key: "link_hover_color",
- label: "Link hover",
- def: "#8a5b00",
- desc: "Link hover color",
- },
- {
- key: "border_color",
- label: "Border color",
- def: "#eeb425",
- desc: "General borders",
- },
- {
- key: "panel_border_color",
- label: "Panel border",
- def: "#e9b124",
- desc: "Panel borders",
- },
- {
- key: "gradient_from",
- label: "Gradient start",
- def: "#f59e0b",
- desc: "Gradient from color",
- },
- {
- key: "gradient_to",
- label: "Gradient end",
- def: "#10b981",
- desc: "Gradient to color",
- },
-];
-
-// ─── Layout fields ───────────────────────────────────────────────────────────
-
-const LAYOUT_FIELDS: Array<{
- key: LayoutKey;
- label: string;
- def: string;
- desc: string;
- type?: string;
-}> = [
- {
- key: "layout:max_width",
- label: "Max Width",
- def: "1280px",
- desc: "Maximum content width",
- },
- {
- key: "layout:spacing",
- label: "Grid Spacing",
- def: "1.5rem",
- desc: "Gap between grid items",
- },
- {
- key: "layout:grid_columns",
- label: "Grid Columns",
- def: "12",
- desc: "Number of grid columns",
- type: "number",
- },
- {
- key: "layout:content_padding",
- label: "Content Padding",
- def: "0.75rem",
- desc: "Horizontal padding on content",
- },
- {
- key: "layout:navbar_height",
- label: "Navbar Height",
- def: "56px",
- desc: "Height of the navigation bar",
- },
-];
-
-// ─── Effect fields ───────────────────────────────────────────────────────────
-
-const EFFECT_FIELDS: Array<{
- key: EffectKey;
- label: string;
- def: string;
- desc: string;
- type?: string;
-}> = [
- {
- key: "effect:card_shadow",
- label: "Card Shadow",
- def: "0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)",
- desc: "Default card box shadow",
- },
- {
- key: "effect:card_shadow_hover",
- label: "Card Shadow (hover)",
- def: "0 8px 24px rgba(0,0,0,0.1), 0 2px 6px rgba(0,0,0,0.06)",
- desc: "Card box shadow on hover",
- },
- {
- key: "effect:border_style",
- label: "Border Style",
- def: "solid",
- desc: "CSS border-style (solid, dashed, dotted, etc.)",
- },
- {
- key: "effect:border_width",
- label: "Border Width",
- def: "1px",
- desc: "Default border width",
- },
- {
- key: "effect:transition_duration",
- label: "Transition Duration",
- def: "200ms",
- desc: "Duration for hover/transition effects",
- },
- {
- key: "effect:animation_scale",
- label: "Animation Scale",
- def: "1",
- desc: "Scale factor for animations (1 = normal)",
- type: "number",
- },
-];
-
-// ─── Media fields ────────────────────────────────────────────────────────────
-
-const MEDIA_FIELDS: Array<{
- key: MediaKey;
- label: string;
- def: string;
- desc: string;
- type?: string;
-}> = [
- {
- key: "media:logo_url",
- label: "Logo URL",
- def: "",
- desc: "Custom logo image URL (overrides default)",
- },
- {
- key: "media:background_image",
- label: "Background Image",
- def: "",
- desc: "Page background image URL",
- },
- {
- key: "media:background_position",
- label: "Background Position",
- def: "center",
- desc: "CSS background-position",
- },
- {
- key: "media:background_size",
- label: "Background Size",
- def: "cover",
- desc: "CSS background-size (cover, contain, auto)",
- },
- {
- key: "media:background_repeat",
- label: "Background Repeat",
- def: "no-repeat",
- desc: "CSS background-repeat",
- },
-];
-
-// ─── Helpers ─────────────────────────────────────────────────────────────────
-
-function getVal(
- values: ScopeValues | null,
- key: string,
- mode: "light" | "dark",
-): string {
- if (!values) return "";
- if (key.startsWith("block:")) return values.blocks[key] ?? "";
- if (key.startsWith("layout:")) return values.layout[key] ?? "";
- if (key.startsWith("effect:")) return values.effects[key] ?? "";
- if (key.startsWith("media:")) return values.media[key] ?? "";
- if (key.startsWith("custom:")) return values.custom[key] ?? "";
- if (mode === "dark") return values.dark[key] ?? "";
- return values.light[key] ?? "";
-}
+ BlocksTab,
+ ColorsTab,
+ CustomTab,
+ EffectsTab,
+ LayoutTab,
+ MediaTab,
+} from "./theme-editor-tabs";
const TYPE_LABELS: Record = {
global: "🌍 Global",
@@ -449,6 +110,7 @@ export function ThemeEditor({
);
}
-// ─── Colors Tab ──────────────────────────────────────────────────────────────
-
-function ColorsTab({
- values,
- mode,
-}: {
- values: ScopeValues;
- mode: "light" | "dark";
-}) {
+function ThemeSaveButton() {
+ const { pending } = useFormStatus();
return (
-
-
- Colors ({mode} mode)
-
-
- Only values you set here override the parent scope. Leave empty to
- inherit.
-
-
-
- {COLOR_FIELDS.map((field) => (
-
- ))}
-
-
- );
-}
-
-// ─── Blocks Tab ──────────────────────────────────────────────────────────────
-
-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 ──────────────────────────────────────────────────────────────
-
-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 ─────────────────────────────────────────────────────────────
-
-function EffectsTab({ values }: { values: ScopeValues }) {
- return (
-
-
- Effects & Transitions
-
-
- Control shadows, borders, and animation timing for this scope.
-
-
-
- {EFFECT_FIELDS.map((field) => (
-
-
-
- ))}
-
-
- );
-}
-
-// ─── Media Tab ───────────────────────────────────────────────────────────────
-
-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 ──────────────────────────────────────────────────────────────
-
-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; {"}"}
-
- ).
-
-
-
-
-
-
- Custom HTML
-
-
- Add custom HTML that injects into the page for this scope. Use for
- custom banners, scripts, or content blocks.
-
-
-
-
+
+ {pending ? "Saving theme…" : "Save Theme Values"}
+
);
}
diff --git a/src/lib/services/user-dashboard.ts b/src/lib/services/user-dashboard.ts
new file mode 100644
index 0000000000..e762a68eb5
--- /dev/null
+++ b/src/lib/services/user-dashboard.ts
@@ -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,
+ };
+}