
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
GDPR Article 15 and CCPA personal data export portal with privacy governance stat cards, granular category export selector, format chooser, and export archive history table.
Also available for Vue ->$pnpm dlx shadcn@latest add https://uipkge.dev/r/react/privacy-data-export-portal.json$npx shadcn@latest add https://uipkge.dev/r/react/privacy-data-export-portal.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/privacy-data-export-portal.json$bunx shadcn@latest add https://uipkge.dev/r/react/privacy-data-export-portal.jsonnpx shadcn@latest add @uipkge-react/privacy-data-export-portalInstalls to:components/blocks/| Name | Type / Values | Default | Required |
|---|---|---|---|
initialCategories | string[] | — | optional |
initialFormat | ExportFormat | — | optional |
className | string | — | optional |
Type aliases exported from this item's source. Use these to shape the data you pass in.
DataCategoryinterface DataCategory {
id: string
name: string
description: string
sizeMb: number
recordCount: string
icon: React.ComponentType<{ className?: string }>
}ArchiveRecordinterface ArchiveRecord {
id: string
name: string
format: ExportFormat
requestedAt: string
expiresAt: string
expiresInDays: number
sizeMb: number
categories: string[]
status: ArchiveStatus
}'use client'
import * as React from 'react'
import {
Activity,
Archive,
Calendar,
Check,
CheckCircle2,
Clock,
CreditCard,
Download,
FileSpreadsheet,
FileText,
FolderArchive,
HardDrive,
Info,
Loader2,
Lock,
RefreshCw,
ShieldCheck,
User,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
export type ExportFormat = 'zip' | 'json' | 'csv'
export type ArchiveStatus = 'ready' | 'processing' | 'expired'
export interface DataCategory {
id: string
name: string
description: string
sizeMb: number
recordCount: string
icon: React.ComponentType<{ className?: string }>
}
export interface ArchiveRecord {
id: string
name: string
format: ExportFormat
requestedAt: string
expiresAt: string
expiresInDays: number
sizeMb: number
categories: string[]
status: ArchiveStatus
}
export interface PrivacyDataExportPortalProps {
initialCategories?: string[]
initialFormat?: ExportFormat
className?: string
}
const categories: DataCategory[] = [
{
id: 'profile',
name: 'Profile & Identity',
description: 'Account details, email history, avatar, and authentication security credentials.',
sizeMb: 1.4,
recordCount: '12 records',
icon: User,
},
{
id: 'activity',
name: 'Activity & Audit Logs',
description: 'Login history, device sessions, security events, and API access traces.',
sizeMb: 8.6,
recordCount: '1,420 events',
icon: Activity,
},
{
id: 'financial',
name: 'Financial & Transactions',
description: 'Invoices, payment receipts, subscription history, and billing ledger entries.',
sizeMb: 3.2,
recordCount: '48 entries',
icon: CreditCard,
},
{
id: 'workspace',
name: 'Workspace Content & Projects',
description: 'Created blocks, saved templates, comments, custom presets, and file uploads.',
sizeMb: 35.0,
recordCount: '312 objects',
icon: FolderArchive,
},
]
const initialArchives: ArchiveRecord[] = [
{
id: 'DPA-2026-0814',
name: 'DPA-2026-0814.zip',
format: 'zip',
requestedAt: 'Aug 14, 2026 · 09:24 UTC',
expiresAt: 'Aug 25, 2026',
expiresInDays: 4,
sizeMb: 42.1,
categories: ['Profile', 'Logs', 'Workspace', 'Billing'],
status: 'ready',
},
{
id: 'DPA-2026-0820',
name: 'DPA-2026-0820.json',
format: 'json',
requestedAt: 'Aug 20, 2026 · 14:15 UTC',
expiresAt: 'Aug 27, 2026',
expiresInDays: 7,
sizeMb: 8.6,
categories: ['Activity & Audit Logs'],
status: 'processing',
},
{
id: 'DPA-2026-0201',
name: 'DPA-2026-0201.zip',
format: 'zip',
requestedAt: 'Feb 01, 2026 · 11:02 UTC',
expiresAt: 'Feb 08, 2026',
expiresInDays: 0,
sizeMb: 38.4,
categories: ['Full Account Archive'],
status: 'expired',
},
]
export function PrivacyDataExportPortal({
initialCategories = ['profile', 'activity', 'financial', 'workspace'],
initialFormat = 'zip',
className,
}: PrivacyDataExportPortalProps) {
const [selectedCategories, setSelectedCategories] = React.useState<string[]>(initialCategories)
const [selectedFormat, setSelectedFormat] = React.useState<ExportFormat>(initialFormat)
const [archives, setArchives] = React.useState<ArchiveRecord[]>(initialArchives)
const [isGenerating, setIsGenerating] = React.useState(false)
const [notification, setNotification] = React.useState<{
title: string
message: string
type: 'success' | 'info'
} | null>(null)
const totalSelectedSize = React.useMemo(() => {
const sum = categories.filter((c) => selectedCategories.includes(c.id)).reduce((acc, c) => acc + c.sizeMb, 0)
return sum.toFixed(1)
}, [selectedCategories])
const isAllSelected = selectedCategories.length === categories.length
const toggleCategory = (categoryId: string) => {
setSelectedCategories((prev) =>
prev.includes(categoryId) ? prev.filter((id) => id !== categoryId) : [...prev, categoryId],
)
}
const toggleAll = () => {
if (isAllSelected) {
setSelectedCategories([])
} else {
setSelectedCategories(categories.map((c) => c.id))
}
}
const triggerExport = (label = 'Custom Data Export', formatToUse = selectedFormat) => {
if (selectedCategories.length === 0) return
setIsGenerating(true)
setNotification({
title: 'Archive Request Queued',
message: `${label} requested. Compiling ${selectedCategories.length} categories (${totalSelectedSize} MB) with AES-256 encryption.`,
type: 'info',
})
setTimeout(() => {
const newId = `DPA-${new Date().toISOString().slice(0, 10).replace(/-/g, '')}-${Math.floor(100 + Math.random() * 900)}`
const catNames = categories.filter((c) => selectedCategories.includes(c.id)).map((c) => c.name.split(' ')[0])
const newRecord: ArchiveRecord = {
id: newId,
name: `${newId}.${formatToUse}`,
format: formatToUse,
requestedAt: 'Just now',
expiresAt: '7 days from now',
expiresInDays: 7,
sizeMb: parseFloat(totalSelectedSize),
categories: catNames,
status: 'ready',
}
setArchives((prev) => [newRecord, ...prev])
setIsGenerating(false)
setNotification({
title: 'Archive Ready',
message: `Export package ${newRecord.name} (${newRecord.sizeMb} MB) has been generated and is ready for download.`,
type: 'success',
})
}, 1200)
}
const requestFullExport = () => {
setSelectedCategories(categories.map((c) => c.id))
setSelectedFormat('zip')
triggerExport('Full Data Export', 'zip')
}
const handleDownload = (archive: ArchiveRecord) => {
setNotification({
title: 'Download Initiated',
message: `Securely downloading ${archive.name} (${archive.sizeMb} MB). SHA-256 checksum verified.`,
type: 'success',
})
}
const handleReRequest = (archive: ArchiveRecord) => {
setNotification({
title: 'Archive Re-generation Requested',
message: `Re-generating expired archive for ${archive.categories.join(', ')}.`,
type: 'info',
})
}
return (
<div data-slot="privacy-data-export-portal" className={cn('w-full space-y-6', className)}>
{/* Header Bar */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h1 className="text-foreground text-2xl font-bold tracking-tight sm:text-3xl">
Data Privacy & Personal Data Export
</h1>
<Badge
variant="outline"
className="border-primary/20 bg-primary/5 text-primary hidden items-center gap-1 text-xs font-medium sm:inline-flex"
>
<Lock className="size-3" />
GDPR Art. 15
</Badge>
</div>
<p className="text-muted-foreground text-sm">
Request, generate, and download your complete personal data archives under GDPR Article 15.
</p>
</div>
<div className="flex items-center gap-2.5">
<Button
type="button"
size="default"
className="gap-2 shadow-xs"
disabled={isGenerating}
onClick={requestFullExport}
>
<Download className="size-4" />
Request Full Data Export
</Button>
</div>
</div>
{/* Notification Banner */}
{notification && (
<div
className={cn(
'flex items-start gap-3 rounded-lg border p-4 text-sm transition-colors',
notification.type === 'success'
? 'border-success/30 bg-success/10 text-success'
: 'border-info/30 bg-info/10 text-info',
)}
>
{notification.type === 'success' ? (
<CheckCircle2 className="text-success mt-0.5 size-5 shrink-0" />
) : (
<Info className="text-info mt-0.5 size-5 shrink-0" />
)}
<div className="flex-1 space-y-0.5">
<p className="font-medium">{notification.title}</p>
<p className="text-xs opacity-90">{notification.message}</p>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs hover:bg-transparent"
onClick={() => setNotification(null)}
>
Dismiss
</Button>
</div>
)}
{/* 4 Privacy & Governance Stat Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{/* Stat 1: Account Creation Date */}
<Card className="border-border shadow-xs">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-muted-foreground text-sm font-medium">Account Creation Date</CardTitle>
<div className="border-border bg-muted/50 text-foreground flex size-8 items-center justify-center rounded-md border">
<Calendar className="text-muted-foreground size-4" />
</div>
</CardHeader>
<CardContent className="space-y-1">
<div className="text-foreground text-xl font-bold tracking-tight tabular-nums">Nov 12, 2024</div>
<p className="text-muted-foreground text-xs">
<span className="text-foreground font-medium tabular-nums">1.8 years</span> active account age
</p>
</CardContent>
</Card>
{/* Stat 2: Total Data Footprint */}
<Card className="border-border shadow-xs">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-muted-foreground text-sm font-medium">Total Data Footprint</CardTitle>
<div className="border-border bg-muted/50 text-foreground flex size-8 items-center justify-center rounded-md border">
<HardDrive className="text-muted-foreground size-4" />
</div>
</CardHeader>
<CardContent className="space-y-1">
<div className="text-foreground text-xl font-bold tracking-tight tabular-nums">48.2 MB</div>
<p className="text-muted-foreground text-xs">Across 6 storage domains</p>
</CardContent>
</Card>
{/* Stat 3: Active Data Retention Policy */}
<Card className="border-border shadow-xs">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-muted-foreground text-sm font-medium">Data Retention Policy</CardTitle>
<div className="border-border bg-muted/50 text-foreground flex size-8 items-center justify-center rounded-md border">
<Clock className="text-muted-foreground size-4" />
</div>
</CardHeader>
<CardContent className="space-y-1">
<div className="text-foreground text-xl font-bold tracking-tight tabular-nums">30-Day Policy</div>
<p className="text-muted-foreground text-xs">Automated purge of activity logs</p>
</CardContent>
</Card>
{/* Stat 4: Consent Status */}
<Card className="border-border shadow-xs">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-muted-foreground text-sm font-medium">Consent Status</CardTitle>
<div className="border-success/20 bg-success/10 text-success flex size-8 items-center justify-center rounded-md border">
<ShieldCheck className="size-4" />
</div>
</CardHeader>
<CardContent className="space-y-1">
<div className="text-foreground text-xl font-bold tracking-tight tabular-nums">3 / 3 Active</div>
<p className="text-success text-xs font-medium">Required Consents Active</p>
</CardContent>
</Card>
</div>
{/* Select Data Categories for Export */}
<Card className="border-border shadow-xs">
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<CardTitle className="text-foreground text-lg font-semibold">Select Data Categories for Export</CardTitle>
<CardDescription>
Choose specific data domains to compile into your encrypted archive package.
</CardDescription>
</div>
<Button variant="outline" size="sm" className="h-8 self-start text-xs sm:self-auto" onClick={toggleAll}>
{isAllSelected ? 'Deselect All' : 'Select All Categories'}
</Button>
</CardHeader>
<CardContent className="space-y-6">
{/* Checkbox Grid of 4 Categories */}
<div className="grid grid-cols-1 gap-3.5 md:grid-cols-2">
{categories.map((cat) => {
const Icon = cat.icon
const isChecked = selectedCategories.includes(cat.id)
return (
<div
key={cat.id}
className={cn(
'group flex cursor-pointer items-start gap-3.5 rounded-lg border p-4 transition-colors',
isChecked
? 'border-primary/50 bg-primary/5 dark:bg-primary/10 shadow-xs'
: 'border-border bg-card hover:bg-muted/40',
)}
onClick={() => toggleCategory(cat.id)}
>
<Checkbox
id={`category-${cat.id}`}
checked={isChecked}
className="mt-1"
onCheckedChange={() => toggleCategory(cat.id)}
onClick={(e) => e.stopPropagation()}
/>
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Icon className="text-muted-foreground size-4" />
<label
htmlFor={`category-${cat.id}`}
className="text-foreground cursor-pointer text-sm leading-none font-medium"
onClick={(e) => {
e.stopPropagation()
toggleCategory(cat.id)
}}
>
{cat.name}
</label>
</div>
<Badge variant="secondary" className="shrink-0 font-mono text-xs tabular-nums">
{cat.sizeMb} MB
</Badge>
</div>
<p className="text-muted-foreground text-xs leading-relaxed">{cat.description}</p>
<div className="text-muted-foreground/80 text-xs tabular-nums">
Estimated footprint: <span className="text-foreground font-medium">{cat.recordCount}</span>
</div>
</div>
</div>
)
})}
</div>
<Separator />
{/* Format selector and generation action bar */}
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-3">
<label className="text-foreground text-sm font-medium whitespace-nowrap">Export Format:</label>
<Select value={selectedFormat} onValueChange={(val: ExportFormat) => setSelectedFormat(val)}>
<SelectTrigger className="bg-background w-full sm:w-[260px]">
<SelectValue placeholder="Select export format" />
</SelectTrigger>
<SelectContent>
<SelectItem value="zip">
<div className="flex items-center gap-2">
<Archive className="text-muted-foreground size-3.5" />
<span>Complete ZIP Bundle (.zip)</span>
</div>
</SelectItem>
<SelectItem value="json">
<div className="flex items-center gap-2">
<FileText className="text-muted-foreground size-3.5" />
<span>JSON Archive (.json)</span>
</div>
</SelectItem>
<SelectItem value="csv">
<div className="flex items-center gap-2">
<FileSpreadsheet className="text-muted-foreground size-3.5" />
<span>CSV Spreadsheet (.csv)</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="text-muted-foreground text-xs sm:text-right">
Selected:{' '}
<span className="text-foreground font-semibold tabular-nums">
{selectedCategories.length} of {categories.length} categories
</span>
<span className="text-foreground font-semibold tabular-nums">~{totalSelectedSize} MB</span>
</div>
<Button
type="button"
className="gap-2 shadow-xs"
disabled={selectedCategories.length === 0 || isGenerating}
onClick={() => triggerExport('Custom Data Export')}
>
{isGenerating ? <Loader2 className="size-4 animate-spin" /> : <Archive className="size-4" />}
{isGenerating ? 'Compiling Archive...' : 'Generate Data Archive'}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Legal Compliance Notice Box */}
<div className="border-border bg-muted/40 text-muted-foreground flex items-start gap-3 rounded-lg border p-4 text-xs shadow-xs">
<Lock className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<p className="leading-relaxed">
Under <span className="text-foreground font-medium">GDPR Article 15</span> (Right of Access) and{' '}
<span className="text-foreground font-medium">CCPA §1798.100</span>, you are entitled to request and receive
all personal data processed by this service. Export packages are generated on-demand, encrypted with AES-256,
and available for download for 7 days before automated deletion.
</p>
</div>
{/* Export Archive History Table */}
<Card className="border-border shadow-xs">
<CardHeader>
<div className="flex items-center justify-between">
<div className="space-y-1">
<CardTitle className="text-foreground text-lg font-semibold">Export Archive History</CardTitle>
<CardDescription>
Previous data archive requests and generation status. Downloads remain available for 7 days.
</CardDescription>
</div>
<Badge variant="outline" className="font-mono text-xs tabular-nums">
{archives.length} archives
</Badge>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[220px]">Archive ID & Format</TableHead>
<TableHead className="min-w-[170px]">Request Date</TableHead>
<TableHead className="min-w-[140px]">Expiration</TableHead>
<TableHead className="min-w-[100px] text-right">File Size</TableHead>
<TableHead className="min-w-[150px]">Status</TableHead>
<TableHead className="min-w-[140px] text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{archives.map((arc) => (
<TableRow key={arc.id} className="hover:bg-muted/40">
<TableCell>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-foreground font-mono text-xs font-semibold">{arc.name}</span>
<Badge variant="outline" className="px-1.5 py-0 font-mono text-xs uppercase">
{arc.format}
</Badge>
</div>
<p className="text-muted-foreground text-xs">{arc.categories.join(', ')}</p>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-xs tabular-nums">{arc.requestedAt}</TableCell>
<TableCell>
<div className="space-y-0.5">
<span
className={cn(
'text-xs font-medium tabular-nums',
arc.status === 'expired'
? 'text-muted-foreground line-through'
: arc.expiresInDays <= 4
? 'text-warning'
: 'text-muted-foreground',
)}
>
{arc.status === 'expired' ? 'Expired' : `Expires in ${arc.expiresInDays} days`}
</span>
<p className="text-muted-foreground/70 text-xs tabular-nums">{arc.expiresAt}</p>
</div>
</TableCell>
<TableCell className="text-foreground text-right font-mono text-xs font-medium tabular-nums">
{arc.sizeMb.toFixed(1)} MB
</TableCell>
<TableCell>
{arc.status === 'ready' && (
<Badge
variant="outline"
className="border-success/30 bg-success/10 text-success items-center gap-1.5 text-xs font-medium"
>
<Check className="size-3" />
Ready for Download
</Badge>
)}
{arc.status === 'processing' && (
<Badge
variant="outline"
className="border-warning/30 bg-warning/10 text-warning items-center gap-1.5 text-xs font-medium"
>
<Loader2 className="size-3 animate-spin" />
Processing Archive
</Badge>
)}
{arc.status === 'expired' && (
<Badge variant="secondary" className="text-muted-foreground text-xs">
Expired
</Badge>
)}
</TableCell>
<TableCell className="text-right">
{arc.status === 'ready' && (
<Button
aria-label="Download attachment"
size="sm"
className="h-8 gap-1.5 text-xs shadow-xs"
onClick={() => handleDownload(arc)}
>
<Download className="size-3.5" />
Download ZIP
</Button>
)}
{arc.status === 'processing' && (
<Button size="sm" variant="secondary" disabled className="h-8 gap-1.5 text-xs opacity-75">
<Loader2 className="size-3.5 animate-spin" />
Processing...
</Button>
)}
{arc.status === 'expired' && (
<Button
size="sm"
variant="outline"
className="h-8 gap-1.5 text-xs"
onClick={() => handleReRequest(arc)}
>
<RefreshCw className="size-3.5" />
Re-request
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
)
}
Raw manifest:https://uipkge.dev/r/react/privacy-data-export-portal.json