Files.jsx
import * as React from 'react';
import { useState, useEffect, useCallback } from 'react';
import { apiGet, apiPatch, apiDelete, uploadFile } from '../hooks/useAPI.js';
import { useChat } from '../layouts/AdminLayout.jsx';
import { TiltCard } from '../components/TiltCard.jsx';
export function Files() {
const { toggleChat } = useChat() || {};
const [files, setFiles] = useState(null);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [showModal, setShowModal] = useState(false);
const [preview, setPreview] = useState(null);
const [editFile, setEditFile] = useState(null);
const [saving, setSaving] = useState(false);
const fetchFiles = useCallback(() => {
setLoading(true);
apiGet('/api/files')
.then(setFiles)
.finally(() => setLoading(false));
}, []);
useEffect(() => {
fetchFiles();
}, [fetchFiles]);
const handleUpload = async (e) => {
e.preventDefault();
const input = e.target.querySelector('input[type="file"]');
if (!input.files?.length) return;
setUploading(true);
try {
await uploadFile(input.files[0]);
setShowModal(false);
setPreview(null);
fetchFiles();
} finally {
setUploading(false);
}
};
const handleFileSelect = (e) => {
const file = e.target.files?.[0];
if (!file) {
setPreview(null);
return;
}
const preview = {
name: file.name,
size: formatSize(file.size),
isImage: file.type.startsWith('image/'),
};
if (preview.isImage) {
const reader = new FileReader();
reader.onload = (e) => {
preview.dataUrl = e.target.result;
setPreview({ ...preview });
};
reader.readAsDataURL(file);
}
setPreview(preview);
};
const deleteFile = async (id) => {
if (!confirm('Delete this file?')) return;
await apiDelete(`/api/files/${id}`);
fetchFiles();
};
const openEdit = (file) => {
setEditFile(file);
};
const handleEditSubmit = async (e) => {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
setSaving(true);
try {
await apiPatch(`/api/files/${editFile.id}`, {
path: formData.get('path') || '',
published: formData.get('published') === 'on',
});
setEditFile(null);
fetchFiles();
} finally {
setSaving(false);
}
};
const formatSize = (bytes) => {
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
return bytes + ' bytes';
};
const totalSize = files?.items?.reduce((sum, f) => sum + (f.size || 0), 0) || 0;
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-base-300 bg-base-200/50">
<div>
<h2 className="font-semibold">Files</h2>
<p className="text-xs text-base-content/50">Uploaded media</p>
</div>
<div className="flex items-center gap-2">
<button onClick={() => setShowModal(true)} className="btn btn-ghost btn-sm gap-1">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Upload
</button>
{toggleChat && (
<button onClick={toggleChat} className="btn btn-soft btn-primary btn-sm gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
AI Assistant
</button>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-4">
{loading ? (
<div className="flex justify-center py-12">
<span className="loading loading-spinner loading-lg" />
</div>
) : files?.items?.length ? (
<div className="space-y-2 max-w-2xl mx-auto">
{files.items.map((file) => (
<TiltCard
key={file.id}
onClick={() => openEdit(file)}
className={`block rounded-lg bg-base-100/80 backdrop-blur-sm border border-white/5 hover:shadow-lg hover:border-white/10 cursor-pointer border-l-4 ${file.published ? 'border-l-success' : 'border-l-warning'}`}
>
<div className="p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{file.mimeType?.startsWith('image/') ? (
<div className="w-10 h-10 rounded-lg bg-base-200 flex items-center justify-center flex-shrink-0 overflow-hidden">
<img src={`/api/files/${file.id}`} alt={file.name} className="w-full h-full object-cover" />
</div>
) : file.mimeType === 'application/pdf' ? (
<div className="w-10 h-10 rounded-lg bg-error/15 flex items-center justify-center flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-error" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
) : file.mimeType?.startsWith('text/') ? (
<div className="w-10 h-10 rounded-lg bg-info/15 flex items-center justify-center flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-info" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
) : (
<div className="w-10 h-10 rounded-lg bg-base-200 flex items-center justify-center flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-base-content/50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
)}
<div className="min-w-0">
<span className="font-semibold text-base-content truncate block">{file.name}</span>
<div className="text-xs text-base-content/40">
{formatSize(file.size)}
{file.path && <span className="font-mono"> ยท /_file/{file.path}</span>}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-base-content/30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</div>
</TiltCard>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-center px-6">
<div className="w-16 h-16 rounded-2xl bg-base-200 flex items-center justify-center mb-4">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-base-content/30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-1">No files yet</h3>
<p className="text-base-content/50 text-sm mb-4 max-w-sm">Upload your first file to get started.</p>
<button onClick={() => setShowModal(true)} className="btn btn-primary btn-sm">Upload File</button>
</div>
)}
</div>
{/* Upload Modal */}
{showModal && (
<dialog className="modal modal-open">
<div className="modal-box w-11/12 max-w-lg">
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onClick={() => { setShowModal(false); setPreview(null); }}>โ</button>
<h3 className="font-bold text-lg mb-4">Upload File</h3>
<form onSubmit={handleUpload} className="space-y-4">
<div className="form-control">
<label className="label">
<span className="label-text">Select File</span>
<span className="label-text-alt text-base-content/50">Max 10MB</span>
</label>
<input
type="file"
className="file-input file-input-bordered w-full"
required
onChange={handleFileSelect}
/>
</div>
{preview && (
<div className="rounded-lg bg-base-200 p-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-base-300 flex items-center justify-center flex-shrink-0">
{preview.isImage ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-base-content/50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
)}
</div>
<div className="min-w-0">
<p className="font-medium truncate">{preview.name}</p>
<p className="text-sm text-base-content/50">{preview.size}</p>
</div>
</div>
{preview.dataUrl && (
<div className="mt-3">
<img src={preview.dataUrl} className="max-h-48 rounded-lg mx-auto" alt="Preview" />
</div>
)}
</div>
)}
<p className="text-xs text-base-content/50">
Supported: Images (PNG, JPEG, GIF, WebP, SVG), PDFs, text files, and more.
</p>
<div className="flex justify-end gap-2 pt-4">
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { setShowModal(false); setPreview(null); }}>Cancel</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={uploading}>
{uploading ? <span className="loading loading-spinner loading-sm" /> : (
<>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Upload
</>
)}
</button>
</div>
</form>
</div>
<div className="modal-backdrop" onClick={() => { setShowModal(false); setPreview(null); }} />
</dialog>
)}
{/* Edit File Modal */}
{editFile && (
<dialog className="modal modal-open">
<div className="modal-box w-11/12 max-w-2xl max-h-[90vh]">
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onClick={() => setEditFile(null)}>โ</button>
<h3 className="font-bold text-lg mb-4">Edit File</h3>
<div className="space-y-6">
{/* File Preview */}
<div className="card bg-base-200">
<div className="card-body p-4">
<h4 className="text-sm font-medium mb-2">Preview</h4>
<div className="flex justify-center py-4 bg-base-300 rounded-lg">
{editFile.mimeType?.startsWith('image/') ? (
<img src={`/api/files/${editFile.id}`} alt={editFile.name} className="max-h-48 max-w-full rounded" />
) : editFile.mimeType === 'application/pdf' ? (
<div className="flex flex-col items-center gap-2 py-4">
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 text-error/60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span className="text-sm text-base-content/50">PDF Document</span>
</div>
) : editFile.mimeType?.startsWith('text/') ? (
<div className="flex flex-col items-center gap-2 py-4">
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 text-info/60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span className="text-sm text-base-content/50">Text File</span>
</div>
) : (
<div className="flex flex-col items-center gap-2 py-4">
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 text-base-content/30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span className="text-sm text-base-content/50">File</span>
</div>
)}
</div>
</div>
</div>
{/* Publishing Settings Form */}
<form onSubmit={handleEditSubmit} className="card bg-base-200">
<div className="card-body p-4 space-y-4">
<h4 className="text-sm font-medium">Publishing Settings</h4>
<div className="form-control">
<label className="label cursor-pointer justify-start gap-4">
<input type="checkbox" name="published" className="checkbox checkbox-primary checkbox-sm" defaultChecked={editFile.published} />
<div>
<span className="label-text font-medium">Published</span>
<p className="text-xs text-base-content/50">Make this file publicly accessible at a custom path</p>
</div>
</label>
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Public Path</span>
</label>
<div className="join w-full">
<span className="join-item btn btn-sm btn-disabled font-mono text-xs">/_file/</span>
<input type="text" name="path" placeholder="images/logo.png"
className="input input-bordered input-sm join-item w-full font-mono"
defaultValue={editFile.path || ''} />
</div>
<label className="label">
<span className="label-text-alt text-base-content/50">Path where the file will be accessible (without leading slash)</span>
</label>
</div>
{editFile.published && editFile.path && (
<div className="alert alert-success">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p className="font-medium text-sm">Public URL</p>
<a href={`/_file/${editFile.path}`} target="_blank" className="link link-hover font-mono text-xs">/_file/{editFile.path}</a>
</div>
</div>
)}
<div className="pt-2 flex justify-between">
<button
type="button"
className="btn btn-ghost btn-sm text-error"
onClick={() => { deleteFile(editFile.id); setEditFile(null); }}
>
Delete
</button>
<div className="flex gap-2">
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setEditFile(null)}>Cancel</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={saving}>
{saving ? <span className="loading loading-spinner loading-sm" /> : 'Save Changes'}
</button>
</div>
</div>
</div>
</form>
{/* File Information */}
<div className="card bg-base-200">
<div className="card-body p-4">
<h4 className="text-sm font-medium mb-2">File Information</h4>
<div className="text-sm space-y-2">
<div className="flex justify-between">
<span className="text-base-content/50">ID</span>
<code className="bg-base-300 px-2 py-0.5 rounded text-xs">{editFile.id}</code>
</div>
<div className="flex justify-between">
<span className="text-base-content/50">Original Name</span>
<span>{editFile.name}</span>
</div>
<div className="flex justify-between">
<span className="text-base-content/50">Size</span>
<span>{formatSize(editFile.size)}</span>
</div>
<div className="flex justify-between">
<span className="text-base-content/50">MIME Type</span>
<code className="bg-base-300 px-2 py-0.5 rounded text-xs">{editFile.mimeType}</code>
</div>
<div className="flex justify-between">
<span className="text-base-content/50">Direct URL</span>
<a href={`/api/files/${editFile.id}`} target="_blank" className="link link-hover font-mono text-xs">/api/files/{editFile.id}</a>
</div>
<div className="flex justify-between">
<span className="text-base-content/50">Uploaded</span>
<span>{new Date(editFile.created).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="modal-backdrop" onClick={() => setEditFile(null)} />
</dialog>
)}
</div>
);
}