Finalize schema and add deployment workflow

This commit is contained in:
dyzulk
2026-01-16 17:42:45 +07:00
parent 1ffdc7ea5f
commit 1e1222be1f
23 changed files with 2931 additions and 25 deletions

56
.github/workflows/deploy-site.yml vendored Normal file
View File

@@ -0,0 +1,56 @@
name: Deploy to GitHub Pages
on:
push:
branches: ["main"]
workflow_dispatch:
# Grant GITHUB_TOKEN the permissions to deploy to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: true
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
env:
# Inject secrets/vars from GitHub Repository Settings
VITE_SUPABASE_URL: ${{ vars.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
VITE_WORKER_URL: ${{ vars.VITE_WORKER_URL }}
VITE_R2_PUBLIC_DOMAIN: ${{ vars.VITE_R2_PUBLIC_DOMAIN }}
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: './dist'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

1474
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,9 @@
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.12.0", "react-router-dom": "^7.12.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7"
}, },

View File

@@ -1,9 +1,18 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom' import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { AuthProvider } from '@/hooks/useAuth' import { AuthProvider } from '@/hooks/useAuth'
import ProtectedRoute from '@/components/ProtectedRoute' import ProtectedRoute from '@/components/ProtectedRoute'
import AdminLayout from '@/components/layout/AdminLayout'
import PublicLayout from '@/components/layout/PublicLayout'
import Home from '@/pages/public/Home' import Home from '@/pages/public/Home'
import Projects from '@/pages/public/Projects'
import Blog from '@/pages/public/Blog'
import BlogPost from '@/pages/public/BlogPost'
import Login from '@/pages/auth/Login' import Login from '@/pages/auth/Login'
import Dashboard from '@/pages/admin/Dashboard' import Dashboard from '@/pages/admin/Dashboard'
import ProjectEditor from '@/pages/admin/ProjectEditor'
import ProjectsList from '@/pages/admin/ProjectsList'
import PostEditor from '@/pages/admin/PostEditor'
import PostsList from '@/pages/admin/PostsList'
function App() { function App() {
return ( return (
@@ -11,12 +20,24 @@ function App() {
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
{/* Public Routes */} {/* Public Routes */}
<Route path="/" element={<Home />} /> <Route element={<PublicLayout />}>
<Route path="/" element={<Home />} />
<Route path="/projects" element={<Projects />} />
<Route path="/blog" element={<Blog />} />
<Route path="/blog/:slug" element={<BlogPost />} />
</Route>
<Route path="/auth/login" element={<Login />} /> <Route path="/auth/login" element={<Login />} />
{/* Admin Protected Routes */} {/* Admin Protected Routes */}
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route path="/admin" element={<Dashboard />} /> <Route element={<AdminLayout />}>
<Route path="/admin" element={<Dashboard />} />
<Route path="/admin/projects" element={<ProjectsList />} />
<Route path="/admin/projects/:id" element={<ProjectEditor />} />
<Route path="/admin/posts" element={<PostsList />} />
<Route path="/admin/posts/:id" element={<PostEditor />} />
</Route>
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@@ -0,0 +1,13 @@
import { Outlet } from 'react-router-dom'
import AdminSidebar from './AdminSidebar'
export default function AdminLayout() {
return (
<div className="flex min-h-screen bg-slate-50 dark:bg-slate-950">
<AdminSidebar />
<main className="flex-1 p-8 overflow-y-auto w-full">
<Outlet />
</main>
</div>
)
}

View File

@@ -0,0 +1,65 @@
import { Link, useLocation } from 'react-router-dom'
import { LayoutDashboard, FileText, Settings, LogOut, Briefcase } from 'lucide-react'
import { cn } from '@/lib/utils'
import { supabase } from '@/lib/supabase'
import { useNavigate } from 'react-router-dom'
const sidebarItems = [
{ icon: LayoutDashboard, label: 'Dashboard', href: '/admin' },
{ icon: Briefcase, label: 'Projects', href: '/admin/projects' },
{ icon: FileText, label: 'Blog Posts', href: '/admin/posts' },
{ icon: Settings, label: 'Settings', href: '/admin/settings' },
]
export default function AdminSidebar() {
const location = useLocation()
const navigate = useNavigate()
const handleLogout = async () => {
await supabase.auth.signOut()
navigate('/auth/login')
}
return (
<div className="h-screen w-64 bg-slate-900 text-white flex flex-col">
<div className="p-6">
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">
Dyzulk Admin
</h1>
</div>
<nav className="flex-1 px-4 space-y-2">
{sidebarItems.map((item) => {
const Icon = item.icon
const isActive = location.pathname === item.href
return (
<Link
key={item.href}
to={item.href}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200",
isActive
? "bg-blue-600 text-white shadow-lg shadow-blue-900/50"
: "text-slate-400 hover:bg-slate-800 hover:text-white"
)}
>
<Icon size={20} />
<span className="font-medium">{item.label}</span>
</Link>
)
})}
</nav>
<div className="p-4 border-t border-slate-800">
<button
onClick={handleLogout}
className="flex items-center gap-3 w-full px-4 py-3 text-red-400 hover:bg-red-950/30 rounded-lg transition-colors"
>
<LogOut size={20} />
<span className="font-medium">Logout</span>
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { Link, useLocation } from 'react-router-dom'
import { cn } from '@/lib/utils'
export default function Navbar() {
const location = useLocation()
const links = [
{ label: 'Home', href: '/' },
{ label: 'Projects', href: '/projects' },
{ label: 'Blog', href: '/blog' },
]
return (
<header className="sticky top-0 z-50 w-full border-b border-slate-200 dark:border-slate-800 bg-white/80 dark:bg-slate-950/80 backdrop-blur-md">
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
<Link to="/" className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-600 to-cyan-500">
Dyzulk.
</Link>
<nav className="flex gap-6">
{links.map((link) => (
<Link
key={link.href}
to={link.href}
className={cn(
"text-sm font-medium transition-colors hover:text-blue-600",
location.pathname === link.href ? "text-blue-600" : "text-slate-600 dark:text-slate-400"
)}
>
{link.label}
</Link>
))}
</nav>
</div>
</header>
)
}

View File

@@ -0,0 +1,16 @@
import { Outlet } from 'react-router-dom'
import Navbar from './Navbar'
export default function PublicLayout() {
return (
<div className="flex flex-col min-h-screen bg-white dark:bg-slate-950 text-slate-900 dark:text-slate-50">
<Navbar />
<main className="flex-1">
<Outlet />
</main>
<footer className="py-8 border-t border-slate-100 dark:border-slate-900 text-center text-slate-500 text-sm">
<p>© {new Date().getFullYear()} Dyzulk. Built with React & Supabase.</p>
</footer>
</div>
)
}

View File

@@ -0,0 +1,91 @@
import { useState } from 'react'
import { useUpload } from '@/hooks/useUpload'
import { Upload, X, CheckCircle, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
interface ImageUploadProps {
onUpload: (url: string) => void
currentImage?: string
label?: string
}
export function ImageUpload({ onUpload, currentImage, label = "Project Image" }: ImageUploadProps) {
const { uploadFile, uploading, error } = useUpload()
const [preview, setPreview] = useState<string | undefined>(currentImage)
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Preview
const objectUrl = URL.createObjectURL(file)
setPreview(objectUrl)
// Upload
const result = await uploadFile(file, 'portfolio')
if (result) {
onUpload(result.url)
}
}
const handleRemove = () => {
setPreview(undefined)
onUpload('')
}
return (
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">{label}</label>
<div className={cn(
"relative rounded-lg border-2 border-dashed border-slate-300 dark:border-slate-700 transition-colors",
"hover:border-blue-500 dark:hover:border-blue-500 bg-slate-50 dark:bg-slate-900/50",
preview ? "h-64" : "h-32"
)}>
{preview ? (
<div className="relative h-full w-full p-2">
<img
src={preview}
alt="Preview"
className="h-full w-full object-cover rounded-md"
/>
<button
onClick={handleRemove}
type="button"
className="absolute top-3 right-3 p-1 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors shadow-lg"
>
<X size={16} />
</button>
{uploading && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center rounded-md">
<Loader2 className="animate-spin text-white" size={32} />
</div>
)}
{!uploading && !error && (
<div className="absolute bottom-3 right-3 p-1.5 bg-green-500 text-white rounded-full shadow-lg">
<CheckCircle size={16} />
</div>
)}
</div>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-slate-400">
{uploading ? (
<Loader2 className="animate-spin mb-2" size={24} />
) : (
<Upload className="mb-2" size={24} />
)}
<span className="text-sm">Click to upload image</span>
<input
type="file"
accept="image/*"
className="absolute inset-0 opacity-0 cursor-pointer"
onChange={handleFileChange}
disabled={uploading}
/>
</div>
)}
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
)
}

View File

@@ -0,0 +1,35 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { cn } from '@/lib/utils'
interface MarkdownViewerProps {
content: string
className?: string
}
export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
return (
<div className={cn("prose prose-slate dark:prose-invert max-w-none", className)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
img: ({node, ...props}) => (
<img
{...props}
className="rounded-lg shadow-md my-6 w-full object-cover max-h-[500px]"
loading="lazy"
/>
),
a: ({node, ...props}) => (
<a {...props} className="text-blue-600 hover:underline" target="_blank" rel="noopener noreferrer" />
),
pre: ({node, ...props}) => (
<pre {...props} className="bg-slate-900 text-slate-50 p-4 rounded-lg overflow-x-auto my-4" />
)
}}
>
{content}
</ReactMarkdown>
</div>
)
}

View File

@@ -0,0 +1,71 @@
import { Link } from 'react-router-dom'
import { ExternalLink, Github } from 'lucide-react'
import { cn } from '@/lib/utils'
interface ProjectCardProps {
project: any // Type this properly later
}
export function ProjectCard({ project }: ProjectCardProps) {
return (
<div className="group relative bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden hover:shadow-xl transition-all duration-300 flex flex-col h-full">
{/* Image */}
<div className="aspect-video w-full overflow-hidden bg-slate-100 dark:bg-slate-800">
{project.cover_image ? (
<img
src={project.cover_image}
alt={project.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-slate-400">
No Image
</div>
)}
</div>
{/* Content */}
<div className="p-5 flex flex-col flex-1">
<h3 className="text-xl font-bold mb-2 group-hover:text-blue-500 transition-colors">
{project.title}
</h3>
<p className="text-slate-600 dark:text-slate-400 text-sm mb-4 line-clamp-3 flex-1">
{project.description}
</p>
{/* Tech Stack */}
<div className="flex flex-wrap gap-2 mb-4">
{project.tech_stack?.map((tech: string) => (
<span key={tech} className="px-2 py-1 text-xs bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 rounded-md">
{tech}
</span>
))}
</div>
{/* Links */}
<div className="flex items-center gap-4 mt-auto pt-4 border-t border-slate-100 dark:border-slate-800">
{project.demo_url && (
<a
href={project.demo_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm font-medium text-blue-600 hover:underline"
>
<ExternalLink size={14} /> Live Demo
</a>
)}
{project.repo_url && (
<a
href={project.repo_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm font-medium text-slate-600 hover:text-black dark:text-slate-400 dark:hover:text-white"
>
<Github size={14} /> Code
</a>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { type LucideIcon } from 'lucide-react'
interface StatCardProps {
label: string
value: string | number
icon: LucideIcon
color: string
}
export function StatCard({ label, value, icon: Icon, color }: StatCardProps) {
return (
<div className="bg-white dark:bg-slate-900 p-6 rounded-xl border border-slate-100 dark:border-slate-800 shadow-sm flex items-center gap-4">
<div className={`p-4 rounded-lg bg-opacity-10 ${color.replace('text-', 'bg-')}`}>
<Icon className={color} size={24} />
</div>
<div>
<p className="text-slate-500 text-sm font-medium">{label}</p>
<h3 className="text-2xl font-bold text-slate-800 dark:text-slate-200">{value}</h3>
</div>
</div>
)
}

67
src/hooks/useUpload.tsx Normal file
View File

@@ -0,0 +1,67 @@
import { useState } from 'react';
interface UploadResult {
url: string;
fileName: string;
}
export const useUpload = () => {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const uploadFile = async (file: File, folder: string = 'uploads'): Promise<UploadResult | null> => {
setUploading(true);
setError(null);
try {
// 1. Get Presigned URL from Worker
const workerUrl = import.meta.env.VITE_WORKER_URL;
const fileName = `${folder}/${Date.now()}-${file.name.replace(/\s+/g, '-')}`;
const response = await fetch(workerUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileName,
fileType: file.type,
}),
});
if (!response.ok) {
throw new Error('Failed to get upload URL');
}
const { url: presignedUrl } = await response.json();
// 2. Upload to R2 directly
const uploadResponse = await fetch(presignedUrl, {
method: 'PUT',
headers: {
'Content-Type': file.type,
},
body: file,
});
if (!uploadResponse.ok) {
throw new Error('Failed to upload file to storage');
}
// 3. Return Public URL
const publicDomain = import.meta.env.VITE_R2_PUBLIC_DOMAIN;
const publicUrl = `${publicDomain}/${fileName}`;
return { url: publicUrl, fileName };
} catch (err: any) {
console.error('Upload Error:', err);
setError(err.message || 'Upload failed');
return null;
} finally {
setUploading(false);
}
};
return { uploadFile, uploading, error };
};

View File

@@ -1,24 +1,58 @@
import { useEffect, useState } from 'react'
import { supabase } from '@/lib/supabase' import { supabase } from '@/lib/supabase'
import { useNavigate } from 'react-router-dom' import { StatCard } from '@/components/ui/stat-card'
import { FileText, Briefcase, Plus } from 'lucide-react'
import { Link } from 'react-router-dom'
export default function Dashboard() { export default function Dashboard() {
const navigate = useNavigate() const [stats, setStats] = useState({ projects: 0, posts: 0 })
const handleLogout = async () => { useEffect(() => {
await supabase.auth.signOut() fetchStats()
navigate('/') }, [])
const fetchStats = async () => {
const [proj, posts] = await Promise.all([
supabase.from('projects').select('*', { count: 'exact', head: true }),
supabase.from('posts').select('*', { count: 'exact', head: true }),
])
setStats({
projects: proj.count || 0,
posts: posts.count || 0,
})
} }
return ( return (
<div className="p-8"> <div>
<h1 className="text-3xl font-bold mb-4">Admin Dashboard</h1> <h1 className="text-3xl font-bold mb-8">Dashboard Overview</h1>
<p className="mb-4">Welcome, Admin!</p>
<button {/* Stats Grid */}
onClick={handleLogout} <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600" <StatCard
> label="Total Projects"
Logout value={stats.projects}
</button> icon={Briefcase}
color="text-blue-500"
/>
<StatCard
label="Blog Posts"
value={stats.posts}
icon={FileText}
color="text-purple-500"
/>
</div>
{/* Quick Actions */}
<h2 className="text-xl font-bold mb-4">Quick Actions</h2>
<div className="flex gap-4">
<Link to="/admin/projects/new" className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
<Plus size={18} /> Add Project
</Link>
<Link to="/admin/posts/new" className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition">
<Plus size={18} /> Write Article
</Link>
</div>
</div> </div>
) )
} }

View File

@@ -0,0 +1,148 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { supabase } from '@/lib/supabase'
import { ImageUpload } from '@/components/ui/image-upload'
import { Loader2, Save, ArrowLeft } from 'lucide-react'
export default function PostEditor() {
const { id } = useParams()
const navigate = useNavigate()
const isEditing = id !== 'new'
const [loading, setLoading] = useState(false)
const [formData, setFormData] = useState({
title: '',
slug: '',
excerpt: '',
content: '',
cover_image: '',
tags: [] as string[],
published: false
})
useEffect(() => {
if (isEditing) fetchPost(id!)
}, [id])
const fetchPost = async (id: string) => {
const { data } = await supabase.from('posts').select('*').eq('id', id).single()
if (data) setFormData({ ...data, tags: data.tags || [] })
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value })
}
const handleSlugGen = () => {
const slug = formData.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
setFormData(prev => ({ ...prev, slug }))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
if (isEditing) {
await supabase.from('posts').update(formData).eq('id', id)
} else {
await supabase.from('posts').insert([formData])
}
navigate('/admin')
} catch (error) {
console.error('Error saving post:', error)
alert('Error saving post')
} finally {
setLoading(false)
}
}
return (
<div className="max-w-4xl mx-auto">
<div className="flex items-center gap-4 mb-6">
<button onClick={() => navigate('/admin')} className="text-slate-500 hover:text-blue-500">
<ArrowLeft />
</button>
<h1 className="text-3xl font-bold">{isEditing ? 'Edit Article' : 'New Article'}</h1>
</div>
<form onSubmit={handleSubmit} className="space-y-6 bg-white dark:bg-slate-900 p-8 rounded-xl border border-slate-100 dark:border-slate-800 shadow-sm">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium">Title</label>
<input
name="title"
value={formData.title}
onChange={handleChange}
onBlur={handleSlugGen}
className="w-full p-2 border rounded-lg bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700"
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Slug</label>
<input
name="slug"
value={formData.slug}
onChange={handleChange}
className="w-full p-2 border rounded-lg bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700 font-mono text-sm"
required
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Cover Image</label>
<ImageUpload
currentImage={formData.cover_image}
onUpload={(url) => setFormData({...formData, cover_image: url})}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Excerpt (Summary)</label>
<textarea
name="excerpt"
value={formData.excerpt}
onChange={handleChange}
rows={2}
className="w-full p-2 border rounded-lg bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Content (Markdown)</label>
<textarea
name="content"
value={formData.content}
onChange={handleChange}
rows={15}
className="w-full p-2 border rounded-lg bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700 font-mono"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="published"
checked={formData.published}
onChange={(e) => setFormData({...formData, published: e.target.checked})}
className="w-5 h-5"
/>
<label htmlFor="published" className="font-medium cursor-pointer">Publish Article</label>
</div>
<button
type="submit"
disabled={loading}
className="w-full md:w-auto px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition flex items-center justify-center gap-2"
>
{loading ? <Loader2 className="animate-spin" /> : <Save size={18} />}
Save Article
</button>
</form>
</div>
)
}

View File

@@ -0,0 +1,92 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { supabase } from '@/lib/supabase'
import { Loader2, Plus, Pencil, Trash2, Eye } from 'lucide-react'
export default function PostsList() {
const [posts, setPosts] = useState<any[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchPosts()
}, [])
const fetchPosts = async () => {
const { data } = await supabase.from('posts').select('*').order('created_at', { ascending: false })
if (data) setPosts(data)
setLoading(false)
}
const handleDelete = async (id: string) => {
if (!window.confirm('Are you sure you want to delete this post?')) return
await supabase.from('posts').delete().eq('id', id)
setPosts(posts.filter(p => p.id !== id))
}
if (loading) return <div className="flex justify-center p-12"><Loader2 className="animate-spin" /></div>
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Manage Articles</h1>
<Link to="/admin/posts/new" className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition">
<Plus size={18} /> New Article
</Link>
</div>
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-100 dark:border-slate-800 shadow-sm overflow-hidden">
<table className="w-full text-left">
<thead className="bg-slate-50 dark:bg-slate-800/50 text-slate-500 font-medium border-b border-slate-100 dark:border-slate-800">
<tr>
<th className="p-4">Title</th>
<th className="p-4 hidden md:table-cell">Status</th>
<th className="p-4 hidden md:table-cell">Views</th>
<th className="p-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{posts.map((post) => (
<tr key={post.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<td className="p-4">
<div className="font-medium">{post.title}</div>
<div className="text-xs text-slate-400 font-mono mt-1">/{post.slug}</div>
</td>
<td className="p-4 hidden md:table-cell">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
post.published
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
}`}>
{post.published ? 'Published' : 'Draft'}
</span>
</td>
<td className="p-4 hidden md:table-cell text-slate-500">
{post.view_count || 0}
</td>
<td className="p-4 text-right space-x-2">
<a href={`/blog/${post.slug}`} target="_blank" className="inline-flex p-2 text-slate-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded-lg transition-colors">
<Eye size={18} />
</a>
<Link to={`/admin/posts/${post.id}`} className="inline-flex p-2 text-slate-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded-lg transition-colors">
<Pencil size={18} />
</Link>
<button onClick={() => handleDelete(post.id)} className="inline-flex p-2 text-slate-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors">
<Trash2 size={18} />
</button>
</td>
</tr>
))}
{posts.length === 0 && (
<tr>
<td colSpan={4} className="p-8 text-center text-slate-500">
No articles found. Write your first one!
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,189 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { supabase } from '@/lib/supabase'
import { ImageUpload } from '@/components/ui/image-upload'
import { Loader2, Save, ArrowLeft } from 'lucide-react'
export default function ProjectEditor() {
const { id } = useParams()
const navigate = useNavigate()
const isEditing = id !== 'new'
const [loading, setLoading] = useState(false)
const [formData, setFormData] = useState({
title: '',
slug: '',
description: '',
content: '',
cover_image: '',
demo_url: '',
repo_url: '',
tech_stack: [] as string[],
featured: false
})
// Tag input state
const [tagInput, setTagInput] = useState('')
useEffect(() => {
if (isEditing) fetchProject(id!)
}, [id])
const fetchProject = async (id: string) => {
const { data } = await supabase.from('projects').select('*').eq('id', id).single()
if (data) setFormData({ ...data, tech_stack: data.tech_stack || [] })
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value })
}
const handleSlugGen = () => {
// Auto-generate slug from title
const slug = formData.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
setFormData(prev => ({ ...prev, slug }))
}
const handleAddTag = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && tagInput.trim()) {
e.preventDefault()
if (!formData.tech_stack.includes(tagInput.trim())) {
setFormData(prev => ({ ...prev, tech_stack: [...prev.tech_stack, tagInput.trim()] }))
}
setTagInput('')
}
}
const removeTag = (tagFn: string) => {
setFormData(prev => ({ ...prev, tech_stack: prev.tech_stack.filter(t => t !== tagFn) }))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
if (isEditing) {
await supabase.from('projects').update(formData).eq('id', id)
} else {
await supabase.from('projects').insert([formData])
}
navigate('/admin')
} catch (error) {
console.error('Error saving project:', error)
alert('Error saving project')
} finally {
setLoading(false)
}
}
return (
<div className="max-w-4xl mx-auto">
<div className="flex items-center gap-4 mb-6">
<button onClick={() => navigate('/admin')} className="text-slate-500 hover:text-blue-500">
<ArrowLeft />
</button>
<h1 className="text-3xl font-bold">{isEditing ? 'Edit Project' : 'New Project'}</h1>
</div>
<form onSubmit={handleSubmit} className="space-y-6 bg-white dark:bg-slate-900 p-8 rounded-xl border border-slate-100 dark:border-slate-800 shadow-sm">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium">Title</label>
<input
name="title"
value={formData.title}
onChange={handleChange}
onBlur={handleSlugGen}
className="w-full p-2 border rounded-lg bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700"
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Slug (URL)</label>
<input
name="slug"
value={formData.slug}
onChange={handleChange}
className="w-full p-2 border rounded-lg bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700 font-mono text-sm"
required
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Cover Image</label>
<ImageUpload
currentImage={formData.cover_image}
onUpload={(url) => setFormData({...formData, cover_image: url})}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Short Description</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
className="w-full p-2 border rounded-lg bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700"
/>
</div>
{/* Tech Stack Tags */}
<div className="space-y-2">
<label className="text-sm font-medium">Tech Stack (Press Enter)</label>
<input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleAddTag}
className="w-full p-2 border rounded-lg bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700"
placeholder="React, Supabase, etc..."
/>
<div className="flex flex-wrap gap-2 mt-2">
{formData.tech_stack.map(tag => (
<span key={tag} className="px-2 py-1 bg-blue-100 text-blue-700 rounded-md text-sm flex items-center gap-1">
{tag}
<button type="button" onClick={() => removeTag(tag)} className="hover:text-blue-900">×</button>
</span>
))}
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Content (Markdown supported)</label>
<textarea
name="content"
value={formData.content}
onChange={handleChange}
rows={10}
className="w-full p-2 border rounded-lg bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700 font-mono"
/>
<p className="text-xs text-slate-500">Tip: You can drag & drop images into a markdown editor later, for now paste URLs.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium">Demo URL</label>
<input name="demo_url" value={formData.demo_url} onChange={handleChange} className="w-full p-2 border rounded-lg bg-slate-50 dark:bg-slate-800" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Repo URL</label>
<input name="repo_url" value={formData.repo_url} onChange={handleChange} className="w-full p-2 border rounded-lg bg-slate-50 dark:bg-slate-800" />
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full md:w-auto px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition flex items-center justify-center gap-2"
>
{loading ? <Loader2 className="animate-spin" /> : <Save size={18} />}
Save Project
</button>
</form>
</div>
)
}

View File

@@ -0,0 +1,94 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { supabase } from '@/lib/supabase'
import { Loader2, Plus, Pencil, Trash2, ExternalLink } from 'lucide-react'
export default function ProjectsList() {
const [projects, setProjects] = useState<any[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchProjects()
}, [])
const fetchProjects = async () => {
const { data } = await supabase.from('projects').select('*').order('created_at', { ascending: false })
if (data) setProjects(data)
setLoading(false)
}
const handleDelete = async (id: string) => {
if (!window.confirm('Are you sure you want to delete this project?')) return
await supabase.from('projects').delete().eq('id', id)
setProjects(projects.filter(p => p.id !== id))
}
if (loading) return <div className="flex justify-center p-12"><Loader2 className="animate-spin" /></div>
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Manage Projects</h1>
<Link to="/admin/projects/new" className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
<Plus size={18} /> New Project
</Link>
</div>
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-100 dark:border-slate-800 shadow-sm overflow-hidden">
<table className="w-full text-left">
<thead className="bg-slate-50 dark:bg-slate-800/50 text-slate-500 font-medium border-b border-slate-100 dark:border-slate-800">
<tr>
<th className="p-4">Title</th>
<th className="p-4 hidden md:table-cell">Tech Stack</th>
<th className="p-4 hidden md:table-cell">Links</th>
<th className="p-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{projects.map((project) => (
<tr key={project.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<td className="p-4">
<div className="font-medium">{project.title}</div>
<div className="text-xs text-slate-400 font-mono mt-1">/{project.slug}</div>
</td>
<td className="p-4 hidden md:table-cell">
<div className="flex flex-wrap gap-1">
{project.tech_stack?.slice(0, 3).map((t: string) => (
<span key={t} className="px-2 py-0.5 bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 text-xs rounded">
{t}
</span>
))}
{project.tech_stack?.length > 3 && <span className="text-xs text-slate-400">+{project.tech_stack.length - 3}</span>}
</div>
</td>
<td className="p-4 hidden md:table-cell">
{project.demo_url && (
<a href={project.demo_url} target="_blank" rel="noopener" className="text-blue-500 hover:underline text-sm flex items-center gap-1">
<ExternalLink size={12} /> Live
</a>
)}
</td>
<td className="p-4 text-right space-x-2">
<Link to={`/admin/projects/${project.id}`} className="inline-flex p-2 text-slate-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded-lg transition-colors">
<Pencil size={18} />
</Link>
<button onClick={() => handleDelete(project.id)} className="inline-flex p-2 text-slate-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors">
<Trash2 size={18} />
</button>
</td>
</tr>
))}
{projects.length === 0 && (
<tr>
<td colSpan={4} className="p-8 text-center text-slate-500">
No projects found. Create your first one!
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}

68
src/pages/public/Blog.tsx Normal file
View File

@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react'
import { supabase } from '@/lib/supabase'
import { Link } from 'react-router-dom'
import { Loader2, Calendar } from 'lucide-react'
export default function Blog() {
const [posts, setPosts] = useState<any[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchPosts()
}, [])
const fetchPosts = async () => {
const { data, error } = await supabase
.from('posts')
.select('*')
.eq('published', true)
.order('created_at', { ascending: false })
if (!error && data) {
setPosts(data)
}
setLoading(false)
}
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin" /></div>
return (
<div className="container mx-auto px-4 py-12 max-w-4xl">
<h1 className="text-4xl font-bold mb-8 text-center">Blog & Tutorials</h1>
<div className="space-y-8">
{posts.map((post) => (
<article key={post.id} className="flex flex-col md:flex-row gap-6 border-b border-slate-100 dark:border-slate-800 pb-8 last:border-0 hover:bg-slate-50 dark:hover:bg-slate-900/50 p-4 rounded-xl transition-colors">
{post.cover_image && (
<div className="w-full md:w-48 h-32 flex-shrink-0 rounded-lg overflow-hidden bg-slate-200">
<img src={post.cover_image} alt={post.title} className="w-full h-full object-cover" />
</div>
)}
<div className="flex-1">
<div className="flex items-center gap-2 text-sm text-slate-500 mb-2">
<Calendar size={14} />
{new Date(post.created_at).toLocaleDateString()}
<div className="flex gap-2">
{post.tags?.map((tag: string) => (
<span key={tag} className="text-blue-500 font-medium">#{tag}</span>
))}
</div>
</div>
<Link to={`/blog/${post.slug}`}>
<h2 className="text-2xl font-bold mb-2 hover:text-blue-600 transition-colors">
{post.title}
</h2>
</Link>
<p className="text-slate-600 dark:text-slate-400 line-clamp-2">
{post.excerpt || 'No description available.'}
</p>
<Link to={`/blog/${post.slug}`} className="inline-block mt-4 text-sm font-medium text-blue-600 hover:underline">
Read Article
</Link>
</div>
</article>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,73 @@
import { useEffect, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { supabase } from '@/lib/supabase'
import { MarkdownViewer } from '@/components/ui/markdown-viewer'
import { Loader2, ArrowLeft, Calendar } from 'lucide-react'
export default function BlogPost() {
const { slug } = useParams()
const [post, setPost] = useState<any>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (slug) fetchPost(slug)
}, [slug])
const fetchPost = async (slug: string) => {
const { data, error } = await supabase
.from('posts')
.select('*')
.eq('slug', slug)
.eq('published', true)
.single()
if (!error && data) {
setPost(data)
}
setLoading(false)
}
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin" /></div>
if (!post) {
return (
<div className="container mx-auto px-4 py-20 text-center">
<h1 className="text-2xl font-bold mb-4">Post not found</h1>
<Link to="/blog" className="text-blue-600 hover:underline"> Back to Blog</Link>
</div>
)
}
return (
<article className="container mx-auto px-4 py-12 max-w-3xl">
<Link to="/blog" className="inline-flex items-center gap-2 text-sm text-slate-500 hover:text-blue-600 mb-8 transition-colors">
<ArrowLeft size={16} /> Back to Blog
</Link>
<header className="mb-8 items-center text-center">
<div className="flex items-center justify-center gap-2 text-sm text-slate-500 mb-4">
<Calendar size={14} />
{new Date(post.created_at).toLocaleDateString()}
<span></span>
<div className="flex gap-2">
{post.tags?.map((tag: string) => (
<span key={tag} className="text-blue-500 font-medium">#{tag}</span>
))}
</div>
</div>
<h1 className="text-4xl md:text-5xl font-bold mb-6 bg-clip-text text-transparent bg-gradient-to-r from-slate-900 to-slate-700 dark:from-white dark:to-slate-300">
{post.title}
</h1>
{post.cover_image && (
<div className="w-full h-[300px] md:h-[400px] rounded-2xl overflow-hidden shadow-lg mb-8">
<img src={post.cover_image} alt={post.title} className="w-full h-full object-cover" />
</div>
)}
</header>
<div className="bg-white dark:bg-slate-950">
<MarkdownViewer content={post.content || ''} />
</div>
</article>
)
}

View File

@@ -1,11 +1,125 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ArrowRight, Code, Terminal, Zap } from 'lucide-react'
import { supabase } from '@/lib/supabase'
import { ProjectCard } from '@/components/ui/project-card'
import { motion } from 'framer-motion'
export default function Home() { export default function Home() {
const [data, setData] = useState<{projects: any[], posts: any[]}>({ projects: [], posts: [] })
useEffect(() => {
fetchHomeData()
}, [])
const fetchHomeData = async () => {
const [projectsRes, postsRes] = await Promise.all([
supabase.from('projects').select('*').limit(3).order('created_at', { ascending: false }),
supabase.from('posts').select('*').limit(3).eq('published', true).order('created_at', { ascending: false })
])
setData({
projects: projectsRes.data || [],
posts: postsRes.data || []
})
}
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen"> <div className="flex flex-col min-h-screen">
<h1 className="text-4xl font-bold mb-4">Dyzulk Portfolio</h1> {/* Hero Section */}
<p className="mb-4">Welcome to the future portfolio.</p> <section className="relative h-[80vh] flex items-center justify-center overflow-hidden bg-slate-50 dark:bg-slate-950">
<Link to="/admin" className="text-blue-500 hover:underline">Go to Admin Dashboard</Link> <div className="absolute inset-0 bg-grid-slate-200/50 dark:bg-grid-slate-800/50 [mask-image:linear-gradient(0deg,white,rgba(255,255,255,0.6))] dark:[mask-image:linear-gradient(0deg,rgba(255,255,255,0.1),rgba(255,255,255,0.5))]" />
<div className="container px-4 text-center z-10">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<h1 className="text-5xl md:text-7xl font-bold mb-6 bg-clip-text text-transparent bg-gradient-to-r from-blue-600 to-cyan-500">
Build. Deploy. Scale.
</h1>
<p className="text-xl text-slate-600 dark:text-slate-300 max-w-2xl mx-auto mb-8">
Full-stack developer specializing in modern web technologies, serverless architectures, and high-performance applications.
</p>
<div className="flex justify-center gap-4">
<Link to="/projects" className="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors">
View Work
</Link>
<Link to="/blog" className="px-6 py-3 border border-slate-300 dark:border-slate-700 rounded-lg font-medium hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
Read Blog
</Link>
</div>
</motion.div>
</div>
</section>
{/* Services/Tech Stack */}
<section className="py-20 bg-white dark:bg-slate-900 border-y border-slate-100 dark:border-slate-800">
<div className="container px-4 grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
{[
{ icon: Code, title: "Frontend Dev", desc: "React, TypeScript, Tailwind CSS, Next.js" },
{ icon: Terminal, title: "Backend Systems", desc: "Node.js, Supabase, PostgreSQL, APIs" },
{ icon: Zap, title: "Performance", desc: "Serverless (Cloudflare), Optimization, SEO" }
].map((item, i) => (
<div key={i} className="p-6 rounded-2xl bg-slate-50 dark:bg-slate-800/50">
<item.icon className="mx-auto mb-4 text-blue-500" size={32} />
<h3 className="text-xl font-bold mb-2">{item.title}</h3>
<p className="text-slate-600 dark:text-slate-400">{item.desc}</p>
</div>
))}
</div>
</section>
{/* Recent Projects */}
<section className="py-24 container px-4 mx-auto">
<div className="flex justify-between items-end mb-12">
<div>
<h2 className="text-3xl font-bold mb-2">Featured Projects</h2>
<p className="text-slate-500">Some of my latest work.</p>
</div>
<Link to="/projects" className="text-blue-600 hover:underline flex items-center gap-1">
View All <ArrowRight size={16} />
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data.projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
</section>
{/* Recent Posts */}
<section className="py-24 bg-slate-50 dark:bg-slate-900/50">
<div className="container px-4 mx-auto">
<div className="flex justify-between items-end mb-12">
<div>
<h2 className="text-3xl font-bold mb-2">Latest Articles</h2>
<p className="text-slate-500">Thoughts on technology and coding.</p>
</div>
<Link to="/blog" className="text-blue-600 hover:underline flex items-center gap-1">
Read Blog <ArrowRight size={16} />
</Link>
</div>
<div className="space-y-6 max-w-4xl mx-auto">
{data.posts.map((post) => (
<Link to={`/blog/${post.slug}`} key={post.id} className="block group">
<article className="flex justify-between items-start p-6 bg-white dark:bg-slate-900 rounded-xl border border-slate-100 dark:border-slate-800 hover:shadow-md transition-shadow">
<div>
<h3 className="text-xl font-bold mb-2 group-hover:text-blue-600 transition-colors">
{post.title}
</h3>
<p className="text-slate-600 dark:text-slate-400 line-clamp-2 mb-2">
{post.excerpt}
</p>
<span className="text-sm text-slate-500">{new Date(post.created_at).toLocaleDateString()}</span>
</div>
</article>
</Link>
))}
</div>
</div>
</section>
</div> </div>
) )
} }

View File

@@ -0,0 +1,56 @@
import { useEffect, useState } from 'react'
import { supabase } from '@/lib/supabase'
import { ProjectCard } from '@/components/ui/project-card'
import { Loader2 } from 'lucide-react'
export default function Projects() {
const [projects, setProjects] = useState<any[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchProjects()
}, [])
const fetchProjects = async () => {
const { data, error } = await supabase
.from('projects')
.select('*')
.order('created_at', { ascending: false })
if (!error && data) {
setProjects(data)
}
setLoading(false)
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<Loader2 className="animate-spin text-blue-500" size={32} />
</div>
)
}
return (
<div className="container mx-auto px-4 py-12">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold mb-4">My Portfolio</h1>
<p className="text-slate-600 dark:text-slate-400 max-w-2xl mx-auto">
Showcase of my detailed projects and technical case studies.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
{projects.length === 0 && (
<div className="text-center text-slate-500 py-12">
No projects found yet.
</div>
)}
</div>
)
}

78
supabase_schema.sql Normal file
View File

@@ -0,0 +1,78 @@
-- Dyzulk Portfolio & Blog Schema (Final Fix)
-- We renamed the constraints to avoid the "already exists" error completely.
-- 1. CLEANUP (Just in case)
drop table if exists public.posts cascade;
drop table if exists public.projects cascade;
drop table if exists public.profiles cascade;
-- 2. POSTS TABLE
create table public.posts (
id uuid not null default gen_random_uuid(),
title text not null,
slug text not null unique,
excerpt text null,
content text null,
cover_image text null,
tags text[] default '{}',
published boolean default false,
view_count integer default 0,
created_at timestamp with time zone not null default now(),
updated_at timestamp with time zone not null default now(),
constraint posts_pkey_new primary key (id) -- Renamed
);
-- 3. PROJECTS TABLE
create table public.projects (
id uuid not null default gen_random_uuid(),
title text not null,
slug text not null unique,
description text null,
content text null,
cover_image text null,
gallery_images text[] default '{}',
tech_stack text[] default '{}',
demo_url text null,
repo_url text null,
featured boolean default false,
created_at timestamp with time zone not null default now(),
updated_at timestamp with time zone not null default now(),
constraint projects_pkey_new primary key (id) -- Renamed
);
-- 4. PROFILES TABLE
create table public.profiles (
id uuid references auth.users not null,
full_name text null,
avatar_url text null,
bio text null,
updated_at timestamp with time zone,
constraint profiles_pkey_new primary key (id), -- Renamed
constraint profiles_auth_fk foreign key (id) references auth.users (id) on delete cascade -- Renamed to profiles_auth_fk
);
-- 5. ENABLE RLS
alter table public.posts enable row level security;
alter table public.projects enable row level security;
alter table public.profiles enable row level security;
-- 6. POLICIES
create policy "Public posts reading" on public.posts for select using (true);
create policy "Public projects reading" on public.projects for select using (true);
create policy "Public profiles reading" on public.profiles for select using (true);
create policy "Admin insert posts" on public.posts for insert to authenticated with check (true);
create policy "Admin update posts" on public.posts for update to authenticated using (true);
create policy "Admin delete posts" on public.posts for delete to authenticated using (true);
create policy "Admin insert projects" on public.projects for insert to authenticated with check (true);
create policy "Admin update projects" on public.projects for update to authenticated using (true);
create policy "Admin delete projects" on public.projects for delete to authenticated using (true);
create policy "User update own profile" on public.profiles for update to authenticated using (auth.uid() = id);
create policy "User insert own profile" on public.profiles for insert to authenticated with check (auth.uid() = id);
-- 7. INDEXES
create index posts_slug_idx_new on public.posts (slug);
create index projects_slug_idx_new on public.projects (slug);
create index posts_tags_idx_new on public.posts using gin (tags);