mirror of
https://github.com/dyzulk/dyzulk.github.io.git
synced 2026-01-26 05:45:28 +07:00
Finalize schema and add deployment workflow
This commit is contained in:
56
.github/workflows/deploy-site.yml
vendored
Normal file
56
.github/workflows/deploy-site.yml
vendored
Normal 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
1474
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,9 @@
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
|
||||
21
src/App.tsx
21
src/App.tsx
@@ -1,9 +1,18 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import { AuthProvider } from '@/hooks/useAuth'
|
||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||
import AdminLayout from '@/components/layout/AdminLayout'
|
||||
import PublicLayout from '@/components/layout/PublicLayout'
|
||||
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 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() {
|
||||
return (
|
||||
@@ -11,12 +20,24 @@ function App() {
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public Routes */}
|
||||
<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 />} />
|
||||
|
||||
{/* Admin Protected Routes */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<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>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
13
src/components/layout/AdminLayout.tsx
Normal file
13
src/components/layout/AdminLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
src/components/layout/AdminSidebar.tsx
Normal file
65
src/components/layout/AdminSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
src/components/layout/Navbar.tsx
Normal file
37
src/components/layout/Navbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
src/components/layout/PublicLayout.tsx
Normal file
16
src/components/layout/PublicLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
src/components/ui/image-upload.tsx
Normal file
91
src/components/ui/image-upload.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
src/components/ui/markdown-viewer.tsx
Normal file
35
src/components/ui/markdown-viewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
71
src/components/ui/project-card.tsx
Normal file
71
src/components/ui/project-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
src/components/ui/stat-card.tsx
Normal file
22
src/components/ui/stat-card.tsx
Normal 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
67
src/hooks/useUpload.tsx
Normal 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 };
|
||||
};
|
||||
@@ -1,24 +1,58 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
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() {
|
||||
const navigate = useNavigate()
|
||||
const [stats, setStats] = useState({ projects: 0, posts: 0 })
|
||||
|
||||
const handleLogout = async () => {
|
||||
await supabase.auth.signOut()
|
||||
navigate('/')
|
||||
useEffect(() => {
|
||||
fetchStats()
|
||||
}, [])
|
||||
|
||||
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 (
|
||||
<div className="p-8">
|
||||
<h1 className="text-3xl font-bold mb-4">Admin Dashboard</h1>
|
||||
<p className="mb-4">Welcome, Admin!</p>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-8">Dashboard Overview</h1>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
|
||||
<StatCard
|
||||
label="Total Projects"
|
||||
value={stats.projects}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
148
src/pages/admin/PostEditor.tsx
Normal file
148
src/pages/admin/PostEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
92
src/pages/admin/PostsList.tsx
Normal file
92
src/pages/admin/PostsList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
189
src/pages/admin/ProjectEditor.tsx
Normal file
189
src/pages/admin/ProjectEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
94
src/pages/admin/ProjectsList.tsx
Normal file
94
src/pages/admin/ProjectsList.tsx
Normal 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
68
src/pages/public/Blog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
src/pages/public/BlogPost.tsx
Normal file
73
src/pages/public/BlogPost.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,125 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
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() {
|
||||
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 (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<h1 className="text-4xl font-bold mb-4">Dyzulk Portfolio</h1>
|
||||
<p className="mb-4">Welcome to the future portfolio.</p>
|
||||
<Link to="/admin" className="text-blue-500 hover:underline">Go to Admin Dashboard</Link>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
{/* Hero Section */}
|
||||
<section className="relative h-[80vh] flex items-center justify-center overflow-hidden bg-slate-50 dark:bg-slate-950">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
56
src/pages/public/Projects.tsx
Normal file
56
src/pages/public/Projects.tsx
Normal 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
78
supabase_schema.sql
Normal 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);
|
||||
Reference in New Issue
Block a user