mirror of
https://github.com/R0m1k3/Menu.git
synced 2026-10-11 17:28:28 +02:00
feat: initialize project with Next.js, Prisma, and admin dashboard functionality
This commit is contained in:
commit
b73aa8616b
37 files changed
+8711
No files matched your search
+43
@@ -0,0 +1,43 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -0,0 +1,78 @@
|
||||
'use server';
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { verifyPassword } from '@/lib/auth';
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Handle admin login.
|
||||
* @param formData - The form data containing the password.
|
||||
*/
|
||||
export async function loginAdmin(formData: FormData) {
|
||||
const password = formData.get('password') as string;
|
||||
|
||||
const config = await prisma.config.findUnique({
|
||||
where: { id: 'admin_password' },
|
||||
});
|
||||
|
||||
if (!config) {
|
||||
redirect('/setup');
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(password, config.value);
|
||||
|
||||
if (isValid) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set('admin_auth', 'true', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
path: '/',
|
||||
});
|
||||
redirect('/admin');
|
||||
} else {
|
||||
throw new Error('Mot de passe incorrect.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout admin.
|
||||
*/
|
||||
export async function logoutAdmin() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete('admin_auth');
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Is authenticated check.
|
||||
*/
|
||||
export async function isAuthenticated() {
|
||||
const cookieStore = await cookies();
|
||||
return cookieStore.has('admin_auth');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new menu button.
|
||||
*/
|
||||
export async function addButton(formData: FormData) {
|
||||
if (!(await isAuthenticated())) throw new Error('Non autorisé');
|
||||
|
||||
const name = formData.get('name') as string;
|
||||
const url = formData.get('url') as string;
|
||||
|
||||
if (!name || !url) throw new Error('Champs requis manquants.');
|
||||
|
||||
await prisma.button.create({
|
||||
data: { name, url, order: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a menu button.
|
||||
*/
|
||||
export async function deleteButton(id: number) {
|
||||
if (!(await isAuthenticated())) throw new Error('Non autorisé');
|
||||
await prisma.button.delete({ where: { id } });
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use server';
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { hashPassword } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Save the initial admin password.
|
||||
* @param formData - The form data containing the password.
|
||||
*/
|
||||
export async function setupAdmin(formData: FormData) {
|
||||
const password = formData.get('password') as string;
|
||||
const confirm = formData.get('confirm') as string;
|
||||
|
||||
if (!password || password !== confirm) {
|
||||
throw new Error('Les mots de passe ne correspondent pas.');
|
||||
}
|
||||
|
||||
// Check if already setup
|
||||
const existing = await prisma.config.findUnique({
|
||||
where: { id: 'admin_password' },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error('La configuration a déjà été effectuée.');
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
await prisma.config.create({
|
||||
data: {
|
||||
id: 'admin_password',
|
||||
value: hashedPassword,
|
||||
},
|
||||
});
|
||||
|
||||
redirect('/admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the application is configured.
|
||||
*/
|
||||
export async function isConfigured() {
|
||||
const config = await prisma.config.findUnique({
|
||||
where: { id: 'admin_password' },
|
||||
});
|
||||
return !!config;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { GlassCard } from '@/components/GlassCard';
|
||||
import { loginAdmin } from '@/app/actions/admin';
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
try {
|
||||
await loginAdmin(formData);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Une erreur est survenue.');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||
<GlassCard style={{ width: '100%', maxWidth: '400px' }}>
|
||||
<h1 style={{ marginBottom: '2rem', fontSize: '1.8rem' }}>Accès Admin</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontSize: '0.9rem', fontWeight: 600 }}>Mot de passe</label>
|
||||
<input name="password" type="password" required placeholder="••••••••" />
|
||||
</div>
|
||||
|
||||
{error && <p style={{ color: 'var(--error)', fontSize: '0.85rem', textAlign: 'center' }}>{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
background: 'var(--accent)',
|
||||
color: 'white',
|
||||
padding: '1rem',
|
||||
borderRadius: '12px',
|
||||
fontWeight: 600,
|
||||
marginTop: '1rem',
|
||||
opacity: loading ? 0.7 : 1
|
||||
}}
|
||||
>
|
||||
{loading ? 'Connexion...' : 'Se connecter'}
|
||||
</button>
|
||||
</form>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { isAuthenticated, logoutAdmin, addButton, deleteButton } from '@/app/actions/admin';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { GlassCard } from '@/components/GlassCard';
|
||||
import { Trash2, LogOut, PlusCircle, Globe } from 'lucide-react';
|
||||
|
||||
export default async function AdminPage() {
|
||||
const auth = await isAuthenticated();
|
||||
|
||||
if (!auth) {
|
||||
redirect('/admin/login');
|
||||
}
|
||||
|
||||
const buttons = await prisma.button.findMany({
|
||||
orderBy: { order: 'asc' },
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="container">
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '3rem' }}>
|
||||
<h1 style={{ marginBottom: 0, fontSize: '2rem' }}>Panel d'Administration</h1>
|
||||
<form action={logoutAdmin}>
|
||||
<button type="submit" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', background: 'transparent', color: 'var(--foreground)', opacity: 0.7 }}>
|
||||
<LogOut size={20} /> Déconnexion
|
||||
</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(300px, 1fr) 2fr', gap: '3rem', alignItems: 'start' }}>
|
||||
{/* Form to add button */}
|
||||
<GlassCard>
|
||||
<h2 style={{ fontSize: '1.25rem', marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<PlusCircle size={20} color="var(--accent)" /> Ajouter un bouton
|
||||
</h2>
|
||||
<form action={addButton} style={{ display: 'flex', flexDirection: 'column', gap: '1.2rem' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '0.4rem', fontSize: '0.85rem', fontWeight: 600 }}>Nom de l'application</label>
|
||||
<input name="name" type="text" placeholder="Ex: Slack, Jira..." required />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '0.4rem', fontSize: '0.85rem', fontWeight: 600 }}>Lien (URL)</label>
|
||||
<input name="url" type="url" placeholder="https://..." required />
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
style={{ background: 'var(--accent)', color: 'white', padding: '0.8rem', borderRadius: '10px', fontWeight: 600, marginTop: '0.5rem' }}
|
||||
>
|
||||
Ajouter au menu
|
||||
</button>
|
||||
</form>
|
||||
</GlassCard>
|
||||
|
||||
{/* List of buttons */}
|
||||
<div>
|
||||
<h2 style={{ fontSize: '1.25rem', marginBottom: '1.5rem' }}>Boutons configurés</h2>
|
||||
{buttons.length === 0 ? (
|
||||
<p style={{ opacity: 0.5 }}>Aucun bouton configuré pour le moment.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{buttons.map((btn: any) => (
|
||||
<GlassCard key={btn.id} style={{ padding: '1.2rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<div style={{ background: 'var(--accent)', padding: '0.5rem', borderRadius: '8px', color: 'white' }}>
|
||||
<Globe size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{btn.name}</div>
|
||||
<div style={{ fontSize: '0.8rem', opacity: 0.6 }}>{btn.url}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action={async () => {
|
||||
'use server';
|
||||
await deleteButton(btn.id);
|
||||
}}>
|
||||
<button
|
||||
type="submit"
|
||||
style={{ background: 'transparent', color: 'var(--error)', padding: '0.5rem', borderRadius: '8px' }}
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</form>
|
||||
</GlassCard>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
+105
@@ -0,0 +1,105 @@
|
||||
:root {
|
||||
/* Common */
|
||||
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* Light Theme */
|
||||
--background: #f5f5f7;
|
||||
--foreground: #1d1d1f;
|
||||
--glass-bg: rgba(255, 255, 255, 0.7);
|
||||
--glass-border: rgba(255, 255, 255, 0.3);
|
||||
--card-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.07);
|
||||
--accent: #0071e3;
|
||||
--error: #ff3b30;
|
||||
--bg-gradient: radial-gradient(circle at 0% 0%, #fff 0%, #f5f5f7 100%);
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
/* Dark Theme */
|
||||
--background: #000000;
|
||||
--foreground: #f5f5f7;
|
||||
--glass-bg: rgba(28, 28, 30, 0.7);
|
||||
--glass-border: rgba(255, 255, 255, 0.1);
|
||||
--card-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.4);
|
||||
--accent: #0a84ff;
|
||||
--error: #ff453a;
|
||||
--bg-gradient: radial-gradient(circle at 100% 100%, #1c1c1e 0%, #000 100%);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-gradient);
|
||||
background-attachment: fixed;
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-family);
|
||||
min-height: 100vh;
|
||||
transition: var(--transition-smooth);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(125, 125, 125, 0.3);
|
||||
border-radius: 10px;
|
||||
border: 3px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(125, 125, 125, 0.5);
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--card-shadow);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.022em;
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
button, .button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
input {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
padding: 0.8rem 1rem;
|
||||
color: var(--foreground);
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { ThemeProvider } from '@/components/ThemeProvider';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Menu Professional',
|
||||
description: 'Industrial dashboard for SaaS applications',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="fr" suppressHydrationWarning>
|
||||
<body className={inter.className}>
|
||||
<ThemeProvider
|
||||
attribute="data-theme"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
.page {
|
||||
--background: #fafafa;
|
||||
--foreground: #fff;
|
||||
|
||||
--text-primary: #000;
|
||||
--text-secondary: #666;
|
||||
|
||||
--button-primary-hover: #383838;
|
||||
--button-secondary-hover: #f2f2f2;
|
||||
--button-secondary-border: #ebebeb;
|
||||
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-geist-sans);
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
background-color: var(--foreground);
|
||||
padding: 120px 60px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.intro h1 {
|
||||
max-width: 320px;
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
line-height: 48px;
|
||||
letter-spacing: -2.4px;
|
||||
text-wrap: balance;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.intro p {
|
||||
max-width: 440px;
|
||||
font-size: 18px;
|
||||
line-height: 32px;
|
||||
text-wrap: balance;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.intro a {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ctas {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
gap: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ctas a {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
border-radius: 128px;
|
||||
border: 1px solid transparent;
|
||||
transition: 0.2s;
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
a.primary {
|
||||
background: var(--text-primary);
|
||||
color: var(--background);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
a.secondary {
|
||||
border-color: var(--button-secondary-border);
|
||||
}
|
||||
|
||||
/* Enable hover only on non-touch devices */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
a.primary:hover {
|
||||
background: var(--button-primary-hover);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
a.secondary:hover {
|
||||
background: var(--button-secondary-hover);
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.main {
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.intro h1 {
|
||||
font-size: 32px;
|
||||
line-height: 40px;
|
||||
letter-spacing: -1.92px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.logo {
|
||||
filter: invert();
|
||||
}
|
||||
|
||||
.page {
|
||||
--background: #000;
|
||||
--foreground: #000;
|
||||
|
||||
--text-primary: #ededed;
|
||||
--text-secondary: #999;
|
||||
|
||||
--button-primary-hover: #ccc;
|
||||
--button-secondary-hover: #1a1a1a;
|
||||
--button-secondary-border: #1a1a1a;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { isConfigured } from '@/app/actions/setup';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { MenuButton } from '@/components/MenuButton';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
|
||||
export default async function Home() {
|
||||
const configured = await isConfigured();
|
||||
|
||||
if (!configured) {
|
||||
redirect('/setup');
|
||||
}
|
||||
|
||||
const buttons = await prisma.button.findMany({
|
||||
orderBy: { order: 'asc' },
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="container">
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4rem' }}>
|
||||
<h1 style={{ marginBottom: 0 }}>Application Menu</h1>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
|
||||
{buttons.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', marginTop: '4rem' }}>
|
||||
<p style={{ opacity: 0.6 }}>Aucune application configurée. Rendez-vous dans le panel admin.</p>
|
||||
<a href="/admin" style={{ color: 'var(--accent)', marginTop: '1rem', display: 'inline-block' }}>Accéder à l'administration</a>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||
gap: '2.5rem'
|
||||
}}>
|
||||
{buttons.map((btn: any) => (
|
||||
<MenuButton
|
||||
key={btn.id}
|
||||
name={btn.name}
|
||||
url={btn.url}
|
||||
iconName="ExternalLink"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { GlassCard } from '@/components/GlassCard';
|
||||
import { setupAdmin } from '@/app/actions/setup';
|
||||
|
||||
export default function SetupPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
try {
|
||||
await setupAdmin(formData);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Une erreur est survenue.');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||
<GlassCard style={{ width: '100%', maxWidth: '450px' }}>
|
||||
<h1 style={{ marginBottom: '1rem', fontSize: '1.8rem' }}>Configuration Initiale</h1>
|
||||
<p style={{ textAlign: 'center', marginBottom: '2rem', color: 'var(--foreground)', opacity: 0.8 }}>
|
||||
Définissez le mot de passe maître pour accéder au panel d'administration.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontSize: '0.9rem', fontWeight: 600 }}>Mot de passe Admin</label>
|
||||
<input name="password" type="password" required placeholder="••••••••" />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontSize: '0.9rem', fontWeight: 600 }}>Confirmer le mot de passe</label>
|
||||
<input name="confirm" type="password" required placeholder="••••••••" />
|
||||
</div>
|
||||
|
||||
{error && <p style={{ color: 'var(--error)', fontSize: '0.85rem', textAlign: 'center' }}>{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
background: 'var(--accent)',
|
||||
color: 'white',
|
||||
padding: '1rem',
|
||||
borderRadius: '12px',
|
||||
fontWeight: 600,
|
||||
marginTop: '1rem',
|
||||
opacity: loading ? 0.7 : 1
|
||||
}}
|
||||
>
|
||||
{loading ? 'Configuration...' : 'Finaliser la configuration'}
|
||||
</button>
|
||||
</form>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
.card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--card-shadow);
|
||||
border-radius: 24px;
|
||||
padding: 2rem;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.hoverable:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 40px 0 rgba(0, 0, 0, 0.15);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import styles from './GlassCard.module.css';
|
||||
|
||||
interface GlassCardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
hoverable?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
as?: any;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const GlassCard: React.FC<GlassCardProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
hoverable = false,
|
||||
style,
|
||||
as: Component = 'div',
|
||||
onClick
|
||||
}) => {
|
||||
return (
|
||||
<Component
|
||||
className={`${styles.card} ${hoverable ? styles.hoverable : ''} ${className}`}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
.menuButton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2.5rem;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
color: var(--foreground);
|
||||
gap: 1.5rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.menuButton:hover .iconWrapper {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 20px var(--accent);
|
||||
}
|
||||
|
||||
.label {
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import * as Icons from 'lucide-react';
|
||||
import { GlassCard } from './GlassCard';
|
||||
import styles from './MenuButton.module.css';
|
||||
|
||||
interface MenuButtonProps {
|
||||
name: string;
|
||||
url: string;
|
||||
iconName?: keyof typeof Icons;
|
||||
}
|
||||
|
||||
export const MenuButton: React.FC<MenuButtonProps> = ({ name, url, iconName = 'ExternalLink' }) => {
|
||||
const Icon = (Icons[iconName] as React.ElementType) || Icons.ExternalLink;
|
||||
|
||||
return (
|
||||
<Link href={url} target="_blank" rel="noopener noreferrer" style={{ textDecoration: 'none' }}>
|
||||
<GlassCard hoverable className={styles.menuButton}>
|
||||
<div className={styles.iconWrapper}>
|
||||
<Icon size={32} />
|
||||
</div>
|
||||
<span className={styles.label}>{name}</span>
|
||||
</GlassCard>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { GlassCard } from './GlassCard';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
// Avoid hydration mismatch
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<GlassCard className="glass" style={{ padding: '0.6rem', border: 'none' }}>
|
||||
<div style={{ width: '24px', height: '24px' }}></div>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GlassCard
|
||||
as="button"
|
||||
className="glass"
|
||||
style={{ padding: '0.6rem', cursor: 'pointer' }}
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={24} /> : <Moon size={24} />}
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
/**
|
||||
* Hash a password using bcrypt.
|
||||
* @param password - The plain text password.
|
||||
* @returns The hashed password.
|
||||
*/
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const saltRounds = 12;
|
||||
return await bcrypt.hash(password, saltRounds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a password against a hash.
|
||||
* @param password - The plain text password.
|
||||
* @param hash - The hashed password.
|
||||
* @returns True if the password matches.
|
||||
*/
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return await bcrypt.compare(password, hash);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3';
|
||||
|
||||
const globalForPrisma = global as unknown as { prisma: PrismaClient };
|
||||
|
||||
const createPrismaClient = () => {
|
||||
const dbUrl = process.env.DATABASE_URL || 'file:./dev.db';
|
||||
const adapter = new PrismaBetterSqlite3({ url: dbUrl });
|
||||
|
||||
return new PrismaClient({ adapter });
|
||||
};
|
||||
|
||||
export const prisma = globalForPrisma.prisma || createPrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+7577
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "menu-sub",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-better-sqlite3": "^7.7.0",
|
||||
"@prisma/client": "^7.7.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"next": "16.2.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.3",
|
||||
"prisma": "^7.7.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// This file was generated by Prisma, and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["driverAdapters"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
}
|
||||
|
||||
model Config {
|
||||
id String @id @default("admin_password")
|
||||
value String
|
||||
}
|
||||
|
||||
model Button {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
url String
|
||||
order Int @default(0)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
+15
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<img alt="Vue logo" src="./assets/logo.png" />
|
||||
<HelloWorld msg="Hello Vue 3.0 + Vite" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {
|
||||
HelloWorld
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
<button @click="count++">count is: {{ count }}</button>
|
||||
<p>Edit <code>components/HelloWorld.vue</code> to test hot module replacement.</p>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'HelloWorld',
|
||||
props: {
|
||||
msg: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
count: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,8 @@
|
||||
#app {
|
||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-align: center;
|
||||
color: #2c3e50;
|
||||
margin-top: 60px;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './index.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in new issue
Block a user