feat: implement comments feature in photo viewer

- Added a new comments section to the photo viewer, allowing users to view, add, and reply to comments.
- Introduced components for comment input, display, and action handling.
- Integrated comments API for fetching and posting comments.
- Enhanced user experience with loading states and error handling for comments.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-26 20:52:36 +08:00
parent 37825d1def
commit b919cde740
38 changed files with 4248 additions and 402 deletions

View File

@@ -0,0 +1,5 @@
import { atom } from 'jotai'
import type { SessionUser } from '~/lib/api/auth'
export const sessionUserAtom = atom<SessionUser | null>(null)

View File

@@ -7,7 +7,7 @@ import { isNil } from 'es-toolkit/compat'
import { useAtomValue } from 'jotai'
import { m } from 'motion/react'
import type { FC } from 'react'
import { Fragment, useMemo } from 'react'
import { Fragment, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { isExiftoolLoadedAtom } from '~/atoms/app'
@@ -26,26 +26,21 @@ import { HistogramChart } from './HistogramChart'
import { MiniMap } from './MiniMap'
import { RawExifViewer } from './RawExifViewer'
export const ExifPanel: FC<{
interface ExifPanelBaseProps {
currentPhoto: PhotoManifestItem
exifData: PickedExif | null
}
interface ExifPanelProps extends ExifPanelBaseProps {
onClose?: () => void
visible?: boolean
}> = ({ currentPhoto, exifData, onClose, visible = true }) => {
}
export const ExifPanel: FC<ExifPanelProps> = ({ currentPhoto, exifData, onClose, visible = true }) => {
const { t } = useTranslation()
const isMobile = useMobile()
const formattedExifData = formatExifData(exifData)
const isExiftoolLoaded = useAtomValue(isExiftoolLoadedAtom)
// Compute decimal GPS coordinates from raw EXIF data
const gpsData = useMemo(() => convertExifGPSToDecimal(exifData), [exifData])
const decimalLatitude = gpsData?.latitude || null
const decimalLongitude = gpsData?.longitude || null
const megaPixels = (((currentPhoto.height * currentPhoto.width) / 1000000) | 0).toString()
return (
<m.div
className={`${
@@ -96,364 +91,392 @@ export const ExifPanel: FC<{
)}
</div>
<ScrollArea
rootClassName="flex-1 min-h-0 overflow-auto lg:overflow-hidden"
viewportClassName="px-4 pb-4 **:select-text"
>
<div className={`space-y-${isMobile ? '3' : '4'}`}>
{/* 基本信息和标签 - 合并到一个 section */}
<div>
<h4 className="mb-2 text-sm font-medium text-white/80">{t('exif.basic.info')}</h4>
<div className="space-y-1 text-sm">
<Row label={t('exif.filename')} value={currentPhoto.title} ellipsis={true} />
<Row label={t('exif.format')} value={currentPhoto.format} />
<Row label={t('exif.dimensions')} value={`${currentPhoto.width} × ${currentPhoto.height}`} />
<Row label={t('exif.file.size')} value={`${(currentPhoto.size / 1024 / 1024).toFixed(1)}MB`} />
{megaPixels && <Row label={t('exif.pixels')} value={`${megaPixels} MP`} />}
{formattedExifData?.colorSpace && (
<Row label={t('exif.color.space')} value={formattedExifData.colorSpace} />
)}
{formattedExifData?.rating && formattedExifData.rating > 0 ? (
<Row label={t('exif.rating')} value={'★'.repeat(formattedExifData.rating)} />
) : null}
{formattedExifData?.dateTime && <Row label={t('exif.capture.time')} value={formattedExifData.dateTime} />}
{formattedExifData?.zone && <Row label={t('exif.time.zone')} value={formattedExifData.zone} />}
{formattedExifData?.artist && <Row label={t('exif.artist')} value={formattedExifData.artist} />}
{formattedExifData?.copyright && <Row label={t('exif.copyright')} value={formattedExifData.copyright} />}
{formattedExifData?.software && <Row label={t('exif.software')} value={formattedExifData.software} />}
</div>
{formattedExifData &&
(formattedExifData.shutterSpeed ||
formattedExifData.iso ||
formattedExifData.aperture ||
formattedExifData.exposureBias ||
formattedExifData.focalLength35mm) && (
<div>
<h4 className="my-2 text-sm font-medium text-white/80">{t('exif.capture.parameters')}</h4>
<div className="grid grid-cols-2 gap-2">
{formattedExifData.focalLength35mm && (
<div className="border-accent/20 bg-accent/10 flex h-6 items-center gap-2 rounded-md border px-2">
<StreamlineImageAccessoriesLensesPhotosCameraShutterPicturePhotographyPicturesPhotoLens className="text-sm text-white/70" />
<span className="text-xs">{formattedExifData.focalLength35mm}mm</span>
</div>
)}
{formattedExifData.aperture && (
<div className="border-accent/20 bg-accent/10 flex h-6 items-center gap-2 rounded-md border px-2">
<TablerAperture className="text-sm text-white/70" />
<span className="text-xs">{formattedExifData.aperture}</span>
</div>
)}
{formattedExifData.shutterSpeed && (
<div className="border-accent/20 bg-accent/10 flex h-6 items-center gap-2 rounded-md border px-2">
<MaterialSymbolsShutterSpeed className="text-sm text-white/70" />
<span className="text-xs">{formattedExifData.shutterSpeed}</span>
</div>
)}
{formattedExifData.iso && (
<div className="border-accent/20 bg-accent/10 flex h-6 items-center gap-2 rounded-md border px-2">
<CarbonIsoOutline className="text-sm text-white/70" />
<span className="text-xs">ISO {formattedExifData.iso}</span>
</div>
)}
{formattedExifData.exposureBias && (
<div className="border-accent/20 bg-accent/10 flex h-6 items-center gap-2 rounded-md border px-2">
<MaterialSymbolsExposure className="text-sm text-white/70" />
<span className="text-xs">{formattedExifData.exposureBias}</span>
</div>
)}
</div>
</div>
)}
{/* 标签信息 - 移到基本信息 section 内 */}
{currentPhoto.tags && currentPhoto.tags.length > 0 && (
<div className="mt-3 mb-3">
<h4 className="mb-2 text-sm font-medium text-white/80">{t('exif.tags')}</h4>
<div className="-ml-1 flex flex-wrap gap-1.5">
{currentPhoto.tags.map((tag) => (
<MotionButtonBase
type="button"
onClick={() => {
window.open(`/?tags=${tag}`, '_blank', 'noopener,noreferrer')
}}
key={tag}
className="glassmorphic-btn border-accent/20 bg-accent/10 inline-flex cursor-pointer items-center rounded-full border px-2 py-1 text-xs text-white/90 backdrop-blur-sm"
>
{tag}
</MotionButtonBase>
))}
</div>
</div>
)}
</div>
{/* 影调分析和直方图 */}
{currentPhoto.toneAnalysis && (
<div>
<h4 className="mb-2 text-sm font-medium text-white/80">{t('exif.tone.analysis.title')}</h4>
<div>
{/* 影调信息 */}
<Row
label={t('exif.tone.type')}
value={(() => {
const toneTypeMap = {
'low-key': t('exif.tone.low-key'),
'high-key': t('exif.tone.high-key'),
normal: t('exif.tone.normal'),
'high-contrast': t('exif.tone.high-contrast'),
}
return toneTypeMap[currentPhoto.toneAnalysis!.toneType] || currentPhoto.toneAnalysis!.toneType
})()}
/>
<div className="mt-1 mb-3 grid grid-cols-2 gap-x-2 gap-y-1 text-sm">
<Row label={t('exif.brightness.title')} value={`${currentPhoto.toneAnalysis.brightness}%`} />
<Row label={t('exif.contrast.title')} value={`${currentPhoto.toneAnalysis.contrast}%`} />
<Row
label={t('exif.shadow.ratio')}
value={`${Math.round(currentPhoto.toneAnalysis.shadowRatio * 100)}%`}
/>
<Row
label={t('exif.highlight.ratio')}
value={`${Math.round(currentPhoto.toneAnalysis.highlightRatio * 100)}%`}
/>
</div>
{/* 直方图 */}
<div className="mb-3">
<div className="mb-2 text-xs font-medium text-white/70">{t('exif.histogram')}</div>
<HistogramChart thumbnailUrl={currentPhoto.thumbnailUrl} />
</div>
</div>
</div>
)}
{formattedExifData && (
<Fragment>
{(formattedExifData.camera || formattedExifData.lens) && (
<div>
<h4 className="my-2 text-sm font-medium text-white/80">{t('exif.device.info')}</h4>
<div className="space-y-1 text-sm">
{formattedExifData.camera && <Row label={t('exif.camera')} value={formattedExifData.camera} />}
{formattedExifData.lens && <Row label={t('exif.lens')} value={formattedExifData.lens} />}
{formattedExifData.lensMake && !formattedExifData.lens?.includes(formattedExifData.lensMake) && (
<Row label={t('exif.lensmake')} value={formattedExifData.lensMake} />
)}
{formattedExifData.focalLength && (
<Row label={t('exif.focal.length.actual')} value={`${formattedExifData.focalLength}mm`} />
)}
{formattedExifData.focalLength35mm && (
<Row label={t('exif.focal.length.equivalent')} value={`${formattedExifData.focalLength35mm}mm`} />
)}
{formattedExifData.maxAperture && (
<Row label={t('exif.max.aperture')} value={`f/${formattedExifData.maxAperture}`} />
)}
</div>
</div>
)}
{/* 新增:拍摄模式信息 */}
{(formattedExifData.exposureMode ||
formattedExifData.exposureProgram ||
formattedExifData.meteringMode ||
formattedExifData.whiteBalance ||
formattedExifData.lightSource ||
formattedExifData.flash) && (
<div>
<h4 className="my-2 text-sm font-medium text-white/80">{t('exif.capture.mode')}</h4>
<div className="space-y-1 text-sm">
{!isNil(formattedExifData.exposureProgram) && (
<Row label={t('exif.exposureprogram.title')} value={formattedExifData.exposureProgram} />
)}
{!isNil(formattedExifData.exposureMode) && (
<Row label={t('exif.exposure.mode.title')} value={formattedExifData.exposureMode} />
)}
{!isNil(formattedExifData.meteringMode) && (
<Row label={t('exif.metering.mode.type')} value={formattedExifData.meteringMode} />
)}
{!isNil(formattedExifData.whiteBalance) && (
<Row label={t('exif.white.balance.title')} value={formattedExifData.whiteBalance} />
)}
{!isNil(formattedExifData.whiteBalanceBias) && (
<Row label={t('exif.white.balance.bias')} value={`${formattedExifData.whiteBalanceBias} Mired`} />
)}
{!isNil(formattedExifData.wbShiftAB) && (
<Row label={t('exif.white.balance.shift.ab')} value={formattedExifData.wbShiftAB} />
)}
{!isNil(formattedExifData.wbShiftGM) && (
<Row label={t('exif.white.balance.shift.gm')} value={formattedExifData.wbShiftGM} />
)}
{/* {!isNil(formattedExifData.whiteBalanceFineTune) && (
<Row
label={t('exif.white.balance.fine.tune')}
value={formattedExifData.whiteBalanceFineTune}
/>
)} */}
{!isNil(formattedExifData.flash) && (
<Row label={t('exif.flash.title')} value={formattedExifData.flash} />
)}
{!isNil(formattedExifData.lightSource) && (
<Row label={t('exif.light.source.type')} value={formattedExifData.lightSource} />
)}
{!isNil(formattedExifData.sceneCaptureType) && (
<Row label={t('exif.scene.capture.type')} value={formattedExifData.sceneCaptureType} />
)}
{!isNil(formattedExifData.flashMeteringMode) && (
<Row label={t('exif.flash.metering.mode')} value={formattedExifData.flashMeteringMode} />
)}
</div>
</div>
)}
{formattedExifData.fujiRecipe && (
<div>
<h4 className="my-2 text-sm font-medium text-white/80">{t('exif.fuji.film.simulation')}</h4>
<div className="space-y-1 text-sm">
{formattedExifData.fujiRecipe.FilmMode && (
<Row label={t('exif.film.mode')} value={formattedExifData.fujiRecipe.FilmMode} />
)}
{!isNil(formattedExifData.fujiRecipe.DynamicRange) && (
<Row label={t('exif.dynamic.range')} value={formattedExifData.fujiRecipe.DynamicRange} />
)}
{!isNil(formattedExifData.fujiRecipe.WhiteBalance) && (
<Row label={t('exif.white.balance.title')} value={formattedExifData.fujiRecipe.WhiteBalance} />
)}
{!isNil(formattedExifData.fujiRecipe.HighlightTone) && (
<Row label={t('exif.highlight.tone')} value={formattedExifData.fujiRecipe.HighlightTone} />
)}
{!isNil(formattedExifData.fujiRecipe.ShadowTone) && (
<Row label={t('exif.shadow.tone')} value={formattedExifData.fujiRecipe.ShadowTone} />
)}
{!isNil(formattedExifData.fujiRecipe.Saturation) && (
<Row label={t('exif.saturation')} value={formattedExifData.fujiRecipe.Saturation} />
)}
{!isNil(formattedExifData.fujiRecipe.Sharpness) && (
<Row label={t('exif.sharpness')} value={formattedExifData.fujiRecipe.Sharpness} />
)}
{!isNil(formattedExifData.fujiRecipe.NoiseReduction) && (
<Row label={t('exif.noise.reduction')} value={formattedExifData.fujiRecipe.NoiseReduction} />
)}
{!isNil(formattedExifData.fujiRecipe.Clarity) && (
<Row label={t('exif.clarity')} value={formattedExifData.fujiRecipe.Clarity} />
)}
{!isNil(formattedExifData.fujiRecipe.ColorChromeEffect) && (
<Row label={t('exif.color.effect')} value={formattedExifData.fujiRecipe.ColorChromeEffect} />
)}
{!isNil(formattedExifData.fujiRecipe.ColorChromeFxBlue) && (
<Row label={t('exif.blue.color.effect')} value={formattedExifData.fujiRecipe.ColorChromeFxBlue} />
)}
{!isNil(formattedExifData.fujiRecipe.WhiteBalanceFineTune) && (
<Row
label={t('exif.white.balance.fine.tune')}
value={formattedExifData.fujiRecipe.WhiteBalanceFineTune}
/>
)}
{(!isNil(formattedExifData.fujiRecipe.GrainEffectRoughness) ||
!isNil(formattedExifData.fujiRecipe.GrainEffectSize)) && (
<>
{formattedExifData.fujiRecipe.GrainEffectRoughness && (
<Row
label={t('exif.grain.effect.intensity')}
value={formattedExifData.fujiRecipe.GrainEffectRoughness}
/>
)}
{!isNil(formattedExifData.fujiRecipe.GrainEffectSize) && (
<Row
label={t('exif.grain.effect.size')}
value={formattedExifData.fujiRecipe.GrainEffectSize}
/>
)}
</>
)}
</div>
</div>
)}
{formattedExifData.gps && (
<div>
<h4 className="my-2 text-sm font-medium text-white/80">{t('exif.gps.location.info')}</h4>
<div className="space-y-1 text-sm">
<Row label={t('exif.gps.latitude')} value={formattedExifData.gps.latitude} />
<Row label={t('exif.gps.longitude')} value={formattedExifData.gps.longitude} />
{formattedExifData.gps.altitude && (
<Row label={t('exif.gps.altitude')} value={`${formattedExifData.gps.altitude}m`} />
)}
{/* 反向地理编码位置信息 */}
{currentPhoto.location && (
<div className="mt-3 space-y-1">
{(currentPhoto.location.city || currentPhoto.location.country) && (
<Row
label={t('exif.gps.city')}
value={[currentPhoto.location.city, currentPhoto.location.country]
.filter(Boolean)
.join(', ')}
/>
)}
{currentPhoto.location.locationName && (
<Row
label={t('exif.gps.address')}
value={currentPhoto.location.locationName}
ellipsis={true}
/>
)}
</div>
)}
{/* Maplibre MiniMap */}
{decimalLatitude !== null && decimalLongitude !== null && (
<div className="mt-3">
<MiniMap latitude={decimalLatitude} longitude={decimalLongitude} photoId={currentPhoto.id} />
</div>
)}
</div>
</div>
)}
{/* 新增:技术参数 */}
{(formattedExifData.brightnessValue ||
formattedExifData.shutterSpeedValue ||
formattedExifData.apertureValue ||
formattedExifData.sensingMethod ||
formattedExifData.focalPlaneXResolution ||
formattedExifData.focalPlaneYResolution) && (
<div>
<h4 className="my-2 text-sm font-medium text-white/80">{t('exif.technical.parameters')}</h4>
<div className="space-y-1 text-sm">
{formattedExifData.brightnessValue && (
<Row label={t('exif.brightness.value')} value={formattedExifData.brightnessValue} />
)}
{formattedExifData.shutterSpeedValue && (
<Row label={t('exif.shutter.speed.value')} value={formattedExifData.shutterSpeedValue} />
)}
{formattedExifData.apertureValue && (
<Row label={t('exif.aperture.value')} value={formattedExifData.apertureValue} />
)}
{formattedExifData.sensingMethod && (
<Row label={t('exif.sensing.method.type')} value={formattedExifData.sensingMethod} />
)}
{(formattedExifData.focalPlaneXResolution || formattedExifData.focalPlaneYResolution) && (
<Row
label={t('exif.focal.plane.resolution')}
value={`${formattedExifData.focalPlaneXResolution || t('exif.not.available')} × ${formattedExifData.focalPlaneYResolution || t('exif.not.available')}`}
/>
)}
</div>
</div>
)}
</Fragment>
)}
</div>
</ScrollArea>
<ExifPanelContent currentPhoto={currentPhoto} exifData={exifData} />
</m.div>
)
}
interface ExifPanelContentProps extends ExifPanelBaseProps {
onTagClick?: (tag: string) => void
rootClassName?: string
viewportClassName?: string
}
export const ExifPanelContent: FC<ExifPanelContentProps> = ({
currentPhoto,
exifData,
onTagClick,
rootClassName = 'flex-1 min-h-0 overflow-auto lg:overflow-hidden',
viewportClassName = 'px-4 pb-4 **:select-text',
}) => {
const { t } = useTranslation()
const isMobile = useMobile()
const formattedExifData = useMemo(() => formatExifData(exifData), [exifData])
const gpsData = useMemo(() => convertExifGPSToDecimal(exifData), [exifData])
const decimalLatitude = gpsData?.latitude ?? null
const decimalLongitude = gpsData?.longitude ?? null
const megaPixels = useMemo(() => {
if (!currentPhoto.height || !currentPhoto.width) {
return null
}
return (((currentPhoto.height * currentPhoto.width) / 1_000_000) | 0).toString()
}, [currentPhoto.height, currentPhoto.width])
const handleTagClick = useCallback(
(tag: string) => {
if (onTagClick) {
onTagClick(tag)
return
}
window.open(`/?tags=${tag}`, '_blank', 'noopener,noreferrer')
},
[onTagClick],
)
return (
<ScrollArea rootClassName={rootClassName} viewportClassName={viewportClassName}>
<div className={`space-y-${isMobile ? '3' : '4'}`}>
{/* 基本信息和标签 - 合并到一个 section */}
<div>
<h4 className="mb-2 text-sm font-medium text-white/80">{t('exif.basic.info')}</h4>
<div className="space-y-1 text-sm">
<Row label={t('exif.filename')} value={currentPhoto.title} ellipsis={true} />
<Row label={t('exif.format')} value={currentPhoto.format} />
<Row label={t('exif.dimensions')} value={`${currentPhoto.width} × ${currentPhoto.height}`} />
<Row label={t('exif.file.size')} value={`${(currentPhoto.size / 1024 / 1024).toFixed(1)}MB`} />
{megaPixels && <Row label={t('exif.pixels')} value={`${megaPixels} MP`} />}
{formattedExifData?.colorSpace && (
<Row label={t('exif.color.space')} value={formattedExifData.colorSpace} />
)}
{formattedExifData?.rating && formattedExifData.rating > 0 ? (
<Row label={t('exif.rating')} value={'★'.repeat(formattedExifData.rating)} />
) : null}
{formattedExifData?.dateTime && <Row label={t('exif.capture.time')} value={formattedExifData.dateTime} />}
{formattedExifData?.zone && <Row label={t('exif.time.zone')} value={formattedExifData.zone} />}
{formattedExifData?.artist && <Row label={t('exif.artist')} value={formattedExifData.artist} />}
{formattedExifData?.copyright && <Row label={t('exif.copyright')} value={formattedExifData.copyright} />}
{formattedExifData?.software && <Row label={t('exif.software')} value={formattedExifData.software} />}
</div>
{formattedExifData &&
(formattedExifData.shutterSpeed ||
formattedExifData.iso ||
formattedExifData.aperture ||
formattedExifData.exposureBias ||
formattedExifData.focalLength35mm) && (
<div>
<h4 className="my-2 text-sm font-medium text-white/80">{t('exif.capture.parameters')}</h4>
<div className="grid grid-cols-2 gap-2">
{formattedExifData.focalLength35mm && (
<div className="border-accent/20 bg-accent/10 flex h-6 items-center gap-2 rounded-md border px-2">
<StreamlineImageAccessoriesLensesPhotosCameraShutterPicturePhotographyPicturesPhotoLens className="text-sm text-white/70" />
<span className="text-xs">{formattedExifData.focalLength35mm}mm</span>
</div>
)}
{formattedExifData.aperture && (
<div className="border-accent/20 bg-accent/10 flex h-6 items-center gap-2 rounded-md border px-2">
<TablerAperture className="text-sm text-white/70" />
<span className="text-xs">{formattedExifData.aperture}</span>
</div>
)}
{formattedExifData.shutterSpeed && (
<div className="border-accent/20 bg-accent/10 flex h-6 items-center gap-2 rounded-md border px-2">
<MaterialSymbolsShutterSpeed className="text-sm text-white/70" />
<span className="text-xs">{formattedExifData.shutterSpeed}</span>
</div>
)}
{formattedExifData.iso && (
<div className="border-accent/20 bg-accent/10 flex h-6 items-center gap-2 rounded-md border px-2">
<CarbonIsoOutline className="text-sm text-white/70" />
<span className="text-xs">ISO {formattedExifData.iso}</span>
</div>
)}
{formattedExifData.exposureBias && (
<div className="border-accent/20 bg-accent/10 flex h-6 items-center gap-2 rounded-md border px-2">
<MaterialSymbolsExposure className="text-sm text-white/70" />
<span className="text-xs">{formattedExifData.exposureBias}</span>
</div>
)}
</div>
</div>
)}
{/* 标签信息 - 移到基本信息 section 内 */}
{currentPhoto.tags && currentPhoto.tags.length > 0 && (
<div className="mt-3 mb-3">
<h4 className="mb-2 text-sm font-medium text-white/80">{t('exif.tags')}</h4>
<div className="-ml-1 flex flex-wrap gap-1.5">
{currentPhoto.tags.map((tag) => (
<MotionButtonBase
type="button"
onClick={() => handleTagClick(tag)}
key={tag}
className="glassmorphic-btn border-accent/20 bg-accent/10 inline-flex cursor-pointer items-center rounded-full border px-2 py-1 text-xs text-white/90 backdrop-blur-sm"
>
{tag}
</MotionButtonBase>
))}
</div>
</div>
)}
</div>
{/* 影调分析和直方图 */}
{currentPhoto.toneAnalysis && (
<div>
<h4 className="mb-2 text-sm font-medium text-white/80">{t('exif.tone.analysis.title')}</h4>
<div>
{/* 影调信息 */}
<Row
label={t('exif.tone.type')}
value={(() => {
const toneTypeMap = {
'low-key': t('exif.tone.low-key'),
'high-key': t('exif.tone.high-key'),
normal: t('exif.tone.normal'),
'high-contrast': t('exif.tone.high-contrast'),
}
return toneTypeMap[currentPhoto.toneAnalysis!.toneType] || currentPhoto.toneAnalysis!.toneType
})()}
/>
<div className="mt-1 mb-3 grid grid-cols-2 gap-x-2 gap-y-1 text-sm">
<Row label={t('exif.brightness.title')} value={`${currentPhoto.toneAnalysis.brightness}%`} />
<Row label={t('exif.contrast.title')} value={`${currentPhoto.toneAnalysis.contrast}%`} />
<Row
label={t('exif.shadow.ratio')}
value={`${Math.round(currentPhoto.toneAnalysis.shadowRatio * 100)}%`}
/>
<Row
label={t('exif.highlight.ratio')}
value={`${Math.round(currentPhoto.toneAnalysis.highlightRatio * 100)}%`}
/>
</div>
{/* 直方图 */}
<div className="mb-3">
<div className="mb-2 text-xs font-medium text-white/70">{t('exif.histogram')}</div>
<HistogramChart thumbnailUrl={currentPhoto.thumbnailUrl} />
</div>
</div>
</div>
)}
{formattedExifData && (
<Fragment>
{(formattedExifData.camera || formattedExifData.lens) && (
<div>
<h4 className="my-2 text-sm font-medium text-white/80">{t('exif.device.info')}</h4>
<div className="space-y-1 text-sm">
{formattedExifData.camera && <Row label={t('exif.camera')} value={formattedExifData.camera} />}
{formattedExifData.lens && <Row label={t('exif.lens')} value={formattedExifData.lens} />}
{formattedExifData.lensMake && !formattedExifData.lens?.includes(formattedExifData.lensMake) && (
<Row label={t('exif.lensmake')} value={formattedExifData.lensMake} />
)}
{formattedExifData.focalLength && (
<Row label={t('exif.focal.length.actual')} value={`${formattedExifData.focalLength}mm`} />
)}
{formattedExifData.focalLength35mm && (
<Row label={t('exif.focal.length.equivalent')} value={`${formattedExifData.focalLength35mm}mm`} />
)}
{formattedExifData.maxAperture && (
<Row label={t('exif.max.aperture')} value={`f/${formattedExifData.maxAperture}`} />
)}
</div>
</div>
)}
{/* 新增:拍摄模式信息 */}
{(formattedExifData.exposureMode ||
formattedExifData.exposureProgram ||
formattedExifData.meteringMode ||
formattedExifData.whiteBalance ||
formattedExifData.lightSource ||
formattedExifData.flash) && (
<div>
<h4 className="my-2 text-sm font-medium text-white/80">{t('exif.capture.mode')}</h4>
<div className="space-y-1 text-sm">
{!isNil(formattedExifData.exposureProgram) && (
<Row label={t('exif.exposureprogram.title')} value={formattedExifData.exposureProgram} />
)}
{!isNil(formattedExifData.exposureMode) && (
<Row label={t('exif.exposure.mode.title')} value={formattedExifData.exposureMode} />
)}
{!isNil(formattedExifData.meteringMode) && (
<Row label={t('exif.metering.mode.type')} value={formattedExifData.meteringMode} />
)}
{!isNil(formattedExifData.whiteBalance) && (
<Row label={t('exif.white.balance.title')} value={formattedExifData.whiteBalance} />
)}
{!isNil(formattedExifData.whiteBalanceBias) && (
<Row label={t('exif.white.balance.bias')} value={`${formattedExifData.whiteBalanceBias} Mired`} />
)}
{!isNil(formattedExifData.wbShiftAB) && (
<Row label={t('exif.white.balance.shift.ab')} value={formattedExifData.wbShiftAB} />
)}
{!isNil(formattedExifData.wbShiftGM) && (
<Row label={t('exif.white.balance.shift.gm')} value={formattedExifData.wbShiftGM} />
)}
{/* {!isNil(formattedExifData.whiteBalanceFineTune) && (
<Row
label={t('exif.white.balance.fine.tune')}
value={formattedExifData.whiteBalanceFineTune}
/>
)} */}
{!isNil(formattedExifData.flash) && (
<Row label={t('exif.flash.title')} value={formattedExifData.flash} />
)}
{!isNil(formattedExifData.lightSource) && (
<Row label={t('exif.light.source.type')} value={formattedExifData.lightSource} />
)}
{!isNil(formattedExifData.sceneCaptureType) && (
<Row label={t('exif.scene.capture.type')} value={formattedExifData.sceneCaptureType} />
)}
{!isNil(formattedExifData.flashMeteringMode) && (
<Row label={t('exif.flash.metering.mode')} value={formattedExifData.flashMeteringMode} />
)}
</div>
</div>
)}
{formattedExifData.fujiRecipe && (
<div>
<h4 className="my-2 text-sm font-medium text-white/80">{t('exif.fuji.film.simulation')}</h4>
<div className="space-y-1 text-sm">
{formattedExifData.fujiRecipe.FilmMode && (
<Row label={t('exif.film.mode')} value={formattedExifData.fujiRecipe.FilmMode} />
)}
{!isNil(formattedExifData.fujiRecipe.DynamicRange) && (
<Row label={t('exif.dynamic.range')} value={formattedExifData.fujiRecipe.DynamicRange} />
)}
{!isNil(formattedExifData.fujiRecipe.WhiteBalance) && (
<Row label={t('exif.white.balance.title')} value={formattedExifData.fujiRecipe.WhiteBalance} />
)}
{!isNil(formattedExifData.fujiRecipe.HighlightTone) && (
<Row label={t('exif.highlight.tone')} value={formattedExifData.fujiRecipe.HighlightTone} />
)}
{!isNil(formattedExifData.fujiRecipe.ShadowTone) && (
<Row label={t('exif.shadow.tone')} value={formattedExifData.fujiRecipe.ShadowTone} />
)}
{!isNil(formattedExifData.fujiRecipe.Saturation) && (
<Row label={t('exif.saturation')} value={formattedExifData.fujiRecipe.Saturation} />
)}
{!isNil(formattedExifData.fujiRecipe.Sharpness) && (
<Row label={t('exif.sharpness')} value={formattedExifData.fujiRecipe.Sharpness} />
)}
{!isNil(formattedExifData.fujiRecipe.NoiseReduction) && (
<Row label={t('exif.noise.reduction')} value={formattedExifData.fujiRecipe.NoiseReduction} />
)}
{!isNil(formattedExifData.fujiRecipe.Clarity) && (
<Row label={t('exif.clarity')} value={formattedExifData.fujiRecipe.Clarity} />
)}
{!isNil(formattedExifData.fujiRecipe.ColorChromeEffect) && (
<Row label={t('exif.color.effect')} value={formattedExifData.fujiRecipe.ColorChromeEffect} />
)}
{!isNil(formattedExifData.fujiRecipe.ColorChromeFxBlue) && (
<Row label={t('exif.blue.color.effect')} value={formattedExifData.fujiRecipe.ColorChromeFxBlue} />
)}
{!isNil(formattedExifData.fujiRecipe.WhiteBalanceFineTune) && (
<Row
label={t('exif.white.balance.fine.tune')}
value={formattedExifData.fujiRecipe.WhiteBalanceFineTune}
/>
)}
{(!isNil(formattedExifData.fujiRecipe.GrainEffectRoughness) ||
!isNil(formattedExifData.fujiRecipe.GrainEffectSize)) && (
<Fragment>
{formattedExifData.fujiRecipe.GrainEffectRoughness && (
<Row
label={t('exif.grain.effect.intensity')}
value={formattedExifData.fujiRecipe.GrainEffectRoughness}
/>
)}
{!isNil(formattedExifData.fujiRecipe.GrainEffectSize) && (
<Row label={t('exif.grain.effect.size')} value={formattedExifData.fujiRecipe.GrainEffectSize} />
)}
</Fragment>
)}
</div>
</div>
)}
{formattedExifData.gps && (
<div>
<h4 className="my-2 text-sm font-medium text-white/80">{t('exif.gps.location.info')}</h4>
<div className="space-y-1 text-sm">
<Row label={t('exif.gps.latitude')} value={formattedExifData.gps.latitude} />
<Row label={t('exif.gps.longitude')} value={formattedExifData.gps.longitude} />
{formattedExifData.gps.altitude && (
<Row label={t('exif.gps.altitude')} value={`${formattedExifData.gps.altitude}m`} />
)}
{/* 反向地理编码位置信息 */}
{currentPhoto.location && (
<div className="mt-3 space-y-1">
{(currentPhoto.location.city || currentPhoto.location.country) && (
<Row
label={t('exif.gps.city')}
value={[currentPhoto.location.city, currentPhoto.location.country].filter(Boolean).join(', ')}
/>
)}
{currentPhoto.location.locationName && (
<Row label={t('exif.gps.address')} value={currentPhoto.location.locationName} ellipsis={true} />
)}
</div>
)}
{/* Maplibre MiniMap */}
{decimalLatitude !== null && decimalLongitude !== null && (
<div className="mt-3">
<MiniMap latitude={decimalLatitude} longitude={decimalLongitude} photoId={currentPhoto.id} />
</div>
)}
</div>
</div>
)}
{/* 新增:技术参数 */}
{(formattedExifData.brightnessValue ||
formattedExifData.shutterSpeedValue ||
formattedExifData.apertureValue ||
formattedExifData.sensingMethod ||
formattedExifData.focalPlaneXResolution ||
formattedExifData.focalPlaneYResolution) && (
<div>
<h4 className="my-2 text-sm font-medium text-white/80">{t('exif.technical.parameters')}</h4>
<div className="space-y-1 text-sm">
{formattedExifData.brightnessValue && (
<Row label={t('exif.brightness.value')} value={formattedExifData.brightnessValue} />
)}
{formattedExifData.shutterSpeedValue && (
<Row label={t('exif.shutter.speed.value')} value={formattedExifData.shutterSpeedValue} />
)}
{formattedExifData.apertureValue && (
<Row label={t('exif.aperture.value')} value={formattedExifData.apertureValue} />
)}
{formattedExifData.sensingMethod && (
<Row label={t('exif.sensing.method.type')} value={formattedExifData.sensingMethod} />
)}
{(formattedExifData.focalPlaneXResolution || formattedExifData.focalPlaneYResolution) && (
<Row
label={t('exif.focal.plane.resolution')}
value={`${formattedExifData.focalPlaneXResolution || t('exif.not.available')} × ${formattedExifData.focalPlaneYResolution || t('exif.not.available')}`}
/>
)}
</div>
</div>
)}
</Fragment>
)}
</div>
</ScrollArea>
)
}

View File

@@ -0,0 +1,125 @@
import type { PickedExif } from '@afilmory/builder'
import { SegmentGroup, SegmentItem } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { injectConfig } from '~/config'
import { useMobile } from '~/hooks/useMobile'
import type { PhotoManifest } from '~/types/photo'
import { CommentsPanel } from './comments'
import { ExifPanelContent } from './ExifPanel'
type Tab = 'info' | 'comments'
export const InspectorPanel: FC<{
currentPhoto: PhotoManifest
exifData: PickedExif | null
onClose?: () => void
visible?: boolean
}> = ({ currentPhoto, exifData, onClose, visible = true }) => {
const { t } = useTranslation()
const isMobile = useMobile()
const [activeTab, setActiveTab] = useState<Tab>('info')
const showSocialFeatures = injectConfig.useCloud
return (
<m.div
className={`${
isMobile
? 'inspector-panel-mobile fixed right-0 bottom-0 left-0 z-10 max-h-[60vh] w-full rounded-t-2xl backdrop-blur-2xl'
: 'relative w-80 shrink-0 backdrop-blur-2xl'
} border-accent/20 flex flex-col text-white`}
initial={{
opacity: 0,
...(isMobile ? { y: 100 } : { x: 100 }),
}}
animate={{
opacity: visible ? 1 : 0,
...(isMobile ? { y: visible ? 0 : 100 } : { x: visible ? 0 : 100 }),
}}
exit={{
opacity: 0,
...(isMobile ? { y: 100 } : { x: 100 }),
}}
transition={Spring.presets.smooth}
style={{
pointerEvents: visible ? 'auto' : 'none',
backgroundImage:
'linear-gradient(to bottom right, rgba(var(--color-materialMedium)), rgba(var(--color-materialThick)), transparent)',
boxShadow:
'0 8px 32px color-mix(in srgb, var(--color-accent) 8%, transparent), 0 4px 16px color-mix(in srgb, var(--color-accent) 6%, transparent), 0 2px 8px rgba(0, 0, 0, 0.1)',
}}
>
{/* Inner glow layer */}
<div
className="pointer-events-none absolute inset-0"
style={{
background:
'linear-gradient(to bottom right, color-mix(in srgb, var(--color-accent) 5%, transparent), transparent, color-mix(in srgb, var(--color-accent) 5%, transparent))',
}}
/>
{/* Header with tabs and actions */}
<div className="relative z-50 mt-2 shrink-0">
<div className="relative mb-3 flex items-center justify-center">
{/* Tab switcher */}
<SegmentGroup
value={activeTab}
onValueChanged={(value) => setActiveTab(value as Tab)}
className="border-accent/20 bg-material-ultra-thick rounded text-white"
>
<SegmentItem
value="info"
activeBgClassName="bg-accent/20"
className="text-white/60 hover:text-white/80 data-[state=active]:text-white"
label={
<div className="flex items-center">
<i className="i-mingcute-information-line mr-1.5" />
{t('inspector.tab.info')}
</div>
}
/>
{showSocialFeatures && (
<SegmentItem
value="comments"
activeBgClassName="bg-accent/20"
className="text-white/60 hover:text-white/80 data-[state=active]:text-white"
label={
<div className="flex items-center">
<i className="i-mingcute-comment-line mr-1.5" />
{t('inspector.tab.comments')}
</div>
}
/>
)}
</SegmentGroup>
{/* Close button (mobile only) */}
{isMobile && onClose && (
<button
type="button"
className="glassmorphic-btn border-accent/20 absolute right-0 flex size-8 items-center justify-center rounded-full border text-white/70 duration-200 hover:text-white"
onClick={onClose}
>
<i className="i-mingcute-close-line text-sm" />
</button>
)}
</div>
</div>
{/* Content area */}
<div className="relative z-10 flex min-h-0 flex-1">
{activeTab === 'info' ? (
<ExifPanelContent currentPhoto={currentPhoto} exifData={exifData} />
) : (
<CommentsPanel photoId={currentPhoto.id} visible={visible} />
)}
</div>
</m.div>
)
}

View File

@@ -0,0 +1,23 @@
import type { PickedExif } from '@afilmory/builder'
import type { FC } from 'react'
import { injectConfig } from '~/config'
import type { PhotoManifest } from '~/types/photo'
import { ExifPanel } from './ExifPanel'
import { InspectorPanel } from './InspectorPanel'
export interface PhotoInspectorProps {
currentPhoto: PhotoManifest
exifData: PickedExif | null
visible?: boolean
onClose?: () => void
}
const CloudInspector: FC<PhotoInspectorProps> = (props) => <InspectorPanel {...props} />
const LegacyInspector: FC<PhotoInspectorProps> = ({ currentPhoto, exifData, ...rest }) => (
<ExifPanel currentPhoto={currentPhoto} exifData={exifData} {...rest} />
)
export const PhotoInspector: FC<PhotoInspectorProps> = injectConfig.useCloud ? CloudInspector : LegacyInspector

View File

@@ -12,18 +12,16 @@ import type { Swiper as SwiperType } from 'swiper'
import { Keyboard, Navigation, Virtual } from 'swiper/modules'
import { Swiper, SwiperSlide } from 'swiper/react'
import { injectConfig } from '~/config'
import { useMobile } from '~/hooks/useMobile'
import type { PhotoManifest } from '~/types/photo'
import { PhotoViewerTransitionPreview } from './animations/PhotoViewerTransitionPreview'
import { usePhotoViewerTransitions } from './animations/usePhotoViewerTransitions'
import { ExifPanel } from './ExifPanel'
import { GalleryThumbnail } from './GalleryThumbnail'
import type { LoadingIndicatorRef } from './LoadingIndicator'
import { LoadingIndicator } from './LoadingIndicator'
import { PhotoInspector } from './PhotoInspector'
import { ProgressiveImage } from './ProgressiveImage'
import { ReactionButton } from './Reaction'
import { SharePanel } from './SharePanel'
interface PhotoViewerProps {
@@ -262,18 +260,6 @@ export const PhotoViewer = ({
</div>
</m.div>
{!isMobile && (injectConfig.useApi || injectConfig.useCloud) && (
<ReactionButton
photoId={currentPhoto.id}
className="absolute right-4 bottom-4"
style={{
opacity: isViewerContentVisible ? 1 : 0,
transition: 'opacity 180ms ease',
pointerEvents: !isViewerContentVisible || isEntryAnimating ? 'none' : 'auto',
}}
/>
)}
{/* 加载指示器 */}
<LoadingIndicator ref={loadingIndicatorRef} />
{/* Swiper 容器 */}
@@ -391,12 +377,12 @@ export const PhotoViewer = ({
</Suspense>
</div>
{/* ExifPanel - 在桌面端始终显示在移动端根据状态显示 */}
{/* PhotoInspector - 在桌面端始终显示,在移动端根据状态显示 */}
<Suspense>
<AnimatePresenceOnlyMobile>
{(!isMobile || showExifPanel) && (
<ExifPanel
<PhotoInspector
currentPhoto={currentPhoto}
exifData={currentPhoto.exif}
visible={isViewerContentVisible}

View File

@@ -0,0 +1,41 @@
import clsx from 'clsx'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
interface CommentActionBarProps {
reacted: boolean
reactionCount: number
onReply: () => void
onToggleReaction: () => void
}
export const CommentActionBar = ({ reacted, reactionCount, onReply, onToggleReaction }: CommentActionBarProps) => {
const { t } = useTranslation()
const handleReaction = useCallback(() => {
onToggleReaction()
}, [onToggleReaction])
return (
<div className="flex items-center gap-4 text-xs text-white/60">
<button
type="button"
onClick={handleReaction}
className={clsx(
'flex items-center gap-1 rounded-full px-2 py-1 transition-colors',
reacted ? 'bg-accent/20 text-white' : 'hover:bg-white/10',
)}
>
<i className={reacted ? 'i-mingcute-heart-fill text-accent' : 'i-mingcute-heart-line'} />
<span>{reactionCount}</span>
</button>
<button
type="button"
className="flex items-center gap-1 rounded-full px-2 py-1 hover:bg-white/10"
onClick={onReply}
>
<i className="i-mingcute-corner-down-right-line" />
{t('comments.reply')}
</button>
</div>
)
}

View File

@@ -0,0 +1,55 @@
import { memo } from 'react'
import type { Comment } from '~/lib/api/comments'
import { CommentActionBar } from './CommentActionBar'
import { CommentContent } from './CommentContent'
import { CommentHeader } from './CommentHeader'
interface CommentCardProps {
comment: Comment
parent: Comment | null
reacted: boolean
onReply: () => void
onToggleReaction: () => void
authorName: (comment: Comment) => string
locale: string
}
export const CommentCard = memo(
({ comment, parent, reacted, onReply, onToggleReaction, authorName, locale }: CommentCardProps) => {
return (
<div
className="border-accent/10 relative overflow-hidden rounded-2xl border bg-white/5 p-3 backdrop-blur-xl"
style={{
boxShadow:
'0 8px 32px color-mix(in srgb, var(--color-accent) 8%, transparent), 0 4px 16px color-mix(in srgb, var(--color-accent) 6%, transparent)',
}}
>
<div
className="pointer-events-none absolute inset-0 opacity-50"
style={{
background:
'linear-gradient(120deg, color-mix(in srgb, var(--color-accent) 7%, transparent), transparent 40%, color-mix(in srgb, var(--color-accent) 7%, transparent))',
}}
/>
<div className="relative z-10 flex gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-sm font-semibold text-white/80">
{(authorName(comment) ?? '?').slice(0, 1).toUpperCase()}
</div>
<div className="flex-1 space-y-2">
<CommentHeader comment={comment} author={authorName(comment)} locale={locale} />
<CommentContent comment={comment} parent={parent} authorName={authorName} />
<CommentActionBar
reacted={reacted}
reactionCount={comment.reactionCounts.like ?? 0}
onReply={onReply}
onToggleReaction={onToggleReaction}
/>
</div>
</div>
</div>
)
},
)
CommentCard.displayName = 'CommentCard'

View File

@@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next'
import type { Comment } from '~/lib/api/comments'
interface CommentContentProps {
comment: Comment
parent: Comment | null
authorName: (comment: Comment) => string
}
export const CommentContent = ({ comment, parent, authorName }: CommentContentProps) => {
const { t } = useTranslation()
return (
<>
{parent ? (
<div className="rounded-lg border border-white/5 bg-white/5 px-3 py-2 text-xs text-white/70">
<div className="mb-1 flex items-center gap-2 text-[11px] tracking-wide text-white/40 uppercase">
<i className="i-mingcute-corner-down-right-line" />
{t('comments.replyingTo', { user: authorName(parent) })}
</div>
<p className="line-clamp-3 text-sm leading-relaxed text-white/70">{parent.content}</p>
</div>
) : null}
<p className="text-sm leading-relaxed text-white/85">{comment.content}</p>
</>
)
}

View File

@@ -0,0 +1,20 @@
import { useTranslation } from 'react-i18next'
import type { Comment } from '~/lib/api/comments'
import { formatRelativeTime } from './format'
export const CommentHeader = ({ comment, author, locale }: { comment: Comment; author: string; locale: string }) => {
const { t } = useTranslation()
return (
<div className="flex flex-wrap items-baseline gap-2">
<span className="text-sm font-medium text-white/90">{author}</span>
<span className="text-xs text-white/45">{formatRelativeTime(comment.createdAt, locale)}</span>
{comment.status === 'pending' && (
<span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-200/80 uppercase">
{t('comments.pending')}
</span>
)}
</div>
)
}

View File

@@ -0,0 +1,82 @@
import { useTranslation } from 'react-i18next'
import type { Comment } from '~/lib/api/comments'
interface CommentInputProps {
isMobile: boolean
sessionUser: { name?: string | null; id?: string | null } | null
replyTo: Comment | null
setReplyTo: (comment: Comment | null) => void
newComment: string
setNewComment: (value: string) => void
onSubmit: (content: string) => void
}
export const CommentInput = ({
isMobile,
sessionUser,
replyTo,
setReplyTo,
newComment,
setNewComment,
onSubmit,
}: CommentInputProps) => {
const { t } = useTranslation()
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSubmit(newComment)
}
return (
<div className="border-accent/10 shrink-0 border-t p-4">
{replyTo ? (
<div className="border-accent/20 bg-accent/5 mb-3 flex items-center justify-between rounded-lg border px-3 py-2 text-xs text-white/80">
<div className="flex items-center gap-2">
<i className="i-mingcute-reply-line text-accent" />
<span>
{t('comments.replyingTo', {
user: replyTo.userId.slice(-6),
})}
</span>
</div>
<button type="button" className="text-white/50 transition hover:text-white" onClick={() => setReplyTo(null)}>
{t('comments.cancelReply')}
</button>
</div>
) : null}
<form onSubmit={handleSubmit} className="flex items-end gap-2">
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-sm font-semibold text-white/80">
{(sessionUser?.name || sessionUser?.id || 'G')[0]}
</div>
<div className="flex-1">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder={t('comments.placeholder')}
rows={isMobile ? 2 : 1}
className="bg-material-medium focus:ring-accent/50 w-full resize-none rounded-lg border-0 px-3 py-2 text-sm text-white placeholder:text-white/40 focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit(e)
}
}}
/>
</div>
<button
type="submit"
disabled={!newComment.trim()}
className="bg-accent shadow-accent/20 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-lg transition disabled:cursor-not-allowed disabled:opacity-40"
>
<i className="i-mingcute-send-line" />
</button>
</form>
<p className="mt-2 text-xs text-white/40">{t('comments.hint')}</p>
{!sessionUser && <p className="mt-1 text-xs text-white/50">{t('comments.loginRequired')}</p>}
</div>
)
}

View File

@@ -0,0 +1,11 @@
import { useTranslation } from 'react-i18next'
export const EmptyState = () => {
const { t } = useTranslation()
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<i className="i-mingcute-comment-line mb-3 text-4xl text-white/30" />
<p className="text-sm text-white/50">{t('comments.empty')}</p>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { useCommentsContext } from './context'
export const ErrorBox = () => {
const { t } = useTranslation()
const { atoms } = useCommentsContext()
const loadMore = useSetAtom(atoms.loadMoreAtom)
return (
<div className="glassmorphic-btn border-accent/20 flex flex-col items-center gap-2 rounded-xl border p-4 text-center text-white/70">
<i className="i-mingcute-alert-line text-accent text-xl" />
<p className="text-sm">{t('comments.error')}</p>
<button
type="button"
onClick={() => loadMore()}
className="bg-accent/90 hover:bg-accent/80 rounded-lg px-3 py-1 text-xs text-white"
>
{t('comments.retry')}
</button>
</div>
)
}

View File

@@ -0,0 +1,14 @@
export const SkeletonList = () => (
<>
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="flex gap-3 rounded-xl border border-white/5 bg-white/5 p-3 backdrop-blur">
<div className="size-8 shrink-0 animate-pulse rounded-full bg-white/10" />
<div className="flex-1 space-y-2">
<div className="h-3 w-24 animate-pulse rounded-full bg-white/10" />
<div className="h-3 w-full animate-pulse rounded-full bg-white/10" />
<div className="h-3 w-2/3 animate-pulse rounded-full bg-white/10" />
</div>
</div>
))}
</>
)

View File

@@ -0,0 +1,184 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import i18next from 'i18next'
import { atom, Provider as JotaiProvider, useSetAtom } from 'jotai'
import type { PropsWithChildren } from 'react'
import { createContext, use, useEffect, useMemo } from 'react'
import { toast } from 'sonner'
import type { Comment } from '~/lib/api/comments'
import { commentsApi } from '~/lib/api/comments'
const PAGE_SIZE = 20
export interface CommentsAtoms {
commentsAtom: ReturnType<typeof atom<Comment[]>>
newCommentAtom: ReturnType<typeof atom<string>>
replyToAtom: ReturnType<typeof atom<Comment | null>>
statusAtom: ReturnType<
typeof atom<{
isLoading: boolean
isError: boolean
isLoadingMore: boolean
nextCursor: string | null
}>
>
submitAtom: ReturnType<typeof atom<null, [string], Promise<void>>>
loadMoreAtom: ReturnType<typeof atom<null, [], Promise<void>>>
toggleReactionAtom: ReturnType<typeof atom<null, [{ comment: Comment; reaction?: string }], Promise<void>>>
}
export interface CommentsContextValue {
atoms: CommentsAtoms
}
const CommentsContext = createContext<CommentsContextValue | null>(null)
function createCommentsAtoms(photoId: string): CommentsAtoms {
const commentsAtom = atom<Comment[]>([])
const newCommentAtom = atom<string>('')
const replyToAtom = atom<Comment | null>(null)
const statusAtom = atom({
isLoading: false,
isError: false,
isLoadingMore: false,
nextCursor: null as string | null,
})
const submitAtom = atom(null, async (get, set, content: string) => {
const replyTo = get(replyToAtom)
try {
set(statusAtom, (prev) => ({ ...prev, isLoading: true }))
const comment = await commentsApi.create({
photoId,
content: content.trim(),
parentId: replyTo?.id ?? null,
})
set(commentsAtom, (prev) => [comment, ...prev])
set(newCommentAtom, '')
set(replyToAtom, null)
toast.success(i18next.t('comments.posted'))
} catch (error: any) {
if (error?.status === 401) {
toast.error(i18next.t('comments.loginRequired'))
} else {
toast.error(i18next.t('comments.postFailed'))
}
} finally {
set(statusAtom, (prev) => ({ ...prev, isLoading: false }))
}
})
const loadMoreAtom = atom(null, async (get, set) => {
const status = get(statusAtom)
if (status.isLoadingMore || !status.nextCursor) return
set(statusAtom, { ...status, isLoadingMore: true })
try {
const result = await commentsApi.list(photoId, status.nextCursor, PAGE_SIZE)
set(commentsAtom, (prev) => [...prev, ...result.items])
set(statusAtom, (prev) => ({ ...prev, nextCursor: result.nextCursor, isLoadingMore: false }))
} catch {
set(statusAtom, (prev) => ({ ...prev, isLoadingMore: false, isError: true }))
}
})
const toggleReactionAtom = atom(
null,
async (get, set, { comment, reaction = 'like' }: { comment: Comment; reaction?: string }) => {
const isActive = comment.viewerReactions.includes(reaction)
set(commentsAtom, (prev) =>
prev.map((item) => {
if (item.id !== comment.id) return item
const counts = { ...item.reactionCounts }
counts[reaction] = Math.max(0, (counts[reaction] ?? 0) + (isActive ? -1 : 1))
const viewerReactions = isActive
? item.viewerReactions.filter((r) => r !== reaction)
: [...item.viewerReactions, reaction]
return { ...item, reactionCounts: counts, viewerReactions }
}),
)
try {
await commentsApi.toggleReaction({ commentId: comment.id, reaction })
} catch (error: any) {
set(commentsAtom, (prev) =>
prev.map((item) => {
if (item.id !== comment.id) return item
const counts = { ...item.reactionCounts }
counts[reaction] = Math.max(0, (counts[reaction] ?? 0) + (isActive ? 1 : -1))
const viewerReactions = isActive
? [...item.viewerReactions, reaction]
: item.viewerReactions.filter((r) => r !== reaction)
return { ...item, reactionCounts: counts, viewerReactions }
}),
)
if (error?.status === 401) {
toast.error(i18next.t('comments.loginRequired'))
} else {
toast.error(i18next.t('comments.reactionFailed'))
}
}
},
)
return {
commentsAtom,
newCommentAtom,
replyToAtom,
statusAtom,
submitAtom,
loadMoreAtom,
toggleReactionAtom,
}
}
export function useCommentsContext(): CommentsContextValue {
const ctx = use(CommentsContext)
if (!ctx) {
throw new Error('CommentsContext not found')
}
return ctx
}
export function CommentsProvider({ photoId, children }: PropsWithChildren<{ photoId: string }>) {
const atoms = useMemo(() => createCommentsAtoms(photoId), [photoId])
const setComments = useSetAtom(atoms.commentsAtom)
const setStatus = useSetAtom(atoms.statusAtom)
const commentsQuery = useInfiniteQuery({
queryKey: ['comments', photoId],
queryFn: ({ pageParam }) => commentsApi.list(photoId, pageParam as string | null, PAGE_SIZE),
getNextPageParam: (last) => last.nextCursor ?? undefined,
initialPageParam: null as string | null,
retry: 1,
})
useEffect(() => {
setStatus((prev) => ({
...prev,
isLoading: commentsQuery.isLoading,
isLoadingMore: commentsQuery.isFetchingNextPage,
}))
}, [commentsQuery.isFetchingNextPage, commentsQuery.isLoading, setStatus])
useEffect(() => {
if (commentsQuery.data) {
setComments(commentsQuery.data.pages.flatMap((page) => page.items))
const nextCursor = commentsQuery.data.pages.at(-1)?.nextCursor ?? null
setStatus((prev) => ({ ...prev, isLoading: false, isError: false, nextCursor }))
}
}, [commentsQuery.data, setComments, setStatus])
useEffect(() => {
if (commentsQuery.isError) {
setStatus((prev) => ({ ...prev, isLoading: false, isError: true }))
}
}, [commentsQuery.isError, setStatus])
const value = useMemo<CommentsContextValue>(() => ({ atoms }), [atoms])
return (
<JotaiProvider>
<CommentsContext value={value}>{children}</CommentsContext>
</JotaiProvider>
)
}

View File

@@ -0,0 +1,29 @@
export function formatRelativeTime(iso: string, locale: string): string {
const date = new Date(iso)
const rawDiff = Math.floor((Date.now() - date.getTime()) / 1000)
const diffSeconds = Math.min(Math.max(rawDiff, -2_592_000), 2_592_000)
const divisions: Array<[number, Intl.RelativeTimeFormatUnit]> = [
[60, 'seconds'],
[60, 'minutes'],
[24, 'hours'],
[7, 'days'],
[4.34524, 'weeks'],
[12, 'months'],
[Number.POSITIVE_INFINITY, 'years'],
]
let unit: Intl.RelativeTimeFormatUnit = 'seconds'
let value = diffSeconds
for (const [amount, nextUnit] of divisions) {
if (Math.abs(value) < amount) {
unit = nextUnit
break
}
value /= amount
}
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
return formatter.format(Math.round(-value), unit)
}

View File

@@ -0,0 +1,109 @@
import { ScrollArea } from '@afilmory/ui'
import { useAtom, useSetAtom } from 'jotai'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { sessionUserAtom } from '~/atoms/session'
import { useMobile } from '~/hooks/useMobile'
import type { Comment } from '~/lib/api/comments'
import { CommentCard } from './CommentCard'
import { CommentInput } from './CommentInput'
import { CommentsProvider, useCommentsContext } from './context'
import { EmptyState } from './EmptyState'
import { ErrorBox } from './ErrorBox'
import { SkeletonList } from './SkeletonList'
export const CommentsPanel: FC<{ photoId: string; visible?: boolean }> = ({ photoId }) => {
return (
<CommentsProvider photoId={photoId}>
<CommentsContent />
</CommentsProvider>
)
}
const CommentsContent: FC = () => {
const { t, i18n } = useTranslation()
const isMobile = useMobile()
const { atoms } = useCommentsContext()
const [comments] = useAtom(atoms.commentsAtom)
const [status] = useAtom(atoms.statusAtom)
const [replyTo, setReplyTo] = useAtom(atoms.replyToAtom)
const [newComment, setNewComment] = useAtom(atoms.newCommentAtom)
const [sessionUser] = useAtom(sessionUserAtom)
const submit = useSetAtom(atoms.submitAtom)
const loadMore = useSetAtom(atoms.loadMoreAtom)
const toggleReaction = useSetAtom(atoms.toggleReactionAtom)
const authorName = (comment: Comment) => {
if (sessionUser?.id && comment.userId === sessionUser.id) {
return t('comments.you')
}
if (comment.userId) {
return t('comments.user', { id: comment.userId.slice(-6) })
}
return t('comments.anonymous')
}
return (
<div className="flex min-h-0 w-full flex-1 flex-col">
<div className="flex items-center justify-between px-4 pt-3 pb-2 text-sm text-white/70">
<div className="flex items-center gap-2">
<i className="i-mingcute-comment-line" />
<span>{t('inspector.tab.comments')}</span>
{comments.length > 0 && (
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/70">{comments.length}</span>
)}
</div>
{status.isLoading && <span className="text-xs text-white/40">{t('comments.loading')}</span>}
</div>
<ScrollArea rootClassName="flex-1 min-h-0" viewportClassName="px-4">
<div className="space-y-4 pb-4">
{status.isLoading ? (
<SkeletonList />
) : status.isError ? (
<ErrorBox />
) : comments.length === 0 ? (
<EmptyState />
) : (
comments.map((comment) => (
<CommentCard
key={comment.id}
comment={comment}
parent={comment.parentId ? (comments.find((c) => c.id === comment.parentId) ?? null) : null}
reacted={comment.viewerReactions.includes('like')}
onReply={() => setReplyTo(comment)}
onToggleReaction={() => toggleReaction({ comment })}
authorName={authorName}
locale={i18n.language || 'en'}
/>
))
)}
{status.nextCursor && (
<button
type="button"
onClick={() => loadMore()}
disabled={status.isLoadingMore}
className="glassmorphic-btn border-accent/30 hover:border-accent/60 mx-auto flex w-full items-center justify-center gap-2 rounded-xl border px-3 py-2 text-sm text-white/80 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-60"
>
<i className="i-mingcute-arrow-down-line" />
{status.isLoadingMore ? t('comments.loading') : t('comments.loadMore')}
</button>
)}
</div>
</ScrollArea>
<CommentInput
isMobile={isMobile}
sessionUser={sessionUser}
replyTo={replyTo}
setReplyTo={setReplyTo}
newComment={newComment}
setNewComment={setNewComment}
onSubmit={(content) => submit(content)}
/>
</div>
)
}

View File

@@ -0,0 +1,20 @@
import { apiFetch } from './http'
export interface SessionUser {
id: string
name?: string | null
image?: string | null
role?: string | null
}
export interface SessionPayload {
user: SessionUser
session: unknown
tenant?: unknown
}
export const authApi = {
async getSession(): Promise<SessionPayload | null> {
return await apiFetch<SessionPayload | null>('/api/auth/session')
},
}

View File

@@ -0,0 +1,100 @@
import { apiFetch } from './http'
export type CommentStatus = 'pending' | 'approved' | 'rejected' | 'hidden'
export interface CommentDto {
id: string
photo_id: string
parent_id: string | null
user_id: string
content: string
status: CommentStatus
created_at: string
updated_at: string
reaction_counts?: Record<string, number>
viewer_reactions?: string[]
}
export interface Comment {
id: string
photoId: string
parentId: string | null
userId: string
content: string
status: CommentStatus
createdAt: string
updatedAt: string
reactionCounts: Record<string, number>
viewerReactions: string[]
}
export interface CommentListResult {
items: Comment[]
nextCursor: string | null
}
export interface CreateCommentInput {
photoId: string
content: string
parentId?: string | null
}
export interface ToggleReactionInput {
commentId: string
reaction: string
}
function mapComment(dto: CommentDto): Comment {
return {
id: dto.id,
photoId: dto.photo_id,
parentId: dto.parent_id,
userId: dto.user_id,
content: dto.content,
status: dto.status,
createdAt: dto.created_at,
updatedAt: dto.updated_at,
reactionCounts: dto.reaction_counts ?? {},
viewerReactions: dto.viewer_reactions ?? [],
}
}
export const commentsApi = {
async list(photoId: string, cursor?: string | null, limit = 20): Promise<CommentListResult> {
const params = new URLSearchParams({
photoId,
limit: String(limit),
})
if (cursor) params.set('cursor', cursor)
const data = await apiFetch<{ items: CommentDto[]; next_cursor: string | null }>(
`/api/comments?${params.toString()}`,
)
return {
items: data.items.map(mapComment),
nextCursor: data.next_cursor ?? null,
}
},
async create(input: CreateCommentInput): Promise<Comment> {
const data = await apiFetch<{ item: CommentDto }>('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
photoId: input.photoId,
content: input.content,
parentId: input.parentId ?? undefined,
}),
})
return mapComment(data.item)
},
async toggleReaction(input: ToggleReactionInput): Promise<Comment> {
const data = await apiFetch<{ item: CommentDto }>(`/api/comments/${input.commentId}/reactions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reaction: input.reaction }),
})
return mapComment(data.item)
},
}

View File

@@ -0,0 +1,26 @@
export class ApiError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly body?: unknown,
) {
super(message)
this.name = 'ApiError'
}
}
export async function apiFetch<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, init)
const contentType = response.headers.get('content-type') ?? ''
const isJson = contentType.toLowerCase().includes('json')
const body = isJson ? await response.json().catch(() => null) : null
if (!response.ok) {
throw new ApiError(`Request failed: ${response.status}`, response.status, body)
}
const payload =
body && typeof body === 'object' && 'data' in (body as Record<string, unknown>) ? (body as any).data : body
return (payload ?? null) as T
}

View File

@@ -0,0 +1,34 @@
import type { ComponentType, PropsWithChildren } from 'react'
import { Fragment } from 'react'
import { injectConfig } from '~/config'
/**
* Higher-order component that conditionally wraps a component based on `injectConfig.useCloud`.
* When `useCloud` is true, returns the wrapped component.
* When `useCloud` is false, returns a Fragment (no wrapper).
*
* This is useful for feature flags and gradual migration scenarios.
*
* @param Component - The component to conditionally wrap
* @returns The wrapped component or Fragment wrapper
*
* @example
* ```tsx
* const CloudSessionProvider = withCloud(SessionProvider)
* // When useCloud is true: <SessionProvider>{children}</SessionProvider>
* // When useCloud is false: <Fragment>{children}</Fragment>
* ```
*/
export function withCloud<P extends PropsWithChildren>(Component: ComponentType<P>): ComponentType<P> {
const WrappedComponent = (props: P) => {
if (injectConfig.useCloud) {
return <Component {...props} />
}
return <Fragment>{props.children}</Fragment>
}
WrappedComponent.displayName = `withCloud(${Component.displayName || Component.name || 'Component'})`
return WrappedComponent as ComponentType<P>
}

View File

@@ -1,27 +1,51 @@
import { Toaster } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Provider } from 'jotai'
import { domMax, LazyMotion, MotionConfig } from 'motion/react'
import type { FC, PropsWithChildren } from 'react'
import { useState } from 'react'
import { withCloud } from '~/lib/hoc/withCloud'
import { jotaiStore } from '~/lib/jotai'
import { ContextMenuProvider } from './context-menu-provider'
import { EventProvider } from './event-provider'
import { I18nProvider } from './i18n-provider'
import { SessionProvider } from './session-provider'
import { StableRouterProvider } from './stable-router-provider'
export const RootProviders: FC<PropsWithChildren> = ({ children }) => (
<LazyMotion features={domMax} strict key="framer">
<MotionConfig transition={Spring.presets.smooth}>
<Provider store={jotaiStore}>
<EventProvider />
<StableRouterProvider />
const CloudSessionProvider = withCloud(SessionProvider)
<ContextMenuProvider />
<I18nProvider>{children}</I18nProvider>
</Provider>
</MotionConfig>
<Toaster />
</LazyMotion>
)
export const RootProviders: FC<PropsWithChildren> = ({ children }) => {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
},
},
}),
)
return (
<LazyMotion features={domMax} strict key="framer">
<MotionConfig transition={Spring.presets.smooth}>
<Provider store={jotaiStore}>
<QueryClientProvider client={queryClient}>
<CloudSessionProvider>
<EventProvider />
<StableRouterProvider />
<ContextMenuProvider />
<I18nProvider>{children}</I18nProvider>
</CloudSessionProvider>
</QueryClientProvider>
</Provider>
</MotionConfig>
<Toaster />
</LazyMotion>
)
}

View File

@@ -0,0 +1,28 @@
import { useQuery } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useEffect } from 'react'
import { sessionUserAtom } from '~/atoms/session'
import { authApi } from '~/lib/api/auth'
export function SessionProvider({ children }: { children: React.ReactNode }) {
const setSessionUser = useSetAtom(sessionUserAtom)
const sessionQuery = useQuery({
queryKey: ['session'],
queryFn: authApi.getSession,
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
})
useEffect(() => {
if (sessionQuery.data?.user) {
setSessionUser(sessionQuery.data.user)
} else if (sessionQuery.data === null) {
// Explicitly set to null when session is null (not logged in)
setSessionUser(null)
}
}, [sessionQuery.data, setSessionUser])
return <>{children}</>
}

View File

@@ -0,0 +1,35 @@
import { Body, ContextParam, Controller, Delete, Get, Param, Post, Query } from '@afilmory/framework'
import { Roles } from 'core/guards/roles.decorator'
import type { Context } from 'hono'
import { CommentReactionDto, CreateCommentDto, ListCommentsQueryDto } from './comment.dto'
import { CommentService } from './comment.service'
@Controller('comments')
export class CommentController {
constructor(private readonly commentService: CommentService) {}
@Post('/')
@Roles('user')
async createComment(@ContextParam() context: Context, @Body() body: CreateCommentDto) {
return await this.commentService.createComment(body, context)
}
@Get('/')
async listComments(@Query() query: ListCommentsQueryDto) {
return await this.commentService.listComments(query)
}
@Post('/:id/reactions')
@Roles('user')
async react(@Param('id') commentId: string, @Body() body: CommentReactionDto) {
return await this.commentService.toggleReaction(commentId, body)
}
@Delete('/:id')
@Roles('user')
async deleteComment(@Param('id') commentId: string) {
await this.commentService.softDelete(commentId)
return { id: commentId, deleted: true }
}
}

View File

@@ -0,0 +1,24 @@
import { createZodSchemaDto } from '@afilmory/framework'
import { z } from 'zod'
export const CreateCommentSchema = z.object({
photoId: z.string().trim().min(1, 'photoId is required'),
content: z.string().trim().min(1, 'content is required').max(1000, 'content too long'),
parentId: z.string().trim().min(1).optional(),
})
export class CreateCommentDto extends createZodSchemaDto(CreateCommentSchema) {}
export const ListCommentsQuerySchema = z.object({
photoId: z.string().trim().min(1, 'photoId is required'),
limit: z.coerce.number().int().min(1).max(50).default(20),
cursor: z.string().trim().min(1).optional(),
})
export class ListCommentsQueryDto extends createZodSchemaDto(ListCommentsQuerySchema) {}
export const CommentReactionSchema = z.object({
reaction: z.string().trim().min(1, 'reaction is required').max(32, 'reaction too long'),
})
export class CommentReactionDto extends createZodSchemaDto(CommentReactionSchema) {}

View File

@@ -0,0 +1,31 @@
import { injectable } from 'tsyringe'
export type CommentModerationAction = 'allow' | 'reject' | 'flag_pending'
export interface CommentModerationHookInput {
tenantId: string
userId: string
photoId: string
parentId?: string | null
content: string
userAgent?: string | null
clientIp?: string | null
}
export interface CommentModerationResult {
action: CommentModerationAction
reason?: string
}
export interface CommentModerationHook {
review: (input: CommentModerationHookInput) => Promise<CommentModerationResult> | CommentModerationResult
}
export const COMMENT_MODERATION_HOOK = Symbol('COMMENT_MODERATION_HOOK')
@injectable()
export class AllowAllCommentModerationHook implements CommentModerationHook {
async review(): Promise<CommentModerationResult> {
return { action: 'allow' }
}
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@afilmory/framework'
import { DatabaseModule } from 'core/database/database.module'
import { CommentController } from './comment.controller'
import { AllowAllCommentModerationHook,COMMENT_MODERATION_HOOK } from './comment.moderation'
import { CommentService } from './comment.service'
@Module({
imports: [DatabaseModule],
controllers: [CommentController],
providers: [
CommentService,
AllowAllCommentModerationHook,
{
provide: COMMENT_MODERATION_HOOK,
useExisting: AllowAllCommentModerationHook,
},
],
})
export class CommentModule {}

View File

@@ -0,0 +1,413 @@
import { commentReactions, comments, photoAssets } from '@afilmory/db'
import { HttpContext } from '@afilmory/framework'
import { getClientIp } from 'core/context/http-context.helper'
import { DbAccessor } from 'core/database/database.provider'
import { BizException, ErrorCode } from 'core/errors'
import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
import { and, eq, gt, inArray, isNull, or, sql } from 'drizzle-orm'
import type { Context } from 'hono'
import { inject, injectable } from 'tsyringe'
import type { CommentReactionDto, CreateCommentDto, ListCommentsQueryDto } from './comment.dto'
import type { CommentModerationHook, CommentModerationHookInput } from './comment.moderation'
import { COMMENT_MODERATION_HOOK } from './comment.moderation'
export interface CommentViewModel {
id: string
photoId: string
parentId: string | null
userId: string
content: string
status: string
createdAt: string
updatedAt: string
}
interface ViewerContext {
userId: string | null
role?: string
}
interface CommentResponseItem extends CommentViewModel {
reactionCounts: Record<string, number>
viewerReactions: string[]
}
@injectable()
export class CommentService {
constructor(
private readonly dbAccessor: DbAccessor,
@inject(COMMENT_MODERATION_HOOK) private readonly moderationHook: CommentModerationHook,
) {}
async createComment(dto: CreateCommentDto, context: Context): Promise<{ item: CommentResponseItem }> {
const tenant = requireTenantContext()
const auth = this.requireAuth()
const db = this.dbAccessor.get()
await this.ensurePhotoExists(tenant.tenant.id, dto.photoId)
const parent = await this.validateParent(dto.parentId, tenant.tenant.id, dto.photoId)
const moderationInput: CommentModerationHookInput = {
tenantId: tenant.tenant.id,
userId: auth.userId,
photoId: dto.photoId,
parentId: parent?.id,
content: dto.content.trim(),
userAgent: context.req.header('user-agent') ?? null,
clientIp: getClientIp(),
}
const moderationResult = await this.moderationHook.review(moderationInput)
if (moderationResult.action === 'reject') {
throw new BizException(ErrorCode.COMMON_FORBIDDEN, {
message: moderationResult.reason ?? '评论未通过审核',
})
}
const status = moderationResult.action === 'flag_pending' ? 'pending' : 'approved'
const [record] = await db
.insert(comments)
.values({
tenantId: tenant.tenant.id,
photoId: dto.photoId,
parentId: parent?.id ?? null,
userId: auth.userId,
content: dto.content.trim(),
status,
userAgent: moderationInput.userAgent ?? null,
clientIp: moderationInput.clientIp ?? null,
})
.returning()
const item = this.toResponse({
...record,
reactionCounts: {},
viewerReactions: [],
})
return { item }
}
async listComments(
query: ListCommentsQueryDto,
): Promise<{ items: CommentResponseItem[]; nextCursor: string | null }> {
const tenant = requireTenantContext()
const viewer = this.getViewer()
const db = this.dbAccessor.get()
const filters = [
eq(comments.tenantId, tenant.tenant.id),
eq(comments.photoId, query.photoId),
isNull(comments.deletedAt),
]
let statusCondition
if (viewer.isAdmin) {
statusCondition = inArray(comments.status, ['approved', 'pending'])
} else if (viewer.userId) {
statusCondition = or(
eq(comments.status, 'approved'),
and(eq(comments.status, 'pending'), eq(comments.userId, viewer.userId)),
)
} else {
statusCondition = eq(comments.status, 'approved')
}
filters.push(statusCondition)
let cursorCondition
if (query.cursor) {
const anchor = await this.findCommentForCursor(query.cursor, tenant.tenant.id, query.photoId)
cursorCondition = or(
gt(comments.createdAt, anchor.createdAt),
and(eq(comments.createdAt, anchor.createdAt), gt(comments.id, anchor.id)),
)
}
const baseWhere = cursorCondition ? and(...filters, cursorCondition) : and(...filters)
const rows = await db
.select({
id: comments.id,
photoId: comments.photoId,
parentId: comments.parentId,
userId: comments.userId,
content: comments.content,
status: comments.status,
createdAt: comments.createdAt,
updatedAt: comments.updatedAt,
})
.from(comments)
.where(baseWhere)
.orderBy(comments.createdAt, comments.id)
.limit(query.limit + 1)
const hasMore = rows.length > query.limit
const items = rows.slice(0, query.limit)
const commentIds = items.map((item) => item.id)
const reactions = await this.fetchReactionAggregations(tenant.tenant.id, commentIds, viewer.userId)
const nextCursor = hasMore && items.length > 0 ? items.at(-1)!.id : null
return {
items: items.map((item) =>
this.toResponse({
...item,
reactionCounts: reactions.counts.get(item.id) ?? {},
viewerReactions: reactions.viewer.get(item.id) ?? [],
}),
),
nextCursor,
}
}
async toggleReaction(commentId: string, body: CommentReactionDto): Promise<{ item: CommentResponseItem }> {
const tenant = requireTenantContext()
const auth = this.requireAuth()
const db = this.dbAccessor.get()
const comment = await this.getCommentById(commentId, tenant.tenant.id)
const [existing] = await db
.select({ id: commentReactions.id })
.from(commentReactions)
.where(
and(
eq(commentReactions.tenantId, tenant.tenant.id),
eq(commentReactions.commentId, comment.id),
eq(commentReactions.userId, auth.userId),
eq(commentReactions.reaction, body.reaction),
),
)
.limit(1)
if (existing) {
await db.delete(commentReactions).where(eq(commentReactions.id, existing.id))
} else {
await db.insert(commentReactions).values({
tenantId: tenant.tenant.id,
commentId: comment.id,
userId: auth.userId,
reaction: body.reaction,
})
}
const aggregation = await this.fetchReactionAggregations(tenant.tenant.id, [comment.id], auth.userId)
const item = this.toResponse({
...comment,
reactionCounts: aggregation.counts.get(comment.id) ?? {},
viewerReactions: aggregation.viewer.get(comment.id) ?? [],
})
return { item }
}
async softDelete(commentId: string): Promise<void> {
const tenant = requireTenantContext()
const auth = this.requireAuth()
const db = this.dbAccessor.get()
const [record] = await db
.select({
id: comments.id,
tenantId: comments.tenantId,
userId: comments.userId,
})
.from(comments)
.where(and(eq(comments.id, commentId), eq(comments.tenantId, tenant.tenant.id), isNull(comments.deletedAt)))
.limit(1)
if (!record) {
throw new BizException(ErrorCode.COMMON_NOT_FOUND, { message: '评论不存在' })
}
const isAdmin = auth.role === 'admin' || auth.role === 'superadmin'
const isOwner = auth.userId === record.userId
if (!isAdmin && !isOwner) {
throw new BizException(ErrorCode.COMMON_FORBIDDEN, { message: '无权删除该评论' })
}
await db
.update(comments)
.set({
status: 'hidden',
deletedAt: new Date().toISOString(),
})
.where(eq(comments.id, record.id))
}
private requireAuth(): { userId: string; role?: string } {
const authContext = HttpContext.getValue('auth') as
| { user?: { id?: string; role?: string }; session?: unknown }
| undefined
if (!authContext?.user || !authContext.session) {
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED)
}
const userId = (authContext.user as { id?: string }).id
if (!userId) {
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED)
}
return { userId, role: (authContext.user as { role?: string }).role }
}
private getViewer(): ViewerContext & { isAdmin: boolean } {
const authContext = HttpContext.getValue('auth') as
| { user?: { id?: string; role?: string }; session?: unknown }
| undefined
const userId = authContext?.user?.id ?? null
const role = authContext?.user?.role
const isAdmin = role === 'admin' || role === 'superadmin'
return { userId, role, isAdmin }
}
private async ensurePhotoExists(tenantId: string, photoId: string): Promise<void> {
const db = this.dbAccessor.get()
const [photo] = await db
.select({ id: photoAssets.id })
.from(photoAssets)
.where(and(eq(photoAssets.tenantId, tenantId), eq(photoAssets.photoId, photoId)))
.limit(1)
if (!photo) {
throw new BizException(ErrorCode.COMMON_NOT_FOUND, { message: '照片不存在' })
}
}
private async validateParent(parentId: string | undefined, tenantId: string, photoId: string) {
if (!parentId) {
return null
}
const db = this.dbAccessor.get()
const [parent] = await db
.select({
id: comments.id,
photoId: comments.photoId,
status: comments.status,
deletedAt: comments.deletedAt,
})
.from(comments)
.where(and(eq(comments.id, parentId), eq(comments.tenantId, tenantId)))
.limit(1)
if (!parent) {
throw new BizException(ErrorCode.COMMON_NOT_FOUND, { message: '引用的评论不存在' })
}
if (parent.photoId !== photoId) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '引用的评论不属于当前照片' })
}
if (parent.status === 'hidden' || parent.status === 'rejected' || parent.deletedAt) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '引用的评论不可用' })
}
return parent
}
private async getCommentById(commentId: string, tenantId: string) {
const db = this.dbAccessor.get()
const [comment] = await db
.select({
id: comments.id,
status: comments.status,
photoId: comments.photoId,
parentId: comments.parentId,
userId: comments.userId,
content: comments.content,
createdAt: comments.createdAt,
updatedAt: comments.updatedAt,
deletedAt: comments.deletedAt,
})
.from(comments)
.where(and(eq(comments.id, commentId), eq(comments.tenantId, tenantId), isNull(comments.deletedAt)))
.limit(1)
if (!comment) {
throw new BizException(ErrorCode.COMMON_NOT_FOUND, { message: '评论不存在或不可操作' })
}
return comment
}
private async fetchReactionAggregations(tenantId: string, commentIds: string[], viewerId: string | null) {
const db = this.dbAccessor.get()
const counts = new Map<string, Record<string, number>>()
const viewer = new Map<string, string[]>()
if (commentIds.length === 0) {
return { counts, viewer }
}
const rows = await db
.select({
commentId: commentReactions.commentId,
reaction: commentReactions.reaction,
total: sql<number>`count(*)`,
})
.from(commentReactions)
.where(and(eq(commentReactions.tenantId, tenantId), inArray(commentReactions.commentId, commentIds)))
.groupBy(commentReactions.commentId, commentReactions.reaction)
for (const row of rows) {
const current = counts.get(row.commentId) ?? {}
current[row.reaction] = row.total
counts.set(row.commentId, current)
}
if (viewerId) {
const viewerRows = await db
.select({
commentId: commentReactions.commentId,
reaction: commentReactions.reaction,
})
.from(commentReactions)
.where(
and(
eq(commentReactions.tenantId, tenantId),
inArray(commentReactions.commentId, commentIds),
eq(commentReactions.userId, viewerId),
),
)
for (const row of viewerRows) {
const existing = viewer.get(row.commentId) ?? []
existing.push(row.reaction)
viewer.set(row.commentId, existing)
}
}
return { counts, viewer }
}
private async findCommentForCursor(commentId: string, tenantId: string, photoId: string) {
const db = this.dbAccessor.get()
const [comment] = await db
.select({
id: comments.id,
createdAt: comments.createdAt,
})
.from(comments)
.where(and(eq(comments.id, commentId), eq(comments.tenantId, tenantId), eq(comments.photoId, photoId)))
.limit(1)
if (!comment) {
throw new BizException(ErrorCode.COMMON_NOT_FOUND, { message: '无效的游标' })
}
return comment
}
private toResponse(model: CommentViewModel & { reactionCounts: Record<string, number>; viewerReactions: string[] }) {
return {
id: model.id,
photoId: model.photoId,
parentId: model.parentId,
userId: model.userId,
content: model.content,
status: model.status,
createdAt: model.createdAt,
updatedAt: model.updatedAt,
reactionCounts: model.reactionCounts,
viewerReactions: model.viewerReactions,
}
}
}

View File

@@ -17,6 +17,7 @@ import { SettingModule } from './configuration/setting/setting.module'
import { SiteSettingModule } from './configuration/site-setting/site-setting.module'
import { StorageSettingModule } from './configuration/storage-setting/storage-setting.module'
import { SystemSettingModule } from './configuration/system-setting/system-setting.module'
import { CommentModule } from './content/comment/comment.module'
import { FeedModule } from './content/feed/feed.module'
import { OgModule } from './content/og/og.module'
import { PhotoModule } from './content/photo/photo.module'
@@ -58,6 +59,7 @@ function createEventModuleOptions(redis: RedisAccessor) {
SystemSettingModule,
SuperAdminModule,
PhotoModule,
CommentModule,
ReactionModule,
DashboardModule,
BillingModule,

View File

@@ -0,0 +1,36 @@
CREATE TYPE "public"."comment_status" AS ENUM('pending', 'approved', 'rejected', 'hidden');--> statement-breakpoint
CREATE TABLE "comment_reaction" (
"id" text PRIMARY KEY NOT NULL,
"tenant_id" text NOT NULL,
"comment_id" text NOT NULL,
"user_id" text NOT NULL,
"reaction" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "uq_comment_reaction_user" UNIQUE("tenant_id","comment_id","user_id","reaction")
);
--> statement-breakpoint
CREATE TABLE "comment" (
"id" text PRIMARY KEY NOT NULL,
"tenant_id" text NOT NULL,
"photo_id" text NOT NULL,
"user_id" text NOT NULL,
"parent_id" text,
"content" text NOT NULL,
"status" "comment_status" DEFAULT 'approved' NOT NULL,
"user_agent" text,
"client_ip" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"deleted_at" timestamp
);
--> statement-breakpoint
ALTER TABLE "photo_asset" ALTER COLUMN "manifest_version" SET DEFAULT 'v9';--> statement-breakpoint
ALTER TABLE "comment_reaction" ADD CONSTRAINT "comment_reaction_tenant_id_tenant_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comment_reaction" ADD CONSTRAINT "comment_reaction_comment_id_comment_id_fk" FOREIGN KEY ("comment_id") REFERENCES "public"."comment"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comment_reaction" ADD CONSTRAINT "comment_reaction_user_id_auth_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comment" ADD CONSTRAINT "comment_tenant_id_tenant_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comment" ADD CONSTRAINT "comment_user_id_auth_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_comment_reaction_comment" ON "comment_reaction" USING btree ("tenant_id","comment_id");--> statement-breakpoint
CREATE INDEX "idx_comment_tenant_photo" ON "comment" USING btree ("tenant_id","photo_id");--> statement-breakpoint
CREATE INDEX "idx_comment_parent" ON "comment" USING btree ("parent_id");--> statement-breakpoint
CREATE INDEX "idx_comment_user" ON "comment" USING btree ("user_id");

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,13 @@
"when": 1764070049538,
"tag": "0009_stormy_sway",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1764154685207,
"tag": "0010_wise_doorman",
"breakpoints": true
}
]
}

View File

@@ -1,6 +1,7 @@
import type { PhotoManifestItem } from '@afilmory/builder'
import type { ManifestVersion } from '@afilmory/builder/manifest/version'
import { CURRENT_MANIFEST_VERSION } from '@afilmory/builder/manifest/version'
import type { ManifestVersion } from '@afilmory/builder/manifest/version.js'
import { CURRENT_MANIFEST_VERSION } from '@afilmory/builder/manifest/version.ts'
import { relations } from 'drizzle-orm'
import {
bigint,
boolean,
@@ -31,6 +32,7 @@ export const userRoleEnum = pgEnum('user_role', ['user', 'admin', 'superadmin'])
export const tenantStatusEnum = pgEnum('tenant_status', ['active', 'inactive', 'suspended'])
export const tenantDomainStatusEnum = pgEnum('tenant_domain_status', ['pending', 'verified', 'disabled'])
export const photoSyncStatusEnum = pgEnum('photo_sync_status', ['pending', 'synced', 'conflict'])
export const commentStatusEnum = pgEnum('comment_status', ['pending', 'approved', 'rejected', 'hidden'])
export const CURRENT_PHOTO_MANIFEST_VERSION: ManifestVersion = CURRENT_MANIFEST_VERSION
export type PhotoAssetConflictType = 'missing-in-storage' | 'metadata-mismatch' | 'photo-id-conflict'
@@ -228,6 +230,63 @@ export const reactions = pgTable(
(t) => [index('idx_reactions_tenant_ref_key').on(t.tenantId, t.refKey)],
)
export const comments = pgTable(
'comment',
{
id: snowflakeId,
tenantId: text('tenant_id')
.notNull()
.references(() => tenants.id, { onDelete: 'cascade' }),
photoId: text('photo_id').notNull(),
userId: text('user_id')
.notNull()
.references(() => authUsers.id, { onDelete: 'cascade' }),
parentId: text('parent_id'),
content: text('content').notNull(),
status: commentStatusEnum('status').notNull().default('approved'),
userAgent: text('user_agent'),
clientIp: text('client_ip'),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { mode: 'string' }),
},
(t) => [
index('idx_comment_tenant_photo').on(t.tenantId, t.photoId),
index('idx_comment_parent').on(t.parentId),
index('idx_comment_user').on(t.userId),
],
)
export const commentsRelations = relations(comments, ({ one, many }) => ({
parent: one(comments, {
fields: [comments.parentId],
references: [comments.id],
}),
children: many(comments),
}))
export const commentReactions = pgTable(
'comment_reaction',
{
id: snowflakeId,
tenantId: text('tenant_id')
.notNull()
.references(() => tenants.id, { onDelete: 'cascade' }),
commentId: text('comment_id')
.notNull()
.references(() => comments.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => authUsers.id, { onDelete: 'cascade' }),
reaction: text('reaction').notNull(),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
},
(t) => [
unique('uq_comment_reaction_user').on(t.tenantId, t.commentId, t.userId, t.reaction),
index('idx_comment_reaction_comment').on(t.tenantId, t.commentId),
],
)
export const managedStorageUsages = pgTable(
'managed_storage_usage',
{
@@ -411,6 +470,8 @@ export const dbSchema = {
settings,
systemSettings,
reactions,
comments,
commentReactions,
managedStorageUsages,
managedStorageFileReferences,
photoAssets,

View File

@@ -14,6 +14,7 @@
"sourceMap": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"allowImportingTsExtensions": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"skipLibCheck": true,

View File

@@ -44,6 +44,26 @@
"action.view.layout": "Layout",
"action.view.settings": "View Settings",
"action.view.title": "View",
"comments.anonymous": "Guest",
"comments.cancelReply": "Cancel",
"comments.empty": "No comments yet. Be the first to comment!",
"comments.error": "Failed to load comments",
"comments.hint": "Press Enter to send, Shift+Enter for new line",
"comments.loadMore": "Load more",
"comments.loading": "Loading comments...",
"comments.loginRequired": "Please sign in to leave a comment.",
"comments.pending": "Pending review",
"comments.placeholder": "Add a comment...",
"comments.postFailed": "Failed to post comment",
"comments.posted": "Comment posted",
"comments.reactionFailed": "Failed to react",
"comments.reply": "Reply",
"comments.replyingTo": "Replying to {{user}}",
"comments.retry": "Retry",
"comments.send": "Send",
"comments.sending": "Sending...",
"comments.user": "User {{id}}",
"comments.you": "You",
"date.day.1": "1st",
"date.day.10": "10th",
"date.day.11": "11th",
@@ -314,6 +334,8 @@
"gallery.built.at": "Built at ",
"gallery.photos_one": "{{count}} photo",
"gallery.photos_other": "{{count}} photos",
"inspector.tab.comments": "Comments",
"inspector.tab.info": "Info",
"loading.converting": "Converting...",
"loading.default": "Loading",
"loading.heic.converting": "Converting HEIC/HEIF image format...",

View File

@@ -41,6 +41,26 @@
"action.view.layout": "布局",
"action.view.settings": "视图设置",
"action.view.title": "视图",
"comments.anonymous": "访客",
"comments.cancelReply": "取消回复",
"comments.empty": "暂无评论。快来发表第一条评论吧!",
"comments.error": "评论加载失败",
"comments.hint": "按 Enter 发送,Shift+Enter 换行",
"comments.loadMore": "加载更多",
"comments.loading": "正在加载评论…",
"comments.loginRequired": "登录后才能发表评论。",
"comments.pending": "等待审核",
"comments.placeholder": "添加评论...",
"comments.postFailed": "发表评论失败",
"comments.posted": "评论已发布",
"comments.reactionFailed": "操作失败,请稍后重试",
"comments.reply": "回复",
"comments.replyingTo": "正在回复 {{user}}",
"comments.retry": "重试",
"comments.send": "发送",
"comments.sending": "发送中…",
"comments.user": "访客 {{id}}",
"comments.you": "你",
"date.day.1": "1日",
"date.day.10": "10日",
"date.day.11": "11日",
@@ -311,6 +331,8 @@
"gallery.built.at": "构建于 ",
"gallery.photos_one": "{{count}} 张照片",
"gallery.photos_other": "{{count}} 张照片",
"inspector.tab.comments": "评论",
"inspector.tab.info": "信息",
"loading.converting": "转换中...",
"loading.default": "加载中",
"loading.heic.converting": "正在转换 HEIC/HEIF 图像格式...",

View File

@@ -15,6 +15,7 @@ export * from './modal'
export * from './portal'
export * from './prompts'
export * from './scroll-areas'
export * from './segment'
export * from './select'
export * from './sonner'
export * from './sonner'

View File

@@ -0,0 +1,8 @@
import { createContext } from 'react'
export interface SegmentGroupContextValue {
value: string
setValue: (value: string) => void
componentId: string
}
export const SegmentGroupContext = createContext<SegmentGroupContextValue>(null!)

View File

@@ -0,0 +1,85 @@
import { clsxm as cn, Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import type { ReactNode } from 'react'
import { use, useId, useMemo, useState } from 'react'
import { SegmentGroupContext } from './ctx'
interface SegmentGroupProps {
value?: string
onValueChanged?: (value: string) => void
}
export const SegmentGroup = (props: ComponentType<SegmentGroupProps>) => {
const { onValueChanged, value, className } = props
const [currentValue, setCurrentValue] = useState(value || '')
const componentId = useId()
return (
// eslint-disable-next-line @eslint-react/no-context-provider
<SegmentGroupContext.Provider
value={useMemo(
() => ({
value: currentValue,
setValue: (value) => {
setCurrentValue(value)
onValueChanged?.(value)
},
componentId,
}),
[componentId, currentValue, onValueChanged],
)}
>
<div
role="tablist"
className={cn(
'bg-fill-tertiary text-text-secondary inline-flex h-9 items-center justify-center rounded-lg p-1 outline-none',
className,
)}
tabIndex={0}
data-orientation="horizontal"
>
{props.children}
</div>
</SegmentGroupContext.Provider>
)
}
export const SegmentItem: Component<{
value: string
label: ReactNode
activeBgClassName?: string
}> = ({ label, value, className, activeBgClassName }) => {
const ctx = use(SegmentGroupContext)
const isActive = ctx.value === value
const { setValue } = ctx
const layoutId = ctx.componentId
return (
<button
type="button"
role="tab"
className={cn(
'ring-offset-background data-[state=active]:text-text relative inline-flex items-center justify-center px-3 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
'focus-visible:ring-accent/30 h-full rounded-md',
className,
)}
tabIndex={-1}
data-orientation="horizontal"
onClick={() => {
setValue(value)
}}
data-state={isActive ? 'active' : 'inactive'}
>
<span className="z-[1]">{label}</span>
{isActive && (
<m.span
layout
transition={Spring.presets.smooth}
layoutId={layoutId}
className={cn('absolute inset-0 z-0 rounded-md', activeBgClassName || 'bg-background')}
/>
)}
</button>
)
}