mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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:
5
apps/web/src/atoms/session.ts
Normal file
5
apps/web/src/atoms/session.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { atom } from 'jotai'
|
||||
|
||||
import type { SessionUser } from '~/lib/api/auth'
|
||||
|
||||
export const sessionUserAtom = atom<SessionUser | null>(null)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
125
apps/web/src/components/ui/photo-viewer/InspectorPanel.tsx
Normal file
125
apps/web/src/components/ui/photo-viewer/InspectorPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
apps/web/src/components/ui/photo-viewer/PhotoInspector.tsx
Normal file
23
apps/web/src/components/ui/photo-viewer/PhotoInspector.tsx
Normal 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
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
184
apps/web/src/components/ui/photo-viewer/comments/context.tsx
Normal file
184
apps/web/src/components/ui/photo-viewer/comments/context.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
apps/web/src/components/ui/photo-viewer/comments/format.ts
Normal file
29
apps/web/src/components/ui/photo-viewer/comments/format.ts
Normal 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)
|
||||
}
|
||||
109
apps/web/src/components/ui/photo-viewer/comments/index.tsx
Normal file
109
apps/web/src/components/ui/photo-viewer/comments/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
apps/web/src/lib/api/auth.ts
Normal file
20
apps/web/src/lib/api/auth.ts
Normal 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')
|
||||
},
|
||||
}
|
||||
100
apps/web/src/lib/api/comments.ts
Normal file
100
apps/web/src/lib/api/comments.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
26
apps/web/src/lib/api/http.ts
Normal file
26
apps/web/src/lib/api/http.ts
Normal 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
|
||||
}
|
||||
34
apps/web/src/lib/hoc/withCloud.tsx
Normal file
34
apps/web/src/lib/hoc/withCloud.tsx
Normal 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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
28
apps/web/src/providers/session-provider.tsx
Normal file
28
apps/web/src/providers/session-provider.tsx
Normal 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}</>
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
24
be/apps/core/src/modules/content/comment/comment.dto.ts
Normal file
24
be/apps/core/src/modules/content/comment/comment.dto.ts
Normal 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) {}
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
20
be/apps/core/src/modules/content/comment/comment.module.ts
Normal file
20
be/apps/core/src/modules/content/comment/comment.module.ts
Normal 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 {}
|
||||
413
be/apps/core/src/modules/content/comment/comment.service.ts
Normal file
413
be/apps/core/src/modules/content/comment/comment.service.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
36
be/packages/db/migrations/0010_wise_doorman.sql
Normal file
36
be/packages/db/migrations/0010_wise_doorman.sql
Normal 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");
|
||||
2087
be/packages/db/migrations/meta/0010_snapshot.json
Normal file
2087
be/packages/db/migrations/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,13 @@
|
||||
"when": 1764070049538,
|
||||
"tag": "0009_stormy_sway",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1764154685207,
|
||||
"tag": "0010_wise_doorman",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"sourceMap": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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 图像格式...",
|
||||
|
||||
@@ -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'
|
||||
|
||||
8
packages/ui/src/segment/ctx.tsx
Normal file
8
packages/ui/src/segment/ctx.tsx
Normal 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!)
|
||||
85
packages/ui/src/segment/index.tsx
Normal file
85
packages/ui/src/segment/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user