refactor: convert admin pages to Tailwind and improve media uploads; fix TypeScript error in commandocentrum
No files matched your search
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 98 KiB |
@@ -35,7 +35,7 @@ export default async function AdminEmulator() {
|
|||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<h1>Emulator Config</h1>
|
<h1>Emulator Config</h1>
|
||||||
<p className="muted">
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
Live key/value config read by the Arcturus emulator from
|
Live key/value config read by the Arcturus emulator from
|
||||||
<code> emulator_settings</code> and <code> emulator_texts</code>. Changes take effect
|
<code> emulator_settings</code> and <code> emulator_texts</code>. Changes take effect
|
||||||
on the next emulator read (some keys require an emulator reload).
|
on the next emulator read (some keys require an emulator reload).
|
||||||
@@ -48,7 +48,7 @@ export default async function AdminEmulator() {
|
|||||||
|
|
||||||
<section className="mt-6">
|
<section className="mt-6">
|
||||||
<h2 className="admin-section-title">Settings</h2>
|
<h2 className="admin-section-title">Settings</h2>
|
||||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
{settings.length} keys.
|
{settings.length} keys.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ export default async function AdminEmulator() {
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ width: 320 }}>Key</th>
|
<th className="w-[320px]">Key</th>
|
||||||
<th>Value</th>
|
<th>Value</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -87,14 +87,14 @@ export default async function AdminEmulator() {
|
|||||||
<td>
|
<td>
|
||||||
<form
|
<form
|
||||||
action={updateEmulatorSetting}
|
action={updateEmulatorSetting}
|
||||||
style={{ display: "flex", gap: "0.5rem" }}
|
className="flex gap-2"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="key" value={s.key} />
|
<input type="hidden" name="key" value={s.key} />
|
||||||
<input
|
<input
|
||||||
name="value"
|
name="value"
|
||||||
defaultValue={s.value}
|
defaultValue={s.value}
|
||||||
maxLength={512}
|
maxLength={512}
|
||||||
style={{ flex: 1 }}
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="btn btn-outline">
|
<button type="submit" className="btn btn-outline">
|
||||||
Save
|
Save
|
||||||
@@ -106,13 +106,13 @@ export default async function AdminEmulator() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{settings.length === 0 ? (
|
{settings.length === 0 ? (
|
||||||
<p className="muted">No emulator settings found.</p>
|
<p className="text-xs text-gray-500 dark:text-gray-400">No emulator settings found.</p>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-6">
|
<section className="mt-6">
|
||||||
<h2 className="admin-section-title">Texts</h2>
|
<h2 className="admin-section-title">Texts</h2>
|
||||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
{textsCount} keys{textsTruncated ? ` (showing first ${texts.length})` : ""}.
|
{textsCount} keys{textsTruncated ? ` (showing first ${texts.length})` : ""}.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -38,14 +38,14 @@ export function EmbedSnippet({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mt-6">
|
<section className="mt-6">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
<div className="flex items-center justify-between gap-4 mb-1">
|
||||||
<h2 style={{ flex: 1, margin: 0 }}>{title}</h2>
|
<h2 className="m-0 text-base font-bold text-[var(--color-text)]">{title}</h2>
|
||||||
<button type="button" className="btn btn-primary" onClick={copy}>
|
<button type="button" className="btn btn-primary" onClick={copy}>
|
||||||
{copied ? 'Copied!' : 'Copy'}
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{description ? (
|
{description ? (
|
||||||
<p className="muted" style={{ marginTop: '0.4rem' }}>
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -56,13 +56,7 @@ export function EmbedSnippet({
|
|||||||
rows={Math.min(Math.max(lines + 1, 3), 14)}
|
rows={Math.min(Math.max(lines + 1, 3), 14)}
|
||||||
onFocus={(e) => e.currentTarget.select()}
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
style={{
|
className="w-full font-mono text-sm resize-vertical"
|
||||||
width: '100%',
|
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
whiteSpace: 'pre',
|
|
||||||
resize: 'vertical',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export default async function AdminRadioPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
|
<nav className="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||||
<Link href="/admin/radio">Radio</Link> ·{' '}
|
<Link href="/admin/radio">Radio</Link> ·{' '}
|
||||||
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
|
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
|
||||||
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
|
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
|
||||||
@@ -117,7 +117,7 @@ export default async function AdminRadioPage() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<h1>Radio Moderation</h1>
|
<h1>Radio Moderation</h1>
|
||||||
<p className="muted" style={{ marginTop: '-0.4rem' }}>
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Latest shouts, DJ applications and on-air schedules (25 most recent of each).
|
Latest shouts, DJ applications and on-air schedules (25 most recent of each).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -141,7 +141,7 @@ export default async function AdminRadioPage() {
|
|||||||
<section className="mt-6">
|
<section className="mt-6">
|
||||||
<h2>Recent Shouts ({shouts.length})</h2>
|
<h2>Recent Shouts ({shouts.length})</h2>
|
||||||
{shouts.length === 0 ? (
|
{shouts.length === 0 ? (
|
||||||
<p className="muted">No shouts to moderate.</p>
|
<p className="text-xs text-gray-500 dark:text-gray-400">No shouts to moderate.</p>
|
||||||
) : (
|
) : (
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -156,10 +156,10 @@ export default async function AdminRadioPage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{shouts.map((s) => (
|
{shouts.map((s) => (
|
||||||
<tr key={String(s.id)}>
|
<tr key={String(s.id)}>
|
||||||
<td>{String(s.id)}</td>
|
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{String(s.id)}</td>
|
||||||
<td>{String(s.userId)}</td>
|
<td>{String(s.userId)}</td>
|
||||||
<td>{s.message}</td>
|
<td>{s.message}</td>
|
||||||
<td className="muted">{formatDate(s.createdAt)}</td>
|
<td className="text-sm text-gray-500 dark:text-gray-400">{formatDate(s.createdAt)}</td>
|
||||||
<td>
|
<td>
|
||||||
<form action={deleteShout}>
|
<form action={deleteShout}>
|
||||||
<input type="hidden" name="id" value={String(s.id)} />
|
<input type="hidden" name="id" value={String(s.id)} />
|
||||||
@@ -178,7 +178,7 @@ export default async function AdminRadioPage() {
|
|||||||
<section className="mt-6">
|
<section className="mt-6">
|
||||||
<h2>DJ Applications ({applications.length})</h2>
|
<h2>DJ Applications ({applications.length})</h2>
|
||||||
{applications.length === 0 ? (
|
{applications.length === 0 ? (
|
||||||
<p className="muted">No applications submitted.</p>
|
<p className="text-xs text-gray-500 dark:text-gray-400">No applications submitted.</p>
|
||||||
) : (
|
) : (
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -207,7 +207,7 @@ export default async function AdminRadioPage() {
|
|||||||
{a.status}
|
{a.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="muted">{formatDate(a.createdAt)}</td>
|
<td className="text-sm text-gray-500 dark:text-gray-400">{formatDate(a.createdAt)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -218,7 +218,7 @@ export default async function AdminRadioPage() {
|
|||||||
<section className="mt-6">
|
<section className="mt-6">
|
||||||
<h2>Schedules ({schedules.length})</h2>
|
<h2>Schedules ({schedules.length})</h2>
|
||||||
{schedules.length === 0 ? (
|
{schedules.length === 0 ? (
|
||||||
<p className="muted">No schedules configured.</p>
|
<p className="text-xs text-gray-500 dark:text-gray-400">No schedules configured.</p>
|
||||||
) : (
|
) : (
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default async function AdminSettings() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid">
|
<div className="grid">
|
||||||
{settings.map((s) => (
|
{settings.map((s) => (
|
||||||
<div key={s.key} className="card">
|
<div key={s.key} className="admin-card">
|
||||||
<div style={{ display: "flex", gap: "0.75rem", alignItems: "baseline" }}>
|
<div style={{ display: "flex", gap: "0.75rem", alignItems: "baseline" }}>
|
||||||
<strong style={{ minWidth: 220 }}>{s.key}</strong>
|
<strong style={{ minWidth: 220 }}>{s.key}</strong>
|
||||||
<form
|
<form
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { deleteShopArticle, updateShopArticle } from "@/actions/admin-shop";
|
import { deleteShopArticle, updateShopArticle } from "@/actions/admin-shop";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { Package } from "lucide-react";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -21,33 +22,45 @@ export default async function EditShopArticle({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<p className="muted">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<Link href="/admin/shop">← Shop</Link>
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--color-primary)]/20 to-[var(--color-primary)]/5 grid place-items-center">
|
||||||
</p>
|
<Package size={20} className="text-[var(--color-primary)]" />
|
||||||
<h1>Edit shop package</h1>
|
</div>
|
||||||
<form action={updateShopArticle} className="card" style={{ display: "grid", gap: "0.6rem" }}>
|
<div>
|
||||||
|
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">Edit shop package</h1>
|
||||||
|
<Link href="/admin/shop" className="text-xs text-[var(--color-text-muted)] hover:text-[var(--color-primary)]">
|
||||||
|
← Back to shop
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={updateShopArticle} className="admin-card">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<input type="hidden" name="id" value={String(pkg.id)} />
|
<input type="hidden" name="id" value={String(pkg.id)} />
|
||||||
<input name="name" defaultValue={pkg.name} placeholder="Name" required maxLength={255} />
|
<input name="name" defaultValue={pkg.name} placeholder="Name" required maxLength={255} className="w-full" />
|
||||||
<input name="info" defaultValue={pkg.info} placeholder="Info / description" maxLength={255} />
|
<input name="info" defaultValue={pkg.info} placeholder="Info / description" maxLength={255} className="w-full" />
|
||||||
<input name="icon" defaultValue={pkg.iconUrl} placeholder="Icon URL" maxLength={255} />
|
<input name="icon" defaultValue={pkg.iconUrl} placeholder="Icon URL" maxLength={255} className="w-full" />
|
||||||
<input name="color" defaultValue={pkg.color} placeholder="Color (e.g. #ffcc00)" maxLength={255} />
|
<input name="color" defaultValue={pkg.color} placeholder="Color (e.g. #ffcc00)" maxLength={255} className="w-full" />
|
||||||
<input name="costs" type="number" min={0} defaultValue={pkg.costs} placeholder="Costs (store price)" required />
|
<input name="costs" type="number" min={0} defaultValue={pkg.costs} placeholder="Costs (store price)" required className="w-full" />
|
||||||
<input name="credits" type="number" min={0} defaultValue={pkg.credits ?? undefined} placeholder="Credits given (optional)" />
|
<input name="credits" type="number" min={0} defaultValue={pkg.credits ?? undefined} placeholder="Credits given (optional)" className="w-full" />
|
||||||
<input name="duckets" type="number" min={0} defaultValue={pkg.duckets ?? undefined} placeholder="Duckets given (optional)" />
|
<input name="duckets" type="number" min={0} defaultValue={pkg.duckets ?? undefined} placeholder="Duckets given (optional)" className="w-full" />
|
||||||
<input name="diamonds" type="number" min={0} defaultValue={pkg.diamonds ?? undefined} placeholder="Diamonds given (optional)" />
|
<input name="diamonds" type="number" min={0} defaultValue={pkg.diamonds ?? undefined} placeholder="Diamonds given (optional)" className="w-full" />
|
||||||
<input name="giveRank" type="number" min={0} defaultValue={pkg.giveRank ?? undefined} placeholder="Give rank (optional)" />
|
<input name="giveRank" type="number" min={0} defaultValue={pkg.giveRank ?? undefined} placeholder="Give rank (optional)" className="w-full" />
|
||||||
<input name="badges" defaultValue={pkg.badges ?? ""} placeholder="Badge codes (optional)" maxLength={255} />
|
<input name="badges" defaultValue={pkg.badges ?? ""} placeholder="Badge codes (optional)" maxLength={255} className="w-full" />
|
||||||
<input name="position" type="number" min={0} defaultValue={pkg.position} placeholder="Position" />
|
<input name="position" type="number" min={0} defaultValue={pkg.position} placeholder="Position" className="w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex gap-2">
|
||||||
<button type="submit" className="btn btn-primary">
|
<button type="submit" className="btn btn-primary">
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
</form>
|
<form action={deleteShopArticle}>
|
||||||
<form action={deleteShopArticle} style={{ marginTop: "1rem" }}>
|
|
||||||
<input type="hidden" name="id" value={String(pkg.id)} />
|
<input type="hidden" name="id" value={String(pkg.id)} />
|
||||||
<button type="submit" className="btn btn-danger">
|
<button type="submit" className="btn btn-danger">
|
||||||
Delete package
|
Delete package
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,28 +1,41 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { createShopArticle } from "@/actions/admin-shop";
|
import { createShopArticle } from "@/actions/admin-shop";
|
||||||
|
import { Package } from "lucide-react";
|
||||||
|
|
||||||
export default function NewShopArticle() {
|
export default function NewShopArticle() {
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<p className="muted">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<Link href="/admin/shop">← Shop</Link>
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--color-primary)]/20 to-[var(--color-primary)]/5 grid place-items-center">
|
||||||
</p>
|
<Package size={20} className="text-[var(--color-primary)]" />
|
||||||
<h1>New shop package</h1>
|
</div>
|
||||||
<form action={createShopArticle} className="card" style={{ display: "grid", gap: "0.6rem" }}>
|
<div>
|
||||||
<input name="name" placeholder="Name" required maxLength={255} />
|
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">New shop package</h1>
|
||||||
<input name="info" placeholder="Info / description" maxLength={255} />
|
<Link href="/admin/shop" className="text-xs text-[var(--color-text-muted)] hover:text-[var(--color-primary)]">
|
||||||
<input name="icon" placeholder="Icon" maxLength={255} />
|
← Back to shop
|
||||||
<input name="color" placeholder="Color (e.g. #ffcc00)" maxLength={255} />
|
</Link>
|
||||||
<input name="costs" type="number" min={0} placeholder="Costs (store price)" required />
|
</div>
|
||||||
<input name="credits" type="number" min={0} placeholder="Credits given (optional)" />
|
</div>
|
||||||
<input name="duckets" type="number" min={0} placeholder="Duckets given (optional)" />
|
|
||||||
<input name="diamonds" type="number" min={0} placeholder="Diamonds given (optional)" />
|
<form action={createShopArticle} className="admin-card">
|
||||||
<input name="giveRank" type="number" min={0} placeholder="Give rank (optional)" />
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<input name="badges" placeholder="Badge codes (optional)" maxLength={255} />
|
<input name="name" placeholder="Name" required maxLength={255} className="w-full" />
|
||||||
<input name="position" type="number" min={0} placeholder="Position" defaultValue={0} />
|
<input name="info" placeholder="Info / description" maxLength={255} className="w-full" />
|
||||||
|
<input name="icon" placeholder="Icon" maxLength={255} className="w-full" />
|
||||||
|
<input name="color" placeholder="Color (e.g. #ffcc00)" maxLength={255} className="w-full" />
|
||||||
|
<input name="costs" type="number" min={0} placeholder="Costs (store price)" required className="w-full" />
|
||||||
|
<input name="credits" type="number" min={0} placeholder="Credits given (optional)" className="w-full" />
|
||||||
|
<input name="duckets" type="number" min={0} placeholder="Duckets given (optional)" className="w-full" />
|
||||||
|
<input name="diamonds" type="number" min={0} placeholder="Diamonds given (optional)" className="w-full" />
|
||||||
|
<input name="giveRank" type="number" min={0} placeholder="Give rank (optional)" className="w-full" />
|
||||||
|
<input name="badges" placeholder="Badge codes (optional)" maxLength={255} className="w-full" />
|
||||||
|
<input name="position" type="number" min={0} placeholder="Position" defaultValue={0} className="w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 text-right">
|
||||||
<button type="submit" className="btn btn-primary">
|
<button type="submit" className="btn btn-primary">
|
||||||
Create
|
Create
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default async function AdminUsers({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="card"
|
className="admin-card mb-6"
|
||||||
style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem", flexWrap: "wrap" }}
|
style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem", flexWrap: "wrap" }}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export function AdminMediaGrid() {
|
|||||||
const [files, setFiles] = useState<MediaFile[]>([]);
|
const [files, setFiles] = useState<MediaFile[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [progress, setProgress] = useState({ current: 0, total: 0 });
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -24,16 +25,27 @@ export function AdminMediaGrid() {
|
|||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0];
|
const files = e.target.files;
|
||||||
if (!file) return;
|
if (!files || files.length === 0) return;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
setProgress({ current: 0, total: files.length });
|
||||||
|
|
||||||
|
const fileArray = Array.from(files);
|
||||||
|
|
||||||
|
await Promise.all(fileArray.map(async (file) => {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.set("file", file);
|
fd.set("file", file);
|
||||||
try {
|
try {
|
||||||
await uploadMedia(fd);
|
await uploadMedia(fd);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
setProgress(prev => ({ ...prev, current: prev.current + 1 }));
|
||||||
|
}));
|
||||||
|
|
||||||
await load();
|
await load();
|
||||||
} catch { /* ignore */ }
|
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
|
setProgress({ current: 0, total: 0 });
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,30 +54,45 @@ export function AdminMediaGrid() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card" style={{ marginTop: "1rem" }}>
|
<div className="admin-card mb-4">
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
|
<div className="flex items-center justify-between mb-3">
|
||||||
<strong>{files.length} image{files.length !== 1 ? "s" : ""}</strong>
|
<strong>{files.length} image{files.length !== 1 ? "s" : ""}</strong>
|
||||||
<label className="btn btn-primary" style={{ cursor: "pointer", fontSize: "0.88rem" }}>
|
<label className="btn btn-primary cursor-pointer text-sm">
|
||||||
{uploading ? "Uploading…" : "Upload image"}
|
{uploading ? "Uploading…" : "Upload image"}
|
||||||
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/gif,image/webp" onChange={handleUpload} style={{ display: "none" }} />
|
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/gif,image/webp" onChange={handleUpload} multiple className="hidden" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{uploading && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex justify-between text-xs mb-1">
|
||||||
|
<span>Uploading images...</span>
|
||||||
|
<span>{progress.current} / {progress.total}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full bg-[var(--color-primary)]/10 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-hover)] transition-all duration-300"
|
||||||
|
style={{ width: `${(progress.current / progress.total) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="muted">Loading…</p>
|
<p className="text-xs text-gray-500 dark:text-gray-400">Loading…</p>
|
||||||
) : files.length === 0 ? (
|
) : files.length === 0 ? (
|
||||||
<p className="muted">No images yet. Upload one above.</p>
|
<p className="text-xs text-gray-500 dark:text-gray-400">No images yet. Upload one above.</p>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))", gap: "0.75rem" }}>
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-3">
|
||||||
{files.map((f) => (
|
{files.map((f) => (
|
||||||
<div key={f.name} style={{ border: "1px solid color-mix(in srgb, var(--color-text-muted) 14%, transparent)", borderRadius: 10, overflow: "hidden", background: "var(--color-background)" }}>
|
<div key={f.name} className="border border-[var(--color-text-muted)]/14 rounded-[10px] overflow-hidden bg-[var(--color-background)]">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src={f.url} alt={f.name} style={{ width: "100%", height: 120, objectFit: "cover", display: "block" }} />
|
<img src={f.url} alt={f.name} className="w-full h-[120px] object-cover block" />
|
||||||
<div style={{ padding: "0.4rem" }}>
|
<div className="p-2">
|
||||||
<p style={{ fontSize: "0.72rem", margin: "0 0 0.3rem", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--color-text-muted)" }}>
|
<p className="text-xs text-[var(--color-text-muted)] m-0 mb-1 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
{f.name}
|
{f.name}
|
||||||
</p>
|
</p>
|
||||||
<button type="button" onClick={() => copyUrl(f.url)} className="btn btn-outline" style={{ fontSize: "0.72rem", padding: "0.2rem 0.5rem", width: "100%" }}>
|
<button type="button" onClick={() => copyUrl(f.url)} className="btn btn-outline text-xs p-1 w-full">
|
||||||
Copy URL
|
Copy URL
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||