Improvements: remove dead config, fix ESM, add URL validation, unify types, add missing logging
- Remove .prettierrc (dead config, Biome replaces Prettier) - Rename lighthouserc.json to lighthouserc.cjs with module.exports for ESM compat - Add logger.warn to empty catch blocks in auth, register, site-settings, prisma-cache, redis, security, rate-limit - Unify ActionResult type: action-helper.ts uses 'ok' consistent with safe-action-shared.ts - Add noUnusedLocals + noUnusedParameters to tsconfig + fix 25 pre-existing unused vars - Replace barrel export src/types/index.ts with direct @/types/common imports - Make trustHost conditional (development only) in auth.ts - Add pre-flight URL validation to update-Nitrov3.sh to catch image.library.url misconfigurations - Improve NITRO_IMAGE_LIBRARY_URL content validation in pre-flight & post-compute checks
This commit is contained in:
1 parent
b922f6d49f
commit
17847545dd
292 files changed
+69195
-67915
No files matched your search
@@ -97,11 +97,12 @@ export function AdminSectionTabs({ tabs }: { tabs: AdminTab[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{order.map((groupId) => {
|
||||
const groupTabs = tabs.filter((tab) => (tab.group ?? "primary") === groupId);
|
||||
const groupTabs = tabs.filter(
|
||||
(tab) => (tab.group ?? "primary") === groupId,
|
||||
);
|
||||
const rowOffset = offset;
|
||||
offset += groupTabs.length;
|
||||
const label =
|
||||
groupId === "tools" ? t("tabs.toolsGroup") : undefined;
|
||||
const label = groupId === "tools" ? t("tabs.toolsGroup") : undefined;
|
||||
return (
|
||||
<TabRow
|
||||
key={groupId}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { MediaPicker } from "./media-picker";
|
||||
import { RichText } from "./rich-text";
|
||||
import { slugify } from "@/lib/format";
|
||||
|
||||
export function ArticleForm({
|
||||
action,
|
||||
@@ -30,7 +30,11 @@ export function ArticleForm({
|
||||
const suggestedSlug = useMemo(() => slugify(title), [title]);
|
||||
|
||||
return (
|
||||
<form action={action} className="admin-card" style={{ display: "grid", gap: "1rem" }}>
|
||||
<form
|
||||
action={action}
|
||||
className="admin-card"
|
||||
style={{ display: "grid", gap: "1rem" }}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
|
||||
@@ -15,8 +15,8 @@ import { RootTabs } from "./root-tabs";
|
||||
import { SortableTree } from "./sortable-tree";
|
||||
import { type CatalogStatsTotals, StatsBar } from "./stats-bar";
|
||||
import {
|
||||
CatalogManagerProvider,
|
||||
type CatalogManagerMode,
|
||||
CatalogManagerProvider,
|
||||
catalogTreeQs,
|
||||
useCatalogManager,
|
||||
} from "./use-catalog-store";
|
||||
@@ -58,8 +58,7 @@ export function CatalogManagerDialog({
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const title =
|
||||
catalogType === "bc" ? "BC Visual Manager" : "Visual Manager";
|
||||
const title = catalogType === "bc" ? "BC Visual Manager" : "Visual Manager";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -120,9 +119,7 @@ function CatalogManagerInner({
|
||||
const loadRootPages = useCallback(async () => {
|
||||
dispatch({ type: "SET_LOADING_ROOT", loading: true });
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/catalog/tree?parentId=-1${catQs}`,
|
||||
);
|
||||
const res = await fetch(`/api/admin/catalog/tree?parentId=-1${catQs}`);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "Failed to load roots");
|
||||
const pages: RootTab[] = (data.pages || []).map(
|
||||
@@ -163,17 +160,21 @@ function CatalogManagerInner({
|
||||
}
|
||||
}
|
||||
void loadRootPages();
|
||||
}, [open, initialRoots, state.rootPages.length, state.activeTabId, dispatch, loadRootPages]);
|
||||
}, [
|
||||
open,
|
||||
initialRoots,
|
||||
state.rootPages.length,
|
||||
state.activeTabId,
|
||||
dispatch,
|
||||
loadRootPages,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) dispatch({ type: "ON_DIALOG_CLOSE" });
|
||||
}, [open, dispatch]);
|
||||
|
||||
const handleTabClick = (id: number) => {
|
||||
if (
|
||||
state.editorDirty &&
|
||||
!confirm("You have unsaved changes. Discard?")
|
||||
) {
|
||||
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) {
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "SET_ACTIVE_TAB", id });
|
||||
@@ -203,10 +204,7 @@ function CatalogManagerInner({
|
||||
};
|
||||
|
||||
const handleSelectPage = (id: number) => {
|
||||
if (
|
||||
state.editorDirty &&
|
||||
!confirm("You have unsaved changes. Discard?")
|
||||
) {
|
||||
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) {
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "SELECT_PAGE", id: id > 0 ? id : null });
|
||||
@@ -216,10 +214,7 @@ function CatalogManagerInner({
|
||||
if (state.activeTabId !== id) {
|
||||
dispatch({ type: "SET_ACTIVE_TAB", id });
|
||||
}
|
||||
if (
|
||||
state.editorDirty &&
|
||||
!confirm("You have unsaved changes. Discard?")
|
||||
) {
|
||||
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) {
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "SELECT_PAGE", id });
|
||||
|
||||
@@ -150,9 +150,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
||||
async (id: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/catalog/tree?pageId=${id}${catQs}`,
|
||||
);
|
||||
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}${catQs}`);
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
const p = data.page as PageDetail;
|
||||
@@ -653,9 +651,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
||||
>
|
||||
{catalogType === "bc" ? (
|
||||
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground space-y-3">
|
||||
<p>
|
||||
BC items are managed on the full Builder Club page editor.
|
||||
</p>
|
||||
<p>BC items are managed on the full Builder Club page editor.</p>
|
||||
<Link href={pageHref} target="_blank">
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
|
||||
@@ -30,8 +30,8 @@ import {
|
||||
reorderBcTreePage,
|
||||
toggleBcPage,
|
||||
} from "@/actions/catalog-bc";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { CatalogIcon } from "@/components/admin/catalog-tree";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
|
||||
@@ -125,20 +125,23 @@ export function SortableTree({
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
);
|
||||
|
||||
const normalizePages = useCallback((pages: TreeNodeData[]): TreeNodeData[] => {
|
||||
return pages.map((p) => ({
|
||||
...p,
|
||||
id: Number(p.id),
|
||||
parentId: Number(p.parentId),
|
||||
enabled: String(p.enabled ?? "0"),
|
||||
visible: String(p.visible ?? "0"),
|
||||
iconImage: Number(p.iconImage) || 0,
|
||||
iconColor: Number(p.iconColor) || 0,
|
||||
childCount: Number(p.childCount) || 0,
|
||||
itemCount: Number(p.itemCount) || 0,
|
||||
orderNum: Number(p.orderNum) || 0,
|
||||
}));
|
||||
}, []);
|
||||
const normalizePages = useCallback(
|
||||
(pages: TreeNodeData[]): TreeNodeData[] => {
|
||||
return pages.map((p) => ({
|
||||
...p,
|
||||
id: Number(p.id),
|
||||
parentId: Number(p.parentId),
|
||||
enabled: String(p.enabled ?? "0"),
|
||||
visible: String(p.visible ?? "0"),
|
||||
iconImage: Number(p.iconImage) || 0,
|
||||
iconColor: Number(p.iconColor) || 0,
|
||||
childCount: Number(p.childCount) || 0,
|
||||
itemCount: Number(p.itemCount) || 0,
|
||||
orderNum: Number(p.orderNum) || 0,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Load children of a page — same endpoint CatalogTree uses successfully.
|
||||
@@ -227,11 +230,7 @@ export function SortableTree({
|
||||
}
|
||||
const node = nodes.get(id);
|
||||
const existing = childrenMap.get(id);
|
||||
if (
|
||||
node &&
|
||||
node.childCount > 0 &&
|
||||
(!existing || existing.length === 0)
|
||||
) {
|
||||
if (node && node.childCount > 0 && (!existing || existing.length === 0)) {
|
||||
dispatch({
|
||||
type: "SET_LOADING_IDS",
|
||||
ids: new Set([...loadingIds, id]),
|
||||
@@ -591,9 +590,7 @@ export function SortableTree({
|
||||
const handleDuplicate = async (id: number) => {
|
||||
if (!canEdit) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/catalog/tree?pageId=${id}${catQs}`,
|
||||
);
|
||||
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}${catQs}`);
|
||||
if (!res.ok) throw new Error();
|
||||
const { page } = await res.json();
|
||||
const result =
|
||||
@@ -662,7 +659,7 @@ export function SortableTree({
|
||||
// Lazy-load each first-level node's children in background
|
||||
for (const id of firstLevel) {
|
||||
const node = nodes.get(id);
|
||||
if (node && node.childCount > 0 && !(childrenMap.get(id)?.length)) {
|
||||
if (node && node.childCount > 0 && !childrenMap.get(id)?.length) {
|
||||
void loadChildren(id)
|
||||
.then((pages) => {
|
||||
dispatch({ type: "SET_CHILDREN", parentId: id, children: pages });
|
||||
@@ -1132,7 +1129,6 @@ export function SortableTree({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,9 +50,9 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import { translateCaption } from "@/lib/catalog-translations";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
|
||||
/* ─── Types ──────────────────────────────────────────────── */
|
||||
|
||||
@@ -514,7 +514,6 @@ export function CatalogTree({
|
||||
function TreeItem({
|
||||
node,
|
||||
level,
|
||||
// biome-ignore lint/correctness/noUnusedFunctionParameters: part of the public component API
|
||||
isSearchResult,
|
||||
defaultExpanded,
|
||||
}: {
|
||||
@@ -523,6 +522,7 @@ function TreeItem({
|
||||
isSearchResult?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
}) {
|
||||
void isSearchResult;
|
||||
const {
|
||||
expandSignal,
|
||||
collapseSignal,
|
||||
|
||||
@@ -4,8 +4,8 @@ import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { modalContentVariants, modalOverlayVariants } from "@/lib/motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ConfirmDialogProps = {
|
||||
open: boolean;
|
||||
|
||||
@@ -27,7 +27,11 @@ export function DashboardChart({
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex h-[200px] items-end gap-1" aria-label={title} role="img">
|
||||
<div
|
||||
className="flex h-[200px] items-end gap-1"
|
||||
aria-label={title}
|
||||
role="img"
|
||||
>
|
||||
{data.map((point) => (
|
||||
<div
|
||||
key={point.label}
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { PER_PAGE_OPTIONS } from "@/lib/admin-helpers";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
/**
|
||||
* One-click filter preset. The chip applies/clears the listed query-string
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { uploadMedia, deleteMedia } from "@/actions/admin-media";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { deleteMedia, uploadMedia } from "@/actions/admin-media";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
type MediaFile = {
|
||||
@@ -222,13 +222,13 @@ export function AdminMediaGrid() {
|
||||
setSelected(f.name);
|
||||
}
|
||||
}}
|
||||
className="group border rounded-[12px] overflow-hidden bg-[var(--admin-canvas)] cursor-pointer transition-all duration-150"
|
||||
style={{
|
||||
borderColor: isSelected
|
||||
? "var(--admin-accent)"
|
||||
: "color-mix(in srgb, var(--admin-text-muted) 14%, transparent)",
|
||||
}}
|
||||
>
|
||||
className="group border rounded-[12px] overflow-hidden bg-[var(--admin-canvas)] cursor-pointer transition-all duration-150"
|
||||
style={{
|
||||
borderColor: isSelected
|
||||
? "var(--admin-accent)"
|
||||
: "color-mix(in srgb, var(--admin-text-muted) 14%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
@@ -246,7 +246,8 @@ export function AdminMediaGrid() {
|
||||
{f.name}
|
||||
</p>
|
||||
<p className="text-[11px] m-0 text-[var(--admin-text-muted)]">
|
||||
{formatSize(f.size)} · {formatDate(new Date(f.uploaded), "date")}
|
||||
{formatSize(f.size)} ·{" "}
|
||||
{formatDate(new Date(f.uploaded), "date")}
|
||||
</p>
|
||||
<div className="flex gap-1.5 mt-2">
|
||||
<button
|
||||
|
||||
@@ -54,7 +54,10 @@ export function PeakHoursHeatmap({ data }: { data: PeakHoursData }) {
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{days.map((day, dayIdx) => (
|
||||
<div key={DAY_KEYS[dayIdx]} className="flex items-center gap-px mb-px">
|
||||
<div
|
||||
key={DAY_KEYS[dayIdx]}
|
||||
className="flex items-center gap-px mb-px"
|
||||
>
|
||||
<span className="w-10 text-xs text-muted-foreground text-right pr-2 shrink-0">
|
||||
{day}
|
||||
</span>
|
||||
|
||||
@@ -36,7 +36,10 @@ export function AmbientOrbs() {
|
||||
if (orbs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none" style={{ zIndex: 1 }}>
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden pointer-events-none"
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
{orbs.map((orb) => (
|
||||
<div
|
||||
key={orb.id}
|
||||
@@ -53,13 +56,17 @@ export function AmbientOrbs() {
|
||||
/>
|
||||
))}
|
||||
<style>{`
|
||||
${orbs.map((orb) => `
|
||||
${orbs
|
||||
.map(
|
||||
(orb) => `
|
||||
@keyframes ambient-drift-${orb.id} {
|
||||
0% { transform: translate(0, 0) scale(1); }
|
||||
33% { transform: translate(${(Math.random() > 0.5 ? "" : "-") + (20 + Math.random() * 30)}px, ${(Math.random() > 0.5 ? "" : "-") + (20 + Math.random() * 30)}px) scale(${0.8 + Math.random() * 0.4}); }
|
||||
66% { transform: translate(${(Math.random() > 0.5 ? "" : "-") + (10 + Math.random() * 40)}px, ${(Math.random() > 0.5 ? "" : "-") + (15 + Math.random() * 35)}px) scale(${0.9 + Math.random() * 0.3}); }
|
||||
100% { transform: translate(0, 0) scale(1); }
|
||||
}`).join("\n")}
|
||||
}`,
|
||||
)
|
||||
.join("\n")}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,7 +40,8 @@ export function AnimatedCounter({
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
>
|
||||
{display.toLocaleString()}{suffix}
|
||||
{display.toLocaleString()}
|
||||
{suffix}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { signIn } from "next-auth/react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { precheckLogin } from "@/actions/auth-precheck";
|
||||
import {
|
||||
CaptchaWidget,
|
||||
type CaptchaPublicConfig,
|
||||
CaptchaWidget,
|
||||
readCaptchaToken,
|
||||
} from "@/components/auth/captcha-widget";
|
||||
|
||||
@@ -68,10 +68,7 @@ export function HomeLoginForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="relative flex flex-col gap-4"
|
||||
>
|
||||
<form onSubmit={onSubmit} className="relative flex flex-col gap-4">
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="login-username"
|
||||
@@ -163,7 +160,8 @@ export function HomeLoginForm({
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor: "color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -187,14 +185,30 @@ export function HomeLoginForm({
|
||||
style={{
|
||||
background: "var(--color-primary)",
|
||||
color: "var(--color-primary-foreground)",
|
||||
boxShadow: "0 4px 20px color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
||||
boxShadow:
|
||||
"0 4px 20px color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
||||
}}
|
||||
>
|
||||
{pending ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
Please wait...
|
||||
</span>
|
||||
|
||||
@@ -6,8 +6,8 @@ import { useTranslations } from "next-intl";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { precheckLogin } from "@/actions/auth-precheck";
|
||||
import {
|
||||
CaptchaWidget,
|
||||
type CaptchaPublicConfig,
|
||||
CaptchaWidget,
|
||||
readCaptchaToken,
|
||||
} from "@/components/auth/captcha-widget";
|
||||
|
||||
@@ -92,11 +92,14 @@ export function LoginForm({
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${needs2fa ? "opacity-50 pointer-events-none" : ""} relative`}>
|
||||
<div
|
||||
className={`${needs2fa ? "opacity-50 pointer-events-none" : ""} relative`}
|
||||
>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
@@ -108,7 +111,8 @@ export function LoginForm({
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
@@ -134,7 +138,8 @@ export function LoginForm({
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor: "color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -158,14 +163,30 @@ export function LoginForm({
|
||||
style={{
|
||||
background: "var(--color-primary)",
|
||||
color: "var(--color-primary-foreground)",
|
||||
boxShadow: "0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
||||
boxShadow:
|
||||
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
||||
}}
|
||||
>
|
||||
{pending ? (
|
||||
<span className="inline-flex items-center justify-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
{t("pleaseWait")}
|
||||
</span>
|
||||
@@ -177,18 +198,24 @@ export function LoginForm({
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
|
||||
<p
|
||||
className="text-xs text-center mt-6"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{t("noAccount")}{" "}
|
||||
<Link href="/register" className="font-extrabold hover:underline" style={{ color: "var(--color-primary)" }}>
|
||||
<Link
|
||||
href="/register"
|
||||
className="font-extrabold hover:underline"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
{t("createOne")}
|
||||
</Link>{" "}
|
||||
·{" "}
|
||||
<Link href="/forgot" className="font-extrabold hover:underline" style={{ color: "var(--color-primary)" }}>
|
||||
<Link
|
||||
href="/forgot"
|
||||
className="font-extrabold hover:underline"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
{t("forgotPassword")}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -14,7 +14,11 @@ interface RegisterFormProps {
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
function passwordStrength(pw: string): { score: number; label: string; color: string } {
|
||||
function passwordStrength(pw: string): {
|
||||
score: number;
|
||||
label: string;
|
||||
color: string;
|
||||
} {
|
||||
let score = 0;
|
||||
if (pw.length >= 8) score += 1;
|
||||
if (pw.length >= 12) score += 1;
|
||||
@@ -72,7 +76,10 @@ export function RegisterForm({
|
||||
>
|
||||
<div className="absolute inset-0 z-10 bg-black/30" />
|
||||
<div className="relative flex items-center justify-center w-full h-full z-20">
|
||||
<div className="relative animate-float" style={{ width: "140px", height: "140px" }}>
|
||||
<div
|
||||
className="relative animate-float"
|
||||
style={{ width: "140px", height: "140px" }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 rounded-full overflow-hidden"
|
||||
style={{ zIndex: -1 }}
|
||||
@@ -142,7 +149,8 @@ export function RegisterForm({
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
@@ -165,10 +173,10 @@ export function RegisterForm({
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -195,7 +203,8 @@ export function RegisterForm({
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
@@ -217,14 +226,18 @@ export function RegisterForm({
|
||||
key={i}
|
||||
className="h-1.5 flex-1 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
backgroundColor: i <= strength.score
|
||||
? strength.color
|
||||
: "color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
|
||||
backgroundColor:
|
||||
i <= strength.score
|
||||
? strength.color
|
||||
: "color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs font-bold" style={{ color: strength.color }}>
|
||||
<p
|
||||
className="text-xs font-bold"
|
||||
style={{ color: strength.color }}
|
||||
>
|
||||
{strength.label}
|
||||
</p>
|
||||
</div>
|
||||
@@ -249,7 +262,8 @@ export function RegisterForm({
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
@@ -272,7 +286,8 @@ export function RegisterForm({
|
||||
style={{
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--color-background) 50%, var(--color-surface))",
|
||||
border: "1px solid color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
@@ -351,14 +366,30 @@ export function RegisterForm({
|
||||
style={{
|
||||
background: "var(--color-primary)",
|
||||
color: "var(--color-primary-foreground)",
|
||||
boxShadow: "0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
||||
boxShadow:
|
||||
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
||||
}}
|
||||
>
|
||||
{isPending ? (
|
||||
<span className="inline-flex items-center justify-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
{t("creatingAccount")}
|
||||
</span>
|
||||
@@ -371,7 +402,9 @@ export function RegisterForm({
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm font-bold hover:underline transition-all"
|
||||
style={{ color: "var(--color-primary-readable, var(--color-primary))" }}
|
||||
style={{
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
}}
|
||||
>
|
||||
{t("alreadyHaveAccount")}
|
||||
</Link>
|
||||
|
||||
@@ -84,7 +84,9 @@ export function Clock() {
|
||||
return (
|
||||
<span
|
||||
className="text-[11px] font-bold tabular-nums select-none whitespace-nowrap"
|
||||
style={{ color: "color-mix(in srgb, var(--color-text-muted) 60%, transparent)" }}
|
||||
style={{
|
||||
color: "color-mix(in srgb, var(--color-text-muted) 60%, transparent)",
|
||||
}}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
|
||||
@@ -22,7 +22,10 @@ export function ErrorScreen({
|
||||
return (
|
||||
<main className="error-screen">
|
||||
<div className="error-screen-glow" aria-hidden />
|
||||
<section className="error-screen-panel" aria-labelledby="error-screen-title">
|
||||
<section
|
||||
className="error-screen-panel"
|
||||
aria-labelledby="error-screen-title"
|
||||
>
|
||||
<p className="error-screen-code">{code}</p>
|
||||
<h1 id="error-screen-title" className="error-screen-title">
|
||||
{title}
|
||||
|
||||
@@ -223,9 +223,7 @@ export function LanguageSwitcher({
|
||||
: "bg-transparent text-[var(--admin-text)] hover:bg-[var(--admin-accent)]/10"
|
||||
}`
|
||||
: `flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-sm font-medium transition-colors border-0 cursor-pointer ${
|
||||
selected
|
||||
? "opacity-100"
|
||||
: "opacity-70 hover:opacity-100"
|
||||
selected ? "opacity-100" : "opacity-70 hover:opacity-100"
|
||||
}`
|
||||
}
|
||||
style={
|
||||
|
||||
@@ -77,10 +77,10 @@ export function MobileNav({
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||
boxShadow:
|
||||
"0 12px 40px color-mix(in srgb, rgb(0 0 0) 22%, transparent)",
|
||||
}}
|
||||
>
|
||||
boxShadow:
|
||||
"0 12px 40px color-mix(in srgb, rgb(0 0 0) 22%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 mb-1 rounded-xl"
|
||||
style={{
|
||||
|
||||
@@ -25,7 +25,11 @@ export async function Navigation({ session }: { session: Session | null }) {
|
||||
ctx.session.user.rank,
|
||||
);
|
||||
showMod =
|
||||
canAccess(ctx.permissions, PERMS.MOD_DASHBOARD, ctx.session.user.rank) ||
|
||||
canAccess(
|
||||
ctx.permissions,
|
||||
PERMS.MOD_DASHBOARD,
|
||||
ctx.session.user.rank,
|
||||
) ||
|
||||
canAccess(
|
||||
ctx.permissions,
|
||||
PERMS.MODERATION_VIEW,
|
||||
@@ -45,8 +49,7 @@ export async function Navigation({ session }: { session: Session | null }) {
|
||||
prisma.messengerOffline.count({ where: { userId: id } }),
|
||||
prisma.messengerFriendrequests.count({ where: { userToId: id } }),
|
||||
]);
|
||||
} catch {
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -185,7 +188,7 @@ export async function Navigation({ session }: { session: Session | null }) {
|
||||
className="mr-1.5"
|
||||
/>
|
||||
<span>{t("messages")}</span>
|
||||
{(unreadMessages + pendingFriendRequests) > 0 && (
|
||||
{unreadMessages + pendingFriendRequests > 0 && (
|
||||
<span
|
||||
className="absolute -top-1.5 -right-1.5 flex min-w-[18px] h-[18px] items-center justify-center rounded-full px-1 text-[10px] font-bold leading-none"
|
||||
style={{
|
||||
@@ -245,14 +248,16 @@ export async function Navigation({ session }: { session: Session | null }) {
|
||||
style={{
|
||||
background: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg px-2 py-1.5 text-xs font-bold transition-colors"
|
||||
style={{
|
||||
background: "color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
useTransition,
|
||||
} from "react";
|
||||
import { saveLogo } from "@/actions/save-logo";
|
||||
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
|
||||
import { HABBO_FONT_GROUPS } from "@/components/public/habbo-fonts";
|
||||
import {
|
||||
getFontInfo,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
renderToCanvas,
|
||||
} from "@/components/public/sprite-font";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
|
||||
|
||||
export default function LogoGenerator({
|
||||
initialText = FALLBACK_HOTEL_NAME,
|
||||
|
||||
@@ -119,5 +119,3 @@ export function RankBadge({ position }: { position: number }) {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ const SW_VERSION = "3";
|
||||
|
||||
export function PwaRegister() {
|
||||
useEffect(() => {
|
||||
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
|
||||
if (typeof navigator === "undefined" || !("serviceWorker" in navigator))
|
||||
return;
|
||||
|
||||
navigator.serviceWorker.getRegistrations().then((regs) => {
|
||||
for (const reg of regs) {
|
||||
|
||||
@@ -8,7 +8,9 @@ export function RoomQuickEntry() {
|
||||
className="hidden sm:flex items-center gap-1"
|
||||
onSubmit={(e) => {
|
||||
const form = e.currentTarget;
|
||||
const input = form.elements.namedItem("roomId") as HTMLInputElement | null;
|
||||
const input = form.elements.namedItem(
|
||||
"roomId",
|
||||
) as HTMLInputElement | null;
|
||||
if (input?.value) {
|
||||
form.action = `/room/${input.value}`;
|
||||
}
|
||||
@@ -24,14 +26,16 @@ export function RoomQuickEntry() {
|
||||
style={{
|
||||
background: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg px-2 py-1 text-xs font-bold transition-colors"
|
||||
style={{
|
||||
background: "color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -2,8 +2,8 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export async function SiteHeader() {
|
||||
|
||||
@@ -172,17 +172,14 @@ export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
|
||||
? `https://fonts.googleapis.com/css2?family=${font.google}&display=swap`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{googleHref ? <link rel="stylesheet" href={googleHref} /> : null}
|
||||
<style nonce={nonce} dangerouslySetInnerHTML={{ __html: css }} />
|
||||
{customCss?.trim() ? (
|
||||
// Staff-authored custom CSS (housekeeping → Theme). Trusted input.
|
||||
<style
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{ __html: customCss }}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{googleHref ? <link rel="stylesheet" href={googleHref} /> : null}
|
||||
<style nonce={nonce} dangerouslySetInnerHTML={{ __html: css }} />
|
||||
{customCss?.trim() ? (
|
||||
// Staff-authored custom CSS (housekeeping → Theme). Trusted input.
|
||||
<style nonce={nonce} dangerouslySetInnerHTML={{ __html: customCss }} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,14 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { Session } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { RoomQuickEntry } from "@/components/room-quick-entry";
|
||||
import { signOut } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { RoomQuickEntry } from "@/components/room-quick-entry";
|
||||
|
||||
function Currency({
|
||||
icon,
|
||||
@@ -65,8 +65,7 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
if (c.type === 0) duckets = c.amount;
|
||||
if (c.type === 5) diamonds = c.amount;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const imager =
|
||||
(await siteSettings.get(
|
||||
@@ -115,8 +114,7 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
prisma.messengerOffline.count({ where: { userId: id } }),
|
||||
prisma.messengerFriendrequests.count({ where: { userToId: id } }),
|
||||
]);
|
||||
} catch {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const friendRequests = await prisma.messengerFriendrequests
|
||||
.findMany({
|
||||
@@ -161,8 +159,10 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
style={{
|
||||
background: "var(--color-primary)",
|
||||
color: "var(--color-primary-foreground)",
|
||||
borderColor: "color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
||||
boxShadow: "0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
||||
boxShadow:
|
||||
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
@@ -200,7 +200,8 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
style={{
|
||||
background: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
@@ -348,7 +349,10 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
}}
|
||||
/>
|
||||
<div className="px-3 py-1.5">
|
||||
<p className="text-xs font-bold mb-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
<p
|
||||
className="text-xs font-bold mb-1"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Friend requests ({pendingFriendRequests})
|
||||
</p>
|
||||
{friendRequestUsers.slice(0, 3).map((u) => (
|
||||
@@ -372,7 +376,10 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
</Link>
|
||||
))}
|
||||
{friendRequestUsers.length > 3 && (
|
||||
<p className="text-xs mt-1 opacity-60" style={{ color: textColor }}>
|
||||
<p
|
||||
className="text-xs mt-1 opacity-60"
|
||||
style={{ color: textColor }}
|
||||
>
|
||||
+{friendRequestUsers.length - 3} more
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -63,10 +63,7 @@ function PopoverContent({
|
||||
}
|
||||
|
||||
/** Radix compat stub — Base UI positions via Positioner. */
|
||||
function PopoverAnchor({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
function PopoverAnchor({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="popover-anchor" className={className} {...props} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,7 @@ import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SelectProps = Omit<
|
||||
SelectPrimitive.Root.Props<string>,
|
||||
"onValueChange"
|
||||
> & {
|
||||
type SelectProps = Omit<SelectPrimitive.Root.Props<string>, "onValueChange"> & {
|
||||
onValueChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
@@ -109,7 +106,9 @@ function SelectContent({
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List className="p-1">{children}</SelectPrimitive.List>
|
||||
<SelectPrimitive.List className="p-1">
|
||||
{children}
|
||||
</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
|
||||
Reference in new issue
Block a user