Skip to content

Commit 8562630

Browse files
authored
feat(admin): auto-cleanup unused content images (iflabx#289)
1 parent 6a4e822 commit 8562630

3 files changed

Lines changed: 105 additions & 5 deletions

File tree

components/admin/content/about-editor.tsx

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import {
1212
import { Separator } from '@components/ui/separator';
1313
import type { SupportedLocale } from '@lib/config/language-config';
1414
import { getLanguageInfo } from '@lib/config/language-config';
15+
import { cleanupUnusedImages } from '@lib/services/content-image-upload-service';
1516
import { useAboutEditorStore } from '@lib/stores/about-editor-store';
17+
import { createClient } from '@lib/supabase/client';
1618
import {
1719
AboutTranslationData,
1820
ComponentInstance,
@@ -27,7 +29,7 @@ import {
2729
} from '@lib/utils/performance';
2830
import { GripVertical, Plus, Redo2, Trash2, Undo2 } from 'lucide-react';
2931

30-
import React, { useCallback, useEffect, useMemo } from 'react';
32+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
3133

3234
import { useTranslations } from 'next-intl';
3335

@@ -58,6 +60,9 @@ export function AboutEditor({
5860
}: AboutEditorProps) {
5961
const t = useTranslations('pages.admin.content.editor');
6062

63+
// User ID state for image cleanup
64+
const [userId, setUserId] = useState<string | null>(null);
65+
6166
// Context menu state
6267
const [contextMenu, setContextMenu] = React.useState<{
6368
x: number;
@@ -131,6 +136,21 @@ export function AboutEditor({
131136
return null;
132137
}, [pageContent, contextMenu?.componentId]);
133138

139+
// Fetch current user ID on mount
140+
useEffect(() => {
141+
const fetchUserId = async () => {
142+
const supabase = createClient();
143+
const {
144+
data: { user },
145+
} = await supabase.auth.getUser();
146+
if (user) {
147+
setUserId(user.id);
148+
}
149+
};
150+
151+
fetchUserId();
152+
}, []);
153+
134154
// Initial load and locale change handling
135155
useEffect(() => {
136156
if (currentTranslation.sections) {
@@ -150,9 +170,19 @@ export function AboutEditor({
150170

151171
// Debounced auto-save function
152172
const debouncedSave = useDebouncedCallback(
153-
useCallback(() => {
173+
useCallback(async () => {
154174
if (!pageContent) return;
155175

176+
// Clean up unused images before saving (only if userId is available)
177+
if (userId && pageContent.sections) {
178+
try {
179+
await cleanupUnusedImages(pageContent.sections, userId);
180+
} catch (error) {
181+
console.error('Failed to cleanup unused images:', error);
182+
// Continue with save even if cleanup fails
183+
}
184+
}
185+
156186
const updatedTranslation: AboutTranslationData = {
157187
sections: pageContent.sections,
158188
metadata: {
@@ -167,9 +197,15 @@ export function AboutEditor({
167197
};
168198

169199
onTranslationsChange(newTranslations);
170-
}, [pageContent, translations, currentLocale, onTranslationsChange]),
200+
}, [
201+
pageContent,
202+
translations,
203+
currentLocale,
204+
onTranslationsChange,
205+
userId,
206+
]),
171207
100, // 100ms delay for near real-time sync
172-
[pageContent, translations, currentLocale]
208+
[pageContent, translations, currentLocale, userId]
173209
);
174210

175211
// Auto-save when pageContent changes

components/admin/content/context-menu.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -662,9 +662,11 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
662662
<ImageUploadDialog
663663
isOpen={isUploadDialogOpen}
664664
onClose={() => setIsUploadDialogOpen(false)}
665-
onUploadSuccess={url => {
665+
onUploadSuccess={(url, path) => {
666666
// Auto-fill the uploaded image URL to src field
667667
handleInputChange('src', url);
668+
// Save image path for cleanup tracking
669+
handleInputChange('_imagePath', path);
668670
}}
669671
userId={userId}
670672
/>

lib/services/content-image-upload-service.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createClient } from '@lib/supabase/client';
2+
import { PageSection } from '@lib/types/about-page-components';
23
import { v4 as uuidv4 } from 'uuid';
34

45
/**
@@ -210,3 +211,64 @@ export async function listUserContentImages(userId: string): Promise<string[]> {
210211

211212
return data.map(file => `user-${userId}/${file.name}`);
212213
}
214+
215+
/**
216+
* Extract all image paths from page sections
217+
*
218+
* @param sections - Page sections array
219+
* @returns Array of image paths used in the page
220+
*/
221+
export function extractImagePathsFromSections(
222+
sections: PageSection[]
223+
): string[] {
224+
return sections.flatMap(section =>
225+
section.columns.flatMap(column =>
226+
column
227+
.filter(
228+
component => component.type === 'image' && component.props._imagePath
229+
)
230+
.map(component => component.props._imagePath as string)
231+
)
232+
);
233+
}
234+
235+
/**
236+
* Clean up unused content images for a specific user
237+
*
238+
* Compares images currently used in page sections with all images in storage,
239+
* and deletes any images that are no longer referenced.
240+
*
241+
* @param sections - Current page sections
242+
* @param userId - User ID
243+
* @returns Number of images deleted
244+
* @throws Error if cleanup operation fails
245+
*/
246+
export async function cleanupUnusedImages(
247+
sections: PageSection[],
248+
userId: string
249+
): Promise<number> {
250+
// Extract image paths currently used in the page into a Set for efficient lookup
251+
const usedImagePaths = new Set(extractImagePathsFromSections(sections));
252+
253+
// Get all images from storage
254+
const allImagePaths = await listUserContentImages(userId);
255+
256+
// Find unused images by filtering against the Set
257+
const unusedImagePaths = allImagePaths.filter(
258+
path => !usedImagePaths.has(path)
259+
);
260+
261+
// Delete unused images
262+
if (unusedImagePaths.length > 0) {
263+
const supabase = createClient();
264+
const { error } = await supabase.storage
265+
.from(BUCKET_NAME)
266+
.remove(unusedImagePaths);
267+
268+
if (error) {
269+
throw new Error(`Failed to cleanup images: ${error.message}`);
270+
}
271+
}
272+
273+
return unusedImagePaths.length;
274+
}

0 commit comments

Comments
 (0)