first commit
@@ -0,0 +1,9 @@
|
|||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
.env.*.local
|
||||||
|
.git
|
||||||
|
.vscode
|
||||||
|
docs
|
||||||
|
README.md
|
||||||
|
AGENTS.md
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- Framework: Next.js 16, App Router, TypeScript strict
|
||||||
|
- Styling: Tailwind CSS v4
|
||||||
|
- UI: shadcn/ui (new-york style, OKLCH)
|
||||||
|
- Animation: Framer Motion
|
||||||
|
- Icons: Lucide React
|
||||||
|
- i18n: next-intl
|
||||||
|
- ORM: Prisma + PostgreSQL
|
||||||
|
- Auth: NextAuth.js v5
|
||||||
|
- Media: Cloudinary
|
||||||
|
- Deploy: Coolify (Docker, standalone output)
|
||||||
|
|
||||||
|
## Sabit Tercihler
|
||||||
|
- Mock data: USE_MOCK=true (demo aşaması)
|
||||||
|
- proxy.ts kullan — middleware.ts deprecated (Next.js 15.3+)
|
||||||
|
- İletişim formu sadece /iletisim sayfasında — ana sayfada olmaz
|
||||||
|
- Footer'da "Created by ayris.tech" linki zorunlu
|
||||||
|
- Dockerfile'da dummy DATABASE_URL (prisma generate için)
|
||||||
|
|
||||||
|
## Altyapı
|
||||||
|
- Gitea: https://git.ayris.tech (kullanıcı: ayrisdev)
|
||||||
|
- Coolify: https://client2.ayris.tech
|
||||||
|
- Cloudflare zone: ayris.tech
|
||||||
|
- Server IP: 188.245.175.169
|
||||||
|
|
||||||
|
## docs/ Klasörü
|
||||||
|
- docs/prd.md → ana içerik kaynağı
|
||||||
|
- docs/*.html → varsa mevcut site içeriği
|
||||||
|
- docs/*.md → ek belgeler
|
||||||
|
|
||||||
|
## Aktif Skill'ler
|
||||||
|
- nextjs-seo → sitemap, metadata, robots.txt
|
||||||
|
- next-best-practices → kod kalitesi
|
||||||
|
- nextjs-app-router-patterns → Server Actions, Suspense
|
||||||
|
- demo-site → komple site üretimi
|
||||||
|
- design-demo → görsel kalite
|
||||||
|
- coolify-deploy → deploy pipeline
|
||||||
|
|
||||||
|
## Proje Özel Notlar
|
||||||
|
<!-- Buraya proje bazlı notlar ekle -->
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
FROM node:22-alpine AS base
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci --legacy-peer-deps
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
# Prisma generate için dummy URL — build sırasında gerçek DB gerekmez
|
||||||
|
ARG DATABASE_URL=postgresql://dummy:dummy@localhost:5432/dummy
|
||||||
|
ENV DATABASE_URL=$DATABASE_URL
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
RUN mkdir .next && chown nextjs:nodejs .next
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -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,7 @@
|
|||||||
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 flex flex-col">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getCheatsheets } from '@/lib/actions/cheatsheetActions';
|
||||||
|
import { BookOpen, Copy, Check, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function CheatsheetsPage() {
|
||||||
|
const [copiedItem, setCopiedItem] = useState<string | null>(null);
|
||||||
|
const [cheatsheets, setCheatsheets] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadData() {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await getCheatsheets();
|
||||||
|
setCheatsheets(res);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopyCommand = async (command: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(command);
|
||||||
|
setCopiedItem(command);
|
||||||
|
setTimeout(() => setCopiedItem(null), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 space-y-10 flex-1 w-full">
|
||||||
|
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-red-500/10 text-red-400 text-xs font-bold border border-red-500/20">
|
||||||
|
<BookOpen className="w-4 h-4" />
|
||||||
|
<span>Quick Reference Cheatsheets</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl sm:text-5xl font-black text-white tracking-tight">
|
||||||
|
Developer Cheatsheets & Essential Commands
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-400 max-w-2xl">
|
||||||
|
Copy Git, Next.js, Docker, and Python commands in one click directly from ayris.tech developer reference guides.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cheatsheets List */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-12 text-center text-slate-400 flex items-center justify-center gap-2">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-red-500" />
|
||||||
|
<span>Loading cheatsheets...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{cheatsheets.map((sheet) => (
|
||||||
|
<div key={sheet.id} className="p-6 rounded-3xl bg-slate-900/60 border border-slate-800 space-y-6 shadow-xl hover:border-slate-700 transition-colors">
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-800 pb-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-[11px] font-bold text-red-400 bg-red-500/10 px-2.5 py-0.5 rounded-full border border-red-500/20">
|
||||||
|
{sheet.category}
|
||||||
|
</span>
|
||||||
|
<h2 className="text-xl font-bold text-white mt-2">{sheet.title}</h2>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-slate-500 font-mono">
|
||||||
|
Updated: {new Date(sheet.lastUpdated).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-400">{sheet.description}</p>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sheet.items?.map((item: any, idx: number) => (
|
||||||
|
<div key={idx} className="p-3.5 rounded-2xl bg-slate-950 border border-slate-800 space-y-2 group">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<code className="text-xs font-mono font-bold text-red-300 bg-slate-900 px-2.5 py-1 rounded-lg border border-slate-800 break-all">
|
||||||
|
{item.command}
|
||||||
|
</code>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopyCommand(item.command)}
|
||||||
|
className="p-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white transition-colors flex-shrink-0"
|
||||||
|
title="Copy Command"
|
||||||
|
>
|
||||||
|
{copiedItem === item.command ? (
|
||||||
|
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3.5 h-3.5 text-slate-400 group-hover:text-white" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-slate-400 pl-1">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { submitContactMessage } from '@/lib/actions/contactActions';
|
||||||
|
import { Mail, Send, CheckCircle2, Sparkles } from 'lucide-react';
|
||||||
|
import { YoutubeIcon as Youtube } from '@/components/icons/YoutubeIcon';
|
||||||
|
|
||||||
|
export default function ContactPage() {
|
||||||
|
const t = useTranslations('contact');
|
||||||
|
const [form, setForm] = useState({ name: '', email: '', subject: '', message: '' });
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setErrorMsg(null);
|
||||||
|
|
||||||
|
const res = await submitContactMessage(form);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
setSubmitted(true);
|
||||||
|
setForm({ name: '', email: '', subject: '', message: '' });
|
||||||
|
} else {
|
||||||
|
setErrorMsg(res.error || 'Failed to submit message');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 space-y-12 flex-1 w-full">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center space-y-3 max-w-2xl mx-auto">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-red-500/10 text-red-400 text-xs font-bold border border-red-500/20">
|
||||||
|
<Mail className="w-4 h-4" />
|
||||||
|
<span>Get in Touch with Creator</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl sm:text-5xl font-black text-white tracking-tight">
|
||||||
|
{t('title')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
{t('subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 items-start max-w-5xl mx-auto">
|
||||||
|
|
||||||
|
{/* Contact Info Card */}
|
||||||
|
<div className="lg:col-span-5 bg-slate-900/80 border border-slate-800 p-8 rounded-3xl space-y-6 shadow-xl">
|
||||||
|
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-red-500" />
|
||||||
|
<span>Why Reach Out?</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<ul className="space-y-4 text-xs text-slate-300">
|
||||||
|
<li className="flex items-start gap-3">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-red-500/10 text-red-400 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
✓
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-bold text-white block">Future Tutorial Requests</span>
|
||||||
|
<span>Suggest coding topics, frameworks, or projects you want covered.</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="flex items-start gap-3">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-red-500/10 text-red-400 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
✓
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-bold text-white block">Sponsorship & Inquiries</span>
|
||||||
|
<span>Discuss channel sponsorships or platform collaborations.</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="flex items-start gap-3">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-red-500/10 text-red-400 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
✓
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-bold text-white block">Code Issues & Feedback</span>
|
||||||
|
<span>Provide feedback on video code snippets or starter templates.</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="pt-6 border-t border-slate-800 space-y-3 text-xs">
|
||||||
|
<div className="flex items-center gap-3 text-slate-300">
|
||||||
|
<Mail className="w-4 h-4 text-red-400" />
|
||||||
|
<span>contact@youtube-devhub.com</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-slate-300">
|
||||||
|
<Youtube className="w-4 h-4 text-red-500" />
|
||||||
|
<span>YouTube: @DevHubChannel</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Area */}
|
||||||
|
<div className="lg:col-span-7 bg-slate-900/60 border border-slate-800 p-8 rounded-3xl shadow-xl">
|
||||||
|
{submitted ? (
|
||||||
|
<div className="p-8 text-center space-y-4 bg-emerald-950/30 border border-emerald-800/40 rounded-2xl">
|
||||||
|
<CheckCircle2 className="w-12 h-12 text-emerald-400 mx-auto animate-bounce" />
|
||||||
|
<h3 className="text-xl font-bold text-white">{t('success')}</h3>
|
||||||
|
<p className="text-xs text-slate-300">Your message has been saved in PostgreSQL. I will reply soon.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setSubmitted(false)}
|
||||||
|
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-xs font-bold text-white rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
Send Another Message
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="p-3 rounded-xl bg-red-950/50 border border-red-800 text-xs text-red-400">
|
||||||
|
{errorMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1.5">{t('name')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-2.5 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-red-500/50"
|
||||||
|
placeholder="John Doe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1.5">{t('email')}</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-2.5 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-red-500/50"
|
||||||
|
placeholder="john@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1.5">{t('subject')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={form.subject}
|
||||||
|
onChange={(e) => setForm({ ...form, subject: e.target.value })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-2.5 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-red-500/50"
|
||||||
|
placeholder="Sponsorship Proposal or New Tutorial Request"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1.5">{t('message')}</label>
|
||||||
|
<textarea
|
||||||
|
rows={5}
|
||||||
|
required
|
||||||
|
value={form.message}
|
||||||
|
onChange={(e) => setForm({ ...form, message: e.target.value })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-2.5 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-red-500/50"
|
||||||
|
placeholder="Type your message here..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full py-3.5 px-6 bg-red-600 hover:bg-red-500 text-white font-bold text-xs rounded-xl shadow-lg shadow-red-600/30 transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<span>Saving to PostgreSQL...</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
<span>{t('send')}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Plus_Jakarta_Sans, JetBrains_Mono } from "next/font/google";
|
||||||
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
|
import { getMessages, setRequestLocale } from 'next-intl/server';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { routing } from '@/i18n/routing';
|
||||||
|
import { Navbar } from '@/components/Navbar';
|
||||||
|
import { Footer } from '@/components/Footer';
|
||||||
|
import "../globals.css";
|
||||||
|
|
||||||
|
const jakartaSans = Plus_Jakarta_Sans({
|
||||||
|
variable: "--font-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["300", "400", "500", "600", "700", "800"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
|
variable: "--font-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500", "600", "700"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "DevHub YouTube | Educational Code & Resource Portal",
|
||||||
|
description: "Official resource portal featuring tabbed code snippets, lesson notes, starter packs, and project downloads for my YouTube tutorials.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return routing.locales.map((locale) => ({ locale }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
children,
|
||||||
|
params
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}>) {
|
||||||
|
const { locale } = await params;
|
||||||
|
|
||||||
|
if (!routing.locales.includes(locale as any)) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
setRequestLocale(locale);
|
||||||
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang={locale}
|
||||||
|
className={`${jakartaSans.variable} ${jetbrainsMono.variable} dark h-full antialiased`}
|
||||||
|
>
|
||||||
|
<body className="min-h-full flex flex-col bg-slate-950 text-slate-100 font-sans selection:bg-red-500 selection:text-white tracking-normal leading-relaxed" suppressHydrationWarning>
|
||||||
|
<NextIntlClientProvider messages={messages}>
|
||||||
|
<Navbar locale={locale} />
|
||||||
|
<main className="flex-1 flex flex-col">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
<Footer locale={locale} />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, use } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { getLessonBySlug, getLessons } from '@/lib/actions/lessonActions';
|
||||||
|
import { VideoPlayer } from '@/components/VideoPlayer';
|
||||||
|
import { CodeBlock } from '@/components/CodeBlock';
|
||||||
|
import { ResourceCard } from '@/components/ResourceCard';
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Code,
|
||||||
|
Download,
|
||||||
|
Clock,
|
||||||
|
FileArchive,
|
||||||
|
FileSpreadsheet,
|
||||||
|
ArrowLeft,
|
||||||
|
Share2,
|
||||||
|
Check,
|
||||||
|
Eye,
|
||||||
|
Calendar,
|
||||||
|
Sparkles,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { YoutubeIcon as Youtube } from '@/components/icons/YoutubeIcon';
|
||||||
|
import { GithubIcon as Github } from '@/components/icons/BrandIcons';
|
||||||
|
|
||||||
|
export default function LessonDetailPage({
|
||||||
|
params
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string; slug: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale, slug } = use(params);
|
||||||
|
const [activeTab, setActiveTab] = useState<'code' | 'notes' | 'downloads' | 'chapters'>('code');
|
||||||
|
const [copiedLink, setCopiedLink] = useState(false);
|
||||||
|
|
||||||
|
const [lesson, setLesson] = useState<any>(null);
|
||||||
|
const [relatedLessons, setRelatedLessons] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadData() {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getLessonBySlug(slug);
|
||||||
|
if (data) {
|
||||||
|
setLesson(data);
|
||||||
|
|
||||||
|
// Auto-select first non-empty tab
|
||||||
|
const hasCode = data.codeSnippets && data.codeSnippets.length > 0;
|
||||||
|
const hasNotes = data.notesMarkdown && data.notesMarkdown.length > 0;
|
||||||
|
const hasDownloads = data.downloads && data.downloads.length > 0;
|
||||||
|
const hasChapters = data.chapters && data.chapters.length > 0;
|
||||||
|
|
||||||
|
if (hasCode) setActiveTab('code');
|
||||||
|
else if (hasNotes) setActiveTab('notes');
|
||||||
|
else if (hasDownloads) setActiveTab('downloads');
|
||||||
|
else if (hasChapters) setActiveTab('chapters');
|
||||||
|
|
||||||
|
const all = await getLessons();
|
||||||
|
setRelatedLessons(all.filter((v: any) => v.id !== data.id).slice(0, 3));
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
loadData();
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
if (!loading && !lesson) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || !lesson) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-24 text-center text-slate-400 flex flex-col items-center justify-center gap-3">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-red-500" />
|
||||||
|
<p className="text-sm font-semibold">Loading tutorial...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCode = lesson.codeSnippets && lesson.codeSnippets.length > 0;
|
||||||
|
const hasNotes = lesson.notesMarkdown && lesson.notesMarkdown.length > 0;
|
||||||
|
const hasDownloads = lesson.downloads && lesson.downloads.length > 0;
|
||||||
|
const hasChapters = lesson.chapters && lesson.chapters.length > 0;
|
||||||
|
const hasAnyResources = hasCode || hasNotes || hasDownloads || hasChapters;
|
||||||
|
|
||||||
|
const handleShare = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(window.location.href);
|
||||||
|
setCopiedLink(true);
|
||||||
|
setTimeout(() => setCopiedLink(false), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Share error:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-10 flex-1 w-full">
|
||||||
|
|
||||||
|
{/* Breadcrumb & Navigation Back */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||||
|
<Link href={`/${locale}`} className="hover:text-white transition-colors">Home</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link href={`/${locale}/lessons`} className="hover:text-white transition-colors">Lessons</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-slate-200 font-semibold truncate max-w-xs">{lesson.title}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleShare}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-300 text-xs font-semibold border border-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
{copiedLink ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
<span className="text-emerald-400">Link Copied</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Share2 className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
<span>Share Page</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/lessons`}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-300 text-xs font-semibold border border-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
|
<span>Back to Lessons</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Header Info */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="px-3 py-1 rounded-full bg-red-500/10 text-red-400 text-xs font-bold border border-red-500/20">
|
||||||
|
{lesson.category}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-xs text-slate-400 bg-slate-900 px-3 py-1 rounded-full border border-slate-800">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-slate-500" />
|
||||||
|
{new Date(lesson.publishDate).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-xs text-slate-400 bg-slate-900 px-3 py-1 rounded-full border border-slate-800">
|
||||||
|
<Eye className="w-3.5 h-3.5 text-slate-500" />
|
||||||
|
{lesson.viewsCount.toLocaleString()} Views
|
||||||
|
</span>
|
||||||
|
{hasDownloads && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-slate-400 bg-slate-900 px-3 py-1 rounded-full border border-slate-800">
|
||||||
|
<Download className="w-3.5 h-3.5 text-slate-500" />
|
||||||
|
{lesson.downloadCount.toLocaleString()} Downloads
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-2xl sm:text-4xl font-black text-white tracking-tight leading-tight">
|
||||||
|
{lesson.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-sm text-slate-300 max-w-3xl leading-relaxed">
|
||||||
|
{lesson.summary}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Embedded Video Player */}
|
||||||
|
<VideoPlayer
|
||||||
|
youtubeId={lesson.youtubeId}
|
||||||
|
youtubeUrl={lesson.youtubeUrl}
|
||||||
|
title={lesson.title}
|
||||||
|
chapters={lesson.chapters}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Resource Tabs (Shown ONLY if resources exist) */}
|
||||||
|
{hasAnyResources && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
{/* Tab Header Buttons */}
|
||||||
|
<div className="flex items-center gap-2 border-b border-slate-800 pb-2 overflow-x-auto scrollbar-none">
|
||||||
|
{hasCode && (
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('code')}
|
||||||
|
className={`flex items-center gap-2 px-5 py-3 rounded-2xl text-xs font-bold transition-all whitespace-nowrap ${
|
||||||
|
activeTab === 'code'
|
||||||
|
? 'bg-red-600 text-white shadow-lg shadow-red-600/30'
|
||||||
|
: 'bg-slate-900 hover:bg-slate-800 text-slate-400 hover:text-white border border-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Code className="w-4 h-4" />
|
||||||
|
<span>Code Blocks ({lesson.codeSnippets.length} Files)</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasNotes && (
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('notes')}
|
||||||
|
className={`flex items-center gap-2 px-5 py-3 rounded-2xl text-xs font-bold transition-all whitespace-nowrap ${
|
||||||
|
activeTab === 'notes'
|
||||||
|
? 'bg-red-600 text-white shadow-lg shadow-red-600/30'
|
||||||
|
: 'bg-slate-900 hover:bg-slate-800 text-slate-400 hover:text-white border border-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
<span>Lesson Notes & Summary</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasDownloads && (
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('downloads')}
|
||||||
|
className={`flex items-center gap-2 px-5 py-3 rounded-2xl text-xs font-bold transition-all whitespace-nowrap ${
|
||||||
|
activeTab === 'downloads'
|
||||||
|
? 'bg-red-600 text-white shadow-lg shadow-red-600/30'
|
||||||
|
: 'bg-slate-900 hover:bg-slate-800 text-slate-400 hover:text-white border border-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
<span>Downloads & Links ({lesson.downloads.length})</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasChapters && (
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('chapters')}
|
||||||
|
className={`flex items-center gap-2 px-5 py-3 rounded-2xl text-xs font-bold transition-all whitespace-nowrap ${
|
||||||
|
activeTab === 'chapters'
|
||||||
|
? 'bg-red-600 text-white shadow-lg shadow-red-600/30'
|
||||||
|
: 'bg-slate-900 hover:bg-slate-800 text-slate-400 hover:text-white border border-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>Video Chapters ({lesson.chapters.length})</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Contents */}
|
||||||
|
<div>
|
||||||
|
{/* TAB 1: CODE BLOCKS */}
|
||||||
|
{activeTab === 'code' && hasCode && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 rounded-xl bg-red-950/20 border border-red-800/30 text-xs text-red-300 flex items-center justify-between">
|
||||||
|
<span>💡 You can inspect all code snippets by clicking file tabs and copy them directly into your project using "Copy Code".</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CodeBlock snippets={lesson.codeSnippets} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB 2: LESSON NOTES */}
|
||||||
|
{activeTab === 'notes' && hasNotes && (
|
||||||
|
<div className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 space-y-4">
|
||||||
|
<h3 className="font-extrabold text-white text-lg flex items-center gap-2">
|
||||||
|
<FileText className="w-5 h-5 text-red-400" />
|
||||||
|
<span>Key Lesson Notes & Takeaways</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{lesson.notesMarkdown.map((note: string, idx: number) => (
|
||||||
|
<div key={idx} className="p-4 rounded-xl bg-slate-950 border border-slate-800 text-xs text-slate-200 leading-relaxed">
|
||||||
|
{note}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB 3: DOWNLOADS & LINKS */}
|
||||||
|
{activeTab === 'downloads' && hasDownloads && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{lesson.downloads.map((item: any, idx: number) => (
|
||||||
|
<div key={idx} className="p-5 rounded-2xl bg-slate-900/80 border border-slate-800 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-slate-800 flex items-center justify-center text-red-400 flex-shrink-0">
|
||||||
|
{item.type === 'zip' && <FileArchive className="w-5 h-5" />}
|
||||||
|
{item.type === 'github' && <Github className="w-5 h-5 text-white" />}
|
||||||
|
{item.type === 'pdf' && <FileSpreadsheet className="w-5 h-5 text-emerald-400" />}
|
||||||
|
{item.type === 'link' && <ExternalLink className="w-5 h-5 text-sky-400" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-white text-xs">{item.title}</h4>
|
||||||
|
{item.size && <p className="text-[11px] text-slate-500 font-mono">Size: {item.size}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="px-4 py-2 rounded-xl bg-red-600 hover:bg-red-500 text-white text-xs font-bold flex items-center gap-1.5 transition-colors shadow-md shadow-red-600/20"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
<span>Download / Open</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB 4: CHAPTERS */}
|
||||||
|
{activeTab === 'chapters' && hasChapters && (
|
||||||
|
<div className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 space-y-3">
|
||||||
|
<h3 className="font-bold text-white text-sm mb-4">Timestamped Video Index</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{lesson.chapters.map((chap: any) => (
|
||||||
|
<div key={chap.time} className="p-3 rounded-xl bg-slate-950 border border-slate-800 flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-mono text-red-400 font-bold bg-slate-900 px-2 py-1 rounded border border-slate-800">
|
||||||
|
{chap.time}
|
||||||
|
</span>
|
||||||
|
<span className="text-white font-medium">{chap.title}</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`${lesson.youtubeUrl}&t=${chap.seconds}s`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-red-400 hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Youtube className="w-3.5 h-3.5" />
|
||||||
|
<span>Watch</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Related YouTube Videos */}
|
||||||
|
<div className="pt-10 border-t border-slate-800 space-y-6">
|
||||||
|
<h3 className="text-xl font-extrabold text-white flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-red-500" />
|
||||||
|
<span>Other Popular Video Tutorials</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{relatedLessons.map((rel) => (
|
||||||
|
<ResourceCard key={rel.id} lesson={rel} locale={locale} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, use } from 'react';
|
||||||
|
import { ResourceCard } from '@/components/ResourceCard';
|
||||||
|
import { getLessons } from '@/lib/actions/lessonActions';
|
||||||
|
import { getCategories } from '@/lib/actions/categoryActions';
|
||||||
|
import { Search, Code2, SlidersHorizontal, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function LessonsPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = use(params);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedCat, setSelectedCat] = useState<string | null>(null);
|
||||||
|
const [lessons, setLessons] = useState<any[]>([]);
|
||||||
|
const [categories, setCategories] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getCategories().then(setCategories);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadData() {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await getLessons({ query: search, category: selectedCat || undefined });
|
||||||
|
setLessons(res);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
loadData();
|
||||||
|
}, [search, selectedCat]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 space-y-8 flex-1 w-full">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-red-500/10 text-red-400 text-xs font-bold border border-red-500/20">
|
||||||
|
<Code2 className="w-4 h-4" />
|
||||||
|
<span>YouTube Content & Code Library</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl sm:text-5xl font-black text-white tracking-tight">
|
||||||
|
All Video Tutorials & Code Resources
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-400 max-w-2xl">
|
||||||
|
Browse, filter, and inspect all source code snippets, lesson notes, starter zip packages, and multi-file project structures.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Bar */}
|
||||||
|
<div className="flex flex-col md:flex-row items-center justify-between gap-4 bg-slate-900/80 p-4 rounded-2xl border border-slate-800">
|
||||||
|
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="relative w-full md:w-96">
|
||||||
|
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search tutorial title or code..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl pl-10 pr-4 py-2 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-red-500/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Pills */}
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto w-full md:w-auto py-1 scrollbar-none">
|
||||||
|
<SlidersHorizontal className="w-4 h-4 text-slate-500 mr-1 flex-shrink-0" />
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedCat(null)}
|
||||||
|
className={`px-3 py-1.5 rounded-xl text-xs font-bold whitespace-nowrap transition-all ${
|
||||||
|
selectedCat === null
|
||||||
|
? 'bg-red-600 text-white shadow-md shadow-red-600/30'
|
||||||
|
: 'bg-slate-950 text-slate-400 hover:text-white border border-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat.id || cat.name}
|
||||||
|
onClick={() => setSelectedCat(selectedCat === cat.name ? null : cat.name)}
|
||||||
|
className={`px-3 py-1.5 rounded-xl text-xs font-bold whitespace-nowrap transition-all ${
|
||||||
|
selectedCat === cat.name
|
||||||
|
? 'bg-red-600 text-white shadow-md shadow-red-600/30'
|
||||||
|
: 'bg-slate-950 text-slate-400 hover:text-white border border-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cat.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-12 text-center text-slate-400 flex items-center justify-center gap-2">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-red-500" />
|
||||||
|
<span>Loading tutorials...</span>
|
||||||
|
</div>
|
||||||
|
) : lessons.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{lessons.map((item) => (
|
||||||
|
<ResourceCard key={item.id} lesson={item} locale={locale} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-12 text-center bg-slate-900/40 rounded-2xl border border-slate-800 text-slate-400 text-xs">
|
||||||
|
No YouTube tutorials match your search filters.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { signIn } from 'next-auth/react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { ShieldAlert, Lock, Mail, ArrowRight, Loader2 } from 'lucide-react';
|
||||||
|
import { YoutubeIcon as Youtube } from '@/components/icons/YoutubeIcon';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [email, setEmail] = useState('admin@ayris.tech');
|
||||||
|
const [password, setPassword] = useState('admin');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
const result = await signIn('credentials', {
|
||||||
|
redirect: false,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
setError('Invalid email or password');
|
||||||
|
setLoading(false);
|
||||||
|
} else {
|
||||||
|
router.push('/admin');
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-950 px-4 py-12">
|
||||||
|
<div className="w-full max-w-md bg-slate-900 border border-slate-800 rounded-3xl p-8 shadow-2xl space-y-6">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-slate-950 border border-slate-800 flex items-center justify-center mx-auto overflow-hidden shadow-xl">
|
||||||
|
<img src="/logo.jpeg" alt="ayris.tech Logo" className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-black text-white tracking-tight">Creator Admin Login</h1>
|
||||||
|
<p className="text-xs text-slate-400">Sign in to manage YouTube lessons, code snippets, and viewer messages.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3.5 rounded-xl bg-red-950/50 border border-red-800 text-xs text-red-300 text-center font-medium">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-slate-300 mb-1">Email Address</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="w-4 h-4 text-slate-500 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl pl-10 pr-4 py-2.5 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-red-500"
|
||||||
|
placeholder="admin@ayris.tech"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-slate-300 mb-1">Password</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="w-4 h-4 text-slate-500 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl pl-10 pr-4 py-2.5 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-red-500"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3 px-4 bg-red-600 hover:bg-red-500 text-white font-bold text-xs rounded-xl shadow-lg shadow-red-600/30 transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span>Signing in...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>Sign In to Dashboard</span>
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="p-3.5 rounded-xl bg-slate-950 border border-slate-800 text-center text-xs space-y-1">
|
||||||
|
<span className="text-slate-500 block font-semibold">Demo Admin Credentials:</span>
|
||||||
|
<div className="font-mono text-slate-300 text-[11px]">
|
||||||
|
Email: <span className="text-red-400 font-bold">admin@ayris.tech</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-slate-300 text-[11px]">
|
||||||
|
Password: <span className="text-red-400 font-bold">admin</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, use } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { ResourceCard } from '@/components/ResourceCard';
|
||||||
|
import { getLessons } from '@/lib/actions/lessonActions';
|
||||||
|
import { getCheatsheets } from '@/lib/actions/cheatsheetActions';
|
||||||
|
import { getCategories } from '@/lib/actions/categoryActions';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Sparkles,
|
||||||
|
Code2,
|
||||||
|
FileCheck2,
|
||||||
|
Layers,
|
||||||
|
Download,
|
||||||
|
Users,
|
||||||
|
CheckCircle2,
|
||||||
|
ArrowRight,
|
||||||
|
ShieldAlert,
|
||||||
|
Zap,
|
||||||
|
Loader2
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { YoutubeIcon as Youtube } from '@/components/icons/YoutubeIcon';
|
||||||
|
|
||||||
|
export default function HomePage({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = use(params);
|
||||||
|
const t = useTranslations('hero');
|
||||||
|
const sec = useTranslations('sections');
|
||||||
|
const feat = useTranslations('features');
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [categories, setCategories] = useState<any[]>([]);
|
||||||
|
const [lessons, setLessons] = useState<any[]>([]);
|
||||||
|
const [cheatsheets, setCheatsheets] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getCategories().then(setCategories);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadData() {
|
||||||
|
setLoading(true);
|
||||||
|
const [fetchedLessons, fetchedCheatsheets] = await Promise.all([
|
||||||
|
getLessons({ query: searchQuery, category: selectedCategory || undefined }),
|
||||||
|
getCheatsheets()
|
||||||
|
]);
|
||||||
|
setLessons(fetchedLessons);
|
||||||
|
setCheatsheets(fetchedCheatsheets);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
loadData();
|
||||||
|
}, [searchQuery, selectedCategory]);
|
||||||
|
|
||||||
|
const featuredVideo = lessons.find((v) => v.isFeatured) || lessons[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col space-y-16 pb-20">
|
||||||
|
|
||||||
|
{/* HERO SECTION */}
|
||||||
|
<section className="relative overflow-hidden pt-12 pb-16 bg-gradient-to-b from-slate-900/80 via-slate-950 to-slate-950 border-b border-slate-800/80">
|
||||||
|
|
||||||
|
{/* Glow Effects */}
|
||||||
|
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[300px] bg-red-600/10 blur-[140px] pointer-events-none rounded-full" />
|
||||||
|
<div className="absolute top-1/3 left-1/4 w-[300px] h-[200px] bg-indigo-600/10 blur-[120px] pointer-events-none rounded-full" />
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10 text-center space-y-8">
|
||||||
|
|
||||||
|
{/* Badge */}
|
||||||
|
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 text-xs font-bold shadow-inner">
|
||||||
|
<Youtube className="w-4 h-4 fill-red-400" />
|
||||||
|
<span>{t('badge')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Heading */}
|
||||||
|
<h1 className="text-4xl sm:text-6xl lg:text-7xl font-extrabold text-white tracking-tight leading-[1.15] max-w-4xl mx-auto">
|
||||||
|
{t('title')}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Subtitle */}
|
||||||
|
<p className="text-base sm:text-lg text-slate-400 max-w-2xl mx-auto font-normal leading-relaxed">
|
||||||
|
{t('subtitle')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="max-w-2xl mx-auto relative group">
|
||||||
|
<div className="absolute -inset-1 rounded-2xl bg-gradient-to-r from-red-600 via-rose-500 to-indigo-600 opacity-30 group-hover:opacity-60 blur-lg transition duration-500" />
|
||||||
|
<div className="relative flex items-center bg-slate-900 border border-slate-700/80 rounded-2xl p-2 shadow-2xl">
|
||||||
|
<Search className="w-5 h-5 text-slate-400 ml-3 flex-shrink-0" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('searchPlaceholder')}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full bg-transparent px-3 py-2 text-sm text-white placeholder-slate-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="px-3 py-1 text-xs text-slate-400 hover:text-white bg-slate-800 rounded-lg mr-1"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Chips */}
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedCategory(null)}
|
||||||
|
className={`px-4 py-1.5 rounded-full text-xs font-bold transition-all ${
|
||||||
|
selectedCategory === null
|
||||||
|
? 'bg-red-600 text-white shadow-lg shadow-red-600/30 ring-2 ring-red-400/50'
|
||||||
|
: 'bg-slate-900 hover:bg-slate-800 text-slate-300 border border-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All Topics
|
||||||
|
</button>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat.id || cat.name}
|
||||||
|
onClick={() => setSelectedCategory(selectedCategory === cat.name ? null : cat.name)}
|
||||||
|
className={`px-4 py-1.5 rounded-full text-xs font-bold transition-all ${
|
||||||
|
selectedCategory === cat.name
|
||||||
|
? 'bg-red-600 text-white shadow-lg shadow-red-600/30 ring-2 ring-red-400/50'
|
||||||
|
: 'bg-slate-900 hover:bg-slate-800 text-slate-300 border border-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cat.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistics Bar */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 pt-6 max-w-4xl mx-auto border-t border-slate-800/80">
|
||||||
|
<div className="p-4 rounded-xl bg-slate-900/40 border border-slate-800/60 text-center">
|
||||||
|
<div className="text-2xl font-black text-white flex items-center justify-center gap-1.5">
|
||||||
|
<Youtube className="w-5 h-5 text-red-500" />
|
||||||
|
<span>120+</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400 mt-1 font-medium">{t('statVideos')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-xl bg-slate-900/40 border border-slate-800/60 text-center">
|
||||||
|
<div className="text-2xl font-black text-white flex items-center justify-center gap-1.5">
|
||||||
|
<Download className="w-5 h-5 text-emerald-400" />
|
||||||
|
<span>100K+</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400 mt-1 font-medium">{t('statDownloads')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-xl bg-slate-900/40 border border-slate-800/60 text-center">
|
||||||
|
<div className="text-2xl font-black text-white flex items-center justify-center gap-1.5">
|
||||||
|
<Users className="w-5 h-5 text-sky-400" />
|
||||||
|
<span>50K+</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400 mt-1 font-medium">{t('statSubscribers')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-xl bg-slate-900/40 border border-slate-800/60 text-center">
|
||||||
|
<div className="text-2xl font-black text-white flex items-center justify-center gap-1.5">
|
||||||
|
<Zap className="w-5 h-5 text-amber-400" />
|
||||||
|
<span>99.8%</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400 mt-1 font-medium">{t('statSatisfaction')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FEATURED LESSON BANNER */}
|
||||||
|
{featuredVideo && !selectedCategory && !searchQuery && (
|
||||||
|
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 w-full">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-extrabold text-white flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-red-500" />
|
||||||
|
<span>{sec('featured')}</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative rounded-3xl bg-slate-900/80 border border-slate-800 overflow-hidden shadow-2xl p-6 sm:p-8 grid grid-cols-1 lg:grid-cols-12 gap-8 items-center">
|
||||||
|
|
||||||
|
{/* Thumbnail preview */}
|
||||||
|
<div className="lg:col-span-7 relative aspect-video rounded-2xl overflow-hidden bg-slate-950 group">
|
||||||
|
<img
|
||||||
|
src={featuredVideo.thumbnailUrl}
|
||||||
|
alt={featuredVideo.title}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-slate-950/40 flex items-center justify-center">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/dersler/${featuredVideo.slug}`}
|
||||||
|
className="w-16 h-16 rounded-full bg-red-600 hover:bg-red-500 text-white flex items-center justify-center shadow-xl shadow-red-600/40 transition-all hover:scale-110"
|
||||||
|
>
|
||||||
|
<Youtube className="w-8 h-8 fill-white ml-0.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info & CTA */}
|
||||||
|
<div className="lg:col-span-5 space-y-4">
|
||||||
|
<div className="inline-block px-3 py-1 rounded-full bg-red-500/10 text-red-400 text-xs font-bold border border-red-500/20">
|
||||||
|
{featuredVideo.category}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-2xl font-black text-white leading-tight">
|
||||||
|
{featuredVideo.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-300 leading-relaxed">
|
||||||
|
{featuredVideo.summary}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<div className="text-xs font-semibold text-slate-400">Files Included:</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{featuredVideo.codeSnippets.map((snip: any) => (
|
||||||
|
<span key={snip.fileName} className="px-2.5 py-1 rounded-lg bg-slate-800 text-slate-300 text-[11px] font-mono border border-slate-700">
|
||||||
|
{snip.fileName}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 flex flex-wrap gap-3">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/dersler/${featuredVideo.slug}`}
|
||||||
|
className="px-5 py-3 rounded-xl bg-red-600 hover:bg-red-500 text-white text-xs font-bold flex items-center gap-2 shadow-lg shadow-red-600/30 transition-all"
|
||||||
|
>
|
||||||
|
<span>Explore Code & Notes</span>
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={featuredVideo.youtubeUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="px-4 py-3 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-bold flex items-center gap-2 transition-colors border border-slate-700"
|
||||||
|
>
|
||||||
|
<Youtube className="w-4 h-4 text-red-500" />
|
||||||
|
<span>Watch on YouTube</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ALL LESSONS GRID */}
|
||||||
|
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 w-full space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-extrabold text-white flex items-center gap-2">
|
||||||
|
<Code2 className="w-6 h-6 text-red-500" />
|
||||||
|
<span>{sec('allLessons')}</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
{lessons.length} tutorials found in database {selectedCategory && `(${selectedCategory})`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/dersler`}
|
||||||
|
className="text-xs font-bold text-red-400 hover:text-red-300 flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<span>View All Lessons</span>
|
||||||
|
<ArrowRight className="w-3.5 h-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-12 text-center text-slate-400 flex items-center justify-center gap-2">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-red-500" />
|
||||||
|
<span>Loading tutorials...</span>
|
||||||
|
</div>
|
||||||
|
) : lessons.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{lessons.map((lesson) => (
|
||||||
|
<ResourceCard key={lesson.id} lesson={lesson} locale={locale} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-12 text-center bg-slate-900/40 rounded-2xl border border-slate-800 space-y-3">
|
||||||
|
<ShieldAlert className="w-10 h-10 text-slate-500 mx-auto" />
|
||||||
|
<h3 className="text-base font-bold text-white">No Tutorials Found</h3>
|
||||||
|
<p className="text-xs text-slate-400 max-w-sm mx-auto">
|
||||||
|
Try adjusting your search terms or clearing selected filters.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => { setSearchQuery(''); setSelectedCategory(null); }}
|
||||||
|
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-xs font-bold text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Reset Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* AYRIS.TECH YOUTUBE CHANNEL FEATURES */}
|
||||||
|
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 w-full">
|
||||||
|
<div className="bg-gradient-to-br from-slate-900 via-slate-950 to-slate-900 rounded-3xl border border-slate-800 p-8 sm:p-12 space-y-8 shadow-2xl relative overflow-hidden">
|
||||||
|
|
||||||
|
<div className="absolute top-0 right-0 w-96 h-96 bg-red-600/5 blur-[120px] pointer-events-none rounded-full" />
|
||||||
|
|
||||||
|
<div className="text-center space-y-3 max-w-2xl mx-auto">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-widest text-red-400 bg-red-500/10 px-3 py-1 rounded-full border border-red-500/20">
|
||||||
|
Creator Innovation
|
||||||
|
</span>
|
||||||
|
<h2 className="text-3xl sm:text-4xl font-black text-white tracking-tight">
|
||||||
|
{sec('whyUsTitle')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs sm:text-sm text-slate-400">
|
||||||
|
{sec('whyUsSubtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 pt-4">
|
||||||
|
|
||||||
|
{/* Feature 1 */}
|
||||||
|
<div className="p-6 rounded-2xl bg-slate-900/80 border border-slate-800 space-y-3 hover:border-red-500/40 transition-colors">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-red-500/10 text-red-400 flex items-center justify-center">
|
||||||
|
<Layers className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-white text-base">{feat('codeTabsTitle')}</h3>
|
||||||
|
<p className="text-xs text-slate-400 leading-relaxed">{feat('codeTabsDesc')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feature 2 */}
|
||||||
|
<div className="p-6 rounded-2xl bg-slate-900/80 border border-slate-800 space-y-3 hover:border-red-500/40 transition-colors">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 text-emerald-400 flex items-center justify-center">
|
||||||
|
<Youtube className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-white text-base">{feat('chaptersTitle')}</h3>
|
||||||
|
<p className="text-xs text-slate-400 leading-relaxed">{feat('chaptersDesc')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feature 3 */}
|
||||||
|
<div className="p-6 rounded-2xl bg-slate-900/80 border border-slate-800 space-y-3 hover:border-red-500/40 transition-colors">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-sky-500/10 text-sky-400 flex items-center justify-center">
|
||||||
|
<Download className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-white text-base">{feat('zipDownloadTitle')}</h3>
|
||||||
|
<p className="text-xs text-slate-400 leading-relaxed">{feat('zipDownloadDesc')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feature 4 */}
|
||||||
|
<div className="p-6 rounded-2xl bg-slate-900/80 border border-slate-800 space-y-3 hover:border-red-500/40 transition-colors">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-amber-500/10 text-amber-400 flex items-center justify-center">
|
||||||
|
<FileCheck2 className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-white text-base">{feat('noDocsTitle')}</h3>
|
||||||
|
<p className="text-xs text-slate-400 leading-relaxed">{feat('noDocsDesc')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FEATURED CHEATSHEETS PREVIEW */}
|
||||||
|
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 w-full space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-extrabold text-white flex items-center gap-2">
|
||||||
|
<FileCheck2 className="w-6 h-6 text-red-500" />
|
||||||
|
<span>{sec('cheatsheetsTitle')}</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/rehberler`}
|
||||||
|
className="text-xs font-bold text-red-400 hover:text-red-300 flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<span>Explore All Cheatsheets</span>
|
||||||
|
<ArrowRight className="w-3.5 h-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{cheatsheets.map((cs) => (
|
||||||
|
<div key={cs.id} className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 space-y-4 hover:bg-slate-900 transition-colors">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-semibold text-red-400 bg-red-500/10 px-2.5 py-0.5 rounded-full border border-red-500/20">
|
||||||
|
{cs.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-bold text-white text-base">{cs.title}</h3>
|
||||||
|
<p className="text-xs text-slate-400 line-clamp-2">{cs.description}</p>
|
||||||
|
|
||||||
|
<div className="space-y-2 pt-2 border-t border-slate-800">
|
||||||
|
{cs.items.slice(0, 3).map((item: any, idx: number) => (
|
||||||
|
<div key={idx} className="bg-slate-950 p-2 rounded-lg text-xs font-mono text-slate-300 flex items-center justify-between">
|
||||||
|
<span className="text-red-300 font-bold truncate max-w-[200px]">{item.command}</span>
|
||||||
|
<span className="text-[10px] text-slate-500 truncate">{item.description}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/rehberler`}
|
||||||
|
className="w-full py-2 flex items-center justify-center gap-1.5 text-xs font-bold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<span>Open Cheatsheet</span>
|
||||||
|
<ArrowRight className="w-3.5 h-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from "@/lib/auth"
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers
|
||||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,130 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-sans);
|
||||||
|
--font-mono: var(--font-mono);
|
||||||
|
--font-heading: var(--font-sans);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "base-nova",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { CodeSnippet } from '@/lib/data';
|
||||||
|
import { Check, Copy, Download, FileCode, Terminal } from 'lucide-react';
|
||||||
|
|
||||||
|
interface CodeBlockProps {
|
||||||
|
snippets: CodeSnippet[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeBlock({ snippets }: CodeBlockProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
if (!snippets || snippets.length === 0) return null;
|
||||||
|
|
||||||
|
const currentSnippet = snippets[activeTab] || snippets[0];
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(currentSnippet.code);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Copy error:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadFile = () => {
|
||||||
|
const element = document.createElement('a');
|
||||||
|
const file = new Blob([currentSnippet.code], { type: 'text/plain;charset=utf-8' });
|
||||||
|
element.href = URL.createObjectURL(file);
|
||||||
|
element.download = currentSnippet.fileName.split('/').pop() || 'code.txt';
|
||||||
|
document.body.appendChild(element);
|
||||||
|
element.click();
|
||||||
|
document.body.removeChild(element);
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = currentSnippet.code.split('\n');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full rounded-2xl bg-slate-950 border border-slate-800 shadow-2xl overflow-hidden my-6">
|
||||||
|
|
||||||
|
{/* Tab Navigation Header */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between bg-slate-900/90 px-4 py-2.5 border-b border-slate-800 gap-3">
|
||||||
|
|
||||||
|
{/* File Tabs */}
|
||||||
|
<div className="flex items-center gap-1.5 overflow-x-auto py-1 scrollbar-none">
|
||||||
|
{snippets.map((snip, index) => (
|
||||||
|
<button
|
||||||
|
key={snip.fileName}
|
||||||
|
onClick={() => setActiveTab(index)}
|
||||||
|
className={`flex items-center gap-2 px-3.5 py-1.5 rounded-lg text-xs font-mono font-medium transition-all ${
|
||||||
|
activeTab === index
|
||||||
|
? 'bg-slate-800 text-white shadow-sm border border-slate-700/80'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FileCode className={`w-3.5 h-3.5 ${activeTab === index ? 'text-red-400' : 'text-slate-500'}`} />
|
||||||
|
<span>{snip.fileName}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleDownloadFile}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold transition-colors border border-slate-700/60"
|
||||||
|
title="Download File"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
<span className="hidden sm:inline">Download</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all border ${
|
||||||
|
copied
|
||||||
|
? 'bg-emerald-950/80 text-emerald-300 border-emerald-800/80'
|
||||||
|
: 'bg-red-600 hover:bg-red-500 text-white border-red-500 shadow-sm shadow-red-600/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
<span>Copied!</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
<span>Copy Code</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Snippet Description bar */}
|
||||||
|
{currentSnippet.description && (
|
||||||
|
<div className="px-4 py-2 bg-slate-900/50 border-b border-slate-800/60 text-xs text-slate-400 font-sans flex items-center gap-2">
|
||||||
|
<Terminal className="w-3.5 h-3.5 text-red-400 flex-shrink-0" />
|
||||||
|
<span>{currentSnippet.description}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Code Display Area with Line Numbers */}
|
||||||
|
<div className="p-4 overflow-x-auto font-mono text-xs leading-relaxed max-h-[480px] bg-slate-950">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<tbody>
|
||||||
|
{lines.map((line, idx) => (
|
||||||
|
<tr key={idx} className="hover:bg-slate-900/40 transition-colors group">
|
||||||
|
<td className="w-10 select-none text-right pr-4 text-slate-600 group-hover:text-slate-500 font-mono text-[11px]">
|
||||||
|
{idx + 1}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-pre text-slate-200 pl-2">
|
||||||
|
{line}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { MessageSquare, Heart } from 'lucide-react';
|
||||||
|
import { YoutubeIcon as Youtube } from '@/components/icons/YoutubeIcon';
|
||||||
|
import { GithubIcon as Github, TwitterIcon as Twitter } from '@/components/icons/BrandIcons';
|
||||||
|
|
||||||
|
export function Footer({ locale }: { locale: string }) {
|
||||||
|
const t = useTranslations('footer');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="w-full border-t border-slate-800 bg-slate-950 text-slate-400 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-4 gap-8 mb-12">
|
||||||
|
|
||||||
|
{/* Brand column */}
|
||||||
|
<div className="space-y-4 md:col-span-1">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-slate-900 border border-slate-800 flex items-center justify-center overflow-hidden">
|
||||||
|
<img src="/logo.jpeg" alt="ayris.tech Logo" className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-white text-lg tracking-tight">ayris.tech YouTube</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs leading-relaxed text-slate-400">
|
||||||
|
Official resource portal featuring tabbed code snippets, lesson notes, starter zip downloads, and timestamps for my YouTube tutorials.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3 text-slate-400">
|
||||||
|
<a href="https://youtube.com" target="_blank" rel="noopener noreferrer" className="hover:text-red-500 transition-colors p-2 bg-slate-900 rounded-lg border border-slate-800">
|
||||||
|
<Youtube className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/ayrisdev" target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors p-2 bg-slate-900 rounded-lg border border-slate-800">
|
||||||
|
<Github className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
<a href="https://twitter.com" target="_blank" rel="noopener noreferrer" className="hover:text-sky-400 transition-colors p-2 bg-slate-900 rounded-lg border border-slate-800">
|
||||||
|
<Twitter className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
<a href="https://discord.com" target="_blank" rel="noopener noreferrer" className="hover:text-indigo-400 transition-colors p-2 bg-slate-900 rounded-lg border border-slate-800">
|
||||||
|
<MessageSquare className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Categories */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-white text-sm mb-4">Popular Topics</h4>
|
||||||
|
<ul className="space-y-2 text-xs">
|
||||||
|
<li><Link href={`/${locale}/lessons?category=Next.js`} className="hover:text-red-400 transition-colors">Next.js 16 App Router</Link></li>
|
||||||
|
<li><Link href={`/${locale}/lessons?category=Python%20%26%20AI`} className="hover:text-red-400 transition-colors">Python AI Agents</Link></li>
|
||||||
|
<li><Link href={`/${locale}/lessons?category=Tailwind%20CSS`} className="hover:text-red-400 transition-colors">Tailwind CSS v4 Tips</Link></li>
|
||||||
|
<li><Link href={`/${locale}/lessons?category=Docker%20%26%20DevOps`} className="hover:text-red-400 transition-colors">Docker & Coolify Deploy</Link></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Links */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-white text-sm mb-4">Quick Links</h4>
|
||||||
|
<ul className="space-y-2 text-xs">
|
||||||
|
<li><Link href={`/${locale}/lessons`} className="hover:text-red-400 transition-colors">All YouTube Lessons</Link></li>
|
||||||
|
<li><Link href={`/${locale}/cheatsheets`} className="hover:text-red-400 transition-colors">Quick Cheatsheets</Link></li>
|
||||||
|
<li><Link href={`/${locale}/contact`} className="hover:text-red-400 transition-colors">Contact & Suggestions</Link></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info & Banner */}
|
||||||
|
<div className="bg-slate-900/60 p-5 rounded-2xl border border-slate-800 flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-bold text-red-400 block mb-1">ayris.tech YouTube Channel</span>
|
||||||
|
<p className="text-[11px] text-slate-300 leading-normal">
|
||||||
|
Official resource portal for all video code snippets, starter packages, and lesson notes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 pt-3 border-t border-slate-800 flex items-center justify-between text-[11px]">
|
||||||
|
<span className="text-slate-400">Educational Portal</span>
|
||||||
|
<span className="text-emerald-400 font-semibold flex items-center gap-1">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span> Live
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Bar with mandatory link */}
|
||||||
|
<div className="max-w-7xl mx-auto pt-8 border-t border-slate-900 flex flex-col sm:flex-row items-center justify-between gap-4 text-xs">
|
||||||
|
<p className="text-slate-400">
|
||||||
|
© {new Date().getFullYear()} ayris.tech YouTube Channel. {t('rights')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* MANDATORY Created by ayris.tech LINK */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-slate-400 flex items-center gap-1">
|
||||||
|
Built with <Heart className="w-3.5 h-3.5 text-red-500 fill-red-500" />
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href="https://ayris.tech"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="px-3 py-1 bg-red-950/60 hover:bg-red-900/80 text-red-400 hover:text-red-300 border border-red-800/40 rounded-full font-bold transition-all shadow-sm shadow-red-900/30"
|
||||||
|
>
|
||||||
|
Created by ayris.tech
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Code2, BookOpen, Mail, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
export function Navbar({ locale }: { locale: string }) {
|
||||||
|
const t = useTranslations('nav');
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: `/${locale}`, label: t('home'), icon: Sparkles },
|
||||||
|
{ href: `/${locale}/lessons`, label: t('lessons'), icon: Code2 },
|
||||||
|
{ href: `/${locale}/cheatsheets`, label: t('cheatsheets'), icon: BookOpen },
|
||||||
|
{ href: `/${locale}/contact`, label: t('contact'), icon: Mail },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 w-full border-b border-slate-800 bg-slate-950/80 backdrop-blur-xl transition-all">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||||
|
|
||||||
|
{/* Logo & Brand */}
|
||||||
|
<Link href={`/${locale}`} className="flex items-center gap-3 group">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-slate-900 border border-slate-800 flex items-center justify-center shadow-lg shadow-red-500/20 group-hover:scale-105 transition-transform overflow-hidden">
|
||||||
|
<img src="/logo.jpeg" alt="ayris.tech Logo" className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-extrabold text-lg text-white tracking-tight flex items-center gap-1.5">
|
||||||
|
ayris.tech <span className="text-xs px-2 py-0.5 rounded-full bg-red-500/10 text-red-400 border border-red-500/20">YouTube</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-slate-400 font-medium">Educational Code & Resource Hub</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Center Nav Links */}
|
||||||
|
<nav className="hidden md:flex items-center gap-1 bg-slate-900/60 p-1.5 rounded-full border border-slate-800">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = pathname === item.href || (item.href !== `/${locale}` && pathname.startsWith(item.href));
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-full text-xs font-semibold transition-all ${
|
||||||
|
isActive
|
||||||
|
? 'bg-red-600 text-white shadow-md shadow-red-600/20'
|
||||||
|
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Right Action: YouTube Subscribe */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<a
|
||||||
|
href="https://youtube.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-red-600 to-red-700 hover:from-red-500 hover:to-red-600 text-white text-xs font-bold rounded-full shadow-lg shadow-red-600/20 hover:shadow-red-600/30 transition-all hover:scale-105"
|
||||||
|
>
|
||||||
|
<img src="/logo.jpeg" alt="Logo" className="w-4 h-4 rounded-full object-cover" />
|
||||||
|
<span>{t('subscribe')}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { VideoLesson } from '@/lib/data';
|
||||||
|
import { Download, Eye, FileCode2, Clock, ArrowRight, Sparkles } from 'lucide-react';
|
||||||
|
import { YoutubeIcon as Youtube } from '@/components/icons/YoutubeIcon';
|
||||||
|
|
||||||
|
interface ResourceCardProps {
|
||||||
|
lesson: VideoLesson;
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourceCard({ lesson, locale }: ResourceCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="group relative bg-slate-900/60 hover:bg-slate-900 border border-slate-800 hover:border-slate-700/80 rounded-2xl overflow-hidden transition-all duration-300 flex flex-col justify-between shadow-xl hover:shadow-2xl hover:shadow-red-500/5 hover:-translate-y-1">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{/* Thumbnail Container */}
|
||||||
|
<div className="relative aspect-video w-full overflow-hidden bg-slate-950">
|
||||||
|
<img
|
||||||
|
src={lesson.thumbnailUrl}
|
||||||
|
alt={lesson.title}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/20 to-transparent opacity-80" />
|
||||||
|
|
||||||
|
{/* Featured Badge */}
|
||||||
|
{lesson.isFeatured && (
|
||||||
|
<div className="absolute top-3 left-3 flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-red-600/90 text-white text-[11px] font-bold shadow-lg backdrop-blur-md">
|
||||||
|
<Sparkles className="w-3 h-3" />
|
||||||
|
<span>FEATURED TUTORIAL</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Category Tag */}
|
||||||
|
<div className="absolute top-3 right-3 px-2.5 py-1 rounded-full bg-slate-900/90 border border-slate-700/80 text-slate-200 text-[11px] font-semibold backdrop-blur-md">
|
||||||
|
{lesson.category}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Duration Badge */}
|
||||||
|
<div className="absolute bottom-3 right-3 flex items-center gap-1 px-2 py-0.5 rounded bg-slate-950/90 text-slate-300 text-[11px] font-mono border border-slate-800">
|
||||||
|
<Clock className="w-3 h-3 text-red-400" />
|
||||||
|
<span>{lesson.duration}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Body */}
|
||||||
|
<div className="p-5 space-y-3">
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{lesson.tags.slice(0, 3).map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="text-[10px] font-medium px-2 py-0.5 rounded bg-slate-800/60 text-slate-400 border border-slate-800"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h3 className="font-bold text-base text-white group-hover:text-red-400 transition-colors line-clamp-2 leading-snug">
|
||||||
|
<Link href={`/${locale}/lessons/${lesson.slug}`}>
|
||||||
|
{lesson.title}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<p className="text-xs text-slate-400 line-clamp-2 leading-relaxed">
|
||||||
|
{lesson.summary}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Stats Bar */}
|
||||||
|
<div className="pt-2 flex items-center justify-between text-[11px] text-slate-400 border-t border-slate-800/60">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<FileCode2 className="w-3.5 h-3.5 text-red-400" />
|
||||||
|
<span>{lesson.codeSnippets.length} Code Files</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Download className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
<span>{lesson.downloadCount.toLocaleString()} Downloads</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Eye className="w-3.5 h-3.5 text-sky-400" />
|
||||||
|
<span>{lesson.viewsCount.toLocaleString()} Views</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Action Footer */}
|
||||||
|
<div className="px-5 pb-5 pt-1">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/lessons/${lesson.slug}`}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl bg-slate-800 hover:bg-red-600 text-slate-200 hover:text-white text-xs font-bold transition-all group-hover:shadow-lg group-hover:shadow-red-600/20"
|
||||||
|
>
|
||||||
|
<span>View Code & Notes</span>
|
||||||
|
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Play, Clock, ExternalLink } from 'lucide-react';
|
||||||
|
import { YoutubeIcon as Youtube } from '@/components/icons/YoutubeIcon';
|
||||||
|
import { VideoChapter } from '@/lib/data';
|
||||||
|
|
||||||
|
interface VideoPlayerProps {
|
||||||
|
youtubeId: string;
|
||||||
|
youtubeUrl: string;
|
||||||
|
title: string;
|
||||||
|
chapters?: VideoChapter[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoPlayer({ youtubeId, youtubeUrl, title, chapters }: VideoPlayerProps) {
|
||||||
|
const [activeSeconds, setActiveSeconds] = useState(0);
|
||||||
|
|
||||||
|
const embedUrl = `https://www.youtube.com/embed/${youtubeId}?autoplay=0&start=${activeSeconds}`;
|
||||||
|
|
||||||
|
const handleChapterClick = (seconds: number) => {
|
||||||
|
setActiveSeconds(seconds);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-4">
|
||||||
|
{/* Video Container */}
|
||||||
|
<div className="relative w-full aspect-video rounded-2xl overflow-hidden bg-slate-900 border border-slate-800 shadow-2xl group">
|
||||||
|
<iframe
|
||||||
|
src={embedUrl}
|
||||||
|
title={title}
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
className="w-full h-full border-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chapters / Timestamps Quick Jumps */}
|
||||||
|
{chapters && chapters.length > 0 && (
|
||||||
|
<div className="bg-slate-900/80 p-4 rounded-xl border border-slate-800 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center gap-1.5">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-red-400" />
|
||||||
|
Timestamped Chapters
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={youtubeUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-red-400 hover:text-red-300 flex items-center gap-1 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<span>Watch on YouTube</span>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{chapters.map((chap) => (
|
||||||
|
<button
|
||||||
|
key={chap.time}
|
||||||
|
onClick={() => handleChapterClick(chap.seconds)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
activeSeconds === chap.seconds
|
||||||
|
? 'bg-red-600 text-white shadow-md shadow-red-600/30 ring-1 ring-red-400'
|
||||||
|
: 'bg-slate-800/80 text-slate-300 hover:bg-slate-800 hover:text-white border border-slate-700/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[11px] text-red-400 font-bold bg-slate-950/60 px-1.5 py-0.5 rounded">
|
||||||
|
{chap.time}
|
||||||
|
</span>
|
||||||
|
<span>{chap.title}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export function GithubIcon({ className = "w-5 h-5", ...props }: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" className={className} {...props}>
|
||||||
|
<path fillRule="evenodd" clipRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.53 1.032 1.53 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TwitterIcon({ className = "w-5 h-5", ...props }: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" className={className} {...props}>
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export function YoutubeIcon({ className = "w-5 h-5", ...props }: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className={className}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-(--card-spacing)", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<InputPrimitive
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import {getRequestConfig} from 'next-intl/server';
|
||||||
|
import {routing} from './routing';
|
||||||
|
|
||||||
|
export default getRequestConfig(async ({requestLocale}) => {
|
||||||
|
let locale = await requestLocale;
|
||||||
|
|
||||||
|
if (!locale || !routing.locales.includes(locale as any)) {
|
||||||
|
locale = routing.defaultLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
messages: (await import(`../messages/${locale}.json`)).default
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import {defineRouting} from 'next-intl/routing';
|
||||||
|
import {createNavigation} from 'next-intl/navigation';
|
||||||
|
|
||||||
|
export const routing = defineRouting({
|
||||||
|
locales: ['en'],
|
||||||
|
defaultLocale: 'en'
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {Link, redirect, usePathname, useRouter, getPathname} =
|
||||||
|
createNavigation(routing);
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
const INITIAL_CATEGORIES = [
|
||||||
|
'Next.js',
|
||||||
|
'React',
|
||||||
|
'Python & AI',
|
||||||
|
'Tailwind CSS',
|
||||||
|
'Docker & DevOps',
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function getCategories() {
|
||||||
|
try {
|
||||||
|
if (!db || !(db as any).category) {
|
||||||
|
console.error('db.category is not initialized yet');
|
||||||
|
return INITIAL_CATEGORIES.map((name) => ({ id: name, name, slug: name.toLowerCase() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
let categories = await db.category.findMany({
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (categories.length === 0) {
|
||||||
|
// Seed default categories if database has none
|
||||||
|
for (const name of INITIAL_CATEGORIES) {
|
||||||
|
const slug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\s-]/g, '')
|
||||||
|
.replace(/[\s_-]+/g, '-');
|
||||||
|
await db.category.upsert({
|
||||||
|
where: { slug },
|
||||||
|
update: { name },
|
||||||
|
create: { name, slug },
|
||||||
|
}).catch(err => console.error('Failed seeding category:', name, err));
|
||||||
|
}
|
||||||
|
|
||||||
|
categories = await db.category.findMany({
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return categories;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching categories:', error);
|
||||||
|
return INITIAL_CATEGORIES.map((name) => ({ id: name, name, slug: name.toLowerCase() }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCategory(name: string) {
|
||||||
|
try {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return { success: false, error: 'Category name cannot be empty' };
|
||||||
|
|
||||||
|
const slug = trimmed
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\s-]/g, '')
|
||||||
|
.replace(/[\s_-]+/g, '-') + '-' + Date.now().toString().slice(-4);
|
||||||
|
|
||||||
|
const newCategory = await db.category.create({
|
||||||
|
data: {
|
||||||
|
name: trimmed,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath('/[locale]');
|
||||||
|
revalidatePath('/[locale]/lessons');
|
||||||
|
revalidatePath('/[locale]/admin');
|
||||||
|
return { success: true, category: newCategory };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error creating category:', error);
|
||||||
|
return { success: false, error: error.message || 'Failed to create category' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCategory(id: string, name: string) {
|
||||||
|
try {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return { success: false, error: 'Category name cannot be empty' };
|
||||||
|
|
||||||
|
const slug = trimmed
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\s-]/g, '')
|
||||||
|
.replace(/[\s_-]+/g, '-') + '-' + Date.now().toString().slice(-4);
|
||||||
|
|
||||||
|
const updatedCategory = await db.category.updateMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ id }, { name: id }, { slug: id }],
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
name: trimmed,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath('/[locale]');
|
||||||
|
revalidatePath('/[locale]/lessons');
|
||||||
|
revalidatePath('/[locale]/admin');
|
||||||
|
return { success: true, count: updatedCategory.count };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error updating category:', error);
|
||||||
|
return { success: false, error: error.message || 'Failed to update category' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCategory(id: string) {
|
||||||
|
try {
|
||||||
|
if (!db || !(db as any).category) {
|
||||||
|
return { success: false, error: 'Category database table is not ready' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.category.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ id }, { name: id }, { slug: id }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath('/[locale]');
|
||||||
|
revalidatePath('/[locale]/dersler');
|
||||||
|
revalidatePath('/[locale]/admin');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error deleting category:', error);
|
||||||
|
return { success: false, error: error.message || 'Failed to delete category' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export async function getCheatsheets() {
|
||||||
|
try {
|
||||||
|
const cheatsheets = await db.cheatsheet.findMany({
|
||||||
|
include: {
|
||||||
|
items: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return cheatsheets;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching cheatsheets:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCheatsheet(data: {
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
items: { command: string; description: string }[];
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const slug = data.title
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^\w\s-]/g, '')
|
||||||
|
.replace(/[\s_-]+/g, '-') + '-' + Date.now().toString().slice(-4);
|
||||||
|
|
||||||
|
const newCheatsheet = await db.cheatsheet.create({
|
||||||
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
slug,
|
||||||
|
category: data.category,
|
||||||
|
description: data.description,
|
||||||
|
tags: [data.category, 'Cheatsheet', 'Commands'],
|
||||||
|
items: {
|
||||||
|
create: data.items.map((item) => ({
|
||||||
|
command: item.command,
|
||||||
|
description: item.description,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath('/[locale]');
|
||||||
|
revalidatePath('/[locale]/cheatsheets');
|
||||||
|
revalidatePath('/[locale]/admin');
|
||||||
|
return { success: true, cheatsheet: newCheatsheet };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error creating cheatsheet:', error);
|
||||||
|
return { success: false, error: error.message || 'Failed to create cheatsheet' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCheatsheet(id: string, data: {
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
items: { command: string; description: string }[];
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
// Delete existing items and recreate
|
||||||
|
await db.cheatsheetItem.deleteMany({ where: { cheatsheetId: id } });
|
||||||
|
|
||||||
|
const updated = await db.cheatsheet.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
category: data.category,
|
||||||
|
description: data.description,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
items: {
|
||||||
|
create: data.items.map((item) => ({
|
||||||
|
command: item.command,
|
||||||
|
description: item.description,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath('/[locale]');
|
||||||
|
revalidatePath('/[locale]/cheatsheets');
|
||||||
|
revalidatePath('/[locale]/admin');
|
||||||
|
return { success: true, cheatsheet: updated };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error updating cheatsheet:', error);
|
||||||
|
return { success: false, error: error.message || 'Failed to update cheatsheet' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCheatsheet(id: string) {
|
||||||
|
try {
|
||||||
|
await db.cheatsheet.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath('/[locale]');
|
||||||
|
revalidatePath('/[locale]/rehberler');
|
||||||
|
revalidatePath('/[locale]/admin');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error deleting cheatsheet:', error);
|
||||||
|
return { success: false, error: error.message || 'Failed to delete cheatsheet' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export async function submitContactMessage(data: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
subject: string;
|
||||||
|
message: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const newMessage = await db.contactMessage.create({
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
email: data.email,
|
||||||
|
subject: data.subject,
|
||||||
|
message: data.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath('/[locale]/admin');
|
||||||
|
return { success: true, message: newMessage };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error saving contact message:', error);
|
||||||
|
return { success: false, error: error.message || 'Failed to submit contact message' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContactMessages() {
|
||||||
|
try {
|
||||||
|
const messages = await db.contactMessage.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return messages;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching contact messages:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
function extractYoutubeId(url: string): string {
|
||||||
|
if (!url) return 'dQw4w9WgXcQ';
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (trimmed.includes('v=')) {
|
||||||
|
return trimmed.split('v=')[1].split('&')[0];
|
||||||
|
}
|
||||||
|
if (trimmed.includes('youtu.be/')) {
|
||||||
|
return trimmed.split('youtu.be/')[1].split('?')[0];
|
||||||
|
}
|
||||||
|
if (trimmed.includes('embed/')) {
|
||||||
|
return trimmed.split('embed/')[1].split('?')[0];
|
||||||
|
}
|
||||||
|
if (trimmed.length === 11) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return 'dQw4w9WgXcQ';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLessons(options?: { query?: string; category?: string }) {
|
||||||
|
try {
|
||||||
|
const { query, category } = options || {};
|
||||||
|
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
where.category = category;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query && query.trim() !== '') {
|
||||||
|
const q = query.trim();
|
||||||
|
where.OR = [
|
||||||
|
{ title: { contains: q, mode: 'insensitive' } },
|
||||||
|
{ summary: { contains: q, mode: 'insensitive' } },
|
||||||
|
{ tags: { hasSome: [q] } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lessons = await db.lesson.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
codeSnippets: true,
|
||||||
|
downloads: true,
|
||||||
|
chapters: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return lessons;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching lessons:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLessonBySlug(slug: string) {
|
||||||
|
try {
|
||||||
|
const lesson = await db.lesson.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
include: {
|
||||||
|
codeSnippets: true,
|
||||||
|
downloads: true,
|
||||||
|
chapters: {
|
||||||
|
orderBy: { seconds: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (lesson) {
|
||||||
|
await db.lesson.update({
|
||||||
|
where: { id: lesson.id },
|
||||||
|
data: { viewsCount: { increment: 1 } },
|
||||||
|
}).catch(err => console.error('Failed to increment view count:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
return lesson;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching lesson by slug:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardStats() {
|
||||||
|
try {
|
||||||
|
const [totalLessons, aggregate, totalMessages] = await Promise.all([
|
||||||
|
db.lesson.count(),
|
||||||
|
db.lesson.aggregate({
|
||||||
|
_sum: {
|
||||||
|
viewsCount: true,
|
||||||
|
downloadCount: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.contactMessage.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalLessons,
|
||||||
|
totalViews: aggregate._sum.viewsCount || 0,
|
||||||
|
totalDownloads: aggregate._sum.downloadCount || 0,
|
||||||
|
totalMessages,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching dashboard stats:', error);
|
||||||
|
return {
|
||||||
|
totalLessons: 0,
|
||||||
|
totalViews: 0,
|
||||||
|
totalDownloads: 0,
|
||||||
|
totalMessages: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLesson(data: {
|
||||||
|
title: string;
|
||||||
|
youtubeUrl: string;
|
||||||
|
category: string;
|
||||||
|
summary: string;
|
||||||
|
duration?: string;
|
||||||
|
notesMarkdown?: string[];
|
||||||
|
codeSnippets?: { fileName: string; language: string; code: string }[];
|
||||||
|
downloads?: { title: string; type: string; url: string; size?: string }[];
|
||||||
|
chapters?: { time: string; seconds: number; title: string }[];
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const slug = data.title
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^\w\s-]/g, '')
|
||||||
|
.replace(/[\s_-]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '') + '-' + Date.now().toString().slice(-4);
|
||||||
|
|
||||||
|
const youtubeId = extractYoutubeId(data.youtubeUrl);
|
||||||
|
const thumbnailUrl = `https://img.youtube.com/vi/${youtubeId}/hqdefault.jpg`;
|
||||||
|
|
||||||
|
const codeSnippets = data.codeSnippets || [];
|
||||||
|
const downloads = data.downloads || [];
|
||||||
|
const chapters = data.chapters || [];
|
||||||
|
const notesMarkdown = data.notesMarkdown || [];
|
||||||
|
|
||||||
|
const newLesson = await db.lesson.create({
|
||||||
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
slug,
|
||||||
|
youtubeId,
|
||||||
|
youtubeUrl: data.youtubeUrl || `https://www.youtube.com/watch?v=${youtubeId}`,
|
||||||
|
thumbnailUrl,
|
||||||
|
duration: data.duration || '15:00',
|
||||||
|
category: data.category,
|
||||||
|
tags: [data.category, 'Tutorial', 'Source Code'],
|
||||||
|
summary: data.summary || data.title,
|
||||||
|
notesMarkdown,
|
||||||
|
codeSnippets: {
|
||||||
|
create: codeSnippets.map((snip) => ({
|
||||||
|
fileName: snip.fileName || 'app/page.tsx',
|
||||||
|
language: snip.language || 'typescript',
|
||||||
|
code: snip.code,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
downloads: {
|
||||||
|
create: downloads.map((dl) => ({
|
||||||
|
title: dl.title,
|
||||||
|
type: dl.type || 'zip',
|
||||||
|
url: dl.url || '#',
|
||||||
|
size: dl.size || '1.0 MB',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
chapters: {
|
||||||
|
create: chapters.map((chap) => ({
|
||||||
|
time: chap.time,
|
||||||
|
seconds: chap.seconds || 0,
|
||||||
|
title: chap.title,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath('/[locale]');
|
||||||
|
revalidatePath('/[locale]/lessons');
|
||||||
|
return { success: true, lesson: newLesson };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error creating lesson:', error);
|
||||||
|
return { success: false, error: error.message || 'Failed to create lesson in database' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLesson(id: string, data: {
|
||||||
|
title: string;
|
||||||
|
youtubeUrl: string;
|
||||||
|
category: string;
|
||||||
|
summary: string;
|
||||||
|
duration?: string;
|
||||||
|
notesMarkdown?: string[];
|
||||||
|
codeSnippets?: { fileName: string; language: string; code: string }[];
|
||||||
|
downloads?: { title: string; type: string; url: string; size?: string }[];
|
||||||
|
chapters?: { time: string; seconds: number; title: string }[];
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const youtubeId = extractYoutubeId(data.youtubeUrl);
|
||||||
|
const thumbnailUrl = `https://img.youtube.com/vi/${youtubeId}/hqdefault.jpg`;
|
||||||
|
|
||||||
|
const codeSnippets = data.codeSnippets || [];
|
||||||
|
const downloads = data.downloads || [];
|
||||||
|
const chapters = data.chapters || [];
|
||||||
|
const notesMarkdown = data.notesMarkdown || [];
|
||||||
|
|
||||||
|
// Delete existing sub-models and recreate
|
||||||
|
await Promise.all([
|
||||||
|
db.codeSnippet.deleteMany({ where: { lessonId: id } }),
|
||||||
|
db.resourceDownload.deleteMany({ where: { lessonId: id } }),
|
||||||
|
db.videoChapter.deleteMany({ where: { lessonId: id } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const updatedLesson = await db.lesson.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
youtubeId,
|
||||||
|
youtubeUrl: data.youtubeUrl,
|
||||||
|
thumbnailUrl,
|
||||||
|
duration: data.duration || '15:00',
|
||||||
|
category: data.category,
|
||||||
|
summary: data.summary,
|
||||||
|
notesMarkdown,
|
||||||
|
codeSnippets: {
|
||||||
|
create: codeSnippets.map((snip) => ({
|
||||||
|
fileName: snip.fileName || 'app/page.tsx',
|
||||||
|
language: snip.language || 'typescript',
|
||||||
|
code: snip.code,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
downloads: {
|
||||||
|
create: downloads.map((dl) => ({
|
||||||
|
title: dl.title,
|
||||||
|
type: dl.type || 'zip',
|
||||||
|
url: dl.url || '#',
|
||||||
|
size: dl.size || '1.0 MB',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
chapters: {
|
||||||
|
create: chapters.map((chap) => ({
|
||||||
|
time: chap.time,
|
||||||
|
seconds: chap.seconds || 0,
|
||||||
|
title: chap.title,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath('/[locale]');
|
||||||
|
revalidatePath('/[locale]/lessons');
|
||||||
|
return { success: true, lesson: updatedLesson };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error updating lesson:', error);
|
||||||
|
return { success: false, error: error.message || 'Failed to update lesson' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLesson(id: string) {
|
||||||
|
try {
|
||||||
|
await db.lesson.delete({ where: { id } });
|
||||||
|
revalidatePath('/[locale]');
|
||||||
|
revalidatePath('/[locale]/lessons');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting lesson:', error);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function incrementDownloadCount(lessonId: string) {
|
||||||
|
try {
|
||||||
|
await db.lesson.update({
|
||||||
|
where: { id: lessonId },
|
||||||
|
data: { downloadCount: { increment: 1 } },
|
||||||
|
});
|
||||||
|
revalidatePath('/[locale]/dersler');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error incrementing download count:', error);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: Record<string, string> = {
|
||||||
|
channelName: 'DevHub YouTube',
|
||||||
|
youtubeUrl: 'https://youtube.com/@DevHubChannel',
|
||||||
|
githubUrl: 'https://github.com/ayrisdev',
|
||||||
|
contactEmail: 'contact@youtube-devhub.com',
|
||||||
|
defaultCategory: 'Next.js',
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getSettings() {
|
||||||
|
try {
|
||||||
|
const dbSettings = await db.setting.findMany();
|
||||||
|
const result = { ...DEFAULT_SETTINGS };
|
||||||
|
|
||||||
|
dbSettings.forEach((item) => {
|
||||||
|
result[item.key] = item.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching settings:', error);
|
||||||
|
return DEFAULT_SETTINGS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSettings(data: Record<string, string>) {
|
||||||
|
try {
|
||||||
|
const promises = Object.entries(data).map(([key, value]) =>
|
||||||
|
db.setting.upsert({
|
||||||
|
where: { key },
|
||||||
|
update: { value },
|
||||||
|
create: { key, value },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
revalidatePath('/[locale]/admin');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error updating settings:', error);
|
||||||
|
return { success: false, error: error.message || 'Failed to update settings' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export async function getUsers() {
|
||||||
|
try {
|
||||||
|
const users = await db.user.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// If database has no users yet, return default admin
|
||||||
|
if (users.length === 0) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Admin User',
|
||||||
|
email: 'admin@ayris.tech',
|
||||||
|
role: 'ADMIN',
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return users;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching users:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAdminUser(data: { name: string; email: string; password?: string }) {
|
||||||
|
try {
|
||||||
|
const newUser = await db.user.create({
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
email: data.email,
|
||||||
|
password: data.password || 'admin123',
|
||||||
|
role: 'ADMIN',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath('/[locale]/admin');
|
||||||
|
return { success: true, user: newUser };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error creating admin user:', error);
|
||||||
|
return { success: false, error: error.message || 'Failed to create admin user' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import NextAuth from "next-auth"
|
||||||
|
import CredentialsProvider from "next-auth/providers/credentials"
|
||||||
|
|
||||||
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
|
providers: [
|
||||||
|
CredentialsProvider({
|
||||||
|
name: "Credentials",
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "email" },
|
||||||
|
password: { label: "Password", type: "password" }
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
// Boilerplate mock logic
|
||||||
|
// TODO: In production, lookup user in Prisma and verify password using bcrypt
|
||||||
|
// const user = await db.user.findUnique({ where: { email: credentials.email } })
|
||||||
|
|
||||||
|
if (credentials?.email === "admin@ayris.tech" && credentials?.password === "admin") {
|
||||||
|
return {
|
||||||
|
id: "1",
|
||||||
|
name: "Admin User",
|
||||||
|
email: "admin@ayris.tech",
|
||||||
|
role: "ADMIN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.role = (user as any).role
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (session.user && token.role) {
|
||||||
|
(session.user as any).role = token.role
|
||||||
|
}
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
signIn: '/login'
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { v2 as cloudinary } from 'cloudinary'
|
||||||
|
|
||||||
|
cloudinary.config({
|
||||||
|
cloud_name: process.env.CLOUDINARY_CLOUD_NAME!,
|
||||||
|
api_key: process.env.CLOUDINARY_API_KEY!,
|
||||||
|
api_secret: process.env.CLOUDINARY_API_SECRET!,
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function uploadImage(file: string, folder: string) {
|
||||||
|
const result = await cloudinary.uploader.upload(file, {
|
||||||
|
folder, transformation: [{ quality: 'auto', fetch_format: 'auto' }],
|
||||||
|
})
|
||||||
|
return { url: result.secure_url, publicId: result.public_id }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteImage(publicId: string) {
|
||||||
|
await cloudinary.uploader.destroy(publicId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { cloudinary }
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
export interface CodeSnippet {
|
||||||
|
fileName: string;
|
||||||
|
language: string;
|
||||||
|
code: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceDownload {
|
||||||
|
title: string;
|
||||||
|
type: 'zip' | 'github' | 'pdf' | 'link';
|
||||||
|
url: string;
|
||||||
|
size?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoChapter {
|
||||||
|
time: string;
|
||||||
|
seconds: number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoLesson {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
youtubeId: string;
|
||||||
|
youtubeUrl: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
duration: string;
|
||||||
|
publishDate: string;
|
||||||
|
category: string;
|
||||||
|
tags: string[];
|
||||||
|
viewsCount: number;
|
||||||
|
downloadCount: number;
|
||||||
|
likesCount: number;
|
||||||
|
summary: string;
|
||||||
|
notesMarkdown: string[];
|
||||||
|
codeSnippets: CodeSnippet[];
|
||||||
|
downloads: ResourceDownload[];
|
||||||
|
chapters: VideoChapter[];
|
||||||
|
isFeatured?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Cheatsheet {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
lastUpdated: string;
|
||||||
|
items: {
|
||||||
|
command: string;
|
||||||
|
description: string;
|
||||||
|
example?: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CATEGORIES = [
|
||||||
|
"Next.js",
|
||||||
|
"React",
|
||||||
|
"Python & AI",
|
||||||
|
"Tailwind CSS",
|
||||||
|
"Docker & DevOps",
|
||||||
|
"JavaScript"
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MOCK_VIDEOS: VideoLesson[] = [
|
||||||
|
{
|
||||||
|
id: "vid-1",
|
||||||
|
slug: "nextjs-16-app-router-full-course",
|
||||||
|
title: "Next.js 16 App Router & Server Actions Complete Masterclass (Code & Project)",
|
||||||
|
youtubeId: "dQw4w9WgXcQ",
|
||||||
|
youtubeUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||||
|
thumbnailUrl: "https://images.unsplash.com/photo-1618401471353-b98afee0b2eb?w=800&auto=format&fit=crop&q=80",
|
||||||
|
duration: "42:15",
|
||||||
|
publishDate: "2026-07-15",
|
||||||
|
category: "Next.js",
|
||||||
|
tags: ["Next.js 16", "App Router", "Server Actions", "TypeScript", "Tailwind CSS v4"],
|
||||||
|
viewsCount: 24500,
|
||||||
|
downloadCount: 4120,
|
||||||
|
likesCount: 1890,
|
||||||
|
isFeatured: true,
|
||||||
|
summary: "In this tutorial, we build a full-stack Next.js 16 application from scratch covering App Router architecture, proxy.ts middleware, Server Actions, and PostgreSQL integration.",
|
||||||
|
notesMarkdown: [
|
||||||
|
"📌 In Next.js 16, using `proxy.ts` is recommended over deprecated `middleware.ts`.",
|
||||||
|
"📌 Server Actions functions are declared with `'use server'` directive and can be invoked directly from client components.",
|
||||||
|
"📌 Data fetching revalidation uses `revalidatePath` or `revalidateTag` helpers.",
|
||||||
|
"📌 Form handling UX is significantly improved using `useActionState` and `useFormStatus` hooks."
|
||||||
|
],
|
||||||
|
codeSnippets: [
|
||||||
|
{
|
||||||
|
fileName: "app/actions.ts",
|
||||||
|
language: "typescript",
|
||||||
|
description: "Server Action function handling form submission",
|
||||||
|
code: `'use server'
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export async function submitProjectIdea(formData: FormData) {
|
||||||
|
const title = formData.get('title') as string;
|
||||||
|
const description = formData.get('description') as string;
|
||||||
|
|
||||||
|
if (!title || title.length < 3) {
|
||||||
|
return { success: false, error: 'Title must be at least 3 characters long.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database insertion simulation
|
||||||
|
console.log('Saved Idea:', { title, description });
|
||||||
|
|
||||||
|
revalidatePath('/ideas');
|
||||||
|
return { success: true, message: 'Your idea was submitted successfully!' };
|
||||||
|
}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileName: "proxy.ts",
|
||||||
|
language: "typescript",
|
||||||
|
description: "Next.js 16 proxy configuration (middleware alternative)",
|
||||||
|
code: `import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import createMiddleware from 'next-intl/middleware';
|
||||||
|
import { routing } from '@/i18n/routing';
|
||||||
|
|
||||||
|
const intlMiddleware = createMiddleware(routing);
|
||||||
|
|
||||||
|
export async function proxy(request: NextRequest) {
|
||||||
|
// Admin route protection example
|
||||||
|
if (request.nextUrl.pathname.includes('/admin')) {
|
||||||
|
// Auth check logic
|
||||||
|
}
|
||||||
|
return intlMiddleware(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!api|_next|_vercel|.*\\\\..*).*)']
|
||||||
|
};`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileName: "components/IdeaForm.tsx",
|
||||||
|
language: "tsx",
|
||||||
|
description: "Client Form component using useActionState",
|
||||||
|
code: `'use client';
|
||||||
|
|
||||||
|
import { useActionState } from 'react';
|
||||||
|
import { submitProjectIdea } from '@/app/actions';
|
||||||
|
|
||||||
|
export function IdeaForm() {
|
||||||
|
const [state, formAction, isPending] = useActionState(submitProjectIdea, null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-4 max-w-md p-6 bg-slate-900 rounded-xl border border-slate-800">
|
||||||
|
<h3 className="text-xl font-bold text-white">Add New Idea</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-slate-400 mb-1">Idea Title</label>
|
||||||
|
<input name="title" required className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button disabled={isPending} className="w-full py-2.5 bg-red-600 hover:bg-red-500 text-white rounded-lg font-medium">
|
||||||
|
{isPending ? 'Submitting...' : 'Share Idea'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{state?.error && <p className="text-red-400 text-sm">{state.error}</p>}
|
||||||
|
{state?.success && <p className="text-emerald-400 text-sm">{state.message}</p>}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
downloads: [
|
||||||
|
{
|
||||||
|
title: "Full Project Source Code (Starter Pack)",
|
||||||
|
type: "zip",
|
||||||
|
url: "https://github.com/ayrisdev/nextjs-16-starter/archive/refs/heads/main.zip",
|
||||||
|
size: "4.2 MB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "GitHub Repository",
|
||||||
|
type: "github",
|
||||||
|
url: "https://github.com/ayrisdev/nextjs-16-starter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Lesson Notes & Architecture (PDF Document)",
|
||||||
|
type: "pdf",
|
||||||
|
url: "#",
|
||||||
|
size: "1.8 MB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
chapters: [
|
||||||
|
{ time: "00:00", seconds: 0, title: "Introduction & Demo" },
|
||||||
|
{ time: "04:15", seconds: 255, title: "Next.js 16 Project Setup & Structure" },
|
||||||
|
{ time: "11:30", seconds: 690, title: "Routing & Middleware with proxy.ts" },
|
||||||
|
{ time: "21:00", seconds: 1260, title: "Form Handling via Server Actions" },
|
||||||
|
{ time: "32:45", seconds: 1965, title: "PostgreSQL & Prisma Integration" },
|
||||||
|
{ time: "40:00", seconds: 2400, title: "Deployment & Summary" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "vid-2",
|
||||||
|
slug: "python-ai-agent-automation",
|
||||||
|
title: "Building Autonomous AI Agents with Python (CrewAI & Gemini API)",
|
||||||
|
youtubeId: "dQw4w9WgXcQ",
|
||||||
|
youtubeUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||||
|
thumbnailUrl: "https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?w=800&auto=format&fit=crop&q=80",
|
||||||
|
duration: "28:50",
|
||||||
|
publishDate: "2026-07-10",
|
||||||
|
category: "Python & AI",
|
||||||
|
tags: ["Python", "CrewAI", "Gemini API", "AI Agents", "Automation"],
|
||||||
|
viewsCount: 18200,
|
||||||
|
downloadCount: 3200,
|
||||||
|
likesCount: 1450,
|
||||||
|
summary: "Build an autonomous AI agent in Python that crawls web data, summarizes tech news, and generates structured reports automatically.",
|
||||||
|
notesMarkdown: [
|
||||||
|
"🤖 AI agents are configured with role, goal, and backstory.",
|
||||||
|
"🤖 Equip agents with real-time web search using Serper API or Tavily.",
|
||||||
|
"🤖 Gemini 1.5 Flash provides high token throughput with minimal latency."
|
||||||
|
],
|
||||||
|
codeSnippets: [
|
||||||
|
{
|
||||||
|
fileName: "agent.py",
|
||||||
|
language: "python",
|
||||||
|
description: "CrewAI Agent Definition",
|
||||||
|
code: `from crewai import Agent, Task, Crew, Process
|
||||||
|
from langchain_google_genai import ChatGoogleGenerativeAI
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Initialize Gemini LLM
|
||||||
|
llm = ChatGoogleGenerativeAI(
|
||||||
|
model="gemini-1.5-flash",
|
||||||
|
google_api_key=os.getenv("GEMINI_API_KEY")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Researcher Agent
|
||||||
|
researcher = Agent(
|
||||||
|
role='Tech Researcher',
|
||||||
|
goal='Discover top AI trends from the past 24 hours',
|
||||||
|
backstory='You are an expert technology journalist following AI developments.',
|
||||||
|
verbose=True,
|
||||||
|
llm=llm
|
||||||
|
)
|
||||||
|
|
||||||
|
# Task Definition
|
||||||
|
task1 = Task(
|
||||||
|
description='Summarize 2026 AI trends in 5 bullet points.',
|
||||||
|
expected_output='A markdown list with 5 key takeaways.',
|
||||||
|
agent=researcher
|
||||||
|
)
|
||||||
|
|
||||||
|
crew = Crew(
|
||||||
|
agents=[researcher],
|
||||||
|
tasks=[task1],
|
||||||
|
process=Process.sequential
|
||||||
|
)
|
||||||
|
|
||||||
|
result = crew.kickoff()
|
||||||
|
print(result)`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
downloads: [
|
||||||
|
{
|
||||||
|
title: "Python AI Agent Project Files",
|
||||||
|
type: "zip",
|
||||||
|
url: "#",
|
||||||
|
size: "2.1 MB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "GitHub Repository",
|
||||||
|
type: "github",
|
||||||
|
url: "https://github.com/ayrisdev/python-ai-agent"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
chapters: [
|
||||||
|
{ time: "00:00", seconds: 0, title: "Introduction & What is an AI Agent?" },
|
||||||
|
{ time: "05:20", seconds: 320, title: "Installing Dependencies (CrewAI & Gemini)" },
|
||||||
|
{ time: "14:10", seconds: 850, title: "Agent & Task Coding" },
|
||||||
|
{ time: "24:00", seconds: 1440, title: "Testing & Output Review" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "vid-3",
|
||||||
|
slug: "tailwind-css-v4-tricks-and-setup",
|
||||||
|
title: "Modern UI Design with Tailwind CSS v4: Tricks, Themes & Components",
|
||||||
|
youtubeId: "dQw4w9WgXcQ",
|
||||||
|
youtubeUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||||
|
thumbnailUrl: "https://images.unsplash.com/photo-1507238691740-187a5b1d37b8?w=800&auto=format&fit=crop&q=80",
|
||||||
|
duration: "35:10",
|
||||||
|
publishDate: "2026-07-02",
|
||||||
|
category: "Tailwind CSS",
|
||||||
|
tags: ["Tailwind CSS v4", "OKLCH", "CSS Grid", "UI Design", "Shadcn"],
|
||||||
|
viewsCount: 31000,
|
||||||
|
downloadCount: 5400,
|
||||||
|
likesCount: 2600,
|
||||||
|
summary: "Learn CSS-first configuration in Tailwind CSS v4 using `@import \"tailwindcss\";`, OKLCH color spaces, and advanced glassmorphism component design.",
|
||||||
|
notesMarkdown: [
|
||||||
|
"🎨 In Tailwind v4, `@theme` directive in `globals.css` replaces `tailwind.config.js`.",
|
||||||
|
"🎨 OKLCH color space ensures consistent perceived contrast across light and dark modes.",
|
||||||
|
"🎨 `@custom-variant dark` provides flexible dark mode scoping."
|
||||||
|
],
|
||||||
|
codeSnippets: [
|
||||||
|
{
|
||||||
|
fileName: "globals.css",
|
||||||
|
language: "css",
|
||||||
|
description: "Tailwind CSS v4 Theme Configuration",
|
||||||
|
code: `@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-primary: oklch(0.65 0.22 260);
|
||||||
|
--color-accent: oklch(0.72 0.19 145);
|
||||||
|
--radius-lg: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: oklch(0.98 0.01 250);
|
||||||
|
--foreground: oklch(0.15 0.02 250);
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
downloads: [
|
||||||
|
{
|
||||||
|
title: "Tailwind v4 UI Starter Pack",
|
||||||
|
type: "zip",
|
||||||
|
url: "#",
|
||||||
|
size: "1.2 MB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
chapters: [
|
||||||
|
{ time: "00:00", seconds: 0, title: "What's New in Tailwind v4" },
|
||||||
|
{ time: "08:30", seconds: 510, title: "Defining Color Palettes with @theme" },
|
||||||
|
{ time: "20:15", seconds: 1215, title: "Building Glassmorphism Cards" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "vid-4",
|
||||||
|
slug: "docker-container-nextjs-deploy",
|
||||||
|
title: "Deploying Next.js & PostgreSQL Applications using Docker & Coolify",
|
||||||
|
youtubeId: "dQw4w9WgXcQ",
|
||||||
|
youtubeUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||||
|
thumbnailUrl: "https://images.unsplash.com/photo-1605745341112-85968b19335b?w=800&auto=format&fit=crop&q=80",
|
||||||
|
duration: "31:45",
|
||||||
|
publishDate: "2026-06-25",
|
||||||
|
category: "Docker & DevOps",
|
||||||
|
tags: ["Docker", "Docker Compose", "Coolify", "Next.js", "DevOps"],
|
||||||
|
viewsCount: 15400,
|
||||||
|
downloadCount: 2900,
|
||||||
|
likesCount: 1120,
|
||||||
|
summary: "Production-ready multi-stage Dockerfile and docker-compose setup to deploy Next.js applications seamlessly to VPS or Coolify.",
|
||||||
|
notesMarkdown: [
|
||||||
|
"🐳 Setting `output: 'standalone'` in Next.js reduces image size from 1GB to 120MB.",
|
||||||
|
"🐳 Define a dummy `DATABASE_URL` during Prisma generate step inside Dockerfile."
|
||||||
|
],
|
||||||
|
codeSnippets: [
|
||||||
|
{
|
||||||
|
fileName: "Dockerfile",
|
||||||
|
language: "dockerfile",
|
||||||
|
description: "Optimized Multi-stage Dockerfile",
|
||||||
|
code: `FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy"
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
downloads: [
|
||||||
|
{
|
||||||
|
title: "Dockerfile & Docker Compose Template",
|
||||||
|
type: "zip",
|
||||||
|
url: "#",
|
||||||
|
size: "350 KB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
chapters: [
|
||||||
|
{ time: "00:00", seconds: 0, title: "Introduction" },
|
||||||
|
{ time: "06:00", seconds: 360, title: "Writing Multi-stage Dockerfile" },
|
||||||
|
{ time: "18:20", seconds: 1100, title: "Coolify & VPS Integration" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MOCK_CHEATSHEETS: Cheatsheet[] = [
|
||||||
|
{
|
||||||
|
id: "cs-1",
|
||||||
|
slug: "git-komutlari-hizli-rehber",
|
||||||
|
title: "Essential Git & GitHub Commands Cheatsheet",
|
||||||
|
category: "DevOps & Tooling",
|
||||||
|
description: "Handy reference for daily Git workflow, branching, and conflict resolution commands.",
|
||||||
|
tags: ["Git", "GitHub", "Terminal"],
|
||||||
|
lastUpdated: "2026-07-20",
|
||||||
|
items: [
|
||||||
|
{ command: "git checkout -b feature/new-feature", description: "Creates a new branch and switches to it." },
|
||||||
|
{ command: "git status", description: "Lists modified and untracked files." },
|
||||||
|
{ command: "git commit -m 'feat: add new page'", description: "Saves changes with a descriptive commit message." },
|
||||||
|
{ command: "git push origin feature/new-feature", description: "Pushes the branch to remote repository." },
|
||||||
|
{ command: "git log --oneline -n 5", description: "Displays last 5 commits in clean single line format." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cs-2",
|
||||||
|
slug: "nextjs-app-router-cheatsheet",
|
||||||
|
title: "Next.js App Router File Structure & Route Handlers",
|
||||||
|
category: "Next.js",
|
||||||
|
description: "Special file conventions (page, layout, loading, error) and their responsibilities.",
|
||||||
|
tags: ["Next.js", "App Router", "React"],
|
||||||
|
lastUpdated: "2026-07-18",
|
||||||
|
items: [
|
||||||
|
{ command: "app/[locale]/page.tsx", description: "Main page component for the route." },
|
||||||
|
{ command: "app/layout.tsx", description: "Root layout component wrapping all pages." },
|
||||||
|
{ command: "app/loading.tsx", description: "Suspense fallback displayed while page loads." },
|
||||||
|
{ command: "app/error.tsx", description: "Client-side Error Boundary component." },
|
||||||
|
{ command: "app/api/route.ts", description: "REST API Endpoint handlers (GET, POST, PUT, DELETE)." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cs-3",
|
||||||
|
slug: "docker-temel-komutlar",
|
||||||
|
title: "Quick Docker & Container Command Reference",
|
||||||
|
category: "Docker & DevOps",
|
||||||
|
description: "Commands for building, running, inspecting logs, and cleaning container resources.",
|
||||||
|
tags: ["Docker", "Containers", "DevOps"],
|
||||||
|
lastUpdated: "2026-07-12",
|
||||||
|
items: [
|
||||||
|
{ command: "docker build -t app-name .", description: "Builds a Docker image from Dockerfile in current directory." },
|
||||||
|
{ command: "docker run -d -p 3000:3000 --name my-app app-name", description: "Runs container in detached mode with port forwarding." },
|
||||||
|
{ command: "docker compose up -d --build", description: "Rebuilds and launches Docker Compose services." },
|
||||||
|
{ command: "docker logs -f my-app", description: "Follows live container output logs." },
|
||||||
|
{ command: "docker system prune -af", description: "Removes all unused containers, networks, and images." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Pool } from 'pg';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const connectionString =
|
||||||
|
process.env.DATABASE_URL ||
|
||||||
|
'postgres://postgres:mBTWE2cDKGExtpktguD3HPe8y9Xr9kWFYdxV4WHQKISLLGoBAS4UdtfSCfXwvPpq@65.109.236.58:37298/postgres';
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPrismaClient(): PrismaClient {
|
||||||
|
if (process.env.NODE_ENV !== 'production' && globalForPrisma.prisma) {
|
||||||
|
// Check if newly added models (e.g. category) exist on cached client
|
||||||
|
if ((globalForPrisma.prisma as any).category) {
|
||||||
|
return globalForPrisma.prisma;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const client = new PrismaClient({ adapter });
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
globalForPrisma.prisma = client;
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const db = getPrismaClient();
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"home": "Startseite",
|
||||||
|
"lessons": "YouTube Kurse",
|
||||||
|
"cheatsheets": "Cheatsheets",
|
||||||
|
"contact": "Kontakt",
|
||||||
|
"admin": "Dashboard",
|
||||||
|
"subscribe": "YouTube Abonnieren"
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"badge": "YouTube Bildungs- und Ressourcenportal",
|
||||||
|
"title": "Schluss mit Google Docs Chaos: Alle Codes & Ressourcen hier!",
|
||||||
|
"subtitle": "Zugriff auf Quellcodes, Tabbed-Codeblöcke, Lektionsnotizen und Projekt-Downloads für meine YouTube-Tutorials an einem sauberen Ort.",
|
||||||
|
"searchPlaceholder": "Suchen Sie nach Kurstitel, Code-Snippet oder Technologie (z. B. Next.js, Python, Docker)...",
|
||||||
|
"statVideos": "YouTube Tutorials",
|
||||||
|
"statDownloads": "Downloads",
|
||||||
|
"statSubscribers": "Abonnenten",
|
||||||
|
"statSatisfaction": "Zufriedenheit"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"featured": "Neuestes YouTube Tutorial & Quellcode",
|
||||||
|
"allLessons": "Alle Video-Tutorials & Codedateien",
|
||||||
|
"whyUsTitle": "Warum diese Website statt Google Docs?",
|
||||||
|
"whyUsSubtitle": "Google Docs-Links zerstören die Codeformatierung. Hier sind die Vorteile unseres neuen Portals:",
|
||||||
|
"cheatsheetsTitle": "Beliebte Cheatsheets",
|
||||||
|
"relatedTitle": "Ähnliche Kurse"
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"codeTabsTitle": "Tabbed-Code-Viewer",
|
||||||
|
"codeTabsDesc": "Untersuchen Sie mehrere Projektdateien mit sauberer Syntaxhervorhebung.",
|
||||||
|
"chaptersTitle": "Zeitgestempelter YouTube-Player",
|
||||||
|
"chaptersDesc": "Springen Sie direkt zum gewünschten Thema im Video.",
|
||||||
|
"zipDownloadTitle": "Ein-Klick-Projekt-Download",
|
||||||
|
"zipDownloadDesc": "Laden Sie Starter-Templates ohne Google Drive-Berechtigungen herunter.",
|
||||||
|
"noDocsTitle": "Saubere Notizen statt unübersichtlicher Docs",
|
||||||
|
"noDocsDesc": "Strukturierte Aufzählungspunkte und hervorgehobene Zusammenfassungen."
|
||||||
|
},
|
||||||
|
"lessonDetail": {
|
||||||
|
"watchOnYoutube": "Auf YouTube ansehen",
|
||||||
|
"tabNotes": "Lektionsnotizen & Zusammenfassung",
|
||||||
|
"tabCode": "Code-Blöcke & Dateien",
|
||||||
|
"tabDownloads": "Downloads & Links",
|
||||||
|
"tabChapters": "Video-Kapitel",
|
||||||
|
"copyCode": "Code kopieren",
|
||||||
|
"codeCopied": "Kopiert!",
|
||||||
|
"downloadFile": "Datei herunterladen",
|
||||||
|
"downloadZip": "Gesamtes Projekt herunterladen (.zip)",
|
||||||
|
"githubRepo": "GitHub Repository",
|
||||||
|
"slidesPdf": "Präsentation / PDF herunterladen"
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"title": "Kontakt & Themenwünsche",
|
||||||
|
"subtitle": "Kontaktieren Sie mich für Fragen zu Videos, Themenwünsche oder Sponsoring.",
|
||||||
|
"name": "Ihr Name",
|
||||||
|
"email": "Ihre E-Mail-Adresse",
|
||||||
|
"subject": "Betreff",
|
||||||
|
"message": "Ihre Nachricht oder Ihr Themenwunsch",
|
||||||
|
"send": "Nachricht senden",
|
||||||
|
"success": "Ihre Nachricht wurde erfolgreich gesendet!"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"title": "Creator Dashboard",
|
||||||
|
"subtitle": "Fügen Sie neue YouTube-Lektionen und Code-Snippets hinzu.",
|
||||||
|
"addVideo": "Neues Tutorial erstellen",
|
||||||
|
"videoTitle": "Videotitel",
|
||||||
|
"youtubeUrl": "YouTube URL",
|
||||||
|
"category": "Kategorie",
|
||||||
|
"codeSnippets": "Codeblöcke",
|
||||||
|
"save": "Speichern & Veröffentlichen"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"rights": "Alle Rechte vorbehalten.",
|
||||||
|
"createdWith": "Created by ayris.tech"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"home": "Home",
|
||||||
|
"lessons": "YouTube Lessons",
|
||||||
|
"cheatsheets": "Cheatsheets",
|
||||||
|
"contact": "Contact",
|
||||||
|
"admin": "Creator Dashboard",
|
||||||
|
"subscribe": "Subscribe on YouTube"
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"badge": "ayris.tech YouTube Channel Portal",
|
||||||
|
"title": "Official Code & Resource Portal for ayris.tech YouTube Channel",
|
||||||
|
"subtitle": "Access code snippets, tabbed project files, lesson notes, and starter downloads for all my YouTube video tutorials in one clean location.",
|
||||||
|
"searchPlaceholder": "Search lesson title, code snippet, or technology (e.g. Next.js, Python, Docker)...",
|
||||||
|
"statVideos": "YouTube Tutorials",
|
||||||
|
"statDownloads": "Resource Downloads",
|
||||||
|
"statSubscribers": "Channel Subscribers",
|
||||||
|
"statSatisfaction": "Viewer Rating"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"featured": "Latest YouTube Tutorial & Source Code",
|
||||||
|
"allLessons": "All Video Tutorials & Code Files",
|
||||||
|
"whyUsTitle": "Everything You Need for ayris.tech Tutorials",
|
||||||
|
"whyUsSubtitle": "Built specifically for ayris.tech YouTube channel viewers to make learning and copying tutorial code effortless:",
|
||||||
|
"cheatsheetsTitle": "Featured Cheatsheets & Quick Reference",
|
||||||
|
"relatedTitle": "Related YouTube Lessons"
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"codeTabsTitle": "Tabbed Code Viewer",
|
||||||
|
"codeTabsDesc": "Inspect multiple project files (page.tsx, actions.ts, etc.) with clean syntax highlighting and copy support.",
|
||||||
|
"chaptersTitle": "Timestamped YouTube Player",
|
||||||
|
"chaptersDesc": "Jump directly to the specific topic inside the video with a single click.",
|
||||||
|
"zipDownloadTitle": "One-Click Project Download",
|
||||||
|
"zipDownloadDesc": "Download complete starter templates and project files directly without broken links.",
|
||||||
|
"noDocsTitle": "Structured Lesson Notes",
|
||||||
|
"noDocsDesc": "Structured bullet points, callout warnings, and formatted lesson summaries for every tutorial."
|
||||||
|
},
|
||||||
|
"lessonDetail": {
|
||||||
|
"watchOnYoutube": "Watch on YouTube",
|
||||||
|
"tabNotes": "Lesson Notes & Summary",
|
||||||
|
"tabCode": "Code Blocks & Files",
|
||||||
|
"tabDownloads": "Downloads & Links",
|
||||||
|
"tabChapters": "Video Chapters",
|
||||||
|
"copyCode": "Copy Code",
|
||||||
|
"codeCopied": "Copied!",
|
||||||
|
"downloadFile": "Download File",
|
||||||
|
"downloadZip": "Download Full Project (.zip)",
|
||||||
|
"githubRepo": "GitHub Repository",
|
||||||
|
"slidesPdf": "Download Presentation / PDF"
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"title": "Contact & Video Topic Suggestions",
|
||||||
|
"subtitle": "Reach out for video questions, future tutorial requests, or business/sponsorship inquiries.",
|
||||||
|
"name": "Your Full Name",
|
||||||
|
"email": "Your Email Address",
|
||||||
|
"subject": "Subject",
|
||||||
|
"message": "Your Message or Requested Tutorial Topic",
|
||||||
|
"send": "Send Message",
|
||||||
|
"success": "Your message has been sent successfully! I will reply as soon as possible."
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"title": "Creator Dashboard - Add New Lesson & Resources",
|
||||||
|
"subtitle": "Attach code blocks, snippets, and download links for your latest YouTube videos.",
|
||||||
|
"addVideo": "Create New YouTube Tutorial",
|
||||||
|
"videoTitle": "Video Title",
|
||||||
|
"youtubeUrl": "YouTube URL or Video ID",
|
||||||
|
"category": "Category",
|
||||||
|
"codeSnippets": "Code Blocks",
|
||||||
|
"save": "Save & Publish"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"rights": "All rights reserved.",
|
||||||
|
"createdWith": "Created by ayris.tech"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"home": "Ana Sayfa",
|
||||||
|
"lessons": "YouTube Dersleri",
|
||||||
|
"cheatsheets": "Rehberler",
|
||||||
|
"contact": "İletişim",
|
||||||
|
"admin": "Yayıncı Paneli",
|
||||||
|
"subscribe": "YouTube'da Abone Ol"
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"badge": "ayris.tech YouTube Kanal Portalı",
|
||||||
|
"title": "ayris.tech YouTube Kanalı Resmi Kod ve Kaynak Portalı",
|
||||||
|
"subtitle": "YouTube eğitim videolarımızdaki kaynak kodlara, sekmeli dosyalara, ders notlarına ve proje indirmelerine tek adresten ulaşın.",
|
||||||
|
"searchPlaceholder": "Ders başlığı, kod parçacığı veya teknoloji ara (ör. Next.js, Python, Docker)...",
|
||||||
|
"statVideos": "YouTube Videosu",
|
||||||
|
"statDownloads": "Proje İndirmesi",
|
||||||
|
"statSubscribers": "Kanal Abonesi",
|
||||||
|
"statSatisfaction": "İzleyici Memnuniyeti"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"featured": "Son YouTube Dersi ve Kaynak Kodları",
|
||||||
|
"allLessons": "Tüm Video Dersler ve Kod Dosyaları",
|
||||||
|
"whyUsTitle": "ayris.tech YouTube Eğitimleri İçin Her Şey",
|
||||||
|
"whyUsSubtitle": "ayris.tech YouTube kanalı izleyicilerinin kodları kolayca incelemesi ve indirmesi için tasarlandı:",
|
||||||
|
"cheatsheetsTitle": "Öne Çıkan Rehberler ve Hızlı Komutlar",
|
||||||
|
"relatedTitle": "İlgili YouTube Dersleri"
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"codeTabsTitle": "Sekmeli Kod İnceleyici",
|
||||||
|
"codeTabsDesc": "Projenin tüm dosyalarını (page.tsx, actions.ts vb.) renkli sözdizimi ve tek tıkla kopyalama ile inceleyin.",
|
||||||
|
"chaptersTitle": "Zaman Damgalı YouTube Oynatıcı",
|
||||||
|
"chaptersDesc": "Tek tıkla videodaki ilgili konunun başladığı saniyeye doğrudan atlayın.",
|
||||||
|
"zipDownloadTitle": "Tek Tıkla Proje İndirme",
|
||||||
|
"zipDownloadDesc": "Tamamlanmış başlangıç şablonlarını ve proje dosyalarını kırık link olmadan indirin.",
|
||||||
|
"noDocsTitle": "Düzenli Ders Notları",
|
||||||
|
"noDocsDesc": "Her video eğitimi için özel hazırlanmış özet maddeler ve önemli ipuçları."
|
||||||
|
},
|
||||||
|
"lessonDetail": {
|
||||||
|
"watchOnYoutube": "YouTube'da İzle",
|
||||||
|
"tabNotes": "Ders Notları ve Özet",
|
||||||
|
"tabCode": "Kod Blokları ve Dosyalar",
|
||||||
|
"tabDownloads": "İndirmeler ve Linkler",
|
||||||
|
"tabChapters": "Video Bölümleri",
|
||||||
|
"copyCode": "Kodu Kopyala",
|
||||||
|
"codeCopied": "Kopyalandı!",
|
||||||
|
"downloadFile": "Dosyayı İndir",
|
||||||
|
"downloadZip": "Tüm Projeyi İndir (.zip)",
|
||||||
|
"githubRepo": "GitHub Reposu",
|
||||||
|
"slidesPdf": "Sunumu / PDF İndir"
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"title": "İletişim ve Video Konusu İstekleri",
|
||||||
|
"subtitle": "Video sorularınız, gelecek eğitim istekleriniz veya sponsorluk görüşmeleri için bana ulaşın.",
|
||||||
|
"name": "Adınız Soyadınız",
|
||||||
|
"email": "E-posta Adresiniz",
|
||||||
|
"subject": "Konu",
|
||||||
|
"message": "Mesajınız veya İstediğiniz Eğitim Konusu",
|
||||||
|
"send": "Mesajı Gönder",
|
||||||
|
"success": "Mesajınız başarıyla gönderildi! En kısa sürede yanıtlayacağım."
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"title": "Yayıncı Paneli - Yeni Ders & Kaynak Ekle",
|
||||||
|
"subtitle": "Son YouTube videolarınız için kod blokları ve indirme linkleri ekleyin.",
|
||||||
|
"addVideo": "Yeni YouTube Dersi Oluştur",
|
||||||
|
"videoTitle": "Video Başlığı",
|
||||||
|
"youtubeUrl": "YouTube Linki veya Video ID",
|
||||||
|
"category": "Kategori",
|
||||||
|
"codeSnippets": "Kod Blokları",
|
||||||
|
"save": "Kaydet & Yayınla"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"rights": "Tüm hakları saklıdır.",
|
||||||
|
"createdWith": "Created by ayris.tech"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { NextConfig } from 'next'
|
||||||
|
import createNextIntlPlugin from 'next-intl/plugin'
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin('./i18n/request.ts')
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{ protocol: 'https', hostname: 'res.cloudinary.com' },
|
||||||
|
{ protocol: 'https', hostname: 'images.unsplash.com' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withNextIntl(nextConfig)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "ayrisai",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.5.0",
|
||||||
|
"@prisma/adapter-pg": "^7.9.0",
|
||||||
|
"@prisma/client": "^7.8.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"cloudinary": "^2.10.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"developer-icons": "^7.0.1",
|
||||||
|
"framer-motion": "^12.40.0",
|
||||||
|
"lucide-react": "^1.18.0",
|
||||||
|
"next": "16.2.9",
|
||||||
|
"next-auth": "^5.0.0-beta.31",
|
||||||
|
"next-intl": "^4.13.0",
|
||||||
|
"pg": "^8.22.0",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4",
|
||||||
|
"shadcn": "^4.11.0",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tw-animate-css": "^1.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.2.9",
|
||||||
|
"prisma": "^7.8.0",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from '@prisma/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
datasource: {
|
||||||
|
url: process.env.DATABASE_URL || 'postgres://postgres:mBTWE2cDKGExtpktguD3HPe8y9Xr9kWFYdxV4WHQKISLLGoBAS4UdtfSCfXwvPpq@65.109.236.58:37298/postgres',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
ADMIN
|
||||||
|
USER
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String?
|
||||||
|
email String @unique
|
||||||
|
password String?
|
||||||
|
role Role @default(USER)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
accounts Account[]
|
||||||
|
sessions Session[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Account {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
type String
|
||||||
|
provider String
|
||||||
|
providerAccountId String
|
||||||
|
refresh_token String? @db.Text
|
||||||
|
access_token String? @db.Text
|
||||||
|
expires_at Int?
|
||||||
|
token_type String?
|
||||||
|
scope String?
|
||||||
|
id_token String? @db.Text
|
||||||
|
session_state String?
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([provider, providerAccountId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionToken String @unique
|
||||||
|
userId String
|
||||||
|
expires DateTime
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model VerificationToken {
|
||||||
|
identifier String
|
||||||
|
token String @unique
|
||||||
|
expires DateTime
|
||||||
|
|
||||||
|
@@unique([identifier, token])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Category {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
slug String @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Lesson {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
title String
|
||||||
|
youtubeId String
|
||||||
|
youtubeUrl String
|
||||||
|
thumbnailUrl String
|
||||||
|
duration String
|
||||||
|
publishDate DateTime @default(now())
|
||||||
|
category String
|
||||||
|
tags String[]
|
||||||
|
viewsCount Int @default(0)
|
||||||
|
downloadCount Int @default(0)
|
||||||
|
likesCount Int @default(0)
|
||||||
|
summary String @db.Text
|
||||||
|
notesMarkdown String[]
|
||||||
|
isFeatured Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
codeSnippets CodeSnippet[]
|
||||||
|
downloads ResourceDownload[]
|
||||||
|
chapters VideoChapter[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model CodeSnippet {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
fileName String
|
||||||
|
language String
|
||||||
|
code String @db.Text
|
||||||
|
description String?
|
||||||
|
lessonId String
|
||||||
|
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model ResourceDownload {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
type String // zip, github, pdf, link
|
||||||
|
url String
|
||||||
|
size String?
|
||||||
|
lessonId String
|
||||||
|
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model VideoChapter {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
time String
|
||||||
|
seconds Int
|
||||||
|
title String
|
||||||
|
lessonId String
|
||||||
|
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model Cheatsheet {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
title String
|
||||||
|
category String
|
||||||
|
description String @db.Text
|
||||||
|
tags String[]
|
||||||
|
lastUpdated DateTime @default(now())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
items CheatsheetItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model CheatsheetItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
command String
|
||||||
|
description String
|
||||||
|
example String?
|
||||||
|
cheatsheetId String
|
||||||
|
cheatsheet Cheatsheet @relation(fields: [cheatsheetId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model ContactMessage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
email String
|
||||||
|
subject String
|
||||||
|
message String @db.Text
|
||||||
|
isRead Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model Setting {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
key String @unique
|
||||||
|
value String @db.Text
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { Pool } from 'pg';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL || 'postgres://postgres:mBTWE2cDKGExtpktguD3HPe8y9Xr9kWFYdxV4WHQKISLLGoBAS4UdtfSCfXwvPpq@65.109.236.58:37298/postgres';
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
const db = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Seeding PostgreSQL database...');
|
||||||
|
|
||||||
|
// Clear existing records
|
||||||
|
await db.videoChapter.deleteMany();
|
||||||
|
await db.resourceDownload.deleteMany();
|
||||||
|
await db.codeSnippet.deleteMany();
|
||||||
|
await db.lesson.deleteMany();
|
||||||
|
await db.cheatsheetItem.deleteMany();
|
||||||
|
await db.cheatsheet.deleteMany();
|
||||||
|
|
||||||
|
// Create Lessons
|
||||||
|
await db.lesson.create({
|
||||||
|
data: {
|
||||||
|
slug: 'nextjs-16-app-router-full-course',
|
||||||
|
title: 'Next.js 16 App Router & Server Actions Complete Masterclass (Code & Project)',
|
||||||
|
youtubeId: 'dQw4w9WgXcQ',
|
||||||
|
youtubeUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
thumbnailUrl: 'https://images.unsplash.com/photo-1618401471353-b98afee0b2eb?w=800&auto=format&fit=crop&q=80',
|
||||||
|
duration: '42:15',
|
||||||
|
category: 'Next.js',
|
||||||
|
tags: ['Next.js 16', 'App Router', 'Server Actions', 'TypeScript', 'Tailwind CSS v4'],
|
||||||
|
viewsCount: 24500,
|
||||||
|
downloadCount: 4120,
|
||||||
|
likesCount: 1890,
|
||||||
|
isFeatured: true,
|
||||||
|
summary: 'In this tutorial, we build a full-stack Next.js 16 application from scratch covering App Router architecture, proxy.ts middleware, Server Actions, and PostgreSQL integration.',
|
||||||
|
notesMarkdown: [
|
||||||
|
'In Next.js 16, using proxy.ts is recommended over deprecated middleware.ts.',
|
||||||
|
'Server Actions functions are declared with "use server" directive and can be invoked directly from client components.',
|
||||||
|
'Data fetching revalidation uses revalidatePath or revalidateTag helpers.',
|
||||||
|
'Form handling UX is significantly improved using useActionState and useFormStatus hooks.'
|
||||||
|
],
|
||||||
|
codeSnippets: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
fileName: 'app/actions.ts',
|
||||||
|
language: 'typescript',
|
||||||
|
description: 'Server Action function handling form submission',
|
||||||
|
code: `'use server'\n\nimport { revalidatePath } from 'next/cache';\n\nexport async function submitProjectIdea(formData: FormData) {\n const title = formData.get('title') as string;\n const description = formData.get('description') as string;\n\n if (!title || title.length < 3) {\n return { success: false, error: 'Title must be at least 3 characters long.' };\n }\n\n // Database insertion simulation\n console.log('Saved Idea:', { title, description });\n\n revalidatePath('/ideas');\n return { success: true, message: 'Your idea was submitted successfully!' };\n}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileName: 'proxy.ts',
|
||||||
|
language: 'typescript',
|
||||||
|
description: 'Next.js 16 proxy configuration (middleware alternative)',
|
||||||
|
code: `import { NextRequest, NextResponse } from 'next/server';\nimport createMiddleware from 'next-intl/middleware';\nimport { routing } from '@/i18n/routing';\n\nconst intlMiddleware = createMiddleware(routing);\n\nexport async function proxy(request: NextRequest) {\n if (request.nextUrl.pathname.includes('/admin')) {\n // Auth check logic\n }\n return intlMiddleware(request);\n}\n\nexport const config = {\n matcher: ['/((?!api|_next|_vercel|.*\\\\..*).*)']\n};`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileName: 'components/IdeaForm.tsx',
|
||||||
|
language: 'tsx',
|
||||||
|
description: 'Client Form component using useActionState',
|
||||||
|
code: `'use client';\n\nimport { useActionState } from 'react';\nimport { submitProjectIdea } from '@/app/actions';\n\nexport function IdeaForm() {\n const [state, formAction, isPending] = useActionState(submitProjectIdea, null);\n\n return (\n <form action={formAction} className="space-y-4 max-w-md p-6 bg-slate-900 rounded-xl border border-slate-800">\n <h3 className="text-xl font-bold text-white">Add New Idea</h3>\n \n <div>\n <label className="block text-sm text-slate-400 mb-1">Idea Title</label>\n <input name="title" required className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white" />\n </div>\n\n <button disabled={isPending} className="w-full py-2.5 bg-red-600 hover:bg-red-500 text-white rounded-lg font-medium">\n {isPending ? 'Submitting...' : 'Share Idea'}\n </button>\n\n {state?.error && <p className="text-red-400 text-sm">{state.error}</p>}\n {state?.success && <p className="text-emerald-400 text-sm">{state.message}</p>}\n </form>\n );\n}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
downloads: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
title: 'Full Project Source Code (Starter Pack)',
|
||||||
|
type: 'zip',
|
||||||
|
url: 'https://github.com/ayrisdev/nextjs-16-starter/archive/refs/heads/main.zip',
|
||||||
|
size: '4.2 MB'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'GitHub Repository',
|
||||||
|
type: 'github',
|
||||||
|
url: 'https://github.com/ayrisdev/nextjs-16-starter'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Lesson Notes & Architecture (PDF Document)',
|
||||||
|
type: 'pdf',
|
||||||
|
url: '#',
|
||||||
|
size: '1.8 MB'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
chapters: {
|
||||||
|
create: [
|
||||||
|
{ time: '00:00', seconds: 0, title: 'Introduction & Demo' },
|
||||||
|
{ time: '04:15', seconds: 255, title: 'Next.js 16 Project Setup & Structure' },
|
||||||
|
{ time: '11:30', seconds: 690, title: 'Routing & Middleware with proxy.ts' },
|
||||||
|
{ time: '21:00', seconds: 1260, title: 'Form Handling via Server Actions' },
|
||||||
|
{ time: '32:45', seconds: 1965, title: 'PostgreSQL & Prisma Integration' },
|
||||||
|
{ time: '40:00', seconds: 2400, title: 'Deployment & Summary' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.lesson.create({
|
||||||
|
data: {
|
||||||
|
slug: 'python-ai-agent-automation',
|
||||||
|
title: 'Building Autonomous AI Agents with Python (CrewAI & Gemini API)',
|
||||||
|
youtubeId: 'dQw4w9WgXcQ',
|
||||||
|
youtubeUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
thumbnailUrl: 'https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?w=800&auto=format&fit=crop&q=80',
|
||||||
|
duration: '28:50',
|
||||||
|
category: 'Python & AI',
|
||||||
|
tags: ['Python', 'CrewAI', 'Gemini API', 'AI Agents', 'Automation'],
|
||||||
|
viewsCount: 18200,
|
||||||
|
downloadCount: 3200,
|
||||||
|
likesCount: 1450,
|
||||||
|
summary: 'Build an autonomous AI agent in Python that crawls web data, summarizes tech news, and generates structured reports automatically.',
|
||||||
|
notesMarkdown: [
|
||||||
|
'🤖 AI agents are configured with role, goal, and backstory.',
|
||||||
|
'🤖 Equip agents with real-time web search using Serper API or Tavily.',
|
||||||
|
'🤖 Gemini 1.5 Flash provides high token throughput with minimal latency.'
|
||||||
|
],
|
||||||
|
codeSnippets: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
fileName: 'agent.py',
|
||||||
|
language: 'python',
|
||||||
|
description: 'CrewAI Agent Definition',
|
||||||
|
code: `from crewai import Agent, Task, Crew, Process\nfrom langchain_google_genai import ChatGoogleGenerativeAI\nimport os\n\nllm = ChatGoogleGenerativeAI(\n model="gemini-1.5-flash",\n google_api_key=os.getenv("GEMINI_API_KEY")\n)\n\nresearcher = Agent(\n role='Tech Researcher',\n goal='Discover top AI trends from the past 24 hours',\n backstory='You are an expert technology journalist following AI developments.',\n verbose=True,\n llm=llm\n)\n\ntask1 = Task(\n description='Summarize 2026 AI trends in 5 bullet points.',\n expected_output='A markdown list with 5 key takeaways.',\n agent=researcher\n)\n\ncrew = Crew(\n agents=[researcher],\n tasks=[task1],\n process=Process.sequential\n)\n\nresult = crew.kickoff()\nprint(result)`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
downloads: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
title: 'Python AI Agent Project Files',
|
||||||
|
type: 'zip',
|
||||||
|
url: '#',
|
||||||
|
size: '2.1 MB'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'GitHub Repository',
|
||||||
|
type: 'github',
|
||||||
|
url: 'https://github.com/ayrisdev/python-ai-agent'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
chapters: {
|
||||||
|
create: [
|
||||||
|
{ time: '00:00', seconds: 0, title: 'Introduction & What is an AI Agent?' },
|
||||||
|
{ time: '05:20', seconds: 320, title: 'Installing Dependencies (CrewAI & Gemini)' },
|
||||||
|
{ time: '14:10', seconds: 850, title: 'Agent & Task Coding' },
|
||||||
|
{ time: '24:00', seconds: 1440, title: 'Testing & Output Review' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.lesson.create({
|
||||||
|
data: {
|
||||||
|
slug: 'tailwind-css-v4-tricks-and-setup',
|
||||||
|
title: 'Modern UI Design with Tailwind CSS v4: Tricks, Themes & Components',
|
||||||
|
youtubeId: 'dQw4w9WgXcQ',
|
||||||
|
youtubeUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
thumbnailUrl: 'https://images.unsplash.com/photo-1507238691740-187a5b1d37b8?w=800&auto=format&fit=crop&q=80',
|
||||||
|
duration: '35:10',
|
||||||
|
category: 'Tailwind CSS',
|
||||||
|
tags: ['Tailwind CSS v4', 'OKLCH', 'CSS Grid', 'UI Design', 'Shadcn'],
|
||||||
|
viewsCount: 31000,
|
||||||
|
downloadCount: 5400,
|
||||||
|
likesCount: 2600,
|
||||||
|
summary: 'Learn CSS-first configuration in Tailwind CSS v4 using @import "tailwindcss";, OKLCH color spaces, and advanced glassmorphism component design.',
|
||||||
|
notesMarkdown: [
|
||||||
|
'🎨 In Tailwind v4, @theme directive in globals.css replaces tailwind.config.js.',
|
||||||
|
'🎨 OKLCH color space ensures consistent perceived contrast across light and dark modes.',
|
||||||
|
'🎨 @custom-variant dark provides flexible dark mode scoping.'
|
||||||
|
],
|
||||||
|
codeSnippets: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
fileName: 'globals.css',
|
||||||
|
language: 'css',
|
||||||
|
description: 'Tailwind CSS v4 Theme Configuration',
|
||||||
|
code: `@import "tailwindcss";\n\n@theme inline {\n --color-primary: oklch(0.65 0.22 260);\n --color-accent: oklch(0.72 0.19 145);\n --radius-lg: 1rem;\n}\n\n:root {\n --background: oklch(0.98 0.01 250);\n --foreground: oklch(0.15 0.02 250);\n}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
downloads: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
title: 'Tailwind v4 UI Starter Pack',
|
||||||
|
type: 'zip',
|
||||||
|
url: '#',
|
||||||
|
size: '1.2 MB'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
chapters: {
|
||||||
|
create: [
|
||||||
|
{ time: '00:00', seconds: 0, title: "What's New in Tailwind v4" },
|
||||||
|
{ time: '08:30', seconds: 510, title: 'Defining Color Palettes with @theme' },
|
||||||
|
{ time: '20:15', seconds: 1215, title: 'Building Glassmorphism Cards' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Cheatsheets
|
||||||
|
await db.cheatsheet.create({
|
||||||
|
data: {
|
||||||
|
slug: 'git-komutlari-hizli-rehber',
|
||||||
|
title: 'Essential Git & GitHub Commands Cheatsheet',
|
||||||
|
category: 'DevOps & Tooling',
|
||||||
|
description: 'Handy reference for daily Git workflow, branching, and conflict resolution commands.',
|
||||||
|
tags: ['Git', 'GitHub', 'Terminal'],
|
||||||
|
items: {
|
||||||
|
create: [
|
||||||
|
{ command: 'git checkout -b feature/new-feature', description: 'Creates a new branch and switches to it.' },
|
||||||
|
{ command: 'git status', description: 'Lists modified and untracked files.' },
|
||||||
|
{ command: 'git commit -m "feat: add new page"', description: 'Saves changes with a descriptive commit message.' },
|
||||||
|
{ command: 'git push origin feature/new-feature', description: 'Pushes the branch to remote repository.' },
|
||||||
|
{ command: 'git log --oneline -n 5', description: 'Displays last 5 commits in clean single line format.' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.cheatsheet.create({
|
||||||
|
data: {
|
||||||
|
slug: 'nextjs-app-router-cheatsheet',
|
||||||
|
title: 'Next.js App Router File Structure & Route Handlers',
|
||||||
|
category: 'Next.js',
|
||||||
|
description: 'Special file conventions (page, layout, loading, error) and their responsibilities.',
|
||||||
|
tags: ['Next.js', 'App Router', 'React'],
|
||||||
|
items: {
|
||||||
|
create: [
|
||||||
|
{ command: 'app/[locale]/page.tsx', description: 'Main page component for the route.' },
|
||||||
|
{ command: 'app/layout.tsx', description: 'Root layout component wrapping all pages.' },
|
||||||
|
{ command: 'app/loading.tsx', description: 'Suspense fallback displayed while page loads.' },
|
||||||
|
{ command: 'app/error.tsx', description: 'Client-side Error Boundary component.' },
|
||||||
|
{ command: 'app/api/route.ts', description: 'REST API Endpoint handlers (GET, POST, PUT, DELETE).' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Seeding completed successfully!');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('Seed error:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await db.$disconnect();
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import createMiddleware from 'next-intl/middleware'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { routing } from '@/i18n/routing'
|
||||||
|
|
||||||
|
const intlMiddleware = createMiddleware(routing)
|
||||||
|
|
||||||
|
export async function proxy(request: NextRequest) {
|
||||||
|
if (request.nextUrl.pathname.includes('/admin')) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session || (session.user as any)?.role !== 'ADMIN') {
|
||||||
|
return NextResponse.redirect(new URL('/login', request.url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return intlMiddleware(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)']
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 409 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -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"]
|
||||||
|
}
|
||||||