Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note
Commits
124708f1
Commit
124708f1
authored
Apr 08, 2026
by
boojack
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
chore: refactor attachment media layout and insert menu organization
parent
7e21b728
Changes
6
Hide whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
230 additions
and
92 deletions
+230
-92
InsertMenu.tsx
web/src/components/MemoEditor/Toolbar/InsertMenu.tsx
+22
-7
useFileUpload.ts
web/src/components/MemoEditor/hooks/useFileUpload.ts
+7
-2
AttachmentListView.tsx
...components/MemoMetadata/Attachment/AttachmentListView.tsx
+169
-67
AudioAttachmentItem.tsx
...omponents/MemoMetadata/Attachment/AudioAttachmentItem.tsx
+20
-15
MotionPhotoPlayer.tsx
web/src/components/MotionPhotoPlayer.tsx
+6
-1
MotionPhotoPreview.tsx
web/src/components/MotionPhotoPreview.tsx
+6
-0
No files found.
web/src/components/MemoEditor/Toolbar/InsertMenu.tsx
View file @
124708f1
...
...
@@ -2,6 +2,7 @@ import { LatLng } from "leaflet";
import
{
uniqBy
}
from
"lodash-es"
;
import
{
FileIcon
,
ImageIcon
,
LinkIcon
,
LoaderIcon
,
type
LucideIcon
,
...
...
@@ -131,14 +132,22 @@ const InsertMenu = (props: InsertMenuProps) => {
setMoreSubmenuOpen
(
false
);
},
[
onToggleFocusMode
]);
const
handleMediaUploadClick
=
useCallback
(()
=>
{
handleUploadClick
(
"image/*,video/*"
);
},
[
handleUploadClick
]);
const
handleFileUploadClick
=
useCallback
(()
=>
{
handleUploadClick
();
},
[
handleUploadClick
]);
const
menuItems
=
useMemo
(
()
=>
[
{
key
:
"upload"
,
label
:
t
(
"
editor.insert-menu.upload-file
"
),
icon
:
Fil
eIcon
,
onClick
:
handleUploadClick
,
key
:
"upload
-media
"
,
label
:
t
(
"
attachment-library.tabs.media
"
),
icon
:
Imag
eIcon
,
onClick
:
handle
Media
UploadClick
,
},
{
key
:
"record-audio"
,
...
...
@@ -146,6 +155,12 @@ const InsertMenu = (props: InsertMenuProps) => {
icon
:
MicIcon
,
onClick
:
()
=>
props
.
onAudioRecorderClick
?.(),
},
{
key
:
"upload-file"
,
label
:
t
(
"common.file"
),
icon
:
FileIcon
,
onClick
:
handleFileUploadClick
,
},
{
key
:
"link"
,
label
:
t
(
"editor.insert-menu.link-memo"
),
...
...
@@ -159,7 +174,7 @@ const InsertMenu = (props: InsertMenuProps) => {
onClick
:
handleLocationClick
,
},
]
satisfies
Array
<
{
key
:
string
;
label
:
string
;
icon
:
LucideIcon
;
onClick
:
()
=>
void
}
>
,
[
handle
LocationClick
,
handleOpenLinkDialog
,
handleUploadClick
,
props
,
t
],
[
handle
FileUploadClick
,
handleLocationClick
,
handleMediaUploadClick
,
handleOpenLinkDialog
,
props
,
t
],
);
return
(
...
...
@@ -171,14 +186,14 @@ const InsertMenu = (props: InsertMenuProps) => {
</
Button
>
</
DropdownMenuTrigger
>
<
DropdownMenuContent
align=
"start"
>
{
menuItems
.
slice
(
0
,
2
).
map
((
item
)
=>
(
{
menuItems
.
slice
(
0
,
3
).
map
((
item
)
=>
(
<
DropdownMenuItem
key=
{
item
.
key
}
onClick=
{
item
.
onClick
}
>
<
item
.
icon
className=
"w-4 h-4"
/>
{
item
.
label
}
</
DropdownMenuItem
>
))
}
<
DropdownMenuSeparator
/>
{
menuItems
.
slice
(
2
).
map
((
item
)
=>
(
{
menuItems
.
slice
(
3
).
map
((
item
)
=>
(
<
DropdownMenuItem
key=
{
item
.
key
}
onClick=
{
item
.
onClick
}
>
<
item
.
icon
className=
"w-4 h-4"
/>
{
item
.
label
}
...
...
web/src/components/MemoEditor/hooks/useFileUpload.ts
View file @
124708f1
...
...
@@ -26,8 +26,13 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void
if
(
fileInputRef
.
current
)
fileInputRef
.
current
.
value
=
""
;
};
const
handleUploadClick
=
()
=>
{
fileInputRef
.
current
?.
click
();
const
handleUploadClick
=
(
accept
=
"*"
)
=>
{
if
(
!
fileInputRef
.
current
)
{
return
;
}
fileInputRef
.
current
.
accept
=
accept
;
fileInputRef
.
current
.
click
();
};
return
{
...
...
web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx
View file @
124708f1
import
{
DownloadIcon
,
FileIcon
,
PaperclipIcon
,
PlayIcon
}
from
"lucide-react"
;
import
type
{
PropsWithChildren
}
from
"react"
;
import
{
useMemo
}
from
"react"
;
import
MetadataSection
from
"@/components/MemoMetadata/MetadataSection"
;
import
MotionPhotoPreview
from
"@/components/MotionPhotoPreview"
;
import
{
cn
}
from
"@/lib/utils"
;
import
type
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service_pb"
;
import
{
getAttachmentUrl
}
from
"@/utils/attachment"
;
import
type
{
PreviewMediaItem
}
from
"@/utils/media-item"
;
import
type
{
AttachmentVisualItem
,
PreviewMediaItem
}
from
"@/utils/media-item"
;
import
{
buildAttachmentVisualItems
}
from
"@/utils/media-item"
;
import
AudioAttachmentItem
from
"./AudioAttachmentItem"
;
import
{
getAttachmentMetadata
,
isAudioAttachment
,
separateAttachments
}
from
"./attachmentHelpers"
;
...
...
@@ -15,6 +16,17 @@ interface AttachmentListViewProps {
onImagePreview
?:
(
items
:
PreviewMediaItem
[],
index
:
number
)
=>
void
;
}
type
VisualItem
=
AttachmentVisualItem
;
const
VISUAL_TILE_CLASS
=
"group relative overflow-hidden rounded-xl border border-border/70 bg-muted/30 text-left transition-colors hover:border-accent/40"
;
const
COVER_MEDIA_CLASS
=
"h-full w-full rounded-none object-cover transition-transform duration-300 group-hover:scale-[1.02]"
;
const
NATURAL_MEDIA_CLASS
=
"block h-auto max-h-[20rem] w-auto max-w-full rounded-none transition-transform duration-300 group-hover:scale-[1.02]"
;
const
SINGLE_VIDEO_CARD_WIDTH_CLASS
=
"w-full max-w-[30rem]"
;
const
TWO_ITEM_GRID_HEIGHT_CLASS
=
"h-[11rem] sm:h-[13rem] md:h-[15rem]"
;
const
MOSAIC_GRID_HEIGHT_CLASS
=
"h-[13rem] sm:h-[16rem] md:h-[18rem]"
;
const
AttachmentMeta
=
({
attachment
}:
{
attachment
:
Attachment
})
=>
{
const
{
fileTypeLabel
,
fileSizeLabel
}
=
getAttachmentMetadata
(
attachment
);
...
...
@@ -50,89 +62,175 @@ const DocumentItem = ({ attachment }: { attachment: Attachment }) => {
);
};
const
MotionItem
=
({
const
getMotionPreviewProps
=
(
item
:
VisualItem
)
=>
({
motionUrl
:
item
.
previewItem
.
kind
===
"motion"
?
item
.
previewItem
.
motionUrl
:
item
.
sourceUrl
,
presentationTimestampUs
:
item
.
previewItem
.
kind
===
"motion"
?
item
.
previewItem
.
presentationTimestampUs
:
undefined
,
});
const
VisualTile
=
({
className
,
onPreview
,
overlayLabel
,
children
,
}:
PropsWithChildren
<
{
className
?:
string
;
onPreview
?:
()
=>
void
;
overlayLabel
?:
string
}
>
)
=>
{
return
(
<
button
type=
"button"
className=
{
cn
(
VISUAL_TILE_CLASS
,
className
)
}
onClick=
{
onPreview
}
>
{
children
}
<
div
className=
"pointer-events-none absolute inset-0 bg-gradient-to-t from-foreground/15 via-transparent to-transparent opacity-0 transition-opacity group-hover:opacity-100"
/>
{
overlayLabel
&&
(
<
div
className=
"pointer-events-none absolute inset-0 flex items-center justify-center bg-black/45 text-2xl font-semibold text-white backdrop-blur-[2px]"
>
{
overlayLabel
}
</
div
>
)
}
</
button
>
);
};
const
VideoPlayBadge
=
({
className
,
children
}:
PropsWithChildren
<
{
className
?:
string
}
>
)
=>
(
<
span
className=
{
cn
(
"pointer-events-none absolute inline-flex items-center justify-center rounded-full bg-background/85 text-foreground shadow-sm backdrop-blur-sm"
,
className
,
)
}
>
{
children
}
</
span
>
);
const
CollageVisualItem
=
({
item
,
featured
=
false
,
onPreview
,
className
,
overlayLabel
,
}:
{
item
:
ReturnType
<
typeof
buildAttachmentVisualItems
>
[
number
];
featured
?:
boolean
;
item
:
VisualItem
;
onPreview
?:
()
=>
void
;
className
?:
string
;
overlayLabel
?:
string
;
})
=>
{
const
motionPreviewProps
=
item
.
kind
===
"motion"
?
getMotionPreviewProps
(
item
)
:
undefined
;
return
(
<
button
type=
"button"
className=
{
cn
(
"group block w-full text-left"
,
featured
?
"max-w-[18rem] sm:max-w-[20rem]"
:
""
)
}
onClick=
{
onPreview
}
>
<
div
className=
{
cn
(
"relative overflow-hidden rounded-xl border border-border/70 bg-muted/30 transition-colors hover:border-accent/40"
,
featured
?
"aspect-[4/3]"
:
"aspect-square"
,
)
}
>
{
item
.
kind
===
"video"
?
(
<
video
src=
{
item
.
sourceUrl
}
className=
"h-full w-full rounded-none object-cover transition-transform duration-300 group-hover:scale-[1.02]"
preload=
"metadata"
/>
)
:
item
.
kind
===
"motion"
?
(
<
MotionPhotoPreview
posterUrl=
{
item
.
posterUrl
}
motionUrl=
{
item
.
previewItem
.
kind
===
"motion"
?
item
.
previewItem
.
motionUrl
:
item
.
sourceUrl
}
alt=
{
item
.
filename
}
presentationTimestampUs=
{
item
.
previewItem
.
kind
===
"motion"
?
item
.
previewItem
.
presentationTimestampUs
:
undefined
}
containerClassName=
"h-full w-full"
badgeClassName=
"left-2 top-2 px-2 py-0.5 text-[10px]"
mediaClassName=
"h-full w-full rounded-none object-cover transition-transform duration-300 group-hover:scale-[1.02]"
/>
)
:
(
<
img
src=
{
item
.
posterUrl
}
alt=
{
item
.
filename
}
className=
"h-full w-full rounded-none object-cover transition-transform duration-300 group-hover:scale-[1.02]"
loading=
"lazy"
decoding=
"async"
/>
)
}
<
div
className=
"pointer-events-none absolute inset-0 bg-gradient-to-t from-foreground/15 via-transparent to-transparent opacity-0 transition-opacity group-hover:opacity-100"
/>
{
item
.
kind
===
"video"
&&
(
<
span
className=
"pointer-events-none absolute bottom-2 right-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-background/80 text-foreground/70 backdrop-blur-sm"
>
<
PlayIcon
className=
"h-3.5 w-3.5 fill-current"
/>
</
span
>
)
}
<
VisualTile
className=
{
cn
(
"block h-full w-full"
,
className
)
}
onPreview=
{
onPreview
}
overlayLabel=
{
overlayLabel
}
>
{
item
.
kind
===
"video"
?
(
<>
<
video
src=
{
item
.
sourceUrl
}
className=
{
COVER_MEDIA_CLASS
}
preload=
"metadata"
/>
{
!
overlayLabel
&&
(
<
VideoPlayBadge
className=
"bottom-2 right-2 h-7 w-7 bg-background/80 text-foreground/70"
>
<
PlayIcon
className=
"h-3.5 w-3.5 fill-current"
/>
</
VideoPlayBadge
>
)
}
</>
)
:
item
.
kind
===
"motion"
&&
motionPreviewProps
?
(
<
MotionPhotoPreview
posterUrl=
{
item
.
posterUrl
}
motionUrl=
{
motionPreviewProps
.
motionUrl
}
alt=
{
item
.
filename
}
presentationTimestampUs=
{
motionPreviewProps
.
presentationTimestampUs
}
containerClassName=
"h-full w-full"
badgeClassName=
"left-2 top-2 px-2 py-0.5 text-[10px]"
mediaClassName=
{
COVER_MEDIA_CLASS
}
/>
)
:
(
<
img
src=
{
item
.
posterUrl
}
alt=
{
item
.
filename
}
className=
{
COVER_MEDIA_CLASS
}
loading=
"lazy"
decoding=
"async"
/>
)
}
</
VisualTile
>
);
};
const
SingleVisualItem
=
({
item
,
onPreview
}:
{
item
:
VisualItem
;
onPreview
?:
()
=>
void
})
=>
{
const
motionPreviewProps
=
item
.
kind
===
"motion"
?
getMotionPreviewProps
(
item
)
:
undefined
;
if
(
item
.
kind
===
"image"
)
{
return
(
<
VisualTile
className=
"inline-block max-w-full"
onPreview=
{
onPreview
}
>
<
img
src=
{
item
.
posterUrl
}
alt=
{
item
.
filename
}
className=
{
NATURAL_MEDIA_CLASS
}
loading=
"lazy"
decoding=
"async"
/>
</
VisualTile
>
);
}
if
(
item
.
kind
===
"motion"
&&
motionPreviewProps
)
{
return
(
<
VisualTile
className=
"inline-block max-w-full"
onPreview=
{
onPreview
}
>
<
MotionPhotoPreview
posterUrl=
{
item
.
posterUrl
}
motionUrl=
{
motionPreviewProps
.
motionUrl
}
alt=
{
item
.
filename
}
presentationTimestampUs=
{
motionPreviewProps
.
presentationTimestampUs
}
containerClassName=
"max-w-full"
posterClassName=
{
cn
(
NATURAL_MEDIA_CLASS
,
"object-contain"
)
}
videoClassName=
"absolute inset-0 h-full w-full rounded-none object-contain transition-transform duration-300 group-hover:scale-[1.02]"
badgeClassName=
"left-2 top-2 px-2 py-0.5 text-[10px]"
/>
</
VisualTile
>
);
}
return
(
<
VisualTile
className=
{
cn
(
"block"
,
SINGLE_VIDEO_CARD_WIDTH_CLASS
)
}
onPreview=
{
onPreview
}
>
<
div
className=
"relative aspect-video bg-black/5"
>
<
video
src=
{
item
.
sourceUrl
}
poster=
{
item
.
posterUrl
}
className=
{
COVER_MEDIA_CLASS
}
preload=
"metadata"
/>
<
div
className=
"pointer-events-none absolute inset-0 bg-gradient-to-t from-black/35 via-black/5 to-transparent"
/>
<
VideoPlayBadge
className=
"bottom-3 right-3 h-9 w-9"
>
<
PlayIcon
className=
"h-4 w-4 fill-current"
/>
</
VideoPlayBadge
>
</
div
>
</
button
>
</
VisualTile
>
);
};
const
VisualGallery
=
({
items
,
onPreview
,
}:
{
items
:
ReturnType
<
typeof
buildAttachmentVisualItems
>
;
onPreview
?:
(
itemId
:
string
)
=>
void
;
})
=>
{
const
VisualGallery
=
({
items
,
onPreview
}:
{
items
:
VisualItem
[];
onPreview
?:
(
itemId
:
string
)
=>
void
})
=>
{
if
(
items
.
length
===
0
)
{
return
null
;
}
if
(
items
.
length
===
1
)
{
return
(
<
div
className=
"flex"
>
<
MotionItem
item=
{
items
[
0
]
}
featured
onPreview=
{
()
=>
onPreview
?.(
items
[
0
].
id
)
}
/>
<
div
className=
"w-full"
>
<
SingleVisualItem
item=
{
items
[
0
]
}
onPreview=
{
()
=>
onPreview
?.(
items
[
0
].
id
)
}
/>
</
div
>
);
}
if
(
items
.
length
===
2
)
{
return
(
<
div
className=
{
cn
(
"grid grid-cols-2 gap-2"
,
TWO_ITEM_GRID_HEIGHT_CLASS
)
}
>
{
items
.
map
((
item
)
=>
(
<
CollageVisualItem
key=
{
item
.
id
}
item=
{
item
}
onPreview=
{
()
=>
onPreview
?.(
item
.
id
)
}
/>
))
}
</
div
>
);
}
if
(
items
.
length
===
3
)
{
return
(
<
div
className=
{
cn
(
"grid grid-cols-2 grid-rows-2 gap-2"
,
MOSAIC_GRID_HEIGHT_CLASS
)
}
>
<
CollageVisualItem
item=
{
items
[
0
]
}
className=
"row-span-2"
onPreview=
{
()
=>
onPreview
?.(
items
[
0
].
id
)
}
/>
<
CollageVisualItem
item=
{
items
[
1
]
}
onPreview=
{
()
=>
onPreview
?.(
items
[
1
].
id
)
}
/>
<
CollageVisualItem
item=
{
items
[
2
]
}
onPreview=
{
()
=>
onPreview
?.(
items
[
2
].
id
)
}
/>
</
div
>
);
}
const
visibleItems
=
items
.
slice
(
0
,
4
);
const
remainingCount
=
items
.
length
-
visibleItems
.
length
;
return
(
<
div
className=
"grid max-w-[22rem] grid-cols-2 gap-1.5 sm:max-w-[24rem]"
>
{
items
.
map
((
item
)
=>
(
<
MotionItem
key=
{
item
.
id
}
item=
{
item
}
onPreview=
{
()
=>
onPreview
?.(
item
.
id
)
}
/>
<
div
className=
{
cn
(
"grid grid-cols-2 grid-rows-2 gap-2"
,
MOSAIC_GRID_HEIGHT_CLASS
)
}
>
{
visibleItems
.
map
((
item
,
index
)
=>
(
<
CollageVisualItem
key=
{
item
.
id
}
item=
{
item
}
overlayLabel=
{
index
===
visibleItems
.
length
-
1
&&
remainingCount
>
0
?
`+${remainingCount}`
:
undefined
}
onPreview=
{
()
=>
onPreview
?.(
item
.
id
)
}
/>
))
}
</
div
>
);
};
const
AudioList
=
({
attachments
}:
{
attachments
:
Attachment
[]
})
=>
(
<
div
className=
"flex flex-col gap-2"
>
const
AudioList
=
({
attachments
,
compact
=
false
}:
{
attachments
:
Attachment
[];
compact
?:
boolean
})
=>
(
<
div
className=
{
cn
(
"gap-2"
,
compact
?
"grid grid-cols-1 sm:grid-cols-2"
:
"flex flex-col"
)
}
>
{
attachments
.
map
((
attachment
)
=>
(
<
AudioAttachmentItem
key=
{
attachment
.
name
}
...
...
@@ -140,6 +238,7 @@ const AudioList = ({ attachments }: { attachments: Attachment[] }) => (
sourceUrl=
{
getAttachmentUrl
(
attachment
)
}
mimeType=
{
attachment
.
type
}
size=
{
Number
(
attachment
.
size
)
}
compact=
{
compact
}
/>
))
}
</
div
>
...
...
@@ -164,7 +263,7 @@ const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewP
const
hasVisual
=
visualItems
.
length
>
0
;
const
hasAudio
=
audio
.
length
>
0
;
const
hasDocs
=
docs
.
length
>
0
;
const
sectionCount
=
[
hasVisual
,
hasAudio
,
hasDocs
].
filter
(
Boolean
).
length
;
const
hasMedia
=
hasVisual
||
hasAudio
;
if
(
attachments
.
length
===
0
)
{
return
null
;
...
...
@@ -182,10 +281,13 @@ const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewP
count=
{
visualItems
.
length
+
audio
.
length
+
docs
.
length
}
contentClassName=
"flex flex-col gap-2 p-2"
>
{
hasVisual
&&
<
VisualGallery
items=
{
visualItems
}
onPreview=
{
handlePreview
}
/>
}
{
hasVisual
&&
sectionCount
>
1
&&
<
Divider
/>
}
{
hasAudio
&&
<
AudioList
attachments=
{
audio
.
filter
(
isAudioAttachment
)
}
/>
}
{
hasAudio
&&
hasDocs
&&
<
Divider
/>
}
{
hasMedia
&&
(
<
div
className=
"flex flex-col gap-2"
>
{
hasVisual
&&
<
VisualGallery
items=
{
visualItems
}
onPreview=
{
handlePreview
}
/>
}
{
hasAudio
&&
<
AudioList
attachments=
{
audio
.
filter
(
isAudioAttachment
)
}
compact
/>
}
</
div
>
)
}
{
hasMedia
&&
hasDocs
&&
<
Divider
/>
}
{
hasDocs
&&
<
DocsList
attachments=
{
docs
}
/>
}
</
MetadataSection
>
);
...
...
web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx
View file @
124708f1
import
{
PauseIcon
,
PlayIcon
}
from
"lucide-react"
;
import
{
useEffect
,
useRef
,
useState
}
from
"react"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
formatFileSize
,
getFileTypeLabel
}
from
"@/utils/format"
;
import
{
formatAudioTime
}
from
"./attachmentHelpers"
;
...
...
@@ -56,9 +57,11 @@ interface AudioAttachmentItemProps {
mimeType
:
string
;
size
?:
number
;
title
?:
string
;
compact
?:
boolean
;
className
?:
string
;
}
const
AudioAttachmentItem
=
({
filename
,
sourceUrl
,
mimeType
,
size
,
title
}:
AudioAttachmentItemProps
)
=>
{
const
AudioAttachmentItem
=
({
filename
,
sourceUrl
,
mimeType
,
size
,
title
,
compact
=
false
,
className
}:
AudioAttachmentItemProps
)
=>
{
const
audioRef
=
useRef
<
HTMLAudioElement
>
(
null
);
const
[
isPlaying
,
setIsPlaying
]
=
useState
(
false
);
const
[
currentTime
,
setCurrentTime
]
=
useState
(
0
);
...
...
@@ -119,9 +122,9 @@ const AudioAttachmentItem = ({ filename, sourceUrl, mimeType, size, title }: Aud
};
return
(
<
div
className=
"rounded-xl border border-border/40 bg-background/75 px-2 py-1.5"
>
<
div
className=
"flex items-
center justify-between gap-1.5
"
>
<
div
className=
"min-w-0 flex flex-1 items-baseline gap-1"
>
<
div
className=
{
cn
(
"rounded-xl border border-border/40 bg-background/75"
,
compact
?
"px-3 py-2.5"
:
"px-2 py-1.5"
,
className
)
}
>
<
div
className=
"flex items-
start justify-between gap-2
"
>
<
div
className=
{
cn
(
"min-w-0 flex flex-1"
,
compact
?
"flex-col gap-0.5"
:
"items-baseline gap-1"
)
}
>
<
div
className=
"truncate text-sm font-medium leading-5 text-foreground"
title=
{
filename
}
>
{
displayTitle
}
</
div
>
...
...
@@ -141,7 +144,7 @@ const AudioAttachmentItem = ({ filename, sourceUrl, mimeType, size, title }: Aud
</
button
>
</
div
>
<
div
className=
"mt-1 flex items-center gap-1"
>
<
div
className=
{
cn
(
"mt-1"
,
compact
?
"space-y-1.5"
:
"flex items-center gap-1"
)
}
>
<
AudioProgressBar
filename=
{
filename
}
currentTime=
{
currentTime
}
...
...
@@ -151,16 +154,18 @@ const AudioAttachmentItem = ({ filename, sourceUrl, mimeType, size, title }: Aud
className=
"min-w-0 flex-1"
/>
<
div
className=
"shrink-0 text-[10px] tabular-nums text-muted-foreground"
>
{
timeLabel
}
</
div
>
<
button
type=
"button"
onClick=
{
handlePlaybackRateChange
}
className=
"inline-flex h-5 shrink-0 items-center justify-center rounded-md border border-transparent px-1 text-[10px] font-medium text-muted-foreground transition-colors hover:border-border/40 hover:text-foreground"
aria
-
label=
{
`Playback speed ${playbackRate}x for ${displayTitle}`
}
>
{
playbackRate
}
x
</
button
>
<
div
className=
{
cn
(
"flex items-center"
,
compact
?
"justify-between gap-2"
:
"shrink-0 gap-1"
)
}
>
<
div
className=
"shrink-0 text-[10px] tabular-nums text-muted-foreground"
>
{
timeLabel
}
</
div
>
<
button
type=
"button"
onClick=
{
handlePlaybackRateChange
}
className=
"inline-flex h-5 shrink-0 items-center justify-center rounded-md border border-transparent px-1 text-[10px] font-medium text-muted-foreground transition-colors hover:border-border/40 hover:text-foreground"
aria
-
label=
{
`Playback speed ${playbackRate}x for ${displayTitle}`
}
>
{
playbackRate
}
x
</
button
>
</
div
>
</
div
>
<
audio
...
...
web/src/components/MotionPhotoPlayer.tsx
View file @
124708f1
...
...
@@ -8,6 +8,8 @@ interface MotionPhotoPlayerProps {
presentationTimestampUs
?:
bigint
;
containerClassName
?:
string
;
mediaClassName
?:
string
;
posterClassName
?:
string
;
videoClassName
?:
string
;
active
?:
boolean
;
loop
?:
boolean
;
}
...
...
@@ -19,6 +21,8 @@ const MotionPhotoPlayer = ({
presentationTimestampUs
,
containerClassName
,
mediaClassName
,
posterClassName
,
videoClassName
,
active
,
loop
=
false
,
}:
MotionPhotoPlayerProps
)
=>
{
...
...
@@ -87,7 +91,7 @@ const MotionPhotoPlayer = ({
<
img
src=
{
posterUrl
}
alt=
{
alt
}
className=
{
cn
(
"block max-h-full max-w-full select-none object-cover"
,
mediaClassName
)
}
className=
{
cn
(
"block max-h-full max-w-full select-none object-cover"
,
mediaClassName
,
posterClassName
)
}
draggable=
{
false
}
loading=
"lazy"
decoding=
"async"
...
...
@@ -100,6 +104,7 @@ const MotionPhotoPlayer = ({
"pointer-events-none absolute inset-0 h-full w-full object-cover transition-opacity duration-200"
,
isPlaying
?
"opacity-100"
:
"opacity-0"
,
mediaClassName
,
videoClassName
,
)
}
muted
playsInline
...
...
web/src/components/MotionPhotoPreview.tsx
View file @
124708f1
...
...
@@ -9,6 +9,8 @@ interface MotionPhotoPreviewProps {
presentationTimestampUs
?:
bigint
;
containerClassName
?:
string
;
mediaClassName
?:
string
;
posterClassName
?:
string
;
videoClassName
?:
string
;
badgeClassName
?:
string
;
loop
?:
boolean
;
}
...
...
@@ -20,6 +22,8 @@ const MotionPhotoPreview = ({
presentationTimestampUs
,
containerClassName
,
mediaClassName
,
posterClassName
,
videoClassName
,
badgeClassName
,
loop
=
false
,
}:
MotionPhotoPreviewProps
)
=>
{
...
...
@@ -40,6 +44,8 @@ const MotionPhotoPreview = ({
loop=
{
loop
}
containerClassName=
{
cn
(
"max-w-full max-h-full"
,
containerClassName
)
}
mediaClassName=
{
mediaClassName
}
posterClassName=
{
posterClassName
}
videoClassName=
{
videoClassName
}
/>
<
div
role=
"button"
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment