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",
|
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
25
src/App.tsx
25
src/App.tsx
@@ -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>
|
||||||
|
|||||||
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 { 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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