+
toggleSelect(pin.cid)}
+ className="w-4 h-4 rounded border-surface-600 bg-surface-800 text-brand-500 focus:ring-brand-500 shrink-0 mr-3"
+ />
@@ -162,6 +209,22 @@ export default function PinsPage() {
)}
+
+ {/* Batch actions bar */}
+
,
+ variant: 'danger',
+ onClick: handleBatchUnpin,
+ },
+ ]}
+ onClear={clearSelection}
+ label="selected"
+ />
);
}
diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx
new file mode 100644
index 0000000..7f07ed1
--- /dev/null
+++ b/src/app/profile/page.tsx
@@ -0,0 +1,185 @@
+'use client';
+
+import { useState } from 'react';
+import PortalLayout from '@/app/layout-portal';
+import { useAuth } from '@/lib/auth';
+import { useNotify } from '@/lib/notifications';
+import { useRouter } from 'next/navigation';
+import {
+ User, Lock, LogOut, Save, Loader2, AlertTriangle, CheckCircle, Eye, EyeOff,
+} from 'lucide-react';
+
+export default function ProfilePage() {
+ const { user, logout, isAdmin } = useAuth();
+ const { notify } = useNotify();
+ const router = useRouter();
+
+ const [currentPass, setCurrentPass] = useState('');
+ const [newPass, setNewPass] = useState('');
+ const [confirmPass, setConfirmPass] = useState('');
+ const [showPasswords, setShowPasswords] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState('');
+
+ async function handleChangePassword(e: React.FormEvent) {
+ e.preventDefault();
+ setError('');
+
+ if (!currentPass || !newPass || !confirmPass) {
+ setError('Vul alle wachtwoordvelden in');
+ return;
+ }
+ if (newPass.length < 6) {
+ setError('Nieuw wachtwoord moet minimaal 6 tekens zijn');
+ return;
+ }
+ if (newPass !== confirmPass) {
+ setError('Nieuwe wachtwoorden komen niet overeen');
+ return;
+ }
+
+ setBusy(true);
+ try {
+ const res = await fetch('/api/auth/password', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ address: user?.address, currentPassword: currentPass, newPassword: newPass }),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error || 'Wachtwoord wijzigen mislukt');
+ }
+ notify({ type: 'success', title: 'Wachtwoord gewijzigd' });
+ setCurrentPass('');
+ setNewPass('');
+ setConfirmPass('');
+ } catch (err: any) {
+ setError(err.message);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function handleLogout() {
+ await logout();
+ notify({ type: 'info', title: 'Uitgelogd' });
+ router.push('/login');
+ }
+
+ return (
+
+ {/* ── Header ── */}
+
+
+
Profiel
+
Je accountinstellingen
+
+
+
+ Uitloggen
+
+
+
+
+ {/* ── Account info ── */}
+
+
+
+
+ {user?.address?.charAt(2).toUpperCase() || '?'}
+
+
+
{user?.address ? `${user.address.slice(0, 6)}...${user.address.slice(-4)}` : 'Onbekend'}
+
+ {isAdmin ? 'Admin' : 'Gebruiker'}
+
+
+
+
+
+
+ Rol
+
+ {isAdmin ? 'Administrator' : 'User'}
+
+
+
+ Sessie
+ Actief
+
+
+
+
+
+ {/* ── Wachtwoord wijzigen ── */}
+
+
+
+
+ Wachtwoord wijzigen
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx
new file mode 100644
index 0000000..b0ee46c
--- /dev/null
+++ b/src/app/register/page.tsx
@@ -0,0 +1,139 @@
+'use client';
+
+import { useState, FormEvent } from 'react';
+import { useRouter } from 'next/navigation';
+import { useNotify } from '@/lib/notifications';
+import { UserPlus, Loader2, AlertTriangle, CheckCircle, ArrowLeft } from 'lucide-react';
+import Link from 'next/link';
+
+export default function RegisterPage() {
+ const router = useRouter();
+ const { notify } = useNotify();
+
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [confirmPass, setConfirmPass] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState('');
+
+ async function handleSubmit(e: FormEvent) {
+ e.preventDefault();
+ setError('');
+
+ if (!username.trim() || !password) {
+ setError('Vul alle velden in');
+ return;
+ }
+ if (password.length < 6) {
+ setError('Wachtwoord moet minimaal 6 tekens zijn');
+ return;
+ }
+ if (password !== confirmPass) {
+ setError('Wachtwoorden komen niet overeen');
+ return;
+ }
+
+ setBusy(true);
+ try {
+ const res = await fetch('/api/users', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username: username.trim(), password }),
+ });
+
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error || `Registratie mislukt (${res.status})`);
+ }
+
+ notify({
+ type: 'success',
+ title: 'Account aangemaakt',
+ message: `Welkom, ${username.trim()}! Je kunt nu inloggen.`,
+ });
+ router.push('/login');
+ } catch (err: any) {
+ setError(err.message || 'Registratie mislukt');
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+ M
+
+
Account aanmaken
+
Maak een nieuw IPFS Portal account
+
+
+ {/* Form */}
+
+
+ {/* Back */}
+
+
+
+ Terug naar home
+
+
+
+
+ );
+}
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
index 8af6ead..b3a7af8 100644
--- a/src/app/settings/page.tsx
+++ b/src/app/settings/page.tsx
@@ -2,6 +2,7 @@
import { useState } from 'react';
import { getSettings, saveSettings, resetSettings, type PortalSettings } from '@/lib/storage';
+import { useTheme } from '@/lib/theme';
import PortalLayout from '@/app/layout-portal';
import {
Settings, Globe, Server, HardDrive, RefreshCw,
@@ -18,6 +19,7 @@ function formatStorageMB(mb: number): string {
/* ════════════════════════════ Page ════════════════════════════ */
export default function SettingsPage() {
+ const { setTheme: applyTheme } = useTheme();
const [form, setForm] = useState
(() => getSettings());
const [original, setOriginal] = useState(() => getSettings());
const [saved, setSaved] = useState(false);
@@ -228,23 +230,23 @@ export default function SettingsPage() {
Appearance
- {(['dark', 'light'] as const).map(theme => (
+ {(['dark', 'light'] as const).map(t => (
update('theme', theme)}
+ key={t}
+ onClick={() => { update('theme', t); applyTheme(t); }}
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors ${
- form.theme === theme
+ form.theme === t
? 'bg-brand-500/20 text-brand-400 border border-brand-500/30'
: 'bg-surface-900 text-surface-400 border border-surface-700 hover:border-surface-500'
}`}
>
- {theme === 'dark' ? : }
- {theme.charAt(0).toUpperCase() + theme.slice(1)}
+ {t === 'dark' ? : }
+ {t.charAt(0).toUpperCase() + t.slice(1)}
))}
- Theme preference is saved but only affects new sessions. Full theme switching coming soon.
+ Theme wordt direct toegepast en opgeslagen in je browser.
),
diff --git a/src/app/upload/components/CryptoUpload.tsx b/src/app/upload/components/CryptoUpload.tsx
new file mode 100644
index 0000000..eb6e2a3
--- /dev/null
+++ b/src/app/upload/components/CryptoUpload.tsx
@@ -0,0 +1,142 @@
+'use client';
+
+import { type RefObject } from 'react';
+import {
+ File, CheckCircle, Copy, ExternalLink, Trash2, ArrowUp,
+} from 'lucide-react';
+import PaymentPanel from '@/components/PaymentPanel';
+
+/* ── Helpers ── */
+function formatBytes(b: number): string {
+ if (b === 0) return '0 B';
+ const units = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.min(Math.floor(Math.log(b) / Math.log(1024)), units.length - 1);
+ const val = b / Math.pow(1024, i);
+ return (val < 10 ? val.toFixed(1) : Math.round(val)) + ' ' + units[i];
+}
+
+/* ── Props ── */
+interface CryptoUploadProps {
+ cryptoFile: File | null;
+ cryptoResult: { cid: string; size: number } | null;
+ copied: string | null;
+ cryptoInputRef: RefObject;
+ onCryptoSelect: () => void;
+ onCryptoFileChange: (e: React.ChangeEvent) => void;
+ onCryptoClear: () => void;
+ onCryptoPaid: (info: { cid: string; txHash?: string; method: string }) => void;
+ doCryptoUpload: () => Promise<{ cid: string; size: number }>;
+ onCopyCid: (cid: string) => void;
+ gatewayLink: (cid: string) => string;
+}
+
+/* ════════════════════════════ Component ════════════════════════════ */
+
+export default function CryptoUpload({
+ cryptoFile, cryptoResult, copied, cryptoInputRef,
+ onCryptoSelect, onCryptoFileChange, onCryptoClear,
+ onCryptoPaid, doCryptoUpload, onCopyCid, gatewayLink,
+}: CryptoUploadProps) {
+
+ return (
+
+ {/* Left: file selection */}
+
+ {!cryptoFile ? (
+
+
+
+
+
+
+ Click to select a file
+
+
+ Single file only. Pay with ETH, USDC, or MAOS.
+
+
+
+
+ ) : (
+
+
+
+
+
{cryptoFile.name}
+
{formatBytes(cryptoFile.size)}
+
+
+
+
+
+
+ {/* Crypto result */}
+ {cryptoResult && (
+
+
+
+ Upload complete
+
+
+
CID
+
+ {cryptoResult.cid}
+ onCopyCid(cryptoResult.cid)} className="p-0.5 hover:text-surface-100">
+ {copied === cryptoResult.cid ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ )}
+
+ )}
+
+
+ {/* Right: PaymentPanel */}
+
+ {cryptoFile ? (
+
+ ) : (
+
+
+
+ Select a file to see pricing
+
+
+ )}
+
+
+ );
+}
diff --git a/src/app/upload/components/QuickUpload.tsx b/src/app/upload/components/QuickUpload.tsx
new file mode 100644
index 0000000..cb409f1
--- /dev/null
+++ b/src/app/upload/components/QuickUpload.tsx
@@ -0,0 +1,316 @@
+'use client';
+
+import { type DragEvent, type RefObject } from 'react';
+import {
+ Upload, File, CheckCircle, XCircle, Loader2,
+ Copy, ExternalLink, Trash2, Globe,
+} from 'lucide-react';
+
+const MAX_FILES = 20;
+const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
+const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
+
+/* ── Helpers ── */
+function formatBytes(b: number): string {
+ if (b === 0) return '0 B';
+ const units = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.min(Math.floor(Math.log(b) / Math.log(1024)), units.length - 1);
+ const val = b / Math.pow(1024, i);
+ return (val < 10 ? val.toFixed(1) : Math.round(val)) + ' ' + units[i];
+}
+
+export interface FileEntry {
+ id: string;
+ file: File;
+ preview?: string;
+ status: 'pending' | 'uploading' | 'done' | 'error';
+ result?: { cid: string; size: number };
+ errorMsg?: string;
+ date?: string;
+}
+
+interface QuickUploadProps {
+ files: FileEntry[];
+ dragOver: boolean;
+ uploading: boolean;
+ uploadIndex: number;
+ uploadDone: boolean;
+ copied: string | null;
+ inputRef: RefObject;
+ onAddFiles: (files: FileList | File[]) => void;
+ onRemoveFile: (id: string) => void;
+ onDragOver: (e: DragEvent) => void;
+ onDragLeave: () => void;
+ onDrop: (e: DragEvent) => void;
+ onBrowse: () => void;
+ onInputChange: (e: React.ChangeEvent) => void;
+ onStartUpload: () => void;
+ onCopyCid: (cid: string) => void;
+ onCopyShareLink: (cid: string) => void;
+ onClearAll: () => void;
+ gatewayLink: (cid: string) => string;
+}
+
+export default function QuickUpload({
+ files, dragOver, uploading, uploadIndex, uploadDone, copied,
+ inputRef, onAddFiles, onRemoveFile, onDragOver, onDragLeave, onDrop,
+ onBrowse, onInputChange, onStartUpload, onCopyCid, onCopyShareLink, onClearAll, gatewayLink,
+}: QuickUploadProps) {
+
+ const doneCount = files.filter(f => f.status === 'done').length;
+ const errorCount = files.filter(f => f.status === 'error').length;
+ const totalCount = files.length;
+ const allDone = files.every(f => f.status === 'done' || f.status === 'error');
+
+ return (
+
+ {/* Drop zone */}
+ {!uploading && !uploadDone && (
+
+
= MAX_FILES}
+ />
+
+
+
+
+ Click to browse or drag & drop files
+
+
+ Up to {MAX_FILES} files, max {formatBytes(MAX_FILE_SIZE)} each
+
+
+
+
+ )}
+
+ {/* File list */}
+ {files.length > 0 && (
+
+
+
+ {totalCount} file{totalCount !== 1 ? 's' : ''} selected
+ {uploading && <> · Uploading {uploadIndex}/{totalCount}>}
+
+ {!uploading && !allDone && (
+
+ Clear all
+
+ )}
+
+
+ {uploading && (
+
+ )}
+
+
+ {files.map((entry, idx) => (
+
+
+ {entry.preview ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {entry.file.name}
+ {entry.status === 'done' && entry.result && (
+
+ )}
+ {entry.status === 'error' && (
+
+ )}
+
+
+ {formatBytes(entry.file.size)}
+ {entry.status === 'uploading' && (
+
+
+ Uploading…
+
+ )}
+ {entry.status === 'done' && entry.result && (
+
+ {entry.result.cid.slice(0, 16)}…
+
+ )}
+ {entry.status === 'error' && (
+
+ {entry.errorMsg}
+
+ )}
+
+
+
+
+ {entry.status === 'done' && entry.result && (
+ <>
+
onCopyCid(entry.result!.cid)}
+ className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
+ title="Copy CID"
+ >
+ {copied === entry.result!.cid ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ >
+ )}
+ {entry.status === 'pending' && !uploading && (
+
onRemoveFile(entry.id)}
+ className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
+ title="Remove"
+ >
+
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ {/* Action buttons */}
+ {files.length > 0 && !allDone && (
+
+ f.status === 'pending').length === 0}
+ className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-brand-600 hover:bg-brand-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors"
+ >
+ {uploading ? (
+ <> Uploading…>
+ ) : (
+ <> Upload All ({files.filter(f => f.status === 'pending').length})>
+ )}
+
+
+ )}
+
+ {/* Results summary */}
+ {uploadDone && (
+
+
+
+ {errorCount === 0 ? (
+
+ ) : (
+
+ )}
+
+ {doneCount}/{totalCount} files uploaded
+
+
+
+
+ Upload more
+
+
+
+ {/* Per-file result details */}
+ {files.filter(f => f.status === 'done').map(entry => entry.result && (
+
+
+ {entry.file.name}
+ {entry.result.cid.slice(0, 16)}…
+ {formatBytes(entry.result.size)}
+
+
+
onCopyCid(entry.result!.cid)}
+ className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors"
+ title="Copy CID"
+ >
+ {copied === entry.result!.cid ? (
+
+ ) : (
+
+ )}
+
+
onCopyShareLink(entry.result!.cid)}
+ className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors"
+ title="Copy share link"
+ >
+ {copied === `gw-${entry.result!.cid}` ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {/* Empty state */}
+ {files.length === 0 && !uploading && (
+
+
+
+ Drag files above or click to browse. No crypto payment needed for quick uploads.
+
+
+ )}
+
+ );
+}
diff --git a/src/app/upload/page.tsx b/src/app/upload/page.tsx
index 32164fb..5020ccd 100644
--- a/src/app/upload/page.tsx
+++ b/src/app/upload/page.tsx
@@ -1,45 +1,26 @@
'use client';
-import { useState, useRef, DragEvent, useCallback } from 'react';
+import { useState, useRef, type DragEvent, useCallback } from 'react';
import { uploadFile } from '@/lib/api';
-import { addToHistory, type UploadRecord } from '@/lib/storage';
+import { addToHistory } from '@/lib/storage';
import PortalLayout from '@/app/layout-portal';
-import PaymentPanel from '@/components/PaymentPanel';
-import {
- Upload, File, Image as FileImage, CheckCircle, XCircle, Loader2,
- Copy, ExternalLink, Trash2, Globe, Clock, ArrowUp, Zap,
-} from 'lucide-react';
+import { checkUploadAllowed } from '@/lib/limits';
+import { useNotify } from '@/lib/notifications';
+import { Zap, ArrowUp } from 'lucide-react';
+import QuickUpload, { type FileEntry } from './components/QuickUpload';
+import CryptoUpload from './components/CryptoUpload';
/* ── Constants ── */
const MAX_FILES = 20;
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
-/* ── Helpers ── */
-function formatBytes(b: number): string {
- if (b === 0) return '0 B';
- const units = ['B', 'KB', 'MB', 'GB'];
- const i = Math.min(Math.floor(Math.log(b) / Math.log(1024)), units.length - 1);
- const val = b / Math.pow(1024, i);
- return (val < 10 ? val.toFixed(1) : Math.round(val)) + ' ' + units[i];
-}
-
-/* ── Types ── */
-interface FileEntry {
- id: string;
- file: File;
- preview?: string; // object URL for images
- status: 'pending' | 'uploading' | 'done' | 'error';
- result?: { cid: string; size: number };
- errorMsg?: string;
- date?: string;
-}
-
type TabMode = 'quick' | 'crypto';
/* ════════════════════════════ Page ════════════════════════════ */
export default function UploadPage() {
+ const { notify } = useNotify();
const inputRef = useRef(null);
const cryptoInputRef = useRef(null);
@@ -87,6 +68,12 @@ export default function UploadPage() {
});
}
+ function clearAll() {
+ files.forEach(f => f.preview && URL.revokeObjectURL(f.preview));
+ setFiles([]);
+ setUploadDone(false);
+ }
+
/* ── Drag / Drop ── */
function onDragOver(e: DragEvent) { e.preventDefault(); setDragOver(true); }
function onDragLeave() { setDragOver(false); }
@@ -108,6 +95,18 @@ export default function UploadPage() {
const pending = files.filter(f => f.status === 'pending');
if (pending.length === 0) return;
+ // Check limits before uploading
+ const limitCheck = await checkUploadAllowed().catch(() => null);
+ if (limitCheck && !limitCheck.allowed) {
+ notify({
+ type: 'error',
+ title: 'Upload limiet bereikt',
+ message: limitCheck.reason || 'Je upload limiet is bereikt',
+ duration: 8000,
+ });
+ return;
+ }
+
setUploading(true);
setUploadIndex(0);
setUploadDone(false);
@@ -115,18 +114,13 @@ export default function UploadPage() {
for (let i = 0; i < pending.length; i++) {
const entry = pending[i];
- // Mark uploading
setFiles(prev => prev.map(f => f.id === entry.id ? { ...f, status: 'uploading' as const } : f));
setUploadIndex(i + 1);
try {
const result = await uploadFile(entry.file);
const date = new Date().toISOString();
-
- // Save to history
addToHistory({ cid: result.cid, name: entry.file.name, size: result.size, date, method: 'quick' });
-
- // Mark done
setFiles(prev => prev.map(f =>
f.id === entry.id ? { ...f, status: 'done' as const, result, date } : f
));
@@ -145,18 +139,19 @@ export default function UploadPage() {
function onCryptoSelect() { cryptoInputRef.current?.click(); }
function onCryptoFileChange(e: React.ChangeEvent) {
const file = e.target.files?.[0];
- if (file) {
- setCryptoFile(file);
- setCryptoResult(null);
- }
+ if (file) { setCryptoFile(file); setCryptoResult(null); }
e.target.value = '';
}
- function onCryptoClear() {
- setCryptoFile(null);
- setCryptoResult(null);
- }
+ function onCryptoClear() { setCryptoFile(null); setCryptoResult(null); }
async function doCryptoUpload(): Promise<{ cid: string; size: number }> {
if (!cryptoFile) throw new Error('No file selected');
+
+ // Check limits before uploading
+ const limitCheck = await checkUploadAllowed().catch(() => null);
+ if (limitCheck && !limitCheck.allowed) {
+ throw new Error(limitCheck.reason || 'Upload limiet bereikt');
+ }
+
const r = await uploadFile(cryptoFile);
setCryptoResult(r);
addToHistory({ cid: r.cid, name: cryptoFile.name, size: r.size, date: new Date().toISOString(), method: 'eth' });
@@ -167,24 +162,23 @@ export default function UploadPage() {
}
/* ── Clipboard ── */
- async function copyCid(cid: string) {
- try {
- await navigator.clipboard.writeText(cid);
- setCopied(cid);
- setTimeout(() => setCopied(null), 2000);
- } catch { /* ignore */ }
+ function copyCid(cid: string) {
+ navigator.clipboard.writeText(cid).catch(() => {});
+ setCopied(cid);
+ setTimeout(() => setCopied(null), 2000);
+ }
+
+ function copyShareLink(cid: string) {
+ const link = gatewayLink(cid);
+ navigator.clipboard.writeText(link).catch(() => {});
+ setCopied(`gw-${cid}`);
+ setTimeout(() => setCopied(null), 2000);
}
function gatewayLink(cid: string) {
return `https://maos.dedyn.io/ipfs/${cid}`;
}
- /* ── Stats ── */
- const doneCount = files.filter(f => f.status === 'done').length;
- const errorCount = files.filter(f => f.status === 'error').length;
- const totalCount = files.length;
- const allDone = files.every(f => f.status === 'done' || f.status === 'error');
-
/* ════════════ Render ════════════ */
return (
@@ -220,370 +214,46 @@ export default function UploadPage() {
- {/* ════════════ TAB: Quick Upload ════════════ */}
+ {/* Quick Upload tab */}
{tab === 'quick' && (
-
- {/* ── Drop zone ── */}
- {!uploading && !uploadDone && (
-
-
= MAX_FILES}
- />
-
-
-
-
- Click to browse or drag & drop files
-
-
- Up to {MAX_FILES} files, max {formatBytes(MAX_FILE_SIZE)} each
-
-
-
-
- )}
-
- {/* ── File list ── */}
- {files.length > 0 && (
-
-
-
- {totalCount} file{totalCount !== 1 ? 's' : ''} selected
- {uploading && <> · Uploading {uploadIndex}/{totalCount}>}
-
- {!uploading && !allDone && (
- { files.forEach(f => f.preview && URL.revokeObjectURL(f.preview)); setFiles([]); setUploadDone(false); }}
- className="text-xs text-surface-500 hover:text-accent-rose transition-colors"
- >
- Clear all
-
- )}
-
-
- {/* Progress bar */}
- {uploading && (
-
- )}
-
- {/* Files */}
-
- {files.map((entry, idx) => (
-
- {/* Preview / icon */}
-
- {entry.preview ? (
-
- ) : (
-
- )}
-
-
- {/* Info */}
-
-
- {entry.file.name}
- {entry.status === 'done' && entry.result && (
-
- )}
- {entry.status === 'error' && (
-
- )}
-
-
- {formatBytes(entry.file.size)}
- {entry.status === 'uploading' && (
-
-
- Uploading…
-
- )}
- {entry.status === 'done' && entry.result && (
-
- {entry.result.cid.slice(0, 16)}…
-
- )}
- {entry.status === 'error' && (
-
- {entry.errorMsg}
-
- )}
-
-
-
- {/* Actions */}
-
- {entry.status === 'done' && entry.result && (
- <>
-
copyCid(entry.result!.cid)}
- className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
- title="Copy CID"
- >
- {copied === entry.result!.cid ? (
-
- ) : (
-
- )}
-
-
-
-
- >
- )}
- {entry.status === 'pending' && !uploading && (
-
removeFile(entry.id)}
- className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
- title="Remove"
- >
-
-
- )}
-
-
- ))}
-
-
- )}
-
- {/* ── Action buttons ── */}
- {files.length > 0 && !allDone && (
-
- f.status === 'pending').length === 0}
- className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-brand-600 hover:bg-brand-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors"
- >
- {uploading ? (
- <> Uploading…>
- ) : (
- <> Upload All ({files.filter(f => f.status === 'pending').length})>
- )}
-
-
- )}
-
- {/* ── Results summary ── */}
- {uploadDone && (
-
-
-
- {errorCount === 0 ? (
-
- ) : (
-
- )}
-
- {doneCount}/{totalCount} files uploaded
-
-
-
{ files.forEach(f => f.preview && URL.revokeObjectURL(f.preview)); setFiles([]); setUploadDone(false); }}
- className="flex items-center gap-1 text-xs text-brand-400 hover:text-brand-300 transition-colors"
- >
-
- Upload more
-
-
-
- {/* Per-file result details */}
- {files.filter(f => f.status === 'done').map(entry => entry.result && (
-
-
- {entry.file.name}
- {entry.result.cid.slice(0, 16)}…
- {formatBytes(entry.result.size)}
-
-
-
copyCid(entry.result!.cid)}
- className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors"
- title="Copy CID"
- >
- {copied === entry.result!.cid ? (
-
- ) : (
-
- )}
-
-
{
- const link = gatewayLink(entry.result!.cid);
- navigator.clipboard.writeText(link).catch(() => {});
- setCopied(`gw-${entry.result!.cid}`);
- setTimeout(() => setCopied(null), 2000);
- }}
- className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors"
- title="Copy share link"
- >
- {copied === `gw-${entry.result!.cid}` ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
- ))}
-
- )}
-
- {/* ── Empty state ── */}
- {files.length === 0 && !uploading && (
-
-
-
- Drag files above or click to browse. No crypto payment needed for quick uploads.
-
-
- )}
-
)}
- {/* ════════════ TAB: Crypto Payment ════════════ */}
+ {/* Crypto Payment tab */}
{tab === 'crypto' && (
-
)}
);
diff --git a/src/app/usage/page.tsx b/src/app/usage/page.tsx
new file mode 100644
index 0000000..cd17d37
--- /dev/null
+++ b/src/app/usage/page.tsx
@@ -0,0 +1,171 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import PortalLayout from '@/app/layout-portal';
+import { getRepoStats, getBWStats, getPeers, listPins } from '@/lib/api';
+import type { RepoStats, BWStats } from '@/lib/api';
+import { checkUploadAllowed, type UsageCheckResult } from '@/lib/limits';
+import { useAuth } from '@/lib/auth';
+import StorageGauge from '@/app/dashboard/components/StorageGauge';
+import FreeTierProgress from '@/app/dashboard/components/FreeTierProgress';
+import {
+ HardDrive, Upload, Wifi, Database, Pin, Activity,
+ ArrowUpRight, ArrowDownLeft,
+} from 'lucide-react';
+
+export default function UsagePage() {
+ const { user } = useAuth();
+ const [usage, setUsage] = useState