feat: implement comments feature (#171)

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-27 00:37:52 +08:00
committed by GitHub
parent 37825d1def
commit cb40fe74d0
116 changed files with 6637 additions and 1129 deletions

View File

@@ -11,10 +11,12 @@ export * from './form'
export * from './hover-card'
export * from './icons'
export * from './lazy-image'
export * from './mobile-tab'
export * from './modal'
export * from './portal'
export * from './prompts'
export * from './scroll-areas'
export * from './segment'
export * from './select'
export * from './sonner'
export * from './sonner'

View File

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

View File

@@ -0,0 +1,86 @@
import { clsxm, Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import type { ReactNode } from 'react'
import { use, useId, useMemo, useState } from 'react'
import { MobileTabGroupContext } from './ctx'
interface MobileTabGroupProps {
value?: string
onValueChanged?: (value: string) => void
}
export const MobileTabGroup = (props: ComponentType<MobileTabGroupProps>) => {
const { onValueChanged, value, className } = props
const [currentValue, setCurrentValue] = useState(value || '')
const componentId = useId()
return (
// eslint-disable-next-line @eslint-react/no-context-provider
<MobileTabGroupContext.Provider
value={useMemo(
() => ({
value: currentValue,
setValue: (value) => {
setCurrentValue(value)
onValueChanged?.(value)
},
componentId,
}),
[componentId, currentValue, onValueChanged],
)}
>
<div
role="tablist"
className={clsxm('relative flex items-center border-b border-accent/10 px-4 pb-3 pt-4', className)}
tabIndex={0}
data-orientation="horizontal"
>
<div className="flex flex-1 gap-1">{props.children}</div>
</div>
</MobileTabGroupContext.Provider>
)
}
export const MobileTabItem: Component<{
value: string
label: ReactNode
activeBgClassName?: string
}> = ({ label, value, className, activeBgClassName }) => {
const ctx = use(MobileTabGroupContext)
const isActive = ctx.value === value
const { setValue } = ctx
const layoutId = ctx.componentId
return (
<button
type="button"
role="tab"
className={clsxm(
'relative flex flex-1 items-center justify-center gap-1.5 rounded-lg px-3 py-2 font-medium text-sm transition-colors',
'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',
isActive ? 'text-white' : 'text-white/60 hover:text-white/80',
className,
)}
tabIndex={-1}
data-orientation="horizontal"
onClick={() => {
setValue(value)
}}
data-state={isActive ? 'active' : 'inactive'}
>
<span className="z-[1]">{label}</span>
{isActive && (
<m.div
layout
layoutId={layoutId}
transition={Spring.presets.smooth}
className={clsxm('absolute inset-x-0 bottom-0 h-0.5 bg-accent/60 rounded-full', activeBgClassName)}
/>
)}
</button>
)
}

View File

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

View File

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

View File

@@ -93,7 +93,7 @@ const SelectContent = ({
className={cn(
'bg-material-medium backdrop-blur-background text-text z-[60] max-h-96 min-w-32 overflow-hidden rounded border p-1',
'shadow-context-menu',
'motion-scale-in-75 motion-duration-150 text-body lg:animate-none',
'motion-scale-in-75 motion-duration-150 text-sm lg:animate-none',
className,
)}
position={position}

View File

@@ -103,10 +103,12 @@ function buildExifTags(photo: PhotoManifestItem): string {
} else {
ss = `${exif.ExposureTime}s`
}
} else if (!ss.endsWith('s') && // If it's a string and doesn't end with s, append it?
} else if (
!ss.endsWith('s') && // If it's a string and doesn't end with s, append it?
// Actually exiftool usually gives nice strings or numbers.
// Let's just trust the value but ensure 's' suffix if it looks like a number
!Number.isNaN(Number(ss))) {
!Number.isNaN(Number(ss))
) {
ss = `${ss}s`
}
tags.push(`<exif:shutterSpeed>${ss}</exif:shutterSpeed>`)