feat: initialize project with Next.js, Prisma, and admin dashboard functionality

This commit is contained in:
Michael committed 2026-04-10 09:58:09 +02:00
commit b73aa8616b
37 files changed
+8711

No files matched your search

+43
View File
@@ -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
+5
View File
@@ -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 -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+36
View File
@@ -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.
+78
View File
@@ -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 } });
}
+48
View File
@@ -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;
}
+57
View File
@@ -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>
);
}
+91
View File
@@ -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>
);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+105
View File
@@ -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);
}
+32
View File
@@ -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>
);
}
+142
View File
@@ -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;
}
}
+48
View File
@@ -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>
);
}
+64
View File
@@ -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>
);
}
+16
View File
@@ -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);
}
+32
View File
@@ -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>
);
};
+37
View File
@@ -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;
}
+28
View File
@@ -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>
);
};
+11
View File
@@ -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>;
}
+35
View File
@@ -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>
);
}
BIN
View File
Binary file not shown.
+18
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+15
View File
@@ -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;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+7577
View File
File diff suppressed because it is too large. Load diff
+33
View File
@@ -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"
}
}
+14
View File
@@ -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"],
},
});
+23
View File
@@ -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
View File
@@ -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

+19
View File
@@ -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>
+8
View File
@@ -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;
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './index.css'
createApp(App).mount('#app')
+34
View File
@@ -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"]
}