ImageDetailPage.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. import { useState, useEffect } from 'react';
  2. import { useParams, useNavigate } from 'react-router-dom';
  3. import { useBaaS } from '@picobaas/client/react';
  4. import { USER_BUCKET } from '../config';
  5. import { formatFileSize, formatDateTime, formatExpiryStatus } from '../utils/formatters';
  6. import { getExpiryFromMetadata } from '../utils/expiry';
  7. import LoadingSpinner from '../components/LoadingSpinner';
  8. import ShareModal from '../components/ShareModal';
  9. import type { ImageFile } from '../types';
  10. export default function ImageDetailPage() {
  11. const { imageId } = useParams<{ imageId: string }>();
  12. const navigate = useNavigate();
  13. const { client } = useBaaS();
  14. const [image, setImage] = useState<ImageFile | null>(null);
  15. const [isLoading, setIsLoading] = useState(true);
  16. const [error, setError] = useState<string | null>(null);
  17. const [showShareModal, setShowShareModal] = useState(false);
  18. const [isDeleting, setIsDeleting] = useState(false);
  19. useEffect(() => {
  20. const fetchImage = async () => {
  21. if (!imageId) {
  22. setError('Image not found');
  23. setIsLoading(false);
  24. return;
  25. }
  26. try {
  27. const storage = client.storage.from(USER_BUCKET);
  28. const path = decodeURIComponent(imageId);
  29. const { data, error: infoError } = await storage.getFileInfo(path);
  30. if (infoError) throw infoError;
  31. if (!data) throw new Error('Image not found');
  32. setImage({
  33. id: data.id,
  34. path: data.path,
  35. name: data.filename || path.split('/').pop() || 'Unknown',
  36. size: data.size,
  37. mimeType: data.mime_type,
  38. url: storage.getPublicUrl(path).data.publicUrl,
  39. createdAt: new Date(data.created_at),
  40. expiresAt: getExpiryFromMetadata(data.custom_metadata),
  41. isPublic: false,
  42. ownerId: data.owner_id,
  43. });
  44. } catch (err) {
  45. setError(err instanceof Error ? err.message : 'Failed to load image');
  46. } finally {
  47. setIsLoading(false);
  48. }
  49. };
  50. fetchImage();
  51. }, [imageId, client]);
  52. const handleDelete = async () => {
  53. if (!image) return;
  54. if (!confirm('Are you sure you want to delete this image?')) return;
  55. setIsDeleting(true);
  56. try {
  57. const storage = client.storage.from(USER_BUCKET);
  58. const { error: deleteError } = await storage.remove([image.path]);
  59. if (deleteError) throw deleteError;
  60. navigate('/dashboard');
  61. } catch (err) {
  62. alert(err instanceof Error ? err.message : 'Delete failed');
  63. setIsDeleting(false);
  64. }
  65. };
  66. if (isLoading) {
  67. return (
  68. <div className="min-h-[calc(100vh-12rem)] flex items-center justify-center">
  69. <LoadingSpinner size="lg" />
  70. </div>
  71. );
  72. }
  73. if (error || !image) {
  74. return (
  75. <div className="min-h-[calc(100vh-12rem)] flex items-center justify-center">
  76. <div className="text-center">
  77. <h2
  78. className="text-xl font-semibold mb-2"
  79. style={{ color: 'var(--text-primary)' }}
  80. >
  81. Image not found
  82. </h2>
  83. <p className="mb-4" style={{ color: 'var(--text-secondary)' }}>{error}</p>
  84. <button onClick={() => navigate('/dashboard')} className="btn btn-primary">
  85. Back to Dashboard
  86. </button>
  87. </div>
  88. </div>
  89. );
  90. }
  91. const expiryStatus = image.expiresAt ? formatExpiryStatus(image.expiresAt) : null;
  92. return (
  93. <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
  94. {/* Back button */}
  95. <button
  96. onClick={() => navigate('/dashboard')}
  97. className="flex items-center gap-2 mb-6 transition-colors"
  98. style={{ color: 'var(--text-secondary)' }}
  99. >
  100. <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  101. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
  102. </svg>
  103. Back to Dashboard
  104. </button>
  105. <div className="grid lg:grid-cols-3 gap-8">
  106. {/* Image */}
  107. <div className="lg:col-span-2">
  108. <div className="card overflow-hidden">
  109. <img
  110. src={image.url}
  111. alt={image.name}
  112. className="w-full h-auto"
  113. />
  114. </div>
  115. </div>
  116. {/* Details */}
  117. <div className="space-y-6">
  118. {/* Info card */}
  119. <div className="card p-6">
  120. <h1
  121. className="text-xl font-bold mb-4 break-words"
  122. style={{ color: 'var(--text-primary)' }}
  123. >
  124. {image.name}
  125. </h1>
  126. <dl className="space-y-3 text-sm">
  127. <div className="flex justify-between">
  128. <dt style={{ color: 'var(--text-muted)' }}>Size</dt>
  129. <dd className="font-medium" style={{ color: 'var(--text-primary)' }}>
  130. {formatFileSize(image.size)}
  131. </dd>
  132. </div>
  133. <div className="flex justify-between">
  134. <dt style={{ color: 'var(--text-muted)' }}>Type</dt>
  135. <dd className="font-medium" style={{ color: 'var(--text-primary)' }}>
  136. {image.mimeType}
  137. </dd>
  138. </div>
  139. <div className="flex justify-between">
  140. <dt style={{ color: 'var(--text-muted)' }}>Uploaded</dt>
  141. <dd className="font-medium" style={{ color: 'var(--text-primary)' }}>
  142. {formatDateTime(image.createdAt)}
  143. </dd>
  144. </div>
  145. {expiryStatus && (
  146. <div className="flex justify-between">
  147. <dt style={{ color: 'var(--text-muted)' }}>Expires</dt>
  148. <dd className={`font-medium ${
  149. expiryStatus.isExpired
  150. ? 'text-red-600 dark:text-red-400'
  151. : expiryStatus.isExpiringSoon
  152. ? 'text-yellow-600 dark:text-yellow-400'
  153. : ''
  154. }`} style={!expiryStatus.isExpired && !expiryStatus.isExpiringSoon ? { color: 'var(--text-primary)' } : undefined}>
  155. {expiryStatus.label}
  156. </dd>
  157. </div>
  158. )}
  159. </dl>
  160. </div>
  161. {/* Actions */}
  162. <div className="card p-6 space-y-3">
  163. <button
  164. onClick={() => setShowShareModal(true)}
  165. className="btn btn-primary w-full flex items-center justify-center gap-2"
  166. >
  167. <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  168. <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" />
  169. </svg>
  170. Share Image
  171. </button>
  172. <a
  173. href={image.url}
  174. download={image.name}
  175. className="btn btn-secondary w-full flex items-center justify-center gap-2"
  176. >
  177. <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  178. <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" />
  179. </svg>
  180. Download
  181. </a>
  182. <button
  183. onClick={handleDelete}
  184. disabled={isDeleting}
  185. className="btn btn-danger w-full flex items-center justify-center gap-2"
  186. >
  187. {isDeleting ? (
  188. <>
  189. <LoadingSpinner size="sm" />
  190. Deleting...
  191. </>
  192. ) : (
  193. <>
  194. <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  195. <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" />
  196. </svg>
  197. Delete Image
  198. </>
  199. )}
  200. </button>
  201. </div>
  202. </div>
  203. </div>
  204. {/* Share Modal */}
  205. <ShareModal
  206. isOpen={showShareModal}
  207. onClose={() => setShowShareModal(false)}
  208. image={image}
  209. />
  210. </div>
  211. );
  212. }