Compare commits
10
Commits
630f036f14
...
2e0548591d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e0548591d | ||
|
|
95d25a6d8a | ||
|
|
f6dc3801c6 | ||
|
|
3f499177b6 | ||
|
|
f3da96ee15 | ||
|
|
29547d1acd | ||
|
|
efe6515fc9 | ||
|
|
6e7ee816ad | ||
|
|
10a06495d8 | ||
|
|
3994d32d35 |
@@ -9,6 +9,8 @@
|
|||||||
## Global
|
## Global
|
||||||
- Utilise Docker & Docker-Compose pour la base de données (MariaDB)
|
- Utilise Docker & Docker-Compose pour la base de données (MariaDB)
|
||||||
- On développe tout en NodeJS notamment avec : NextJS & Prisma pour la partie backend et React pour la partie frontend
|
- On développe tout en NodeJS notamment avec : NextJS & Prisma pour la partie backend et React pour la partie frontend
|
||||||
|
- Utilise Tailwind pour le CSS
|
||||||
|
- L'application est toujours multilangue (EN + FR)
|
||||||
|
|
||||||
## Application : Ombrora-YTDLP
|
## Application : Ombrora-YTDLP
|
||||||
|
|
||||||
|
|||||||
Generated
+11
@@ -15,6 +15,7 @@
|
|||||||
"mariadb": "^3.5.3",
|
"mariadb": "^3.5.3",
|
||||||
"next": "^16.3.0",
|
"next": "^16.3.0",
|
||||||
"next-intl": "^4.13.6",
|
"next-intl": "^4.13.6",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"prisma": "^7.9.1",
|
"prisma": "^7.9.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8"
|
"react-dom": "^19.2.8"
|
||||||
@@ -7096,6 +7097,16 @@
|
|||||||
"integrity": "sha512-M2L8jtPEAXj0CPmXbiW66THdr3OnDqA9IsU1hqv3CdxtVow3Bl9eXPdT9Opeji7L4AFrUZ016dJOs+CoTw66OA==",
|
"integrity": "sha512-M2L8jtPEAXj0CPmXbiW66THdr3OnDqA9IsU1hqv3CdxtVow3Bl9eXPdT9Opeji7L4AFrUZ016dJOs+CoTw66OA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/next-themes": {
|
||||||
|
"version": "0.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
||||||
|
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-addon-api": {
|
"node_modules/node-addon-api": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"mariadb": "^3.5.3",
|
"mariadb": "^3.5.3",
|
||||||
"next": "^16.3.0",
|
"next": "^16.3.0",
|
||||||
"next-intl": "^4.13.6",
|
"next-intl": "^4.13.6",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"prisma": "^7.9.1",
|
"prisma": "^7.9.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8"
|
"react-dom": "^19.2.8"
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
|
import { Inter } from 'next/font/google'
|
||||||
import { NextIntlClientProvider } from 'next-intl'
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
import { getTranslations, getMessages } from 'next-intl/server'
|
import { getTranslations, getMessages } from 'next-intl/server'
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
|
import { ThemeProvider } from 'next-themes'
|
||||||
import { routing } from '@/i18n/routing'
|
import { routing } from '@/i18n/routing'
|
||||||
import { LanguageSwitcher } from '@/components/LanguageSwitcher'
|
import { Header } from '@/components/Header'
|
||||||
|
import '../globals.css'
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ['latin', 'latin-ext'] })
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
@@ -12,7 +17,26 @@ export async function generateMetadata({
|
|||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { locale } = await params
|
const { locale } = await params
|
||||||
const t = await getTranslations({ locale, namespace: 'meta' })
|
const t = await getTranslations({ locale, namespace: 'meta' })
|
||||||
return { title: t('title') }
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('title'),
|
||||||
|
description: t('description'),
|
||||||
|
robots: { index: true, follow: true },
|
||||||
|
openGraph: {
|
||||||
|
title: t('title'),
|
||||||
|
description: t('description'),
|
||||||
|
url: `${baseUrl}/${locale}`,
|
||||||
|
siteName: 'Ombrora',
|
||||||
|
locale,
|
||||||
|
},
|
||||||
|
alternates: {
|
||||||
|
canonical: `${baseUrl}/${locale}`,
|
||||||
|
languages: Object.fromEntries(
|
||||||
|
routing.locales.map((l) => [l, `/${l}`])
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
@@ -29,14 +53,44 @@ export default async function LocaleLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messages = await getMessages()
|
const messages = await getMessages()
|
||||||
|
const t = await getTranslations({ locale, namespace: 'footer' })
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? ''
|
||||||
|
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebApplication',
|
||||||
|
name: 'Ombrora',
|
||||||
|
url: baseUrl,
|
||||||
|
description: 'Free, fast, private video downloader.',
|
||||||
|
applicationCategory: 'MultimediaApplication',
|
||||||
|
operatingSystem: 'Web',
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'EUR',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale}>
|
<html lang={locale} suppressHydrationWarning>
|
||||||
<body>
|
<head>
|
||||||
<NextIntlClientProvider messages={messages}>
|
<script
|
||||||
<LanguageSwitcher />
|
type="application/ld+json"
|
||||||
{children}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
</NextIntlClientProvider>
|
/>
|
||||||
|
</head>
|
||||||
|
<body className={`${inter.className} bg-white dark:bg-slate-950 text-gray-900 dark:text-gray-50 antialiased min-h-screen flex flex-col`}>
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
|
<NextIntlClientProvider messages={messages}>
|
||||||
|
<Header />
|
||||||
|
<div className="flex-1">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<footer className="border-t border-gray-100 dark:border-slate-800 py-6 text-center text-sm text-gray-400 dark:text-slate-500">
|
||||||
|
{t('copyright')}
|
||||||
|
</footer>
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getTranslations } from 'next-intl/server'
|
import { getTranslations } from 'next-intl/server'
|
||||||
import { SubmitForm } from '@/components/SubmitForm'
|
import { SubmitForm } from '@/components/SubmitForm'
|
||||||
|
import { ReassuranceSection } from '@/components/ReassuranceSection'
|
||||||
|
|
||||||
export default async function Home({
|
export default async function Home({
|
||||||
params,
|
params,
|
||||||
@@ -8,10 +9,29 @@ export default async function Home({
|
|||||||
}) {
|
}) {
|
||||||
const { locale } = await params
|
const { locale } = await params
|
||||||
const t = await getTranslations({ locale, namespace: 'home' })
|
const t = await getTranslations({ locale, namespace: 'home' })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ padding: '2rem' }}>
|
<main>
|
||||||
<h1>{t('heading')}</h1>
|
<section
|
||||||
<SubmitForm />
|
className="flex flex-col items-center justify-center px-6 pt-20 pb-16 text-center"
|
||||||
|
aria-labelledby="hero-heading"
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
id="hero-heading"
|
||||||
|
className="text-4xl md:text-5xl font-bold tracking-tight text-gray-900 dark:text-gray-50 mb-4 max-w-2xl"
|
||||||
|
>
|
||||||
|
{t('heading')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-gray-500 dark:text-gray-400 mb-10">
|
||||||
|
{t('subheading')}
|
||||||
|
</p>
|
||||||
|
<SubmitForm />
|
||||||
|
<p className="mt-5 text-xs text-gray-400 dark:text-slate-500 tracking-wide uppercase">
|
||||||
|
{t('formats')}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ReassuranceSection locale={locale} />
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ export default async function StatusPage({
|
|||||||
}) {
|
}) {
|
||||||
const { locale, uuid } = await params
|
const { locale, uuid } = await params
|
||||||
const t = await getTranslations({ locale, namespace: 'status' })
|
const t = await getTranslations({ locale, namespace: 'status' })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ padding: '2rem' }}>
|
<main className="flex flex-col items-center px-6 py-16">
|
||||||
<h1>{t('heading')}</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-gray-50 mb-10">
|
||||||
|
{t('heading')}
|
||||||
|
</h1>
|
||||||
<StatusView uuid={uuid} />
|
<StatusView uuid={uuid} />
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||||
|
import { LanguageSwitcher } from '@/components/LanguageSwitcher'
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 border-b border-gray-100 dark:border-slate-800 bg-white/80 dark:bg-slate-950/80 backdrop-blur-sm">
|
||||||
|
<div className="max-w-5xl mx-auto px-6 py-3 flex items-center justify-between">
|
||||||
|
<span className="text-xl font-bold tracking-tight select-none">
|
||||||
|
<span className="text-violet-600 dark:text-violet-400">O</span>mbrora
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ThemeToggle />
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,11 +4,11 @@ import { useLocale } from 'next-intl'
|
|||||||
import { useRouter, usePathname } from '@/navigation'
|
import { useRouter, usePathname } from '@/navigation'
|
||||||
import { routing } from '@/i18n/routing'
|
import { routing } from '@/i18n/routing'
|
||||||
|
|
||||||
const FLAGS: Record<string, string> = {
|
const FLAG_LABELS: Record<string, string> = {
|
||||||
en: '🇺🇸',
|
en: 'English',
|
||||||
fr: '🇫🇷',
|
fr: 'Français',
|
||||||
es: '🇪🇸',
|
es: 'Español',
|
||||||
it: '🇮🇹',
|
it: 'Italiano',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LanguageSwitcher() {
|
export function LanguageSwitcher() {
|
||||||
@@ -17,21 +17,26 @@ export function LanguageSwitcher() {
|
|||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', padding: '0.5rem 2rem' }}>
|
<div className="flex items-center gap-1">
|
||||||
{routing.locales.map((loc) => (
|
{routing.locales.map((loc) => (
|
||||||
<button
|
<button
|
||||||
key={loc}
|
key={loc}
|
||||||
onClick={() => router.replace(pathname, { locale: loc })}
|
onClick={() => router.replace(pathname, { locale: loc })}
|
||||||
aria-label={loc}
|
aria-label={FLAG_LABELS[loc]}
|
||||||
style={{
|
className={[
|
||||||
fontSize: '1.5rem',
|
'rounded transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500',
|
||||||
background: 'none',
|
loc === locale
|
||||||
border: 'none',
|
? 'opacity-100 ring-2 ring-violet-500 ring-offset-1 ring-offset-white dark:ring-offset-slate-950'
|
||||||
cursor: 'pointer',
|
: 'opacity-40 hover:opacity-70',
|
||||||
opacity: loc === locale ? 1 : 0.4,
|
].join(' ')}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{FLAGS[loc]}
|
<img
|
||||||
|
src={`/flags/${loc}.svg`}
|
||||||
|
alt={FLAG_LABELS[loc]}
|
||||||
|
width={28}
|
||||||
|
height={20}
|
||||||
|
className="rounded-sm block"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
|
||||||
|
function GiftIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<path d="M20 12v10H4V12"/>
|
||||||
|
<path d="M2 7h20v5H2z"/>
|
||||||
|
<path d="M12 22V7"/>
|
||||||
|
<path d="M12 7H7.5a2.5 2.5 0 010-5C11 2 12 7 12 7z"/>
|
||||||
|
<path d="M12 7h4.5a2.5 2.5 0 000-5C13 2 12 7 12 7z"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BoltIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LockIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||||
|
<path d="M7 11V7a5 5 0 0110 0v4"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilmIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/>
|
||||||
|
<line x1="7" y1="2" x2="7" y2="22"/>
|
||||||
|
<line x1="17" y1="2" x2="17" y2="22"/>
|
||||||
|
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||||
|
<line x1="2" y1="7" x2="7" y2="7"/>
|
||||||
|
<line x1="2" y1="17" x2="7" y2="17"/>
|
||||||
|
<line x1="17" y1="17" x2="22" y2="17"/>
|
||||||
|
<line x1="17" y1="7" x2="22" y2="7"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ReassuranceSection({ locale }: { locale: string }) {
|
||||||
|
const t = await getTranslations({ locale, namespace: 'reassurance' })
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ title: t('free'), desc: t('freeDesc'), Icon: GiftIcon },
|
||||||
|
{ title: t('fast'), desc: t('fastDesc'), Icon: BoltIcon },
|
||||||
|
{ title: t('private'), desc: t('privateDesc'), Icon: LockIcon },
|
||||||
|
{ title: t('formats'), desc: t('formatsDesc'), Icon: FilmIcon },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-16 px-6" aria-label="Avantages">
|
||||||
|
<div className="max-w-5xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{items.map(({ title, desc, Icon }) => (
|
||||||
|
<div
|
||||||
|
key={title}
|
||||||
|
className="rounded-xl border border-gray-100 dark:border-slate-800 bg-gray-50 dark:bg-slate-900 p-5 text-center"
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400 mb-3">
|
||||||
|
<Icon />
|
||||||
|
</div>
|
||||||
|
<p className="font-semibold text-sm text-gray-900 dark:text-gray-50">{title}</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,6 +15,15 @@ type DownloadData = {
|
|||||||
tokenExpiresAt: string | null
|
tokenExpiresAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Spinner() {
|
||||||
|
return (
|
||||||
|
<svg className="animate-spin w-8 h-8 text-violet-600 dark:text-violet-400" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function StatusView({ uuid }: { uuid: string }) {
|
export function StatusView({ uuid }: { uuid: string }) {
|
||||||
const t = useTranslations('status')
|
const t = useTranslations('status')
|
||||||
const locale = useLocale()
|
const locale = useLocale()
|
||||||
@@ -45,14 +54,33 @@ export function StatusView({ uuid }: { uuid: string }) {
|
|||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [uuid])
|
}, [uuid])
|
||||||
|
|
||||||
if (notFound) return <p>{t('notFound')}</p>
|
const cardClass = 'max-w-md w-full mx-auto rounded-2xl border border-gray-100 dark:border-slate-800 bg-gray-50 dark:bg-slate-900 p-8'
|
||||||
if (!data) return <p>{t('loading')}</p>
|
|
||||||
|
if (notFound) {
|
||||||
|
return (
|
||||||
|
<div className={cardClass}>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-center">{t('notFound')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className={`${cardClass} flex flex-col items-center gap-4`}>
|
||||||
|
<Spinner />
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-sm">{t('loading')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (data.status === 'PENDING' || data.status === 'PROCESSING') {
|
if (data.status === 'PENDING' || data.status === 'PROCESSING') {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={`${cardClass} flex flex-col items-center gap-4`}>
|
||||||
<p>{data.status === 'PENDING' ? t('pending') : t('processing')}</p>
|
<Spinner />
|
||||||
<p>{t('polling')}</p>
|
<p className="font-medium text-gray-900 dark:text-gray-50">
|
||||||
|
{data.status === 'PENDING' ? t('pending') : t('processing')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 text-center">{t('polling')}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -62,16 +90,24 @@ export function StatusView({ uuid }: { uuid: string }) {
|
|||||||
? `(${(Number(data.fileSize) / 1_048_576).toFixed(1)} MB)`
|
? `(${(Number(data.fileSize) / 1_048_576).toFixed(1)} MB)`
|
||||||
: ''
|
: ''
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={`${cardClass} flex flex-col items-center gap-5`}>
|
||||||
<p>{t('ready')} {sizeMb}</p>
|
<div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-green-600 dark:text-green-400" aria-hidden>
|
||||||
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-gray-50">
|
||||||
|
{t('ready')} {sizeMb}
|
||||||
|
</p>
|
||||||
<a
|
<a
|
||||||
href={`/api/download/${data.downloadToken}`}
|
href={`/api/download/${data.downloadToken}`}
|
||||||
download={data.fileName ?? undefined}
|
download={data.fileName ?? undefined}
|
||||||
|
className="w-full text-center rounded-xl bg-violet-600 hover:bg-violet-700 dark:bg-violet-500 dark:hover:bg-violet-600 text-white font-medium py-3 transition-colors"
|
||||||
>
|
>
|
||||||
{t('download')} {data.fileName}
|
{t('download')} {data.fileName}
|
||||||
</a>
|
</a>
|
||||||
{data.tokenExpiresAt && (
|
{data.tokenExpiresAt && (
|
||||||
<p style={{ fontSize: '0.85rem', color: '#666' }}>
|
<p className="text-xs text-gray-400 dark:text-slate-500">
|
||||||
{t('linkExpires')}{' '}
|
{t('linkExpires')}{' '}
|
||||||
{new Date(data.tokenExpiresAt).toLocaleString(locale)}
|
{new Date(data.tokenExpiresAt).toLocaleString(locale)}
|
||||||
</p>
|
</p>
|
||||||
@@ -82,9 +118,9 @@ export function StatusView({ uuid }: { uuid: string }) {
|
|||||||
|
|
||||||
if (data.status === 'FAILED') {
|
if (data.status === 'FAILED') {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={cardClass}>
|
||||||
<p>{t('failed')}</p>
|
<p className="font-medium text-red-600 dark:text-red-400 mb-3">{t('failed')}</p>
|
||||||
<pre style={{ background: '#fee', padding: '0.5rem', overflowX: 'auto' }}>
|
<pre className="text-xs bg-red-50 dark:bg-red-950/40 border border-red-100 dark:border-red-900/50 rounded-lg p-4 overflow-x-auto text-red-700 dark:text-red-300 whitespace-pre-wrap">
|
||||||
{data.errorMsg}
|
{data.errorMsg}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,8 +128,16 @@ export function StatusView({ uuid }: { uuid: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.status === 'FILE_DELETED') {
|
if (data.status === 'FILE_DELETED') {
|
||||||
return <p>{t('deleted')}</p>
|
return (
|
||||||
|
<div className={cardClass}>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-center">{t('deleted')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <p>{t('unknown')}: {data.status}</p>
|
return (
|
||||||
|
<div className={cardClass}>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-center">{t('unknown')}: {data.status}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,18 @@ import { useRouter } from '@/navigation'
|
|||||||
const FORMATS = ['mp4', 'mp3', 'webm', 'mkv']
|
const FORMATS = ['mp4', 'mp3', 'webm', 'mkv']
|
||||||
const QUALITIES = ['best', '1080p', '720p', '480p', '360p']
|
const QUALITIES = ['best', '1080p', '720p', '480p', '360p']
|
||||||
|
|
||||||
|
function Spinner() {
|
||||||
|
return (
|
||||||
|
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectClass =
|
||||||
|
'rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 py-1 text-gray-900 dark:text-gray-50 text-sm outline-none focus:ring-2 focus:ring-violet-500'
|
||||||
|
|
||||||
export function SubmitForm() {
|
export function SubmitForm() {
|
||||||
const t = useTranslations('home')
|
const t = useTranslations('home')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -52,58 +64,82 @@ export function SubmitForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem', maxWidth: 480 }}>
|
<form onSubmit={handleSubmit} className="w-full max-w-2xl mx-auto">
|
||||||
<label>
|
{/* URL input with inline submit button */}
|
||||||
{t('urlLabel')}
|
<div className="relative">
|
||||||
<input
|
{/* Violet glow behind the input */}
|
||||||
type="url"
|
<div className="absolute inset-0 -z-10 rounded-xl bg-violet-500 blur-3xl opacity-15 dark:opacity-20" aria-hidden />
|
||||||
value={url}
|
<div className="flex rounded-xl border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 overflow-hidden shadow-sm focus-within:ring-2 focus-within:ring-violet-500">
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
<input
|
||||||
required
|
type="url"
|
||||||
placeholder="https://www.youtube.com/watch?v=..."
|
value={url}
|
||||||
style={{ display: 'block', width: '100%' }}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
/>
|
required
|
||||||
</label>
|
placeholder="https://www.youtube.com/watch?v=..."
|
||||||
|
className="flex-1 px-4 py-3 bg-transparent text-sm outline-none placeholder:text-gray-400 dark:placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-2 px-5 py-3 bg-violet-600 hover:bg-violet-700 dark:bg-violet-500 dark:hover:bg-violet-600 text-white text-sm font-medium transition-colors disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loading && <Spinner />}
|
||||||
|
{loading ? t('submitting') : t('submit')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label>
|
{/* Options row */}
|
||||||
{t('format')}
|
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-3 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<select value={format} onChange={(e) => setFormat(e.target.value)} style={{ display: 'block' }}>
|
<label className="flex items-center gap-2">
|
||||||
{FORMATS.map((f) => <option key={f}>{f}</option>)}
|
<span>{t('format')}</span>
|
||||||
</select>
|
<select
|
||||||
</label>
|
value={format}
|
||||||
|
onChange={(e) => setFormat(e.target.value)}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
{FORMATS.map((f) => <option key={f}>{f}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label>
|
<label className="flex items-center gap-2">
|
||||||
{t('quality')}
|
<span>{t('quality')}</span>
|
||||||
<select value={quality} onChange={(e) => setQuality(e.target.value)} style={{ display: 'block' }}>
|
<select
|
||||||
{QUALITIES.map((q) => <option key={q}>{q}</option>)}
|
value={quality}
|
||||||
</select>
|
onChange={(e) => setQuality(e.target.value)}
|
||||||
</label>
|
className={selectClass}
|
||||||
|
>
|
||||||
|
{QUALITIES.map((q) => <option key={q}>{q}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label>
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={subtitles}
|
checked={subtitles}
|
||||||
onChange={(e) => setSubtitles(e.target.checked)}
|
onChange={(e) => setSubtitles(e.target.checked)}
|
||||||
/>{' '}
|
className="accent-violet-600 w-4 h-4"
|
||||||
{t('subtitles')}
|
/>
|
||||||
</label>
|
<span>{t('subtitles')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label>
|
<details className="w-full mt-1">
|
||||||
{t('advanced')}
|
<summary className="cursor-pointer select-none text-gray-500 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors">
|
||||||
<input
|
{t('advanced')}
|
||||||
type="text"
|
</summary>
|
||||||
value={extraArgs}
|
<input
|
||||||
onChange={(e) => setExtraArgs(e.target.value)}
|
type="text"
|
||||||
placeholder='["--sponsorblock-remove","all"]'
|
value={extraArgs}
|
||||||
style={{ display: 'block', width: '100%' }}
|
onChange={(e) => setExtraArgs(e.target.value)}
|
||||||
/>
|
placeholder='["--sponsorblock-remove","all"]'
|
||||||
</label>
|
className="mt-2 w-full rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-50 outline-none focus:ring-2 focus:ring-violet-500"
|
||||||
|
/>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
{error && (
|
||||||
|
<p className="mt-3 text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
<button type="submit" disabled={loading}>
|
)}
|
||||||
{loading ? t('submitting') : t('submit')}
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
function SunIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<circle cx="12" cy="12" r="5"/>
|
||||||
|
<line x1="12" y1="1" x2="12" y2="3"/>
|
||||||
|
<line x1="12" y1="21" x2="12" y2="23"/>
|
||||||
|
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
||||||
|
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||||
|
<line x1="1" y1="12" x2="3" y2="12"/>
|
||||||
|
<line x1="21" y1="12" x2="23" y2="12"/>
|
||||||
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
||||||
|
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MoonIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => { setMounted(true) }, [])
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
// Prevents hydration mismatch -- renders a same-size placeholder
|
||||||
|
return <div className="w-9 h-9" />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||||
|
aria-label="Basculer le theme"
|
||||||
|
className="rounded-lg p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? <SunIcon /> : <MoonIcon />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ jest.mock('@/lib/prisma', () => ({
|
|||||||
jest.mock('@/lib/token')
|
jest.mock('@/lib/token')
|
||||||
jest.mock('child_process')
|
jest.mock('child_process')
|
||||||
jest.mock('fs', () => ({
|
jest.mock('fs', () => ({
|
||||||
|
existsSync: jest.fn(() => false),
|
||||||
readdirSync: jest.fn(() => ['uuid-abc.mp4']),
|
readdirSync: jest.fn(() => ['uuid-abc.mp4']),
|
||||||
statSync: jest.fn(() => ({ size: 1024 })),
|
statSync: jest.fn(() => ({ size: 1024 })),
|
||||||
}))
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user