feat(data-management): add data management module for photo asset maintenance

- Introduced the DataManagementModule, including a controller and service for managing photo asset records.
- Implemented functionality to truncate photo asset records from the database, enhancing data management capabilities.
- Updated existing photo asset deletion logic to support optional deletion from storage.
- Added a new DataManagementPanel in the dashboard for user interaction with data management features.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-14 16:38:15 +08:00
parent 2b30668072
commit 5e4b4bb4d1
33 changed files with 628 additions and 72 deletions

View File

@@ -2,7 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'
import type { AfilmoryBuilder } from '../builder/builder.js'
import type { StorageManager } from '../storage/index.js'
import type { StorageConfig } from '../storage/interfaces.js'
import type { GitHubConfig, S3Config, StorageConfig } from '../storage/interfaces.js'
import type { PhotoProcessingLoggers } from './logger-adapter.js'
export interface PhotoExecutionContext {
@@ -44,11 +44,11 @@ export function createStorageKeyNormalizer(storageConfig: StorageConfig): (key:
switch (storageConfig.provider) {
case 's3': {
basePrefix = sanitizeStoragePath(storageConfig.prefix)
basePrefix = sanitizeStoragePath((storageConfig as S3Config).prefix)
break
}
case 'github': {
basePrefix = sanitizeStoragePath(storageConfig.path)
basePrefix = sanitizeStoragePath((storageConfig as GitHubConfig).path)
break
}
default: {

View File

@@ -28,7 +28,7 @@ export default function eagleStoragePlugin(options: EagleStoragePluginOptions =
const eagleConfig = storage
const key = payload.item.s3Key
const meta = await readImageMetadata(eagleConfig.libraryPath, key)
const meta = await readImageMetadata((eagleConfig as EagleConfig).libraryPath, key)
// Append folder names as tags if enabled
if (eagleConfig.folderAsTag) {
@@ -36,7 +36,7 @@ export default function eagleStoragePlugin(options: EagleStoragePluginOptions =
const indexCacheKey = 'afilmory:eagle:folderIndex'
let folderIndex = runShared.get(indexCacheKey) as Map<string, string[]> | undefined
if (!folderIndex) {
folderIndex = await getEagleFolderIndex(eagleConfig.libraryPath)
folderIndex = await getEagleFolderIndex((eagleConfig as EagleConfig).libraryPath)
runShared.set(indexCacheKey, folderIndex)
}
const folderNames = (meta.folders ?? [])
@@ -52,7 +52,7 @@ export default function eagleStoragePlugin(options: EagleStoragePluginOptions =
}
}
// Apply omitTagNamesInMetadata filter
const omit = new Set(eagleConfig.omitTagNamesInMetadata ?? [])
const omit = new Set((eagleConfig as EagleConfig).omitTagNamesInMetadata ?? [])
if (omit.size > 0 && meta.tags) {
meta.tags = meta.tags.filter((t) => !omit.has(t))
}

View File

@@ -1,5 +1,5 @@
import { StorageManager } from '../../storage/index.js'
import type { StorageConfig } from '../../storage/interfaces.js'
import type { S3Config, StorageConfig } from '../../storage/interfaces.js'
import type { BuilderPlugin } from '../types.js'
import type { ThumbnailPluginData } from './shared.js'
import {
@@ -56,7 +56,7 @@ function joinSegments(...segments: Array<string | null | undefined>): string {
function resolveRemotePrefix(config: UploadableStorageConfig, directory: string): string {
switch (config.provider) {
case 's3': {
const base = trimSlashes(config.prefix)
const base = trimSlashes((config as S3Config).prefix)
return joinSegments(base, directory)
}
case 'github': {

View File

@@ -85,6 +85,11 @@ export type DialogContentProps = React.ComponentProps<typeof DialogPrimitive.Con
HTMLMotionProps<'div'> & {
from?: FlipDirection
transition?: Transition
/**
* Whether the dialog can be dismissed by clicking outside (on the overlay).
* Defaults to `true`.
*/
dismissOnOutsideClick?: boolean
}
const contentTransition: Transition = {
@@ -97,6 +102,8 @@ function DialogContent({
children,
from = 'top',
transition = contentTransition,
dismissOnOutsideClick = true,
onInteractOutside,
...props
}: DialogContentProps) {
const { isOpen } = useDialog()
@@ -118,7 +125,17 @@ function DialogContent({
transition={{ duration: 0.2, ease: 'easeInOut' }}
/>
</DialogOverlay>
<DialogPrimitive.Content asChild forceMount {...props}>
<DialogPrimitive.Content
asChild
forceMount
{...props}
onInteractOutside={(event) => {
if (!dismissOnOutsideClick) {
event.preventDefault()
}
onInteractOutside?.(event)
}}
>
<motion.div
key="dialog-content"
data-slot="dialog-content"

View File

@@ -60,14 +60,21 @@ function ModalWrapper({ item }: { item: ModalItem }) {
const { contentProps, contentClassName } = Component
const mergedContentConfig = {
...contentProps,
...item.modalContent,
}
const { dismissOnOutsideClick = true, ...restContentConfig } = mergedContentConfig
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className={clsxm('w-full max-w-md', contentClassName)}
transition={Spring.presets.smooth}
onAnimationComplete={handleAnimationComplete}
{...contentProps}
{...item.modalContent}
dismissOnOutsideClick={item.dismissOnOutsideClick ?? dismissOnOutsideClick}
{...restContentConfig}
>
<Component modalId={item.id} dismiss={dismiss} {...(item.props as any)} />
</DialogContent>

View File

@@ -1,17 +1,28 @@
import { atom } from 'jotai'
import { modalStore } from './store'
import type { ModalComponent, ModalContentConfig, ModalItem } from './types'
import type { ModalComponent, ModalItem, ModalPresentConfig } from './types'
export const modalItemsAtom = atom<ModalItem[]>([])
const modalCloseRegistry = new Map<string, () => void>()
export const Modal = {
present<P = unknown>(Component: ModalComponent<P>, props?: P, modalContent?: ModalContentConfig): string {
present<P = unknown>(Component: ModalComponent<P>, props?: P, config?: ModalPresentConfig): string {
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
const items = modalStore.get(modalItemsAtom)
modalStore.set(modalItemsAtom, [...items, { id, component: Component as ModalComponent<any>, props, modalContent }])
const { dismissOnOutsideClick, ...modalContent } = config ?? {}
modalStore.set(modalItemsAtom, [
...items,
{
id,
component: Component as ModalComponent<any>,
props,
modalContent,
dismissOnOutsideClick,
},
])
return id
},

View File

@@ -2,7 +2,14 @@ import type * as DialogPrimitive from '@radix-ui/react-dialog'
import type { HTMLMotionProps } from 'motion/react'
import type { FC } from 'react'
export type DialogContentProps = React.ComponentProps<typeof DialogPrimitive.Content> & HTMLMotionProps<'div'>
export type DialogContentProps = React.ComponentProps<typeof DialogPrimitive.Content> &
HTMLMotionProps<'div'> & {
/**
* Whether the dialog can be dismissed by clicking outside (on the overlay).
* Defaults to `true`.
*/
dismissOnOutsideClick?: boolean
}
export type ModalComponentProps = {
modalId: string
@@ -16,9 +23,22 @@ export type ModalComponent<P = unknown> = FC<ModalComponentProps & P> & {
export type ModalContentConfig = Partial<DialogContentProps>
export type ModalPresentConfig = ModalContentConfig & {
/**
* Control whether this modal can be dismissed by clicking outside.
* Defaults to `true` when omitted.
*/
dismissOnOutsideClick?: boolean
}
export type ModalItem = {
id: string
component: ModalComponent<any>
props?: unknown
modalContent?: ModalContentConfig
/**
* When `false`, prevent dismissing this modal via outside clicks.
* `undefined` means "use default" (treated as `true`).
*/
dismissOnOutsideClick?: boolean
}