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
63a17d89
Commit
63a17d89
authored
Mar 31, 2026
by
memoclaw
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
refactor: split audio attachment view into reusable components
parent
1921b576
Changes
3
Show whitespace changes
Inline
Side-by-side
Showing
3 changed files
with
249 additions
and
45 deletions
+249
-45
AttachmentListView.tsx
...components/MemoMetadata/Attachment/AttachmentListView.tsx
+7
-45
AudioAttachmentItem.tsx
...omponents/MemoMetadata/Attachment/AudioAttachmentItem.tsx
+178
-0
attachmentViewHelpers.ts
...mponents/MemoMetadata/Attachment/attachmentViewHelpers.ts
+64
-0
No files found.
web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx
View file @
63a17d89
import
{
File
AudioIcon
,
File
Icon
,
PaperclipIcon
}
from
"lucide-react"
;
import
{
FileIcon
,
PaperclipIcon
}
from
"lucide-react"
;
import
{
useMemo
}
from
"react"
;
import
{
useMemo
}
from
"react"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
cn
}
from
"@/lib/utils"
;
import
type
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service_pb"
;
import
type
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service_pb"
;
import
{
getAttachmentType
,
getAttachmentUrl
}
from
"@/utils/attachment"
;
import
{
getAttachmentUrl
}
from
"@/utils/attachment"
;
import
{
formatFileSize
,
getFileTypeLabel
}
from
"@/utils/format"
;
import
SectionHeader
from
"../SectionHeader"
;
import
SectionHeader
from
"../SectionHeader"
;
import
AttachmentCard
from
"./AttachmentCard"
;
import
AttachmentCard
from
"./AttachmentCard"
;
import
AudioAttachmentItem
from
"./AudioAttachmentItem"
;
import
{
getAttachmentMetadata
,
isImageAttachment
,
separateAttachments
}
from
"./attachmentViewHelpers"
;
interface
AttachmentListViewProps
{
interface
AttachmentListViewProps
{
attachments
:
Attachment
[];
attachments
:
Attachment
[];
onImagePreview
?:
(
urls
:
string
[],
index
:
number
)
=>
void
;
onImagePreview
?:
(
urls
:
string
[],
index
:
number
)
=>
void
;
}
}
const
isImageAttachment
=
(
attachment
:
Attachment
):
boolean
=>
getAttachmentType
(
attachment
)
===
"image/*"
;
const
isVideoAttachment
=
(
attachment
:
Attachment
):
boolean
=>
getAttachmentType
(
attachment
)
===
"video/*"
;
const
isAudioAttachment
=
(
attachment
:
Attachment
):
boolean
=>
getAttachmentType
(
attachment
)
===
"audio/*"
;
const
separateAttachments
=
(
attachments
:
Attachment
[])
=>
{
const
visual
:
Attachment
[]
=
[];
const
audio
:
Attachment
[]
=
[];
const
docs
:
Attachment
[]
=
[];
for
(
const
attachment
of
attachments
)
{
if
(
isImageAttachment
(
attachment
)
||
isVideoAttachment
(
attachment
))
{
visual
.
push
(
attachment
);
}
else
if
(
isAudioAttachment
(
attachment
))
{
audio
.
push
(
attachment
);
}
else
{
docs
.
push
(
attachment
);
}
}
return
{
visual
,
audio
,
docs
};
};
const
DocumentItem
=
({
attachment
}:
{
attachment
:
Attachment
})
=>
{
const
DocumentItem
=
({
attachment
}:
{
attachment
:
Attachment
})
=>
{
const
fileTypeLabel
=
getFileTypeLabel
(
attachment
.
type
);
const
{
fileTypeLabel
,
fileSizeLabel
}
=
getAttachmentMetadata
(
attachment
);
const
fileSizeLabel
=
attachment
.
size
?
formatFileSize
(
Number
(
attachment
.
size
))
:
undefined
;
return
(
return
(
<
div
className=
"flex items-center gap-1 px-1 py-1 rounded text-xs text-muted-foreground hover:text-foreground hover:bg-accent/20 transition-colors whitespace-nowrap"
>
<
div
className=
"flex items-center gap-1 px-1 py-1 rounded text-xs text-muted-foreground hover:text-foreground hover:bg-accent/20 transition-colors whitespace-nowrap"
>
...
@@ -62,22 +40,6 @@ const DocumentItem = ({ attachment }: { attachment: Attachment }) => {
...
@@ -62,22 +40,6 @@ const DocumentItem = ({ attachment }: { attachment: Attachment }) => {
);
);
};
};
const
AudioItem
=
({
attachment
}:
{
attachment
:
Attachment
})
=>
{
const
sourceUrl
=
getAttachmentUrl
(
attachment
);
return
(
<
div
className=
"flex flex-col gap-1 px-1 py-1"
>
<
div
className=
"flex items-center gap-1 text-xs text-muted-foreground"
>
<
FileAudioIcon
className=
"w-3 h-3 shrink-0"
/>
<
span
className=
"truncate"
title=
{
attachment
.
filename
}
>
{
attachment
.
filename
}
</
span
>
</
div
>
<
audio
src=
{
sourceUrl
}
controls
preload=
"metadata"
className=
"w-full h-8"
/>
</
div
>
);
};
interface
VisualItemProps
{
interface
VisualItemProps
{
attachment
:
Attachment
;
attachment
:
Attachment
;
onImageClick
?:
(
url
:
string
)
=>
void
;
onImageClick
?:
(
url
:
string
)
=>
void
;
...
@@ -114,9 +76,9 @@ const VisualGrid = ({ attachments, onImageClick }: { attachments: Attachment[];
...
@@ -114,9 +76,9 @@ const VisualGrid = ({ attachments, onImageClick }: { attachments: Attachment[];
);
);
const
AudioList
=
({
attachments
}:
{
attachments
:
Attachment
[]
})
=>
(
const
AudioList
=
({
attachments
}:
{
attachments
:
Attachment
[]
})
=>
(
<
div
className=
"flex flex-col gap-
1
"
>
<
div
className=
"flex flex-col gap-
2
"
>
{
attachments
.
map
((
attachment
)
=>
(
{
attachments
.
map
((
attachment
)
=>
(
<
AudioItem
key=
{
attachment
.
name
}
attachment=
{
attachment
}
/>
<
Audio
Attachment
Item
key=
{
attachment
.
name
}
attachment=
{
attachment
}
/>
))
}
))
}
</
div
>
</
div
>
);
);
...
...
web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx
0 → 100644
View file @
63a17d89
import
{
FileAudioIcon
,
PauseIcon
,
PlayIcon
}
from
"lucide-react"
;
import
{
useEffect
,
useRef
,
useState
}
from
"react"
;
import
type
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service_pb"
;
import
{
getAttachmentUrl
}
from
"@/utils/attachment"
;
import
{
formatAudioTime
,
getAttachmentMetadata
}
from
"./attachmentViewHelpers"
;
const
AUDIO_PLAYBACK_RATES
=
[
1
,
1.5
,
2
]
as
const
;
interface
AudioProgressBarProps
{
attachment
:
Attachment
;
currentTime
:
number
;
duration
:
number
;
progressPercent
:
number
;
onSeek
:
(
value
:
string
)
=>
void
;
}
const
AudioProgressBar
=
({
attachment
,
currentTime
,
duration
,
progressPercent
,
onSeek
}:
AudioProgressBarProps
)
=>
(
<
div
className=
"mt-2 flex items-center gap-2.5"
>
<
div
className=
"relative flex h-4 min-w-0 flex-1 items-center"
>
<
div
className=
"absolute inset-x-0 h-1 rounded-full bg-muted/75"
/>
<
div
className=
"absolute left-0 h-1 rounded-full bg-foreground/20"
style=
{
{
width
:
`${Math.min(progressPercent, 100)}%`
}
}
/>
<
input
type=
"range"
min=
{
0
}
max=
{
duration
||
1
}
step=
{
0.1
}
value=
{
Math
.
min
(
currentTime
,
duration
||
0
)
}
onChange=
{
(
e
)
=>
onSeek
(
e
.
target
.
value
)
}
aria
-
label=
{
`Seek ${attachment.filename}`
}
className=
"relative z-10 h-4 w-full cursor-pointer appearance-none bg-transparent outline-none disabled:cursor-default
[&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full
[&::-webkit-slider-runnable-track]:bg-transparent
[&::-webkit-slider-thumb]:mt-[-3px] [&::-webkit-slider-thumb]:size-2 [&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border [&::-webkit-slider-thumb]:border-border/50
[&::-webkit-slider-thumb]:bg-background/95
[&::-moz-range-track]:h-1 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-transparent
[&::-moz-range-thumb]:size-2 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border
[&::-moz-range-thumb]:border-border/50 [&::-moz-range-thumb]:bg-background/95"
disabled=
{
duration
===
0
}
/>
</
div
>
<
div
className=
"shrink-0 text-[11px] tabular-nums text-muted-foreground"
>
{
formatAudioTime
(
currentTime
)
}
/
{
duration
>
0
?
formatAudioTime
(
duration
)
:
"--:--"
}
</
div
>
</
div
>
);
const
AudioAttachmentItem
=
({
attachment
}:
{
attachment
:
Attachment
})
=>
{
const
sourceUrl
=
getAttachmentUrl
(
attachment
);
const
audioRef
=
useRef
<
HTMLAudioElement
>
(
null
);
const
[
isPlaying
,
setIsPlaying
]
=
useState
(
false
);
const
[
currentTime
,
setCurrentTime
]
=
useState
(
0
);
const
[
duration
,
setDuration
]
=
useState
(
0
);
const
[
playbackRate
,
setPlaybackRate
]
=
useState
<
(
typeof
AUDIO_PLAYBACK_RATES
)[
number
]
>
(
1
);
const
{
fileTypeLabel
,
fileSizeLabel
}
=
getAttachmentMetadata
(
attachment
);
const
progressPercent
=
duration
>
0
?
(
currentTime
/
duration
)
*
100
:
0
;
useEffect
(()
=>
{
if
(
!
audioRef
.
current
)
{
return
;
}
audioRef
.
current
.
playbackRate
=
playbackRate
;
},
[
playbackRate
]);
const
togglePlayback
=
async
()
=>
{
const
audio
=
audioRef
.
current
;
if
(
!
audio
)
{
return
;
}
if
(
audio
.
paused
)
{
try
{
await
audio
.
play
();
}
catch
{
setIsPlaying
(
false
);
}
return
;
}
audio
.
pause
();
};
const
handleSeek
=
(
value
:
string
)
=>
{
const
audio
=
audioRef
.
current
;
const
nextTime
=
Number
(
value
);
if
(
!
audio
||
Number
.
isNaN
(
nextTime
))
{
return
;
}
audio
.
currentTime
=
nextTime
;
setCurrentTime
(
nextTime
);
};
const
handlePlaybackRateChange
=
()
=>
{
const
currentRateIndex
=
AUDIO_PLAYBACK_RATES
.
findIndex
((
rate
)
=>
rate
===
playbackRate
);
const
nextRate
=
AUDIO_PLAYBACK_RATES
[(
currentRateIndex
+
1
)
%
AUDIO_PLAYBACK_RATES
.
length
];
setPlaybackRate
(
nextRate
);
};
const
handleDuration
=
(
value
:
number
)
=>
{
setDuration
(
Number
.
isFinite
(
value
)
?
value
:
0
);
};
return
(
<
div
className=
"rounded-xl border border-border/35 bg-background/70 px-2.5 py-2.5"
>
<
div
className=
"flex items-start gap-2.5"
>
<
div
className=
"mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted/55 text-muted-foreground"
>
<
FileAudioIcon
className=
"size-3.5"
/>
</
div
>
<
div
className=
"flex min-w-0 flex-1 items-start justify-between gap-3"
>
<
div
className=
"min-w-0 flex-1"
>
<
div
className=
"truncate text-sm font-medium leading-5 text-foreground"
title=
{
attachment
.
filename
}
>
{
attachment
.
filename
}
</
div
>
<
div
className=
"flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs leading-4 text-muted-foreground"
>
<
span
>
{
fileTypeLabel
}
</
span
>
{
fileSizeLabel
&&
(
<>
<
span
className=
"text-muted-foreground/50"
>
•
</
span
>
<
span
>
{
fileSizeLabel
}
</
span
>
</>
)
}
</
div
>
</
div
>
<
div
className=
"mt-0.5 flex shrink-0 items-center gap-1"
>
<
button
type=
"button"
onClick=
{
handlePlaybackRateChange
}
className=
"inline-flex h-6 items-center justify-center px-1 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground"
aria
-
label=
{
`Playback speed ${playbackRate}x for ${attachment.filename}`
}
>
{
playbackRate
}
x
</
button
>
<
button
type=
"button"
onClick=
{
togglePlayback
}
className=
"inline-flex size-6.5 items-center justify-center rounded-md border border-border/45 bg-background/85 text-foreground transition-colors hover:bg-muted/45"
aria
-
label=
{
isPlaying
?
`Pause ${attachment.filename}`
:
`Play ${attachment.filename}`
}
>
{
isPlaying
?
<
PauseIcon
className=
"size-3"
/>
:
<
PlayIcon
className=
"size-3 translate-x-[0.5px]"
/>
}
</
button
>
</
div
>
</
div
>
</
div
>
<
AudioProgressBar
attachment=
{
attachment
}
currentTime=
{
currentTime
}
duration=
{
duration
}
progressPercent=
{
progressPercent
}
onSeek=
{
handleSeek
}
/>
<
audio
ref=
{
audioRef
}
src=
{
sourceUrl
}
preload=
"metadata"
className=
"hidden"
onLoadedMetadata=
{
(
e
)
=>
handleDuration
(
e
.
currentTarget
.
duration
)
}
onDurationChange=
{
(
e
)
=>
handleDuration
(
e
.
currentTarget
.
duration
)
}
onTimeUpdate=
{
(
e
)
=>
setCurrentTime
(
e
.
currentTarget
.
currentTime
)
}
onPlay=
{
()
=>
setIsPlaying
(
true
)
}
onPause=
{
()
=>
setIsPlaying
(
false
)
}
onEnded=
{
()
=>
{
setIsPlaying
(
false
);
setCurrentTime
(
0
);
}
}
/>
</
div
>
);
};
export
default
AudioAttachmentItem
;
web/src/components/MemoMetadata/Attachment/attachmentViewHelpers.ts
0 → 100644
View file @
63a17d89
import
type
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service_pb"
;
import
{
getAttachmentType
}
from
"@/utils/attachment"
;
import
{
formatFileSize
,
getFileTypeLabel
}
from
"@/utils/format"
;
export
interface
AttachmentGroups
{
visual
:
Attachment
[];
audio
:
Attachment
[];
docs
:
Attachment
[];
}
export
interface
AttachmentMetadata
{
fileTypeLabel
:
string
;
fileSizeLabel
?:
string
;
}
export
const
isImageAttachment
=
(
attachment
:
Attachment
):
boolean
=>
getAttachmentType
(
attachment
)
===
"image/*"
;
export
const
isVideoAttachment
=
(
attachment
:
Attachment
):
boolean
=>
getAttachmentType
(
attachment
)
===
"video/*"
;
export
const
isAudioAttachment
=
(
attachment
:
Attachment
):
boolean
=>
getAttachmentType
(
attachment
)
===
"audio/*"
;
export
const
separateAttachments
=
(
attachments
:
Attachment
[]):
AttachmentGroups
=>
{
const
groups
:
AttachmentGroups
=
{
visual
:
[],
audio
:
[],
docs
:
[],
};
for
(
const
attachment
of
attachments
)
{
if
(
isImageAttachment
(
attachment
)
||
isVideoAttachment
(
attachment
))
{
groups
.
visual
.
push
(
attachment
);
continue
;
}
if
(
isAudioAttachment
(
attachment
))
{
groups
.
audio
.
push
(
attachment
);
continue
;
}
groups
.
docs
.
push
(
attachment
);
}
return
groups
;
};
export
const
getAttachmentMetadata
=
(
attachment
:
Attachment
):
AttachmentMetadata
=>
({
fileTypeLabel
:
getFileTypeLabel
(
attachment
.
type
),
fileSizeLabel
:
attachment
.
size
?
formatFileSize
(
Number
(
attachment
.
size
))
:
undefined
,
});
export
const
formatAudioTime
=
(
seconds
:
number
):
string
=>
{
if
(
!
Number
.
isFinite
(
seconds
)
||
seconds
<
0
)
{
return
"0:00"
;
}
const
rounded
=
Math
.
floor
(
seconds
);
const
hours
=
Math
.
floor
(
rounded
/
3600
);
const
minutes
=
Math
.
floor
((
rounded
%
3600
)
/
60
);
const
secs
=
rounded
%
60
;
if
(
hours
>
0
)
{
return
`
${
hours
}
:
${
minutes
.
toString
().
padStart
(
2
,
"0"
)}
:
${
secs
.
toString
().
padStart
(
2
,
"0"
)}
`
;
}
return
`
${
minutes
}
:
${
secs
.
toString
().
padStart
(
2
,
"0"
)}
`
;
};
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