bento-grid login page, mobile friendly docs page

This commit is contained in:
colinislit
2025-11-20 20:14:29 +01:00
parent 540bffa9ab
commit 5f7ef0801d
8 changed files with 220 additions and 83 deletions

View File

@@ -52,8 +52,8 @@ export default async function ReleasePage({ params }: ReleasePageProps) {
const { frontmatter, content } = release const { frontmatter, content } = release
return ( return (
<div className="min-h-screen bg-white pt-24 pb-16"> <div className="min-h-screen bg-white pb-16">
<article className="max-w-4xl mx-auto px-4 md:px-8"> <article className="max-w-4xl mx-auto px-4 md:px-8 pt-20 md:pt-8">
{/* Header */} {/* Header */}
<header className="mb-8 pb-8 border-b border-slate-200"> <header className="mb-8 pb-8 border-b border-slate-200">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">

View File

@@ -9,8 +9,8 @@
import Link from 'next/link' import Link from 'next/link'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { ChevronRight, ChevronDown } from 'lucide-react' import { ChevronRight, ChevronDown, ChevronLeft, X } from 'lucide-react'
import { useState } from 'react' import { useState, useEffect } from 'react'
import type { ReleaseNote, GroupMetadata, CategoryMetadata } from '@/lib/mdx/documentatie' import type { ReleaseNote, GroupMetadata, CategoryMetadata } from '@/lib/mdx/documentatie'
interface ReleaseSidebarProps { interface ReleaseSidebarProps {
@@ -23,6 +23,7 @@ interface ReleaseSidebarProps {
export function ReleaseSidebar({ releases, metadata }: ReleaseSidebarProps) { export function ReleaseSidebar({ releases, metadata }: ReleaseSidebarProps) {
const pathname = usePathname() const pathname = usePathname()
const [isExpanded, setIsExpanded] = useState(false)
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({ const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({
foundation: true, foundation: true,
features: true, features: true,
@@ -30,6 +31,11 @@ export function ReleaseSidebar({ releases, metadata }: ReleaseSidebarProps) {
bugs: true, bugs: true,
}) })
// Auto-close sidebar on navigation
useEffect(() => {
setIsExpanded(false)
}, [pathname])
const toggleGroup = (groupId: string) => { const toggleGroup = (groupId: string) => {
setExpandedGroups(prev => ({ setExpandedGroups(prev => ({
...prev, ...prev,
@@ -50,34 +56,125 @@ export function ReleaseSidebar({ releases, metadata }: ReleaseSidebarProps) {
return ( return (
<> <>
{/* Mobile: Horizontal scroll menu */} {/* MOBILE: Overlay when expanded */}
<div className="lg:hidden bg-white border-b border-slate-200 sticky top-16 z-40"> {isExpanded && (
<div className="flex gap-2 p-4 overflow-x-auto"> <div
<Link className="lg:hidden fixed inset-0 bg-black/40 backdrop-blur-sm z-30 top-16"
href="/documentatie" onClick={() => setIsExpanded(false)}
className={`flex-shrink-0 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${pathname === '/documentatie' aria-hidden="true"
? 'bg-teal-100 text-teal-700' />
: 'bg-slate-100 text-slate-600 hover:bg-slate-200' )}
}`}
>
Overzicht
</Link>
{releases.map((release) => (
<Link
key={release.slug}
href={`/documentatie/${release.slug}`}
className={`flex-shrink-0 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${pathname === `/documentatie/${release.slug}`
? 'bg-teal-100 text-teal-700'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{release.frontmatter.title}
</Link>
))}
</div>
</div>
{/* Desktop: Fixed sidebar */} {/* MOBILE: Collapsible sidebar from left */}
<aside
className={`
lg:hidden
fixed top-16 bottom-0 left-0 z-40
bg-white border-r border-slate-200
transition-all duration-300 ease-in-out
${isExpanded ? 'w-80 shadow-2xl' : 'w-12'}
`}
>
{/* Collapsed state: Icon bar */}
{!isExpanded && (
<div className="flex flex-col items-center pt-6">
<button
onClick={() => setIsExpanded(true)}
className="p-2 rounded-lg hover:bg-teal-50 transition-colors group"
aria-label="Open documentatie menu"
>
<ChevronRight className="w-6 h-6 text-slate-600 group-hover:text-teal-600 transition-colors" />
</button>
</div>
)}
{/* Expanded state: Full navigation */}
{isExpanded && (
<div className="p-6 overflow-y-auto h-full">
{/* Header met close button */}
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
<h2 className="text-lg font-bold text-slate-900">Documentatie</h2>
<button
onClick={() => setIsExpanded(false)}
className="p-1.5 rounded-lg hover:bg-slate-100 transition-colors group"
aria-label="Sluit menu"
>
<ChevronLeft className="w-5 h-5 text-slate-500 group-hover:text-slate-700 transition-colors" />
</button>
</div>
<nav className="space-y-1">
{/* Overzicht link */}
<Link
href="/documentatie"
className={`flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
pathname === '/documentatie' && !isReleaseDetail
? 'bg-teal-50 text-teal-700'
: 'text-slate-700 hover:bg-slate-50'
}`}
>
<span>Overzicht</span>
</Link>
{/* Grouped releases */}
{metadata?.groups && metadata.groups
.sort((a, b) => a.order - b.order)
.map((group) => {
const groupReleases = releasesByGroup[group.id] || []
const isGroupExpanded = expandedGroups[group.id]
return (
<div key={group.id} className="space-y-1">
{/* Group header */}
<button
onClick={() => toggleGroup(group.id)}
className="w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wide hover:text-slate-700 transition-colors"
>
<span>{group.title}</span>
{isGroupExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
{/* Group items */}
{isGroupExpanded && (
<div className="space-y-1 ml-2">
{groupReleases.map((release) => {
const isActive = pathname === `/documentatie/${release.slug}`
return (
<Link
key={release.slug}
href={`/documentatie/${release.slug}`}
className={`flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors group ${
isActive
? 'bg-teal-50 text-teal-700'
: 'text-slate-700 hover:bg-slate-50'
}`}
>
<div className="flex items-center gap-2 min-w-0">
<StatusDot status={release.frontmatter.status} />
<span className="whitespace-normal">{release.frontmatter.title}</span>
</div>
<ChevronRight className={`w-4 h-4 flex-shrink-0 transition-opacity ${
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-50'
}`} />
</Link>
)
})}
</div>
)}
</div>
)
})}
</nav>
</div>
)}
</aside>
{/* DESKTOP: Fixed sidebar */}
<aside className="hidden lg:block w-80 fixed top-16 bottom-0 bg-white border-r border-slate-200 overflow-y-auto"> <aside className="hidden lg:block w-80 fixed top-16 bottom-0 bg-white border-r border-slate-200 overflow-y-auto">
<div className="p-6"> <div className="p-6">
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-4"> <h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-4">
@@ -97,7 +194,7 @@ export function ReleaseSidebar({ releases, metadata }: ReleaseSidebarProps) {
</Link> </Link>
{/* Grouped releases */} {/* Grouped releases */}
{metadata.groups {metadata?.groups && metadata.groups
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
.map((group) => { .map((group) => {
const groupReleases = releasesByGroup[group.id] || [] const groupReleases = releasesByGroup[group.id] || []

View File

@@ -5,27 +5,37 @@
*/ */
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { getAllReleases, getCategoryMetadata } from '@/lib/mdx/documentatie' import { getAllReleases, getCategoryMetadata, type ReleaseNote } from '@/lib/mdx/documentatie'
import { ReleaseSidebar } from './components/release-sidebar' import ReleaseSidebarWrapper from './components/release-sidebar-wrapper'
interface ReleasesLayoutProps { interface ReleasesLayoutProps {
children: ReactNode children: ReactNode
} }
export default async function ReleasesLayout({ children }: ReleasesLayoutProps) { export default async function ReleasesLayout({ children }: ReleasesLayoutProps) {
const releases = await getAllReleases() let releases: ReleaseNote[] = []
const metadata = await getCategoryMetadata() let metadata = { groups: [], categories: [] } as Awaited<ReturnType<typeof getCategoryMetadata>>
try {
releases = await getAllReleases()
metadata = await getCategoryMetadata()
} catch (error) {
console.error('Error loading releases or metadata:', error)
}
return ( return (
<div className="min-h-screen bg-slate-50"> <div className="min-h-screen bg-slate-50">
<div className="max-w-[1600px] mx-auto"> {/* Mobile sidebar is sticky, so we need padding for header only */}
<div className="flex"> <div className="pt-16 lg:pt-0">
{/* Sidebar - hidden on mobile, fixed on desktop */} <div className="max-w-[1600px] mx-auto">
<ReleaseSidebar releases={releases} metadata={metadata} /> <div className="flex">
{/* Sidebar - collapsible on mobile (48px collapsed), fixed on desktop (320px) */}
<ReleaseSidebarWrapper releases={releases} metadata={metadata} />
{/* Main Content */} {/* Main Content - margin for collapsed sidebar on mobile, fixed sidebar on desktop */}
<div className="flex-1 lg:ml-80"> <div className="flex-1 ml-12 lg:ml-80">
{children} {children}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -21,38 +21,38 @@ export default async function ReleasesPage() {
const planned = releases.filter(r => r.frontmatter.status === 'planned') const planned = releases.filter(r => r.frontmatter.status === 'planned')
return ( return (
<div className="min-h-screen bg-slate-50 pt-24 pb-16"> <div className="min-h-screen bg-slate-50 pb-16">
<div className="max-w-4xl mx-auto px-4 md:px-8"> <div className="max-w-4xl mx-auto px-4 sm:px-6 md:px-8">
{/* Header */} {/* Header */}
<div className="mb-12"> <div className="mb-8 md:mb-12 pt-20 md:pt-8">
<h1 className="text-4xl md:text-5xl font-bold text-slate-900 mb-4"> <h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-slate-900 mb-3 md:mb-4">
Feature Documentatie Feature Documentatie
</h1> </h1>
<p className="text-xl text-slate-600 max-w-3xl"> <p className="text-lg md:text-xl text-slate-600 max-w-3xl leading-relaxed">
Uitgebreide documentatie van alle gebouwde functionaliteit. Build in public met transparantie over features, implementatie en kosten. Uitgebreide documentatie van alle gebouwde functionaliteit. Build in public met transparantie over features, implementatie en kosten.
</p> </p>
</div> </div>
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-12"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-12">
<div className="bg-white rounded-lg border border-slate-200 p-4 text-center"> <div className="bg-white rounded-lg border border-slate-200 p-6 text-center shadow-sm">
<div className="text-3xl font-bold text-teal-600">{completed.length}</div> <div className="text-4xl md:text-5xl font-bold text-teal-600 mb-2">{completed.length}</div>
<div className="text-sm text-slate-500 mt-1">Voltooid</div> <div className="text-sm font-medium text-slate-600">Voltooid</div>
</div> </div>
<div className="bg-white rounded-lg border border-slate-200 p-4 text-center"> <div className="bg-white rounded-lg border border-slate-200 p-6 text-center shadow-sm">
<div className="text-3xl font-bold text-amber-600">{inProgress.length}</div> <div className="text-4xl md:text-5xl font-bold text-amber-600 mb-2">{inProgress.length}</div>
<div className="text-sm text-slate-500 mt-1">In Progress</div> <div className="text-sm font-medium text-slate-600">In Progress</div>
</div> </div>
<div className="bg-white rounded-lg border border-slate-200 p-4 text-center"> <div className="bg-white rounded-lg border border-slate-200 p-6 text-center shadow-sm">
<div className="text-3xl font-bold text-slate-400">{planned.length}</div> <div className="text-4xl md:text-5xl font-bold text-slate-400 mb-2">{planned.length}</div>
<div className="text-sm text-slate-500 mt-1">Gepland</div> <div className="text-sm font-medium text-slate-600">Gepland</div>
</div> </div>
</div> </div>
{/* Completed Releases */} {/* Completed Releases */}
{completed.length > 0 && ( {completed.length > 0 && (
<section className="mb-12"> <section className="mb-10 md:mb-12">
<h2 className="text-2xl font-bold text-slate-900 mb-4"> <h2 className="text-xl md:text-2xl font-bold text-slate-900 mb-4 md:mb-6">
Voltooid Voltooid
</h2> </h2>
<div className="space-y-4"> <div className="space-y-4">
@@ -65,8 +65,8 @@ export default async function ReleasesPage() {
{/* In Progress Releases */} {/* In Progress Releases */}
{inProgress.length > 0 && ( {inProgress.length > 0 && (
<section className="mb-12"> <section className="mb-10 md:mb-12">
<h2 className="text-2xl font-bold text-slate-900 mb-4"> <h2 className="text-xl md:text-2xl font-bold text-slate-900 mb-4 md:mb-6">
🔄 In Progress 🔄 In Progress
</h2> </h2>
<div className="space-y-4"> <div className="space-y-4">
@@ -79,8 +79,8 @@ export default async function ReleasesPage() {
{/* Planned Releases */} {/* Planned Releases */}
{planned.length > 0 && ( {planned.length > 0 && (
<section className="mb-12"> <section className="mb-10 md:mb-12">
<h2 className="text-2xl font-bold text-slate-900 mb-4"> <h2 className="text-xl md:text-2xl font-bold text-slate-900 mb-4 md:mb-6">
Gepland Gepland
</h2> </h2>
<div className="space-y-4"> <div className="space-y-4">
@@ -99,33 +99,33 @@ function ReleaseCard({ release }: { release: any }) {
return ( return (
<Link <Link
href={`/documentatie/${release.slug}`} href={`/documentatie/${release.slug}`}
className="block bg-white rounded-lg border border-slate-200 p-6 hover:border-teal-300 hover:shadow-md transition-all" className="block bg-white rounded-lg border border-slate-200 p-5 md:p-6 hover:border-teal-300 hover:shadow-md transition-all active:scale-[0.98]"
> >
<div className="flex items-start justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4">
<div className="flex-1"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2"> <div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 mb-2">
<h3 className="text-xl font-bold text-slate-900"> <h3 className="text-lg md:text-xl font-bold text-slate-900 leading-tight">
{release.frontmatter.title} {release.frontmatter.title}
</h3> </h3>
<StatusBadge status={release.frontmatter.status} /> <StatusBadge status={release.frontmatter.status} />
</div> </div>
<p className="text-slate-600 mb-3"> <p className="text-slate-600 mb-3 text-sm md:text-base leading-relaxed">
{release.frontmatter.description} {release.frontmatter.description}
</p> </p>
<div className="flex items-center gap-4 text-sm text-slate-500"> <div className="flex flex-wrap items-center gap-2 md:gap-4 text-xs md:text-sm text-slate-500">
<span className="capitalize">{release.frontmatter.group.replace('-', ' ')}</span> <span className="capitalize">{release.frontmatter.group.replace('-', ' ')}</span>
<span></span> <span className="hidden sm:inline"></span>
<span>v{release.frontmatter.version}</span> <span>v{release.frontmatter.version}</span>
<span></span> <span className="hidden sm:inline"></span>
<span>{new Date(release.frontmatter.releaseDate).toLocaleDateString('nl-NL', { <span className="whitespace-nowrap">{new Date(release.frontmatter.releaseDate).toLocaleDateString('nl-NL', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric' day: 'numeric'
})}</span> })}</span>
</div> </div>
</div> </div>
<div className="text-slate-400"> <div className="text-slate-400 flex-shrink-0 self-start sm:self-center">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 md:w-6 md:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg> </svg>
</div> </div>

View File

@@ -22,8 +22,11 @@ export async function getClients(filters?: ClientFilters) {
// Apply search filter // Apply search filter
if (filters?.search) { if (filters?.search) {
const search = `%${filters.search}%`; const searchTerm = filters.search.trim();
query = query.or(`first_name.ilike.${search},last_name.ilike.${search}`); // Use PostgREST or() syntax: column.operator.value,column.operator.value
// Format: column.operator.value,column.operator.value (no quotes needed for ilike)
const searchPattern = `%${searchTerm}%`;
query = query.or(`first_name.ilike.${searchPattern},last_name.ilike.${searchPattern}`);
} }
// Apply sorting // Apply sorting

View File

@@ -247,6 +247,16 @@ body {
} }
} }
/* Scrollbar hide utility for horizontal scroll menus */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
/* AI source highlighting (gebruikt in Intake editor) */ /* AI source highlighting (gebruikt in Intake editor) */
.ai-source-highlight { .ai-source-highlight {
background-color: #fef08a; /* lichtgeel */ background-color: #fef08a; /* lichtgeel */

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { ReactNode } from "react"; import { ReactNode } from "react";
import { ArrowRightIcon } from "@radix-ui/react-icons"; import { ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -71,7 +71,7 @@ const BentoCard = ({
className="pointer-events-auto inline-flex items-center justify-center rounded-md text-sm font-medium hover:bg-accent hover:text-accent-foreground h-9 px-3" className="pointer-events-auto inline-flex items-center justify-center rounded-md text-sm font-medium hover:bg-accent hover:text-accent-foreground h-9 px-3"
> >
{cta} {cta}
<ArrowRightIcon className="ml-2 h-4 w-4" /> <ArrowRight className="ml-2 h-4 w-4" />
</a> </a>
</div> </div>
<div className="pointer-events-none absolute inset-0 transform-gpu transition-all duration-300 group-hover:bg-black/[.03] group-hover:dark:bg-neutral-800/10" /> <div className="pointer-events-none absolute inset-0 transform-gpu transition-all duration-300 group-hover:bg-black/[.03] group-hover:dark:bg-neutral-800/10" />

View File

@@ -116,7 +116,24 @@ interface IndexData {
} }
export async function getCategoryMetadata(): Promise<IndexData> { export async function getCategoryMetadata(): Promise<IndexData> {
const indexPath = path.join(RELEASES_DIR, '_index.json') try {
const indexContent = fs.readFileSync(indexPath, 'utf-8') const indexPath = path.join(RELEASES_DIR, '_index.json')
return JSON.parse(indexContent)
if (!fs.existsSync(indexPath)) {
console.warn('_index.json not found, returning empty metadata')
return {
groups: [],
categories: []
}
}
const indexContent = fs.readFileSync(indexPath, 'utf-8')
return JSON.parse(indexContent)
} catch (error) {
console.error('Error loading category metadata:', error)
return {
groups: [],
categories: []
}
}
} }