mirror of
https://github.com/R0m1k3/Menu.git
synced 2026-10-11 17:28:28 +02:00
feat: initialize Prisma schema with Config and Button models
This commit is contained in:
1 parent
ca99378348
commit
485aba96fd
10 files changed
+405
-238
No files matched your search
+26
-6
@@ -8,7 +8,6 @@ import { revalidatePath } from 'next/cache';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle admin login.
|
* Handle admin login.
|
||||||
* @param formData - The form data containing the password.
|
|
||||||
*/
|
*/
|
||||||
export async function loginAdmin(formData: FormData) {
|
export async function loginAdmin(formData: FormData) {
|
||||||
const password = formData.get('password') as string;
|
const password = formData.get('password') as string;
|
||||||
@@ -28,7 +27,7 @@ export async function loginAdmin(formData: FormData) {
|
|||||||
cookieStore.set('admin_auth', 'true', {
|
cookieStore.set('admin_auth', 'true', {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
maxAge: 60 * 60 * 24, // 24 hours
|
maxAge: 60 * 60 * 24,
|
||||||
path: '/',
|
path: '/',
|
||||||
});
|
});
|
||||||
redirect('/admin');
|
redirect('/admin');
|
||||||
@@ -55,21 +54,43 @@ export async function isAuthenticated() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a new menu button.
|
* Add a new menu button with optional icon.
|
||||||
*/
|
*/
|
||||||
export async function addButton(formData: FormData) {
|
export async function addButton(formData: FormData) {
|
||||||
if (!(await isAuthenticated())) throw new Error('Non autorisé');
|
if (!(await isAuthenticated())) throw new Error('Non autorisé');
|
||||||
|
|
||||||
const name = formData.get('name') as string;
|
const name = formData.get('name') as string;
|
||||||
const url = formData.get('url') as string;
|
const url = formData.get('url') as string;
|
||||||
|
const icon = (formData.get('icon') as string) || null;
|
||||||
|
|
||||||
if (!name || !url) throw new Error('Champs requis manquants.');
|
if (!name || !url) throw new Error('Champs requis manquants.');
|
||||||
|
|
||||||
await prisma.button.create({
|
await prisma.button.create({
|
||||||
data: { name, url, order: 0 },
|
data: { name, url, icon, order: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath('/admin');
|
||||||
|
revalidatePath('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing button (name, url, icon).
|
||||||
|
*/
|
||||||
|
export async function updateButton(formData: FormData) {
|
||||||
|
if (!(await isAuthenticated())) throw new Error('Non autorisé');
|
||||||
|
|
||||||
|
const id = parseInt(formData.get('id') as string, 10);
|
||||||
|
const name = formData.get('name') as string;
|
||||||
|
const url = formData.get('url') as string;
|
||||||
|
const icon = (formData.get('icon') as string) || null;
|
||||||
|
|
||||||
|
if (!id || !name || !url) throw new Error('Champs requis manquants.');
|
||||||
|
|
||||||
|
await prisma.button.update({
|
||||||
|
where: { id },
|
||||||
|
data: { name, url, icon },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Revalidate both admin and home pages to reflect new button immediately
|
|
||||||
revalidatePath('/admin');
|
revalidatePath('/admin');
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
}
|
}
|
||||||
@@ -81,7 +102,6 @@ export async function deleteButton(id: number) {
|
|||||||
if (!(await isAuthenticated())) throw new Error('Non autorisé');
|
if (!(await isAuthenticated())) throw new Error('Non autorisé');
|
||||||
await prisma.button.delete({ where: { id } });
|
await prisma.button.delete({ where: { id } });
|
||||||
|
|
||||||
// Revalidate both admin and home pages to reflect deletion immediately
|
|
||||||
revalidatePath('/admin');
|
revalidatePath('/admin');
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
}
|
}
|
||||||
+54
-101
@@ -1,162 +1,118 @@
|
|||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { isAuthenticated, logoutAdmin, addButton, deleteButton } from '@/app/actions/admin';
|
import { isAuthenticated, logoutAdmin, deleteButton } from '@/app/actions/admin';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { GlassCard } from '@/components/GlassCard';
|
|
||||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||||
import { Trash2, LogOut, PlusCircle, Globe, ArrowLeft } from 'lucide-react';
|
import { LogOut, ArrowLeft, Trash2, Globe } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { AddButtonForm } from '@/components/AddButtonForm';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
function getDomainFromUrl(url: string): string {
|
function getDomain(url: string): string {
|
||||||
try {
|
try { return new URL(url).hostname; } catch { return url; }
|
||||||
return new URL(url).hostname;
|
|
||||||
} catch {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
const auth = await isAuthenticated();
|
const auth = await isAuthenticated();
|
||||||
|
if (!auth) redirect('/admin/login');
|
||||||
if (!auth) {
|
|
||||||
redirect('/admin/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
const buttons = await prisma.button.findMany({
|
const buttons = await prisma.button.findMany({
|
||||||
orderBy: { order: 'asc' },
|
orderBy: { order: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container" style={{ maxWidth: '900px' }}>
|
<div style={{ minHeight: '100vh', padding: '1.5rem 2rem', maxWidth: '860px', margin: '0 auto' }}>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header style={{
|
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2.5rem' }}>
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: '2.5rem',
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
<Link href="/" aria-label="Retour" style={{
|
<Link href="/" style={{
|
||||||
background: 'var(--glass-bg)',
|
background: 'rgba(255,255,255,0.1)',
|
||||||
backdropFilter: 'blur(20px)',
|
backdropFilter: 'blur(16px)',
|
||||||
WebkitBackdropFilter: 'blur(20px)',
|
border: '1px solid rgba(255,255,255,0.15)',
|
||||||
border: '1px solid var(--glass-border)',
|
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
width: '38px',
|
width: '36px', height: '36px',
|
||||||
height: '38px',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}>
|
}}>
|
||||||
<ArrowLeft size={16} color="var(--fg-secondary)" />
|
<ArrowLeft size={15} color="rgba(255,255,255,0.8)" />
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ fontSize: '1.6rem', fontWeight: 700, letterSpacing: '-0.03em' }}>
|
<h1 style={{ fontSize: '1.4rem', fontWeight: 700, letterSpacing: '-0.02em', color: '#fff' }}>
|
||||||
Administration
|
Administration
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ fontSize: '0.85rem', color: 'var(--fg-secondary)', marginTop: '0.1rem' }}>
|
<p style={{ fontSize: '0.8rem', color: 'rgba(255,255,255,0.45)', marginTop: '0.1rem' }}>
|
||||||
Gérez vos raccourcis d'applications
|
{buttons.length} application{buttons.length !== 1 ? 's' : ''} configurée{buttons.length !== 1 ? 's' : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<form action={logoutAdmin}>
|
<form action={logoutAdmin}>
|
||||||
<button type="submit" style={{
|
<button type="submit" style={{
|
||||||
background: 'var(--glass-bg)',
|
background: 'rgba(255,255,255,0.08)',
|
||||||
backdropFilter: 'blur(20px)',
|
backdropFilter: 'blur(12px)',
|
||||||
WebkitBackdropFilter: 'blur(20px)',
|
border: '1px solid rgba(255,255,255,0.12)',
|
||||||
border: '1px solid var(--glass-border)',
|
|
||||||
borderRadius: '50px',
|
borderRadius: '50px',
|
||||||
padding: '0.5rem 1rem',
|
padding: '0.45rem 0.9rem',
|
||||||
display: 'flex',
|
color: 'rgba(255,255,255,0.7)',
|
||||||
alignItems: 'center',
|
fontSize: '0.82rem',
|
||||||
gap: '0.4rem',
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: 'var(--fg-secondary)',
|
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||||
}}>
|
}}>
|
||||||
<LogOut size={15} /> Déconnexion
|
<LogOut size={13} /> Déconnexion
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Add form */}
|
{/* Add form */}
|
||||||
<GlassCard style={{ padding: '1.75rem', marginBottom: '2rem' }}>
|
<AddButtonForm />
|
||||||
<h2 style={{
|
|
||||||
fontSize: '1rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
marginBottom: '1.25rem',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.5rem',
|
|
||||||
}}>
|
|
||||||
<PlusCircle size={18} color="var(--accent)" />
|
|
||||||
Ajouter une application
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<form action={addButton} style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
|
||||||
<div style={{ flex: '1 1 180px' }}>
|
|
||||||
<label style={{ display: 'block', fontSize: '0.8rem', fontWeight: 600, color: 'var(--fg-secondary)', marginBottom: '0.4rem' }}>Nom</label>
|
|
||||||
<input name="name" type="text" placeholder="Ex: Slack, Notion..." required />
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: '2 1 280px' }}>
|
|
||||||
<label style={{ display: 'block', fontSize: '0.8rem', fontWeight: 600, color: 'var(--fg-secondary)', marginBottom: '0.4rem' }}>URL</label>
|
|
||||||
<input name="url" type="url" placeholder="https://..." required />
|
|
||||||
</div>
|
|
||||||
<div style={{ alignSelf: 'flex-end' }}>
|
|
||||||
<button type="submit" className="btn-primary" style={{ whiteSpace: 'nowrap' }}>
|
|
||||||
Ajouter →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</GlassCard>
|
|
||||||
|
|
||||||
{/* Button list */}
|
{/* Button list */}
|
||||||
{buttons.length === 0 ? (
|
{buttons.length === 0 ? (
|
||||||
<div style={{ textAlign: 'center', padding: '4rem', color: 'var(--fg-secondary)', fontSize: '0.9rem' }}>
|
<div style={{ textAlign: 'center', padding: '4rem', color: 'rgba(255,255,255,0.35)', fontSize: '0.9rem' }}>
|
||||||
Aucun bouton configuré. Ajoutez-en un ci-dessus.
|
Aucun bouton configuré.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
{buttons.map((btn: any) => {
|
{(buttons as any[]).map((btn) => {
|
||||||
const domain = getDomainFromUrl(btn.url);
|
const domain = getDomain(btn.url);
|
||||||
const faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
|
const faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlassCard key={btn.id} style={{
|
<div key={btn.id} style={{
|
||||||
padding: '0.9rem 1.25rem',
|
background: 'rgba(255,255,255,0.06)',
|
||||||
|
backdropFilter: 'blur(16px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.10)',
|
||||||
|
borderRadius: '14px',
|
||||||
|
padding: '0.85rem 1.2rem',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
gap: '1rem',
|
gap: '1rem',
|
||||||
borderRadius: '14px',
|
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.85rem', minWidth: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.85rem', minWidth: 0 }}>
|
||||||
|
{/* Icon: emoji or favicon */}
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '36px',
|
width: '40px', height: '40px',
|
||||||
height: '36px',
|
|
||||||
borderRadius: '10px',
|
borderRadius: '10px',
|
||||||
background: 'var(--bg-base)',
|
background: 'rgba(255,255,255,0.08)',
|
||||||
border: '1px solid var(--glass-border)',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
overflow: 'hidden',
|
fontSize: '1.3rem',
|
||||||
}}>
|
}}>
|
||||||
|
{btn.icon ? btn.icon : (
|
||||||
<Image src={faviconUrl} alt={btn.name} width={20} height={20} unoptimized />
|
<Image src={faviconUrl} alt={btn.name} width={20} height={20} unoptimized />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div style={{ fontWeight: 600, fontSize: '0.9rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
<div style={{ fontWeight: 600, fontSize: '0.9rem', color: '#fff', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
{btn.name}
|
{btn.name}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.78rem', color: 'var(--fg-secondary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
<div style={{ fontSize: '0.76rem', color: 'rgba(255,255,255,0.4)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '360px' }}>
|
||||||
{btn.url}
|
{btn.url}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,21 +124,18 @@ export default async function AdminPage() {
|
|||||||
}}>
|
}}>
|
||||||
<button type="submit" style={{
|
<button type="submit" style={{
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
color: 'var(--fg-secondary)',
|
color: 'rgba(255,255,255,0.3)',
|
||||||
padding: '0.4rem',
|
padding: '0.4rem',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
transition: 'color 200ms',
|
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--error)')}
|
onMouseEnter={(e) => (e.currentTarget.style.color = '#ff6b7a')}
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--fg-secondary)')}
|
onMouseLeave={(e) => (e.currentTarget.style.color = 'rgba(255,255,255,0.3)')}
|
||||||
>
|
>
|
||||||
<Trash2 size={17} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</GlassCard>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+42
-90
@@ -1,54 +1,41 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
--font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
--ease: cubic-bezier(0.4, 0, 0.2, 1);
|
--ease: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--duration: 260ms;
|
|
||||||
|
|
||||||
/* ─── Dark (Default: Purple → Wine) ─── */
|
/* Dark (default) */
|
||||||
--bg-from: #3a0f3c;
|
--bg-from: #2c0a30;
|
||||||
--bg-to: #7b1535;
|
--bg-mid: #5a0f28;
|
||||||
|
--bg-to: #8c1525;
|
||||||
|
|
||||||
--fg: #ffffff;
|
--fg: #ffffff;
|
||||||
--fg-secondary: rgba(255, 255, 255, 0.55);
|
--fg-secondary: rgba(255, 255, 255, 0.5);
|
||||||
|
|
||||||
--card-bg: rgba(255, 255, 255, 0.10);
|
--card-bg: rgba(255, 255, 255, 0.09);
|
||||||
--card-border: rgba(255, 255, 255, 0.12);
|
--card-border: rgba(255, 255, 255, 0.13);
|
||||||
--card-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
|
--card-shadow: 0 2px 12px rgba(0,0,0,0.3), 0 1px 0 rgba(255,255,255,0.06) inset;
|
||||||
--card-hover-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
|
--card-hover-bg: rgba(255, 255, 255, 0.14);
|
||||||
--card-hover-bg: rgba(255, 255, 255, 0.15);
|
--card-hover-shadow: 0 16px 48px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.10) inset;
|
||||||
|
|
||||||
--accent: #ffffff;
|
|
||||||
--accent-btn: rgba(255, 255, 255, 0.18);
|
|
||||||
--error: #FF6B7A;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='light'] {
|
[data-theme='light'] {
|
||||||
/* ─── Light: Lavender → Pink ─── */
|
--bg-from: #e0c4ef;
|
||||||
--bg-from: #e8d5f5;
|
--bg-mid: #e8b8cc;
|
||||||
--bg-to: #f5c2d0;
|
--bg-to: #f0b8be;
|
||||||
--fg: #1c1c2e;
|
|
||||||
--fg-secondary: rgba(28, 28, 46, 0.55);
|
|
||||||
|
|
||||||
--card-bg: rgba(255, 255, 255, 0.45);
|
--fg: #1c0a22;
|
||||||
--card-border: rgba(255, 255, 255, 0.8);
|
--fg-secondary: rgba(28, 10, 34, 0.5);
|
||||||
--card-shadow: 0 4px 24px rgba(100, 30, 60, 0.10);
|
|
||||||
--card-hover-shadow: 0 12px 40px rgba(100, 30, 60, 0.18);
|
|
||||||
--card-hover-bg: rgba(255, 255, 255, 0.65);
|
|
||||||
|
|
||||||
--accent: #1c1c2e;
|
--card-bg: rgba(255, 255, 255, 0.50);
|
||||||
--accent-btn: rgba(28, 28, 46, 0.12);
|
--card-border: rgba(255, 255, 255, 0.85);
|
||||||
--error: #e0334e;
|
--card-shadow: 0 2px 12px rgba(120,30,60,0.08), 0 1px 0 rgba(255,255,255,0.9) inset;
|
||||||
|
--card-hover-bg: rgba(255, 255, 255, 0.70);
|
||||||
|
--card-hover-shadow: 0 16px 48px rgba(120,30,60,0.15), 0 1px 0 rgba(255,255,255,0.9) inset;
|
||||||
}
|
}
|
||||||
|
|
||||||
*, *::before, *::after {
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
box-sizing: border-box;
|
html, body { height: 100%; }
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -57,80 +44,45 @@ body {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
background: linear-gradient(145deg, var(--bg-from) 0%, var(--bg-to) 100%);
|
/* Multi-stop radial mesh gradient pour plus de profondeur */
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 10% 10%, rgba(120, 30, 180, 0.35) 0%, transparent 55%),
|
||||||
|
radial-gradient(ellipse at 90% 85%, rgba(160, 20, 40, 0.4) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 50% 50%, rgba(80, 10, 50, 0.6) 0%, transparent 80%),
|
||||||
|
linear-gradient(160deg, var(--bg-from) 0%, var(--bg-mid) 50%, var(--bg-to) 100%);
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
transition: background var(--duration) var(--ease), color var(--duration) var(--ease);
|
transition: background var(--ease) 260ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
a { text-decoration: none; color: inherit; }
|
a { text-decoration: none; color: inherit; }
|
||||||
button { cursor: pointer; border: none; outline: none; font-family: var(--font); }
|
button { cursor: pointer; border: none; outline: none; font-family: var(--font); }
|
||||||
|
|
||||||
/* ── Scrollbar ── */
|
/* Scrollbar */
|
||||||
::-webkit-scrollbar { width: 6px; }
|
::-webkit-scrollbar { width: 6px; }
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.18); border-radius: 10px; }
|
||||||
background: rgba(255,255,255,0.2);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Input ── */
|
/* Inputs */
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="password"],
|
input[type="password"],
|
||||||
input[type="url"],
|
input[type="url"],
|
||||||
input[type="email"] {
|
input[type="email"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.85rem 1rem;
|
padding: 0.82rem 1rem;
|
||||||
background: var(--card-bg);
|
background: rgba(255,255,255,0.07);
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
-webkit-backdrop-filter: blur(16px);
|
-webkit-backdrop-filter: blur(16px);
|
||||||
border: 1px solid var(--card-border);
|
border: 1px solid rgba(255,255,255,0.13);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
font-size: 0.95rem;
|
font-size: 0.92rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: all var(--duration) var(--ease);
|
transition: all 240ms var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus {
|
input:focus {
|
||||||
border-color: rgba(255,255,255,0.5);
|
border-color: rgba(255,255,255,0.45);
|
||||||
box-shadow: 0 0 0 3px rgba(255,255,255,0.10);
|
box-shadow: 0 0 0 3px rgba(255,255,255,0.08);
|
||||||
|
background: rgba(255,255,255,0.11);
|
||||||
}
|
}
|
||||||
|
|
||||||
input::placeholder { color: var(--fg-secondary); }
|
input::placeholder { color: var(--fg-secondary); }
|
||||||
|
|
||||||
/* ── Glass card ── */
|
|
||||||
.glass-card {
|
|
||||||
background: var(--card-bg);
|
|
||||||
backdrop-filter: blur(24px) saturate(1.4);
|
|
||||||
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
|
||||||
border: 1px solid var(--card-border);
|
|
||||||
box-shadow: var(--card-shadow);
|
|
||||||
border-radius: 20px;
|
|
||||||
transition: all var(--duration) var(--ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Btn primary ── */
|
|
||||||
.btn-primary {
|
|
||||||
background: rgba(255,255,255,0.18);
|
|
||||||
color: var(--fg);
|
|
||||||
border: 1px solid rgba(255,255,255,0.25);
|
|
||||||
padding: 0.75rem 1.4rem;
|
|
||||||
border-radius: 50px;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
transition: all var(--duration) var(--ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: rgba(255,255,255,0.28);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
+44
-30
@@ -17,33 +17,26 @@ export default async function Home() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||||
minHeight: '100vh',
|
|
||||||
display: 'flex',
|
{/* Top controls */}
|
||||||
flexDirection: 'column',
|
|
||||||
}}>
|
|
||||||
{/* Top bar */}
|
|
||||||
<header style={{
|
<header style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: '1.5rem 2rem',
|
padding: '1.5rem 2rem',
|
||||||
gap: '0.75rem',
|
gap: '0.6rem',
|
||||||
}}>
|
}}>
|
||||||
<Link href="/admin" aria-label="Administration" style={{
|
<Link href="/admin" aria-label="Administration" style={{
|
||||||
background: 'rgba(255,255,255,0.12)',
|
background: 'rgba(255,255,255,0.10)',
|
||||||
backdropFilter: 'blur(16px)',
|
backdropFilter: 'blur(16px)',
|
||||||
WebkitBackdropFilter: 'blur(16px)',
|
WebkitBackdropFilter: 'blur(16px)',
|
||||||
border: '1px solid rgba(255,255,255,0.18)',
|
border: '1px solid rgba(255,255,255,0.15)',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
width: '38px',
|
width: '36px', height: '36px',
|
||||||
height: '38px',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
transition: 'background 260ms ease',
|
|
||||||
}}>
|
}}>
|
||||||
<Settings size={16} color="rgba(255,255,255,0.8)" />
|
<Settings size={15} color="rgba(255,255,255,0.7)" />
|
||||||
</Link>
|
</Link>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</header>
|
</header>
|
||||||
@@ -52,27 +45,48 @@ export default async function Home() {
|
|||||||
<main style={{
|
<main style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: '2rem',
|
padding: '2rem',
|
||||||
|
gap: '3rem',
|
||||||
}}>
|
}}>
|
||||||
{buttons.length === 0 ? (
|
{/* Title */}
|
||||||
<div style={{ textAlign: 'center', color: 'rgba(255,255,255,0.7)' }}>
|
<div style={{ textAlign: 'center' }}>
|
||||||
<div style={{ fontSize: '3rem', marginBottom: '1rem' }}>🔗</div>
|
<h1 style={{
|
||||||
<p style={{ fontWeight: 600, fontSize: '1.1rem', marginBottom: '0.5rem', color: '#fff' }}>
|
fontSize: 'clamp(1.8rem, 4vw, 2.8rem)',
|
||||||
Aucune application configurée
|
fontWeight: 800,
|
||||||
|
letterSpacing: '-0.04em',
|
||||||
|
color: '#fff',
|
||||||
|
textShadow: '0 2px 20px rgba(0,0,0,0.3)',
|
||||||
|
}}>
|
||||||
|
Menu Application
|
||||||
|
</h1>
|
||||||
|
{buttons.length > 0 && (
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.45)', fontSize: '0.9rem', marginTop: '0.4rem', fontWeight: 400 }}>
|
||||||
|
{buttons.length} application{buttons.length > 1 ? 's' : ''} disponible{buttons.length > 1 ? 's' : ''}
|
||||||
</p>
|
</p>
|
||||||
<p style={{ fontSize: '0.9rem', marginBottom: '2rem' }}>
|
)}
|
||||||
Ajoutez vos premiers raccourcis depuis l'administration.
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
{buttons.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', color: 'rgba(255,255,255,0.5)' }}>
|
||||||
|
<div style={{ fontSize: '3rem', marginBottom: '1rem' }}>🔗</div>
|
||||||
|
<p style={{ fontWeight: 600, fontSize: '1.05rem', color: '#fff', marginBottom: '0.5rem' }}>
|
||||||
|
Aucune application
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: '0.88rem', marginBottom: '1.75rem' }}>
|
||||||
|
Ajoutez vos raccourcis depuis l'administration.
|
||||||
</p>
|
</p>
|
||||||
<Link href="/admin" style={{
|
<Link href="/admin" style={{
|
||||||
padding: '0.7rem 1.5rem',
|
padding: '0.65rem 1.4rem',
|
||||||
background: 'rgba(255,255,255,0.18)',
|
background: 'rgba(255,255,255,0.15)',
|
||||||
border: '1px solid rgba(255,255,255,0.25)',
|
border: '1px solid rgba(255,255,255,0.22)',
|
||||||
borderRadius: '50px',
|
borderRadius: '50px',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: '0.9rem',
|
fontSize: '0.88rem',
|
||||||
backdropFilter: 'blur(12px)',
|
backdropFilter: 'blur(12px)',
|
||||||
}}>
|
}}>
|
||||||
Configurer →
|
Configurer →
|
||||||
@@ -84,10 +98,10 @@ export default async function Home() {
|
|||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
gap: '1.25rem',
|
gap: '1.25rem',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
maxWidth: '900px',
|
maxWidth: '960px',
|
||||||
}}>
|
}}>
|
||||||
{buttons.map((btn: any) => (
|
{(buttons as any[]).map((btn) => (
|
||||||
<MenuButton key={btn.id} name={btn.name} url={btn.url} />
|
<MenuButton key={btn.id} name={btn.name} url={btn.url} icon={btn.icon} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { addButton } from '@/app/actions/admin';
|
||||||
|
import { EmojiPicker } from '@/components/EmojiPicker';
|
||||||
|
import { PlusCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
export function AddButtonForm() {
|
||||||
|
const [icon, setIcon] = useState('🔗');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
formData.set('icon', icon);
|
||||||
|
try {
|
||||||
|
await addButton(formData);
|
||||||
|
(e.target as HTMLFormElement).reset();
|
||||||
|
setIcon('🔗');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(255,255,255,0.07)',
|
||||||
|
backdropFilter: 'blur(24px)',
|
||||||
|
WebkitBackdropFilter: 'blur(24px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.12)',
|
||||||
|
borderRadius: '20px',
|
||||||
|
padding: '1.75rem',
|
||||||
|
marginBottom: '2rem',
|
||||||
|
}}>
|
||||||
|
<h2 style={{
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'rgba(255,255,255,0.5)',
|
||||||
|
marginBottom: '1.25rem',
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}>
|
||||||
|
<PlusCircle size={15} /> AJOUTER UNE APPLICATION
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||||
|
{/* Emoji picker */}
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: 600, color: 'rgba(255,255,255,0.45)', marginBottom: '0.45rem' }}>
|
||||||
|
ICÔNE
|
||||||
|
</label>
|
||||||
|
<EmojiPicker value={icon} onChange={setIcon} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div style={{ flex: '1 1 140px' }}>
|
||||||
|
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: 600, color: 'rgba(255,255,255,0.45)', marginBottom: '0.45rem' }}>
|
||||||
|
NOM
|
||||||
|
</label>
|
||||||
|
<input name="name" type="text" placeholder="Ex: Slack, Notion…" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL */}
|
||||||
|
<div style={{ flex: '2 1 240px' }}>
|
||||||
|
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: 600, color: 'rgba(255,255,255,0.45)', marginBottom: '0.45rem' }}>
|
||||||
|
URL
|
||||||
|
</label>
|
||||||
|
<input name="url" type="url" placeholder="https://…" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255,255,255,0.16)',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.22)',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '0.75rem 1.25rem',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.88rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'all 200ms ease',
|
||||||
|
opacity: loading ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? '…' : 'Ajouter →'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/* ──────────────────────────────────────────────────────────────
|
||||||
|
EmojiPicker — composant client pour sélectionner un emoji
|
||||||
|
utilisé dans le formulaire admin
|
||||||
|
────────────────────────────────────────────────────────────── */
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const QUICK_EMOJIS = [
|
||||||
|
'🚀','📊','💼','🔗','📁','📧','📅','🧠',
|
||||||
|
'💻','🖥️','📱','🔑','⚙️','🛠️','📈','🌐',
|
||||||
|
'💬','🤝','📝','🔒','☁️','🗂️','🎯','📌',
|
||||||
|
'🏠','🏢','👥','🔔','✅','❌','💡','🎨',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface EmojiPickerProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (emoji: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmojiPicker({ value, onChange }: EmojiPickerProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
{/* Trigger button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
style={{
|
||||||
|
width: '52px',
|
||||||
|
height: '52px',
|
||||||
|
borderRadius: '14px',
|
||||||
|
background: 'rgba(255,255,255,0.08)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.15)',
|
||||||
|
fontSize: '1.6rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'background 200ms ease',
|
||||||
|
}}
|
||||||
|
title="Choisir une icône"
|
||||||
|
>
|
||||||
|
{value || '🔗'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown picker */}
|
||||||
|
{open && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '60px',
|
||||||
|
left: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
background: 'rgba(30, 10, 35, 0.92)',
|
||||||
|
backdropFilter: 'blur(30px)',
|
||||||
|
WebkitBackdropFilter: 'blur(30px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.12)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '1rem',
|
||||||
|
width: '268px',
|
||||||
|
boxShadow: '0 12px 40px rgba(0,0,0,0.5)',
|
||||||
|
}}>
|
||||||
|
<p style={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.4)', marginBottom: '0.75rem', fontWeight: 600, letterSpacing: '0.05em' }}>
|
||||||
|
SÉLECTIONNER UNE ICÔNE
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Quick emoji grid */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: '4px', marginBottom: '0.75rem' }}>
|
||||||
|
{QUICK_EMOJIS.map((emoji) => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onChange(emoji); setOpen(false); }}
|
||||||
|
style={{
|
||||||
|
background: value === emoji ? 'rgba(255,255,255,0.18)' : 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '1.2rem',
|
||||||
|
padding: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 150ms',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.12)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = value === emoji ? 'rgba(255,255,255,0.18)' : 'transparent')}
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manual input */}
|
||||||
|
<div style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: '0.75rem' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Coller un emoji…"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
background: 'rgba(255,255,255,0.06)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.12)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '0.4rem 0.6rem',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '1rem',
|
||||||
|
}}
|
||||||
|
maxLength={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,17 +9,17 @@ import styles from './MenuButton.module.css';
|
|||||||
interface MenuButtonProps {
|
interface MenuButtonProps {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
icon?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDomain(url: string): string {
|
function getDomain(url: string): string {
|
||||||
try {
|
try { return new URL(url).hostname; } catch { return url; }
|
||||||
return new URL(url).hostname;
|
|
||||||
} catch {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MenuButton: React.FC<MenuButtonProps> = ({ name, url }) => {
|
/**
|
||||||
|
* MenuButton — displays emoji icon if set, falls back to Google favicon.
|
||||||
|
*/
|
||||||
|
export const MenuButton: React.FC<MenuButtonProps> = ({ name, url, icon }) => {
|
||||||
const domain = getDomain(url);
|
const domain = getDomain(url);
|
||||||
const faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=64`;
|
const faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=64`;
|
||||||
const [faviconError, setFaviconError] = useState(false);
|
const [faviconError, setFaviconError] = useState(false);
|
||||||
@@ -27,7 +27,11 @@ export const MenuButton: React.FC<MenuButtonProps> = ({ name, url }) => {
|
|||||||
return (
|
return (
|
||||||
<Link href={url} target="_blank" rel="noopener noreferrer" className={styles.menuButton}>
|
<Link href={url} target="_blank" rel="noopener noreferrer" className={styles.menuButton}>
|
||||||
<div className={styles.faviconWrapper}>
|
<div className={styles.faviconWrapper}>
|
||||||
{!faviconError ? (
|
{icon ? (
|
||||||
|
// Custom emoji icon
|
||||||
|
<span style={{ fontSize: '2.2rem', lineHeight: 1 }}>{icon}</span>
|
||||||
|
) : !faviconError ? (
|
||||||
|
// Auto favicon from Google CDN
|
||||||
<Image
|
<Image
|
||||||
src={faviconUrl}
|
src={faviconUrl}
|
||||||
alt={`${name} favicon`}
|
alt={`${name} favicon`}
|
||||||
|
|||||||
+11
-4
@@ -3,8 +3,7 @@ import path from 'path';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the SQLite database schema using raw SQL.
|
* Initializes the SQLite database schema using raw SQL.
|
||||||
* This bypasses Prisma CLI entirely, avoiding CLI environment issues in Docker.
|
* Safe to run multiple times — uses IF NOT EXISTS and handles migrations.
|
||||||
* Safe to run multiple times (CREATE TABLE IF NOT EXISTS).
|
|
||||||
*/
|
*/
|
||||||
export function initializeDatabase(): void {
|
export function initializeDatabase(): void {
|
||||||
const dbUrl = process.env.DATABASE_URL || 'file:/app/prisma/dev.db';
|
const dbUrl = process.env.DATABASE_URL || 'file:/app/prisma/dev.db';
|
||||||
@@ -18,7 +17,7 @@ export function initializeDatabase(): void {
|
|||||||
try {
|
try {
|
||||||
const db = new Database(resolvedPath);
|
const db = new Database(resolvedPath);
|
||||||
|
|
||||||
// Create Config table
|
// Config table
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS "Config" (
|
CREATE TABLE IF NOT EXISTS "Config" (
|
||||||
"id" TEXT NOT NULL PRIMARY KEY,
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
@@ -26,7 +25,7 @@ export function initializeDatabase(): void {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Create Button table
|
// Button table (base)
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS "Button" (
|
CREATE TABLE IF NOT EXISTS "Button" (
|
||||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -36,6 +35,14 @@ export function initializeDatabase(): void {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Migration: add icon column if missing (added in v2)
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE "Button" ADD COLUMN "icon" TEXT;`);
|
||||||
|
console.log('[DB Init] Migration applied: Button.icon column added.');
|
||||||
|
} catch {
|
||||||
|
// Column already exists — no action needed
|
||||||
|
}
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
console.log('[DB Init] Schema ready.');
|
console.log('[DB Init] Schema ready.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -20,5 +20,6 @@ model Button {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
url String
|
url String
|
||||||
|
icon String?
|
||||||
order Int @default(0)
|
order Int @default(0)
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user