| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235 |
- import { useState, useEffect } from 'react';
- import { useParams, useNavigate } from 'react-router-dom';
- import { useBaaS } from '@picobaas/client/react';
- import { USER_BUCKET } from '../config';
- import { formatFileSize, formatDateTime, formatExpiryStatus } from '../utils/formatters';
- import { getExpiryFromMetadata } from '../utils/expiry';
- import LoadingSpinner from '../components/LoadingSpinner';
- import ShareModal from '../components/ShareModal';
- import type { ImageFile } from '../types';
- export default function ImageDetailPage() {
- const { imageId } = useParams<{ imageId: string }>();
- const navigate = useNavigate();
- const { client } = useBaaS();
- const [image, setImage] = useState<ImageFile | null>(null);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState<string | null>(null);
- const [showShareModal, setShowShareModal] = useState(false);
- const [isDeleting, setIsDeleting] = useState(false);
- useEffect(() => {
- const fetchImage = async () => {
- if (!imageId) {
- setError('Image not found');
- setIsLoading(false);
- return;
- }
- try {
- const storage = client.storage.from(USER_BUCKET);
- const path = decodeURIComponent(imageId);
- const { data, error: infoError } = await storage.getFileInfo(path);
- if (infoError) throw infoError;
- if (!data) throw new Error('Image not found');
- setImage({
- id: data.id,
- path: data.path,
- name: data.filename || path.split('/').pop() || 'Unknown',
- size: data.size,
- mimeType: data.mime_type,
- url: storage.getPublicUrl(path).data.publicUrl,
- createdAt: new Date(data.created_at),
- expiresAt: getExpiryFromMetadata(data.custom_metadata),
- isPublic: false,
- ownerId: data.owner_id,
- });
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to load image');
- } finally {
- setIsLoading(false);
- }
- };
- fetchImage();
- }, [imageId, client]);
- const handleDelete = async () => {
- if (!image) return;
- if (!confirm('Are you sure you want to delete this image?')) return;
- setIsDeleting(true);
- try {
- const storage = client.storage.from(USER_BUCKET);
- const { error: deleteError } = await storage.remove([image.path]);
- if (deleteError) throw deleteError;
- navigate('/dashboard');
- } catch (err) {
- alert(err instanceof Error ? err.message : 'Delete failed');
- setIsDeleting(false);
- }
- };
- if (isLoading) {
- return (
- <div className="min-h-[calc(100vh-12rem)] flex items-center justify-center">
- <LoadingSpinner size="lg" />
- </div>
- );
- }
- if (error || !image) {
- return (
- <div className="min-h-[calc(100vh-12rem)] flex items-center justify-center">
- <div className="text-center">
- <h2
- className="text-xl font-semibold mb-2"
- style={{ color: 'var(--text-primary)' }}
- >
- Image not found
- </h2>
- <p className="mb-4" style={{ color: 'var(--text-secondary)' }}>{error}</p>
- <button onClick={() => navigate('/dashboard')} className="btn btn-primary">
- Back to Dashboard
- </button>
- </div>
- </div>
- );
- }
- const expiryStatus = image.expiresAt ? formatExpiryStatus(image.expiresAt) : null;
- return (
- <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
- {/* Back button */}
- <button
- onClick={() => navigate('/dashboard')}
- className="flex items-center gap-2 mb-6 transition-colors"
- style={{ color: 'var(--text-secondary)' }}
- >
- <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
- </svg>
- Back to Dashboard
- </button>
- <div className="grid lg:grid-cols-3 gap-8">
- {/* Image */}
- <div className="lg:col-span-2">
- <div className="card overflow-hidden">
- <img
- src={image.url}
- alt={image.name}
- className="w-full h-auto"
- />
- </div>
- </div>
- {/* Details */}
- <div className="space-y-6">
- {/* Info card */}
- <div className="card p-6">
- <h1
- className="text-xl font-bold mb-4 break-words"
- style={{ color: 'var(--text-primary)' }}
- >
- {image.name}
- </h1>
- <dl className="space-y-3 text-sm">
- <div className="flex justify-between">
- <dt style={{ color: 'var(--text-muted)' }}>Size</dt>
- <dd className="font-medium" style={{ color: 'var(--text-primary)' }}>
- {formatFileSize(image.size)}
- </dd>
- </div>
- <div className="flex justify-between">
- <dt style={{ color: 'var(--text-muted)' }}>Type</dt>
- <dd className="font-medium" style={{ color: 'var(--text-primary)' }}>
- {image.mimeType}
- </dd>
- </div>
- <div className="flex justify-between">
- <dt style={{ color: 'var(--text-muted)' }}>Uploaded</dt>
- <dd className="font-medium" style={{ color: 'var(--text-primary)' }}>
- {formatDateTime(image.createdAt)}
- </dd>
- </div>
- {expiryStatus && (
- <div className="flex justify-between">
- <dt style={{ color: 'var(--text-muted)' }}>Expires</dt>
- <dd className={`font-medium ${
- expiryStatus.isExpired
- ? 'text-red-600 dark:text-red-400'
- : expiryStatus.isExpiringSoon
- ? 'text-yellow-600 dark:text-yellow-400'
- : ''
- }`} style={!expiryStatus.isExpired && !expiryStatus.isExpiringSoon ? { color: 'var(--text-primary)' } : undefined}>
- {expiryStatus.label}
- </dd>
- </div>
- )}
- </dl>
- </div>
- {/* Actions */}
- <div className="card p-6 space-y-3">
- <button
- onClick={() => setShowShareModal(true)}
- className="btn btn-primary w-full flex items-center justify-center gap-2"
- >
- <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
- </svg>
- Share Image
- </button>
- <a
- href={image.url}
- download={image.name}
- className="btn btn-secondary w-full flex items-center justify-center gap-2"
- >
- <svg className="h-5 w-5" 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-4l-4 4m0 0l-4-4m4 4V4" />
- </svg>
- Download
- </a>
- <button
- onClick={handleDelete}
- disabled={isDeleting}
- className="btn btn-danger w-full flex items-center justify-center gap-2"
- >
- {isDeleting ? (
- <>
- <LoadingSpinner size="sm" />
- Deleting...
- </>
- ) : (
- <>
- <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
- </svg>
- Delete Image
- </>
- )}
- </button>
- </div>
- </div>
- </div>
- {/* Share Modal */}
- <ShareModal
- isOpen={showShareModal}
- onClose={() => setShowShareModal(false)}
- image={image}
- />
- </div>
- );
- }
|