mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
Polish settings UI, add tooltips, replace timeAgo with date-fns
Clean up settings section design: consolidate backup cards, add hover-reveal actions with tooltips, improve icon sizing across components, and replace custom timeAgo helper with date-fns formatDistanceToNow in webhook card. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -51,7 +51,7 @@ export default function RegisterPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||||
<IconLock size={24} className="text-primary" />
|
<IconLock className="size-6 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h1 className="font-display text-xl tracking-tight">
|
<h1 className="font-display text-xl tracking-tight">
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function ContinueWatchingCard({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center bg-gradient-to-br from-card via-secondary to-muted">
|
<div className="flex h-full items-center justify-center bg-gradient-to-br from-card via-secondary to-muted">
|
||||||
<IconPlayerPlay size={32} className="text-muted-foreground/30" />
|
<IconPlayerPlay className="size-8 text-muted-foreground/30" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
|
||||||
@@ -76,7 +76,7 @@ export function ContinueWatchingCard({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
|
||||||
<IconPlayerPlay size={14} />
|
<IconPlayerPlay className="size-3.5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{progress > 0 && (
|
{progress > 0 && (
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export async function ContinueWatchingSection({ userId }: { userId: string }) {
|
|||||||
return (
|
return (
|
||||||
<FeedSection
|
<FeedSection
|
||||||
title="Continue Watching"
|
title="Continue Watching"
|
||||||
icon={<IconPlayerPlay size={20} className="text-primary" />}
|
icon={<IconPlayerPlay className="size-5 text-primary" />}
|
||||||
>
|
>
|
||||||
<ContinueWatchingList items={items} />
|
<ContinueWatchingList items={items} />
|
||||||
</FeedSection>
|
</FeedSection>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export async function LibrarySection({ userId }: { userId: string }) {
|
|||||||
return (
|
return (
|
||||||
<FeedSection
|
<FeedSection
|
||||||
title="In Your Library"
|
title="In Your Library"
|
||||||
icon={<IconSparkles size={20} className="text-primary" />}
|
icon={<IconSparkles className="size-5 text-primary" />}
|
||||||
>
|
>
|
||||||
<TitleGrid items={items} />
|
<TitleGrid items={items} />
|
||||||
</FeedSection>
|
</FeedSection>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export async function RecommendationsSection({ userId }: { userId: string }) {
|
|||||||
return (
|
return (
|
||||||
<FeedSection
|
<FeedSection
|
||||||
title="Recommended for You"
|
title="Recommended for You"
|
||||||
icon={<IconSparkles size={20} className="text-primary" />}
|
icon={<IconSparkles className="size-5 text-primary" />}
|
||||||
>
|
>
|
||||||
<TitleGrid items={items} />
|
<TitleGrid items={items} />
|
||||||
</FeedSection>
|
</FeedSection>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
|
|||||||
<div
|
<div
|
||||||
className={`flex h-6 w-6 items-center justify-center rounded-md ${def.bgColor}`}
|
className={`flex h-6 w-6 items-center justify-center rounded-md ${def.bgColor}`}
|
||||||
>
|
>
|
||||||
<Icon size={13} className={def.color} />
|
<Icon className={`size-[13px] ${def.color}`} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
{def.label}
|
{def.label}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export async function StatsSection({ userId }: { userId: string }) {
|
|||||||
{isEmpty && (
|
{isEmpty && (
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed border-border/50 py-16 text-center">
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed border-border/50 py-16 text-center">
|
||||||
<div className="animate-gentle-float rounded-full bg-primary/10 p-4">
|
<div className="animate-gentle-float rounded-full bg-primary/10 p-4">
|
||||||
<IconDeviceTv size={32} className="text-primary" />
|
<IconDeviceTv className="size-8 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="font-medium">Your library is empty</p>
|
<p className="font-medium">Your library is empty</p>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function HeroBanner({
|
|||||||
</span>
|
</span>
|
||||||
{voteAverage > 0 && (
|
{voteAverage > 0 && (
|
||||||
<span className="flex items-center gap-1 text-sm text-primary">
|
<span className="flex items-center gap-1 text-sm text-primary">
|
||||||
<IconStar size={14} className="fill-primary" />
|
<IconStar className="size-3.5 fill-primary" />
|
||||||
{voteAverage.toFixed(1)}
|
{voteAverage.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -88,7 +88,7 @@ export function HeroBanner({
|
|||||||
href={href}
|
href={href}
|
||||||
className="mt-4 inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-all hover:shadow-md hover:shadow-primary/20"
|
className="mt-4 inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-all hover:shadow-md hover:shadow-primary/20"
|
||||||
>
|
>
|
||||||
<IconPlus size={16} />
|
<IconPlus className="size-4" />
|
||||||
Add to Library
|
Add to Library
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -66,19 +66,19 @@ export default async function ExplorePage() {
|
|||||||
|
|
||||||
<TitleRow
|
<TitleRow
|
||||||
heading="Trending Today"
|
heading="Trending Today"
|
||||||
icon={<IconFlame size={20} className="text-primary" />}
|
icon={<IconFlame className="size-5 text-primary" />}
|
||||||
items={trendingItems.slice(0, 20)}
|
items={trendingItems.slice(0, 20)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TitleRow
|
<TitleRow
|
||||||
heading="Popular Movies"
|
heading="Popular Movies"
|
||||||
icon={<IconMovie size={20} className="text-primary" />}
|
icon={<IconMovie className="size-5 text-primary" />}
|
||||||
items={popularMovieItems.slice(0, 20)}
|
items={popularMovieItems.slice(0, 20)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TitleRow
|
<TitleRow
|
||||||
heading="Popular TV Shows"
|
heading="Popular TV Shows"
|
||||||
icon={<IconDeviceTv size={20} className="text-primary" />}
|
icon={<IconDeviceTv className="size-5 text-primary" />}
|
||||||
items={popularTvItems.slice(0, 20)}
|
items={popularTvItems.slice(0, 20)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function AccountSection({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<IconUser size={16} className="text-muted-foreground" />
|
<IconUser className="size-4 text-muted-foreground" />
|
||||||
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
Account
|
Account
|
||||||
</h2>
|
</h2>
|
||||||
@@ -59,7 +59,7 @@ export function AccountSection({
|
|||||||
router.refresh();
|
router.refresh();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconLogout size={14} />
|
<IconLogout />
|
||||||
Sign out
|
Sign out
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
|
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import type { BackupInfo } from "@/lib/services/backup";
|
import type { BackupInfo } from "@/lib/services/backup";
|
||||||
import {
|
import {
|
||||||
createBackupAction,
|
createBackupAction,
|
||||||
@@ -208,38 +213,50 @@ export function BackupSection({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<Button
|
<Tooltip>
|
||||||
variant="ghost"
|
<TooltipTrigger
|
||||||
size="icon-sm"
|
|
||||||
nativeButton={false}
|
|
||||||
render={
|
|
||||||
<a
|
|
||||||
href={`/api/backup/${backup.filename}`}
|
|
||||||
download
|
|
||||||
title="Download"
|
|
||||||
aria-label="Download backup"
|
|
||||||
>
|
|
||||||
<IconCloudDownload />
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger
|
|
||||||
render={
|
render={
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
disabled={deleting === backup.filename}
|
nativeButton={false}
|
||||||
title="Delete"
|
render={
|
||||||
|
<a
|
||||||
|
href={`/api/backup/${backup.filename}`}
|
||||||
|
download
|
||||||
|
aria-label="Download backup"
|
||||||
|
>
|
||||||
|
<IconCloudDownload />
|
||||||
|
</a>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
{deleting === backup.filename ? (
|
<TooltipContent>Download</TooltipContent>
|
||||||
<Spinner />
|
</Tooltip>
|
||||||
) : (
|
<AlertDialog>
|
||||||
<IconTrash />
|
<Tooltip>
|
||||||
)}
|
<AlertDialogTrigger
|
||||||
</AlertDialogTrigger>
|
render={
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
disabled={deleting === backup.filename}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{deleting === backup.filename ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<IconTrash />
|
||||||
|
)}
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<TooltipContent>Delete</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete backup?</AlertDialogTitle>
|
<AlertDialogTitle>Delete backup?</AlertDialogTitle>
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function IntegrationsSection({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<IconWebhook size={16} className="text-muted-foreground" />
|
<IconWebhook className="size-4 text-muted-foreground" />
|
||||||
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
Integrations
|
Integrations
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function ServerSection({
|
|||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
<IconUserPlus size={16} className="text-primary" />
|
<IconUserPlus className="size-4 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Open registration</CardTitle>
|
<CardTitle>Open registration</CardTitle>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function SettingsShell({
|
|||||||
>
|
>
|
||||||
<motion.div variants={sectionVariants}>
|
<motion.div variants={sectionVariants}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<IconSettings size={20} className="text-primary" />
|
<IconSettings className="size-5 text-primary" />
|
||||||
<h1 className="font-display text-3xl tracking-tight">Settings</h1>
|
<h1 className="font-display text-3xl tracking-tight">Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -49,17 +50,6 @@ export interface WebhookConnection {
|
|||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function timeAgo(dateStr: string): string {
|
|
||||||
const diff = Date.now() - new Date(dateStr).getTime();
|
|
||||||
const mins = Math.floor(diff / 60000);
|
|
||||||
if (mins < 1) return "just now";
|
|
||||||
if (mins < 60) return `${mins}m ago`;
|
|
||||||
const hours = Math.floor(mins / 60);
|
|
||||||
if (hours < 24) return `${hours}h ago`;
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
return `${days}d ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WebhookCard({
|
export function WebhookCard({
|
||||||
provider,
|
provider,
|
||||||
connection,
|
connection,
|
||||||
@@ -122,15 +112,14 @@ export function WebhookCard({
|
|||||||
<CardDescription>
|
<CardDescription>
|
||||||
{connection
|
{connection
|
||||||
? connection.lastEventAt
|
? connection.lastEventAt
|
||||||
? `Last event ${timeAgo(connection.lastEventAt)}`
|
? `Last event ${formatDistanceToNow(new Date(connection.lastEventAt), { addSuffix: true })}`
|
||||||
: "Connected — no events yet"
|
: "Connected — no events yet"
|
||||||
: "Not configured"}
|
: "Not configured"}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<IconChevronDown
|
<IconChevronDown
|
||||||
size={16}
|
className={`size-4 text-muted-foreground transition-transform duration-200 ${cardOpen ? "rotate-180" : ""}`}
|
||||||
className={`text-muted-foreground transition-transform duration-200 ${cardOpen ? "rotate-180" : ""}`}
|
|
||||||
/>
|
/>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -151,10 +140,7 @@ export function WebhookCard({
|
|||||||
|
|
||||||
{isPlex && (
|
{isPlex && (
|
||||||
<div className="flex gap-2.5 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2.5">
|
<div className="flex gap-2.5 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2.5">
|
||||||
<IconInfoCircle
|
<IconInfoCircle className="mt-0.5 size-3.5 shrink-0 text-primary" />
|
||||||
size={14}
|
|
||||||
className="mt-0.5 shrink-0 text-primary"
|
|
||||||
/>
|
|
||||||
<p className="text-xs leading-relaxed text-foreground/80">
|
<p className="text-xs leading-relaxed text-foreground/80">
|
||||||
Plex webhooks require an active{" "}
|
Plex webhooks require an active{" "}
|
||||||
<a
|
<a
|
||||||
@@ -240,9 +226,9 @@ export function WebhookCard({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<IconCheck size={14} className="text-green-400" />
|
<IconCheck className="text-green-400" />
|
||||||
) : (
|
) : (
|
||||||
<IconCopy size={14} />
|
<IconCopy />
|
||||||
)}
|
)}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Copy URL</TooltipContent>
|
<TooltipContent>Copy URL</TooltipContent>
|
||||||
@@ -256,7 +242,7 @@ export function WebhookCard({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onRegenerateToken(provider)}
|
onClick={() => onRegenerateToken(provider)}
|
||||||
>
|
>
|
||||||
<IconRefresh size={12} />
|
<IconRefresh />
|
||||||
Regenerate URL
|
Regenerate URL
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -264,7 +250,7 @@ export function WebhookCard({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onDelete(provider)}
|
onClick={() => onDelete(provider)}
|
||||||
>
|
>
|
||||||
<IconTrash size={12} />
|
<IconTrash />
|
||||||
Disconnect
|
Disconnect
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,8 +261,7 @@ export function WebhookCard({
|
|||||||
<Collapsible open={setupOpen} onOpenChange={setSetupOpen}>
|
<Collapsible open={setupOpen} onOpenChange={setSetupOpen}>
|
||||||
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded-md py-1 text-xs text-muted-foreground transition-colors hover:text-foreground">
|
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded-md py-1 text-xs text-muted-foreground transition-colors hover:text-foreground">
|
||||||
<IconChevronDown
|
<IconChevronDown
|
||||||
size={12}
|
className={`size-3 transition-transform ${setupOpen ? "rotate-0" : "-rotate-90"}`}
|
||||||
className={`transition-transform ${setupOpen ? "rotate-0" : "-rotate-90"}`}
|
|
||||||
/>
|
/>
|
||||||
Setup instructions
|
Setup instructions
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export default async function SettingsPage() {
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<IconServerCog size={16} className="text-muted-foreground" />
|
<IconServerCog className="size-4 text-muted-foreground" />
|
||||||
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
Server
|
Server
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const steps = [
|
|||||||
className="inline-flex items-center gap-1 font-medium text-primary underline decoration-primary/30 underline-offset-2 transition-colors hover:decoration-primary"
|
className="inline-flex items-center gap-1 font-medium text-primary underline decoration-primary/30 underline-offset-2 transition-colors hover:decoration-primary"
|
||||||
>
|
>
|
||||||
themoviedb.org
|
themoviedb.org
|
||||||
<IconExternalLink size={14} />
|
<IconExternalLink className="size-3.5" />
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
and sign up for a free account.
|
and sign up for a free account.
|
||||||
</>
|
</>
|
||||||
@@ -43,7 +43,7 @@ const steps = [
|
|||||||
className="inline-flex items-center gap-1 font-medium text-primary underline decoration-primary/30 underline-offset-2 transition-colors hover:decoration-primary"
|
className="inline-flex items-center gap-1 font-medium text-primary underline decoration-primary/30 underline-offset-2 transition-colors hover:decoration-primary"
|
||||||
>
|
>
|
||||||
Settings → API
|
Settings → API
|
||||||
<IconExternalLink size={14} />
|
<IconExternalLink className="size-3.5" />
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
and request an API key. Choose “Developer” when asked. You
|
and request an API key. Choose “Developer” when asked. You
|
||||||
need the{" "}
|
need the{" "}
|
||||||
@@ -133,7 +133,7 @@ export default function SetupPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<motion.div variants={sectionVariants} className="space-y-3">
|
<motion.div variants={sectionVariants} className="space-y-3">
|
||||||
<div className="inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/5 px-3 py-1 text-xs font-medium text-primary">
|
<div className="inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/5 px-3 py-1 text-xs font-medium text-primary">
|
||||||
<IconKey size={14} />
|
<IconKey className="size-3.5" />
|
||||||
Setup required
|
Setup required
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-display text-3xl tracking-tight sm:text-4xl">
|
<h1 className="font-display text-3xl tracking-tight sm:text-4xl">
|
||||||
@@ -188,12 +188,12 @@ export default function SetupPage() {
|
|||||||
>
|
>
|
||||||
{copiedIdx === idx ? (
|
{copiedIdx === idx ? (
|
||||||
<>
|
<>
|
||||||
<IconCheck size={12} className="text-green-400" />
|
<IconCheck className="size-3 text-green-400" />
|
||||||
Copied
|
Copied
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<IconCopy size={12} />
|
<IconCopy className="size-3" />
|
||||||
Copy
|
Copy
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -217,7 +217,7 @@ export default function SetupPage() {
|
|||||||
{configured === true ? (
|
{configured === true ? (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10">
|
||||||
<IconCheck size={20} className="text-green-400" />
|
<IconCheck className="size-5 text-green-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-green-400">
|
<p className="font-medium text-green-400">
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ export function StarRating({ value, onChange }: StarRatingProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{filled ? (
|
{filled ? (
|
||||||
<IconStarFilled size={18} className="text-primary" />
|
<IconStarFilled className="size-4.5 text-primary" />
|
||||||
) : (
|
) : (
|
||||||
<IconStar size={18} className="text-muted-foreground/30" />
|
<IconStar className="size-4.5 text-muted-foreground/30" />
|
||||||
)}
|
)}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
|
|||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary/10 px-4 text-sm font-medium text-primary ring-1 ring-primary/20 transition-all hover:bg-primary/15 hover:ring-primary/30 active:scale-[0.97]"
|
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary/10 px-4 text-sm font-medium text-primary ring-1 ring-primary/20 transition-all hover:bg-primary/15 hover:ring-primary/30 active:scale-[0.97]"
|
||||||
>
|
>
|
||||||
<IconPlus size={14} strokeWidth={2.5} />
|
<IconPlus className="size-3.5" strokeWidth={2.5} />
|
||||||
Watchlist
|
Watchlist
|
||||||
</motion.button>
|
</motion.button>
|
||||||
) : (
|
) : (
|
||||||
@@ -66,14 +66,8 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
|
|||||||
className={`group inline-flex h-9 items-center gap-2 rounded-lg px-4 text-sm font-medium ring-1 transition-all active:scale-[0.97] ${config.class} ${config.bgClass} ${config.borderClass} hover:ring-destructive/30 hover:bg-destructive/10 hover:text-destructive`}
|
className={`group inline-flex h-9 items-center gap-2 rounded-lg px-4 text-sm font-medium ring-1 transition-all active:scale-[0.97] ${config.class} ${config.bgClass} ${config.borderClass} hover:ring-destructive/30 hover:bg-destructive/10 hover:text-destructive`}
|
||||||
>
|
>
|
||||||
<span className="grid [&>svg]:col-start-1 [&>svg]:row-start-1">
|
<span className="grid [&>svg]:col-start-1 [&>svg]:row-start-1">
|
||||||
<config.icon
|
<config.icon className="size-3.5 transition-opacity group-hover:opacity-0" />
|
||||||
size={14}
|
<IconX className="size-3.5 opacity-0 text-destructive transition-opacity group-hover:opacity-100" />
|
||||||
className="transition-opacity group-hover:opacity-0"
|
|
||||||
/>
|
|
||||||
<IconX
|
|
||||||
size={14}
|
|
||||||
className="opacity-0 text-destructive transition-opacity group-hover:opacity-100"
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="grid [&>span]:col-start-1 [&>span]:row-start-1">
|
<span className="grid [&>span]:col-start-1 [&>span]:row-start-1">
|
||||||
<span className="transition-opacity group-hover:opacity-0">
|
<span className="transition-opacity group-hover:opacity-0">
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function TitleActions() {
|
|||||||
onClick={handleWatchMovie}
|
onClick={handleWatchMovie}
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-all active:scale-[0.97] hover:shadow-md hover:shadow-primary/20"
|
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-all active:scale-[0.97] hover:shadow-md hover:shadow-primary/20"
|
||||||
>
|
>
|
||||||
<IconPlayerPlay size={14} />
|
<IconPlayerPlay className="size-3.5" />
|
||||||
Mark Watched
|
Mark Watched
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function TitleSeasons() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<IconChecks size={14} />
|
<IconChecks className="size-3.5" />
|
||||||
Mark All Watched
|
Mark All Watched
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -160,15 +160,9 @@ export function TitleSeasons() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
<IconChevronUp
|
<IconChevronUp className="size-4 text-muted-foreground" />
|
||||||
size={16}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<IconChevronDown
|
<IconChevronDown className="size-4 text-muted-foreground" />
|
||||||
size={16}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -221,7 +215,7 @@ export function TitleSeasons() {
|
|||||||
damping: 15,
|
damping: 15,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconCheck size={14} />
|
<IconCheck className="size-3.5" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function AuthForm({
|
|||||||
>
|
>
|
||||||
<div className="space-y-2 text-center">
|
<div className="space-y-2 text-center">
|
||||||
<Link href="/" className="inline-flex justify-center text-primary">
|
<Link href="/" className="inline-flex justify-center text-primary">
|
||||||
<SofaLogo size={36} />
|
<SofaLogo className="size-9" />
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-lg font-medium">
|
<h1 className="text-lg font-medium">
|
||||||
{isRegister ? "Create your account" : "Welcome back"}
|
{isRegister ? "Create your account" : "Welcome back"}
|
||||||
@@ -124,7 +124,7 @@ export function AuthForm({
|
|||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-border/50 bg-background/50 text-sm font-medium transition-all hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-border/50 bg-background/50 text-sm font-medium transition-all hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<IconKey size={16} />
|
<IconKey className="size-4" />
|
||||||
{oidcLoading
|
{oidcLoading
|
||||||
? "Redirecting..."
|
? "Redirecting..."
|
||||||
: `Sign in with ${authConfig?.oidcProviderName || "SSO"}`}
|
: `Sign in with ${authConfig?.oidcProviderName || "SSO"}`}
|
||||||
|
|||||||
@@ -235,9 +235,9 @@ export function CommandPalette() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||||
{r.type === "movie" ? (
|
{r.type === "movie" ? (
|
||||||
<IconMovie size={11} />
|
<IconMovie className="size-[11px]" />
|
||||||
) : (
|
) : (
|
||||||
<IconDeviceTv size={11} />
|
<IconDeviceTv className="size-[11px]" />
|
||||||
)}
|
)}
|
||||||
<span className="uppercase">{r.type}</span>
|
<span className="uppercase">{r.type}</span>
|
||||||
{r.releaseDate && (
|
{r.releaseDate && (
|
||||||
@@ -273,10 +273,7 @@ export function CommandPalette() {
|
|||||||
onSelect={() => handleRecentSearch(q)}
|
onSelect={() => handleRecentSearch(q)}
|
||||||
className="group"
|
className="group"
|
||||||
>
|
>
|
||||||
<IconSearch
|
<IconSearch className="size-3.5 text-muted-foreground" />
|
||||||
size={14}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<span className="flex-1">{q}</span>
|
<span className="flex-1">{q}</span>
|
||||||
<span
|
<span
|
||||||
data-slot="command-shortcut"
|
data-slot="command-shortcut"
|
||||||
@@ -290,7 +287,7 @@ export function CommandPalette() {
|
|||||||
}}
|
}}
|
||||||
className="rounded-sm p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-data-[selected=true]:opacity-100"
|
className="rounded-sm p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-data-[selected=true]:opacity-100"
|
||||||
>
|
>
|
||||||
<IconX size={12} />
|
<IconX className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -305,7 +302,7 @@ export function CommandPalette() {
|
|||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconHome size={14} />
|
<IconHome className="size-3.5" />
|
||||||
Go to Dashboard
|
Go to Dashboard
|
||||||
<CommandShortcut>G H</CommandShortcut>
|
<CommandShortcut>G H</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -315,7 +312,7 @@ export function CommandPalette() {
|
|||||||
router.push("/explore");
|
router.push("/explore");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconSearch size={14} />
|
<IconSearch className="size-3.5" />
|
||||||
Go to Explore
|
Go to Explore
|
||||||
<CommandShortcut>G E</CommandShortcut>
|
<CommandShortcut>G E</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -325,7 +322,7 @@ export function CommandPalette() {
|
|||||||
setHelpOpen(true);
|
setHelpOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconKeyboard size={14} />
|
<IconKeyboard className="size-3.5" />
|
||||||
Keyboard Shortcuts
|
Keyboard Shortcuts
|
||||||
<CommandShortcut>?</CommandShortcut>
|
<CommandShortcut>?</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export function LandingPage({ posterUrls }: { posterUrls: string[] }) {
|
|||||||
delay: 0.1,
|
delay: 0.1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SofaLogo size={96} className="sm:h-28 sm:w-28 md:h-32 md:w-32" />
|
<SofaLogo className="size-24 sm:size-28 md:size-32" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<motion.p
|
<motion.p
|
||||||
className="mx-auto max-w-md text-lg leading-relaxed text-muted-foreground"
|
className="mx-auto max-w-md text-lg leading-relaxed text-muted-foreground"
|
||||||
|
|||||||
@@ -34,8 +34,7 @@ export function MobileTabBar() {
|
|||||||
className="relative flex flex-1 flex-col items-center justify-center gap-0.5"
|
className="relative flex flex-1 flex-col items-center justify-center gap-0.5"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
size={20}
|
className={`size-5 ${isActive ? "text-primary" : "text-muted-foreground"}`}
|
||||||
className={isActive ? "text-primary" : "text-muted-foreground"}
|
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={`text-[10px] font-medium ${isActive ? "text-primary" : "text-muted-foreground"}`}
|
className={`text-[10px] font-medium ${isActive ? "text-primary" : "text-muted-foreground"}`}
|
||||||
|
|||||||
+26
-19
@@ -7,6 +7,11 @@ import Link from "next/link";
|
|||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { SofaLogo } from "@/components/sofa-logo";
|
import { SofaLogo } from "@/components/sofa-logo";
|
||||||
import { Kbd } from "@/components/ui/kbd";
|
import { Kbd } from "@/components/ui/kbd";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { commandPaletteOpenAtom } from "@/lib/atoms/command-palette";
|
import { commandPaletteOpenAtom } from "@/lib/atoms/command-palette";
|
||||||
import { signOut, useSession } from "@/lib/auth/client";
|
import { signOut, useSession } from "@/lib/auth/client";
|
||||||
|
|
||||||
@@ -29,7 +34,7 @@ export function NavBar() {
|
|||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
className="shrink-0 text-foreground transition-colors hover:text-primary"
|
className="shrink-0 text-foreground transition-colors hover:text-primary"
|
||||||
>
|
>
|
||||||
<SofaLogo size={28} />
|
<SofaLogo className="size-7" />
|
||||||
</Link>
|
</Link>
|
||||||
<div className="hidden items-center gap-1 sm:flex">
|
<div className="hidden items-center gap-1 sm:flex">
|
||||||
{navLinks.map((link) => {
|
{navLinks.map((link) => {
|
||||||
@@ -61,14 +66,14 @@ export function NavBar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 items-center justify-end gap-3 sm:flex-none">
|
<div className="flex flex-1 items-center justify-end gap-2 sm:flex-none sm:gap-1">
|
||||||
{/* Mobile search trigger */}
|
{/* Mobile search trigger */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCommandPaletteOpen(true)}
|
onClick={() => setCommandPaletteOpen(true)}
|
||||||
className="flex flex-1 items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-1.5 text-sm text-muted-foreground transition-all hover:border-primary/20 hover:bg-card sm:hidden"
|
className="flex flex-1 items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-1.5 text-sm text-muted-foreground transition-all hover:border-primary/20 hover:bg-card sm:hidden"
|
||||||
>
|
>
|
||||||
<IconSearch size={14} />
|
<IconSearch className="size-3.5" />
|
||||||
<span>Search...</span>
|
<span>Search...</span>
|
||||||
</button>
|
</button>
|
||||||
{/* Desktop search trigger pill */}
|
{/* Desktop search trigger pill */}
|
||||||
@@ -77,32 +82,34 @@ export function NavBar() {
|
|||||||
onClick={() => setCommandPaletteOpen(true)}
|
onClick={() => setCommandPaletteOpen(true)}
|
||||||
className="hidden items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-1.5 text-sm text-muted-foreground transition-all hover:border-primary/20 hover:bg-card sm:inline-flex"
|
className="hidden items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-1.5 text-sm text-muted-foreground transition-all hover:border-primary/20 hover:bg-card sm:inline-flex"
|
||||||
>
|
>
|
||||||
<IconSearch size={14} />
|
<IconSearch className="size-3.5" />
|
||||||
<span>Search...</span>
|
<span>Search...</span>
|
||||||
<Kbd className="ml-1">⌘K</Kbd>
|
<Kbd className="ml-1">⌘K</Kbd>
|
||||||
</button>
|
</button>
|
||||||
<div className="hidden items-center gap-2 rounded-lg border border-border/30 px-2.5 py-1 sm:flex">
|
<div className="mx-1.5 hidden h-4 w-px bg-border/50 sm:block" />
|
||||||
<span className="text-sm text-muted-foreground">
|
<Tooltip>
|
||||||
{session?.user?.name}
|
<TooltipTrigger
|
||||||
</span>
|
render={<Link href="/settings" />}
|
||||||
<Link
|
className="hidden items-center gap-1.5 rounded-md px-2 py-1.5 text-sm leading-none text-muted-foreground transition-colors hover:text-foreground sm:inline-flex"
|
||||||
href="/settings"
|
|
||||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
>
|
||||||
<IconSettings size={14} />
|
{session?.user?.name}
|
||||||
</Link>
|
<IconSettings className="size-3.5" />
|
||||||
<button
|
</TooltipTrigger>
|
||||||
type="button"
|
<TooltipContent>Settings</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await signOut();
|
await signOut();
|
||||||
router.push("/");
|
router.push("/");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}}
|
}}
|
||||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
className="hidden h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:inline-flex"
|
||||||
>
|
>
|
||||||
<IconLogout size={14} />
|
<IconLogout className="size-[15px]" />
|
||||||
</button>
|
</TooltipTrigger>
|
||||||
</div>
|
<TooltipContent>Log out</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
export function SofaLogo({
|
export function SofaLogo({ className }: { className?: string }) {
|
||||||
size = 24,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
size?: number;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
className={className}
|
className={className ?? "size-6"}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Sofa"
|
aria-label="Sofa"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -69,11 +69,11 @@ export function TitleCard({
|
|||||||
<div className="px-3 pb-3 pt-2.5">
|
<div className="px-3 pb-3 pt-2.5">
|
||||||
<p className="line-clamp-1 text-sm font-medium leading-snug">{title}</p>
|
<p className="line-clamp-1 text-sm font-medium leading-snug">{title}</p>
|
||||||
<div className="mt-1.5 flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="mt-1.5 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<TypeIcon size={14} className="shrink-0 text-primary/60" />
|
<TypeIcon className="size-3.5 shrink-0 text-primary/60" />
|
||||||
{year && <span>{year}</span>}
|
{year && <span>{year}</span>}
|
||||||
{voteAverage != null && voteAverage > 0 && (
|
{voteAverage != null && voteAverage > 0 && (
|
||||||
<span className="ml-auto flex items-center gap-0.5 text-primary/80">
|
<span className="ml-auto flex items-center gap-0.5 text-primary/80">
|
||||||
<IconStarFilled size={11} />
|
<IconStarFilled className="size-[11px]" />
|
||||||
{voteAverage.toFixed(1)}
|
{voteAverage.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user