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
067d7ff0
Commit
067d7ff0
authored
Apr 06, 2026
by
boojack
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
chore: refactor memo editor audio recording flow
parent
c3e7e2c3
Changes
20
Show whitespace changes
Inline
Side-by-side
Showing
20 changed files
with
542 additions
and
414 deletions
+542
-414
InsertMenu.tsx
web/src/components/MemoEditor/Toolbar/InsertMenu.tsx
+19
-10
AudioRecorderPanel.tsx
...c/components/MemoEditor/components/AudioRecorderPanel.tsx
+52
-0
EditorContent.tsx
web/src/components/MemoEditor/components/EditorContent.tsx
+2
-0
EditorToolbar.tsx
web/src/components/MemoEditor/components/EditorToolbar.tsx
+2
-2
VoiceRecorderPanel.tsx
...c/components/MemoEditor/components/VoiceRecorderPanel.tsx
+0
-135
index.ts
web/src/components/MemoEditor/components/index.ts
+1
-1
index.ts
web/src/components/MemoEditor/hooks/index.ts
+1
-1
useAudioRecorder.ts
web/src/components/MemoEditor/hooks/useAudioRecorder.ts
+72
-39
useFileUpload.ts
web/src/components/MemoEditor/hooks/useFileUpload.ts
+1
-0
index.tsx
web/src/components/MemoEditor/index.tsx
+40
-61
memoService.ts
web/src/components/MemoEditor/services/memoService.ts
+1
-2
validationService.ts
web/src/components/MemoEditor/services/validationService.ts
+3
-3
actions.ts
web/src/components/MemoEditor/state/actions.ts
+11
-16
reducer.ts
web/src/components/MemoEditor/state/reducer.ts
+16
-25
types.ts
web/src/components/MemoEditor/state/types.ts
+11
-20
attachment.ts
web/src/components/MemoEditor/types/attachment.ts
+38
-0
components.ts
web/src/components/MemoEditor/types/components.ts
+5
-9
AttachmentListEditor.tsx
...mponents/MemoMetadata/Attachment/AttachmentListEditor.tsx
+242
-83
en.json
web/src/locales/en.json
+15
-6
tr.json
web/src/locales/tr.json
+10
-1
No files found.
web/src/components/MemoEditor/Toolbar/InsertMenu.tsx
View file @
067d7ff0
...
@@ -20,6 +20,7 @@ import {
...
@@ -20,6 +20,7 @@ import {
DropdownMenu
,
DropdownMenu
,
DropdownMenuContent
,
DropdownMenuContent
,
DropdownMenuItem
,
DropdownMenuItem
,
DropdownMenuSeparator
,
DropdownMenuSub
,
DropdownMenuSub
,
DropdownMenuSubContent
,
DropdownMenuSubContent
,
DropdownMenuSubTrigger
,
DropdownMenuSubTrigger
,
...
@@ -135,28 +136,28 @@ const InsertMenu = (props: InsertMenuProps) => {
...
@@ -135,28 +136,28 @@ const InsertMenu = (props: InsertMenuProps) => {
[
[
{
{
key
:
"upload"
,
key
:
"upload"
,
label
:
t
(
"
common.upload
"
),
label
:
t
(
"
editor.insert-menu.upload-file
"
),
icon
:
FileIcon
,
icon
:
FileIcon
,
onClick
:
handleUploadClick
,
onClick
:
handleUploadClick
,
},
},
{
key
:
"record-audio"
,
label
:
t
(
"editor.audio-recorder.trigger"
),
icon
:
MicIcon
,
onClick
:
()
=>
props
.
onAudioRecorderClick
?.(),
},
{
{
key
:
"link"
,
key
:
"link"
,
label
:
t
(
"
tooltip
.link-memo"
),
label
:
t
(
"
editor.insert-menu
.link-memo"
),
icon
:
LinkIcon
,
icon
:
LinkIcon
,
onClick
:
handleOpenLinkDialog
,
onClick
:
handleOpenLinkDialog
,
},
},
{
{
key
:
"location"
,
key
:
"location"
,
label
:
t
(
"
tooltip.select
-location"
),
label
:
t
(
"
editor.insert-menu.add
-location"
),
icon
:
MapPinIcon
,
icon
:
MapPinIcon
,
onClick
:
handleLocationClick
,
onClick
:
handleLocationClick
,
},
},
{
key
:
"voice-note"
,
label
:
t
(
"editor.voice-recorder.trigger"
),
icon
:
MicIcon
,
onClick
:
()
=>
props
.
onVoiceRecorderClick
?.(),
},
]
satisfies
Array
<
{
key
:
string
;
label
:
string
;
icon
:
LucideIcon
;
onClick
:
()
=>
void
}
>
,
]
satisfies
Array
<
{
key
:
string
;
label
:
string
;
icon
:
LucideIcon
;
onClick
:
()
=>
void
}
>
,
[
handleLocationClick
,
handleOpenLinkDialog
,
handleUploadClick
,
props
,
t
],
[
handleLocationClick
,
handleOpenLinkDialog
,
handleUploadClick
,
props
,
t
],
);
);
...
@@ -170,12 +171,20 @@ const InsertMenu = (props: InsertMenuProps) => {
...
@@ -170,12 +171,20 @@ const InsertMenu = (props: InsertMenuProps) => {
</
Button
>
</
Button
>
</
DropdownMenuTrigger
>
</
DropdownMenuTrigger
>
<
DropdownMenuContent
align=
"start"
>
<
DropdownMenuContent
align=
"start"
>
{
menuItems
.
map
((
item
)
=>
(
{
menuItems
.
slice
(
0
,
2
).
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
)
=>
(
<
DropdownMenuItem
key=
{
item
.
key
}
onClick=
{
item
.
onClick
}
>
<
DropdownMenuItem
key=
{
item
.
key
}
onClick=
{
item
.
onClick
}
>
<
item
.
icon
className=
"w-4 h-4"
/>
<
item
.
icon
className=
"w-4 h-4"
/>
{
item
.
label
}
{
item
.
label
}
</
DropdownMenuItem
>
</
DropdownMenuItem
>
))
}
))
}
<
DropdownMenuSeparator
/>
{
/* View submenu with Focus Mode */
}
{
/* View submenu with Focus Mode */
}
<
DropdownMenuSub
open=
{
moreSubmenuOpen
}
onOpenChange=
{
setMoreSubmenuOpen
}
>
<
DropdownMenuSub
open=
{
moreSubmenuOpen
}
onOpenChange=
{
setMoreSubmenuOpen
}
>
<
DropdownMenuSubTrigger
onPointerEnter=
{
handleTriggerEnter
}
onPointerLeave=
{
handleTriggerLeave
}
>
<
DropdownMenuSubTrigger
onPointerEnter=
{
handleTriggerEnter
}
onPointerLeave=
{
handleTriggerLeave
}
>
...
...
web/src/components/MemoEditor/components/AudioRecorderPanel.tsx
0 → 100644
View file @
067d7ff0
import
{
LoaderCircleIcon
,
XIcon
}
from
"lucide-react"
;
import
type
{
FC
}
from
"react"
;
import
{
formatAudioTime
}
from
"@/components/MemoMetadata/Attachment/attachmentHelpers"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
type
{
AudioRecorderPanelProps
}
from
"../types/components"
;
export
const
AudioRecorderPanel
:
FC
<
AudioRecorderPanelProps
>
=
({
audioRecorder
,
onStop
,
onCancel
})
=>
{
const
t
=
useTranslate
();
const
{
status
,
elapsedSeconds
}
=
audioRecorder
;
const
isRequestingPermission
=
status
===
"requesting_permission"
;
return
(
<
div
className=
"w-full rounded-lg border border-border/60 bg-muted/20 px-2.5 py-2"
>
<
div
className=
"flex items-center gap-2"
>
<
div
className=
"min-w-0 flex-1"
>
<
div
className=
"truncate text-sm font-medium text-foreground"
>
{
isRequestingPermission
?
t
(
"editor.audio-recorder.requesting-permission"
)
:
t
(
"editor.audio-recorder.recording"
)
}
</
div
>
</
div
>
<
div
className=
{
cn
(
"inline-flex shrink-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium"
,
isRequestingPermission
?
"border border-border/60 bg-background text-muted-foreground"
:
"border border-destructive/20 bg-destructive/[0.08] text-destructive"
,
)
}
>
{
isRequestingPermission
?
(
<
LoaderCircleIcon
className=
"size-3 animate-spin"
/>
)
:
(
<
span
className=
"size-2 rounded-full bg-destructive"
/>
)
}
{
formatAudioTime
(
elapsedSeconds
)
}
</
div
>
<
div
className=
"ml-auto flex shrink-0 items-center gap-1"
>
<
Button
variant=
"ghost"
size=
"icon"
onClick=
{
onCancel
}
aria
-
label=
{
t
(
"common.cancel"
)
}
>
<
XIcon
className=
"size-4"
/>
</
Button
>
<
Button
size=
"sm"
className=
"gap-1.5"
onClick=
{
onStop
}
disabled=
{
isRequestingPermission
}
>
<
span
className=
"size-2.5 rounded-[2px] bg-current"
aria
-
hidden=
"true"
/>
{
t
(
"editor.audio-recorder.stop"
)
}
</
Button
>
</
div
>
</
div
>
</
div
>
);
};
web/src/components/MemoEditor/components/EditorContent.tsx
View file @
067d7ff0
...
@@ -13,6 +13,7 @@ export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({
...
@@ -13,6 +13,7 @@ export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({
const
localFiles
:
LocalFile
[]
=
Array
.
from
(
files
).
map
((
file
)
=>
({
const
localFiles
:
LocalFile
[]
=
Array
.
from
(
files
).
map
((
file
)
=>
({
file
,
file
,
previewUrl
:
createBlobUrl
(
file
),
previewUrl
:
createBlobUrl
(
file
),
origin
:
"upload"
,
}));
}));
localFiles
.
forEach
((
localFile
)
=>
dispatch
(
actions
.
addLocalFile
(
localFile
)));
localFiles
.
forEach
((
localFile
)
=>
dispatch
(
actions
.
addLocalFile
(
localFile
)));
});
});
...
@@ -49,6 +50,7 @@ export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({
...
@@ -49,6 +50,7 @@ export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({
const
localFiles
:
LocalFile
[]
=
files
.
map
((
file
)
=>
({
const
localFiles
:
LocalFile
[]
=
files
.
map
((
file
)
=>
({
file
,
file
,
previewUrl
:
createBlobUrl
(
file
),
previewUrl
:
createBlobUrl
(
file
),
origin
:
"upload"
,
}));
}));
localFiles
.
forEach
((
localFile
)
=>
dispatch
(
actions
.
addLocalFile
(
localFile
)));
localFiles
.
forEach
((
localFile
)
=>
dispatch
(
actions
.
addLocalFile
(
localFile
)));
event
.
preventDefault
();
event
.
preventDefault
();
...
...
web/src/components/MemoEditor/components/EditorToolbar.tsx
View file @
067d7ff0
...
@@ -7,7 +7,7 @@ import InsertMenu from "../Toolbar/InsertMenu";
...
@@ -7,7 +7,7 @@ import InsertMenu from "../Toolbar/InsertMenu";
import
VisibilitySelector
from
"../Toolbar/VisibilitySelector"
;
import
VisibilitySelector
from
"../Toolbar/VisibilitySelector"
;
import
type
{
EditorToolbarProps
}
from
"../types"
;
import
type
{
EditorToolbarProps
}
from
"../types"
;
export
const
EditorToolbar
:
FC
<
EditorToolbarProps
>
=
({
onSave
,
onCancel
,
memoName
,
on
Voice
RecorderClick
})
=>
{
export
const
EditorToolbar
:
FC
<
EditorToolbarProps
>
=
({
onSave
,
onCancel
,
memoName
,
on
Audio
RecorderClick
})
=>
{
const
t
=
useTranslate
();
const
t
=
useTranslate
();
const
{
state
,
actions
,
dispatch
}
=
useEditorContext
();
const
{
state
,
actions
,
dispatch
}
=
useEditorContext
();
const
{
valid
}
=
validationService
.
canSave
(
state
);
const
{
valid
}
=
validationService
.
canSave
(
state
);
...
@@ -35,7 +35,7 @@ export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoNa
...
@@ -35,7 +35,7 @@ export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoNa
onLocationChange=
{
handleLocationChange
}
onLocationChange=
{
handleLocationChange
}
onToggleFocusMode=
{
handleToggleFocusMode
}
onToggleFocusMode=
{
handleToggleFocusMode
}
memoName=
{
memoName
}
memoName=
{
memoName
}
on
VoiceRecorderClick=
{
onVoice
RecorderClick
}
on
AudioRecorderClick=
{
onAudio
RecorderClick
}
/>
/>
</
div
>
</
div
>
...
...
web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx
deleted
100644 → 0
View file @
c3e7e2c3
import
{
AudioLinesIcon
,
LoaderCircleIcon
,
MicIcon
,
RotateCcwIcon
,
SquareIcon
,
Trash2Icon
}
from
"lucide-react"
;
import
type
{
FC
}
from
"react"
;
import
{
AudioAttachmentItem
}
from
"@/components/MemoMetadata/Attachment"
;
import
{
formatAudioTime
}
from
"@/components/MemoMetadata/Attachment/attachmentHelpers"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
type
{
VoiceRecorderPanelProps
}
from
"../types/components"
;
export
const
VoiceRecorderPanel
:
FC
<
VoiceRecorderPanelProps
>
=
({
voiceRecorder
,
onStart
,
onStop
,
onKeep
,
onDiscard
,
onRecordAgain
,
onClose
,
})
=>
{
const
t
=
useTranslate
();
const
{
status
,
elapsedSeconds
,
error
,
recording
}
=
voiceRecorder
;
const
isRecording
=
status
===
"recording"
;
const
isRequestingPermission
=
status
===
"requesting_permission"
;
const
isUnsupported
=
status
===
"unsupported"
;
const
hasRecording
=
status
===
"recorded"
&&
recording
;
return
(
<
div
className=
"w-full rounded-xl border border-border/60 bg-muted/25 px-3 py-3"
>
<
div
className=
"flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"
>
<
div
className=
"flex min-w-0 items-start gap-3"
>
<
div
className=
{
cn
(
"flex size-10 shrink-0 items-center justify-center rounded-xl border border-border/60 bg-background/80 text-muted-foreground"
,
isRecording
&&
"border-destructive/30 bg-destructive/10 text-destructive"
,
hasRecording
&&
"text-foreground"
,
)
}
>
{
isRequestingPermission
?
(
<
LoaderCircleIcon
className=
"size-4 animate-spin"
/>
)
:
hasRecording
?
(
<
AudioLinesIcon
className=
"size-4"
/>
)
:
(
<
MicIcon
className=
"size-4"
/>
)
}
</
div
>
<
div
className=
"min-w-0 flex-1"
>
<
div
className=
"text-sm font-medium text-foreground"
>
{
isRecording
?
t
(
"editor.voice-recorder.recording"
)
:
isRequestingPermission
?
t
(
"editor.voice-recorder.requesting-permission"
)
:
hasRecording
?
t
(
"editor.voice-recorder.ready"
)
:
isUnsupported
?
t
(
"editor.voice-recorder.unsupported"
)
:
error
?
t
(
"editor.voice-recorder.error"
)
:
t
(
"editor.voice-recorder.title"
)
}
</
div
>
<
div
className=
"mt-1 text-sm text-muted-foreground"
>
{
isRecording
?
t
(
"editor.voice-recorder.recording-description"
,
{
duration
:
formatAudioTime
(
elapsedSeconds
)
})
:
isRequestingPermission
?
t
(
"editor.voice-recorder.requesting-permission-description"
)
:
hasRecording
?
t
(
"editor.voice-recorder.ready-description"
)
:
isUnsupported
?
t
(
"editor.voice-recorder.unsupported-description"
)
:
error
?
error
:
t
(
"editor.voice-recorder.idle-description"
)
}
</
div
>
</
div
>
</
div
>
{
isRecording
&&
(
<
div
className=
"inline-flex items-center gap-2 rounded-full border border-destructive/20 bg-destructive/[0.08] px-2.5 py-1 text-xs font-medium text-destructive"
>
<
span
className=
"size-2 rounded-full bg-destructive"
/>
{
formatAudioTime
(
elapsedSeconds
)
}
</
div
>
)
}
</
div
>
{
hasRecording
&&
(
<
div
className=
"mt-3"
>
<
AudioAttachmentItem
filename=
{
recording
.
localFile
.
file
.
name
}
sourceUrl=
{
recording
.
localFile
.
previewUrl
}
mimeType=
{
recording
.
mimeType
}
size=
{
recording
.
localFile
.
file
.
size
}
title=
"Voice note"
/>
</
div
>
)
}
<
div
className=
"mt-3 flex flex-wrap items-center justify-end gap-2"
>
{
hasRecording
?
(
<>
<
Button
variant=
"ghost"
size=
"sm"
onClick=
{
onDiscard
}
>
<
Trash2Icon
/>
{
t
(
"editor.voice-recorder.discard"
)
}
</
Button
>
<
Button
variant=
"outline"
size=
"sm"
onClick=
{
onRecordAgain
}
>
<
RotateCcwIcon
/>
{
t
(
"editor.voice-recorder.record-again"
)
}
</
Button
>
<
Button
size=
"sm"
onClick=
{
onKeep
}
>
<
AudioLinesIcon
/>
{
t
(
"editor.voice-recorder.keep"
)
}
</
Button
>
</>
)
:
isRecording
?
(
<
Button
size=
"sm"
onClick=
{
onStop
}
>
<
SquareIcon
/>
{
t
(
"editor.voice-recorder.stop"
)
}
</
Button
>
)
:
(
<>
<
Button
variant=
"ghost"
size=
"sm"
onClick=
{
onClose
}
>
{
t
(
"common.close"
)
}
</
Button
>
{
!
isUnsupported
&&
(
<
Button
size=
"sm"
onClick=
{
onStart
}
disabled=
{
isRequestingPermission
}
>
{
isRequestingPermission
?
<
LoaderCircleIcon
className=
"animate-spin"
/>
:
<
MicIcon
/>
}
{
isRequestingPermission
?
t
(
"editor.voice-recorder.requesting"
)
:
t
(
"editor.voice-recorder.start"
)
}
</
Button
>
)
}
</>
)
}
</
div
>
</
div
>
);
};
web/src/components/MemoEditor/components/index.ts
View file @
067d7ff0
// UI components for MemoEditor
// UI components for MemoEditor
export
*
from
"./AudioRecorderPanel"
;
export
*
from
"./EditorContent"
;
export
*
from
"./EditorContent"
;
export
*
from
"./EditorMetadata"
;
export
*
from
"./EditorMetadata"
;
export
*
from
"./EditorToolbar"
;
export
*
from
"./EditorToolbar"
;
export
{
FocusModeExitButton
,
FocusModeOverlay
}
from
"./FocusModeOverlay"
;
export
{
FocusModeExitButton
,
FocusModeOverlay
}
from
"./FocusModeOverlay"
;
export
{
TimestampPopover
}
from
"./TimestampPopover"
;
export
{
TimestampPopover
}
from
"./TimestampPopover"
;
export
*
from
"./VoiceRecorderPanel"
;
web/src/components/MemoEditor/hooks/index.ts
View file @
067d7ff0
// Custom hooks for MemoEditor (internal use only)
// Custom hooks for MemoEditor (internal use only)
export
{
useAudioRecorder
}
from
"./useAudioRecorder"
;
export
{
useAutoSave
}
from
"./useAutoSave"
;
export
{
useAutoSave
}
from
"./useAutoSave"
;
export
{
useBlobUrls
}
from
"./useBlobUrls"
;
export
{
useBlobUrls
}
from
"./useBlobUrls"
;
export
{
useDragAndDrop
}
from
"./useDragAndDrop"
;
export
{
useDragAndDrop
}
from
"./useDragAndDrop"
;
...
@@ -8,4 +9,3 @@ export { useKeyboard } from "./useKeyboard";
...
@@ -8,4 +9,3 @@ export { useKeyboard } from "./useKeyboard";
export
{
useLinkMemo
}
from
"./useLinkMemo"
;
export
{
useLinkMemo
}
from
"./useLinkMemo"
;
export
{
useLocation
}
from
"./useLocation"
;
export
{
useLocation
}
from
"./useLocation"
;
export
{
useMemoInit
}
from
"./useMemoInit"
;
export
{
useMemoInit
}
from
"./useMemoInit"
;
export
{
useVoiceRecorder
}
from
"./useVoiceRecorder"
;
web/src/components/MemoEditor/hooks/use
Voice
Recorder.ts
→
web/src/components/MemoEditor/hooks/use
Audio
Recorder.ts
View file @
067d7ff0
...
@@ -4,13 +4,13 @@ import { useBlobUrls } from "./useBlobUrls";
...
@@ -4,13 +4,13 @@ import { useBlobUrls } from "./useBlobUrls";
const
FALLBACK_AUDIO_MIME_TYPE
=
"audio/webm"
;
const
FALLBACK_AUDIO_MIME_TYPE
=
"audio/webm"
;
interface
Voice
RecorderActions
{
interface
Audio
RecorderActions
{
set
Voice
RecorderSupport
:
(
value
:
boolean
)
=>
void
;
set
Audio
RecorderSupport
:
(
value
:
boolean
)
=>
void
;
set
Voice
RecorderPermission
:
(
value
:
"unknown"
|
"granted"
|
"denied"
)
=>
void
;
set
Audio
RecorderPermission
:
(
value
:
"unknown"
|
"granted"
|
"denied"
)
=>
void
;
set
VoiceRecorderStatus
:
(
value
:
"idle"
|
"requesting_permission"
|
"recording"
|
"recorded
"
|
"error"
|
"unsupported"
)
=>
void
;
set
AudioRecorderStatus
:
(
value
:
"idle"
|
"requesting_permission"
|
"recording
"
|
"error"
|
"unsupported"
)
=>
void
;
set
Voice
RecorderElapsed
:
(
value
:
number
)
=>
void
;
set
Audio
RecorderElapsed
:
(
value
:
number
)
=>
void
;
set
Voice
RecorderError
:
(
value
?:
string
)
=>
void
;
set
Audio
RecorderError
:
(
value
?:
string
)
=>
void
;
setVoiceRecording
:
(
value
?:
{
localFile
:
LocalFile
;
durationSeconds
:
number
;
mimeType
:
string
}
)
=>
void
;
onRecordingComplete
:
(
localFile
:
LocalFile
)
=>
void
;
}
}
const
AUDIO_MIME_TYPE_CANDIDATES
=
[
"audio/webm;codecs=opus"
,
"audio/webm"
,
"audio/mp4"
,
"audio/ogg;codecs=opus"
]
as
const
;
const
AUDIO_MIME_TYPE_CANDIDATES
=
[
"audio/webm;codecs=opus"
,
"audio/webm"
,
"audio/mp4"
,
"audio/ogg;codecs=opus"
]
as
const
;
...
@@ -39,17 +39,22 @@ function createRecordedFile(blob: Blob, mimeType: string): File {
...
@@ -39,17 +39,22 @@ function createRecordedFile(blob: Blob, mimeType: string): File {
const
extension
=
getFileExtension
(
mimeType
);
const
extension
=
getFileExtension
(
mimeType
);
const
now
=
new
Date
();
const
now
=
new
Date
();
const
datePart
=
[
now
.
getFullYear
(),
String
(
now
.
getMonth
()
+
1
).
padStart
(
2
,
"0"
),
String
(
now
.
getDate
()).
padStart
(
2
,
"0"
)].
join
(
""
);
const
datePart
=
[
now
.
getFullYear
(),
String
(
now
.
getMonth
()
+
1
).
padStart
(
2
,
"0"
),
String
(
now
.
getDate
()).
padStart
(
2
,
"0"
)].
join
(
""
);
const
timePart
=
[
String
(
now
.
getHours
()).
padStart
(
2
,
"0"
),
String
(
now
.
getMinutes
()).
padStart
(
2
,
"0"
)].
join
(
""
);
const
timePart
=
[
String
(
now
.
getHours
()).
padStart
(
2
,
"0"
),
String
(
now
.
getMinutes
()).
padStart
(
2
,
"0"
),
String
(
now
.
getSeconds
()).
padStart
(
2
,
"0"
),
].
join
(
""
);
return
new
File
([
blob
],
`voice-note-
${
datePart
}
-
${
timePart
}
.
${
extension
}
`
,
{
type
:
mimeType
});
return
new
File
([
blob
],
`voice-note-
${
datePart
}
-
${
timePart
}
.
${
extension
}
`
,
{
type
:
mimeType
});
}
}
export
const
use
VoiceRecorder
=
(
actions
:
Voice
RecorderActions
)
=>
{
export
const
use
AudioRecorder
=
(
actions
:
Audio
RecorderActions
)
=>
{
const
mediaRecorderRef
=
useRef
<
MediaRecorder
|
null
>
(
null
);
const
mediaRecorderRef
=
useRef
<
MediaRecorder
|
null
>
(
null
);
const
mediaStreamRef
=
useRef
<
MediaStream
|
null
>
(
null
);
const
mediaStreamRef
=
useRef
<
MediaStream
|
null
>
(
null
);
const
chunksRef
=
useRef
<
Blob
[]
>
([]);
const
chunksRef
=
useRef
<
Blob
[]
>
([]);
const
startedAtRef
=
useRef
<
number
|
null
>
(
null
);
const
startedAtRef
=
useRef
<
number
|
null
>
(
null
);
const
elapsedTimerRef
=
useRef
<
number
|
null
>
(
null
);
const
elapsedTimerRef
=
useRef
<
number
|
null
>
(
null
);
const
recorderMimeTypeRef
=
useRef
<
string
>
(
FALLBACK_AUDIO_MIME_TYPE
);
const
recorderMimeTypeRef
=
useRef
<
string
>
(
FALLBACK_AUDIO_MIME_TYPE
);
const
startRequestIdRef
=
useRef
(
0
);
const
{
createBlobUrl
}
=
useBlobUrls
();
const
{
createBlobUrl
}
=
useBlobUrls
();
const
cleanupTimer
=
()
=>
{
const
cleanupTimer
=
()
=>
{
...
@@ -79,15 +84,15 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => {
...
@@ -79,15 +84,15 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => {
typeof
navigator
.
mediaDevices
?.
getUserMedia
===
"function"
&&
typeof
navigator
.
mediaDevices
?.
getUserMedia
===
"function"
&&
typeof
MediaRecorder
!==
"undefined"
;
typeof
MediaRecorder
!==
"undefined"
;
actions
.
set
Voice
RecorderSupport
(
isSupported
);
actions
.
set
Audio
RecorderSupport
(
isSupported
);
if
(
!
isSupported
)
{
if
(
!
isSupported
)
{
actions
.
set
Voice
RecorderStatus
(
"unsupported"
);
actions
.
set
Audio
RecorderStatus
(
"unsupported"
);
actions
.
set
VoiceRecorderError
(
"Voice
recording is not supported in this browser."
);
actions
.
set
AudioRecorderError
(
"Audio
recording is not supported in this browser."
);
return
;
return
;
}
}
actions
.
set
Voice
RecorderStatus
(
"idle"
);
actions
.
set
Audio
RecorderStatus
(
"idle"
);
actions
.
set
Voice
RecorderError
(
undefined
);
actions
.
set
Audio
RecorderError
(
undefined
);
return
()
=>
{
return
()
=>
{
resetRecorderRefs
();
resetRecorderRefs
();
...
@@ -95,24 +100,31 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => {
...
@@ -95,24 +100,31 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => {
},
[
actions
]);
},
[
actions
]);
const
startRecording
=
async
()
=>
{
const
startRecording
=
async
()
=>
{
const
requestId
=
startRequestIdRef
.
current
+
1
;
startRequestIdRef
.
current
=
requestId
;
if
(
if
(
typeof
navigator
===
"undefined"
||
typeof
navigator
===
"undefined"
||
typeof
navigator
.
mediaDevices
?.
getUserMedia
!==
"function"
||
typeof
navigator
.
mediaDevices
?.
getUserMedia
!==
"function"
||
typeof
MediaRecorder
===
"undefined"
typeof
MediaRecorder
===
"undefined"
)
{
)
{
actions
.
set
Voice
RecorderSupport
(
false
);
actions
.
set
Audio
RecorderSupport
(
false
);
actions
.
set
Voice
RecorderStatus
(
"unsupported"
);
actions
.
set
Audio
RecorderStatus
(
"unsupported"
);
actions
.
set
VoiceRecorderError
(
"Voice
recording is not supported in this browser."
);
actions
.
set
AudioRecorderError
(
"Audio
recording is not supported in this browser."
);
return
;
return
;
}
}
actions
.
setVoiceRecorderError
(
undefined
);
actions
.
setAudioRecorderError
(
undefined
);
actions
.
setVoiceRecorderStatus
(
"requesting_permission"
);
actions
.
setAudioRecorderStatus
(
"requesting_permission"
);
actions
.
setVoiceRecorderElapsed
(
0
);
actions
.
setAudioRecorderElapsed
(
0
);
actions
.
setVoiceRecording
(
undefined
);
try
{
try
{
const
stream
=
await
navigator
.
mediaDevices
.
getUserMedia
({
audio
:
true
});
const
stream
=
await
navigator
.
mediaDevices
.
getUserMedia
({
audio
:
true
});
if
(
startRequestIdRef
.
current
!==
requestId
)
{
stream
.
getTracks
().
forEach
((
track
)
=>
track
.
stop
());
return
;
}
const
mimeType
=
getSupportedAudioMimeType
()
??
FALLBACK_AUDIO_MIME_TYPE
;
const
mimeType
=
getSupportedAudioMimeType
()
??
FALLBACK_AUDIO_MIME_TYPE
;
const
mediaRecorder
=
new
MediaRecorder
(
stream
,
getSupportedAudioMimeType
()
?
{
mimeType
}
:
undefined
);
const
mediaRecorder
=
new
MediaRecorder
(
stream
,
getSupportedAudioMimeType
()
?
{
mimeType
}
:
undefined
);
...
@@ -122,47 +134,68 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => {
...
@@ -122,47 +134,68 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => {
chunksRef
.
current
=
[];
chunksRef
.
current
=
[];
mediaRecorder
.
addEventListener
(
"dataavailable"
,
(
event
)
=>
{
mediaRecorder
.
addEventListener
(
"dataavailable"
,
(
event
)
=>
{
if
(
startRequestIdRef
.
current
!==
requestId
)
{
return
;
}
if
(
event
.
data
.
size
>
0
)
{
if
(
event
.
data
.
size
>
0
)
{
chunksRef
.
current
.
push
(
event
.
data
);
chunksRef
.
current
.
push
(
event
.
data
);
}
}
});
});
mediaRecorder
.
addEventListener
(
"stop"
,
()
=>
{
mediaRecorder
.
addEventListener
(
"stop"
,
()
=>
{
if
(
startRequestIdRef
.
current
!==
requestId
)
{
return
;
}
const
durationSeconds
=
startedAtRef
.
current
?
Math
.
max
(
0
,
Math
.
round
((
Date
.
now
()
-
startedAtRef
.
current
)
/
1000
))
:
0
;
const
durationSeconds
=
startedAtRef
.
current
?
Math
.
max
(
0
,
Math
.
round
((
Date
.
now
()
-
startedAtRef
.
current
)
/
1000
))
:
0
;
const
blob
=
new
Blob
(
chunksRef
.
current
,
{
type
:
recorderMimeTypeRef
.
current
});
const
blob
=
new
Blob
(
chunksRef
.
current
,
{
type
:
recorderMimeTypeRef
.
current
});
if
(
blob
.
size
===
0
)
{
actions
.
setAudioRecorderElapsed
(
0
);
actions
.
setAudioRecorderError
(
undefined
);
actions
.
setAudioRecorderStatus
(
"idle"
);
resetRecorderRefs
();
return
;
}
const
file
=
createRecordedFile
(
blob
,
recorderMimeTypeRef
.
current
);
const
file
=
createRecordedFile
(
blob
,
recorderMimeTypeRef
.
current
);
const
previewUrl
=
createBlobUrl
(
file
);
const
previewUrl
=
createBlobUrl
(
file
);
actions
.
setVoiceRecording
({
actions
.
onRecordingComplete
({
localFile
:
{
file
,
file
,
previewUrl
,
previewUrl
,
},
origin
:
"audio_recording"
,
audioMeta
:
{
durationSeconds
,
durationSeconds
,
mimeType
:
recorderMimeTypeRef
.
current
,
}
,
});
});
actions
.
setVoiceRecorderElapsed
(
durationSeconds
);
actions
.
setAudioRecorderElapsed
(
0
);
actions
.
setVoiceRecorderStatus
(
"recorded"
);
actions
.
setAudioRecorderError
(
undefined
);
actions
.
setAudioRecorderStatus
(
"idle"
);
resetRecorderRefs
();
resetRecorderRefs
();
});
});
mediaRecorder
.
start
();
mediaRecorder
.
start
();
startedAtRef
.
current
=
Date
.
now
();
startedAtRef
.
current
=
Date
.
now
();
actions
.
set
Voice
RecorderPermission
(
"granted"
);
actions
.
set
Audio
RecorderPermission
(
"granted"
);
actions
.
set
Voice
RecorderStatus
(
"recording"
);
actions
.
set
Audio
RecorderStatus
(
"recording"
);
elapsedTimerRef
.
current
=
window
.
setInterval
(()
=>
{
elapsedTimerRef
.
current
=
window
.
setInterval
(()
=>
{
if
(
startedAtRef
.
current
)
{
if
(
startedAtRef
.
current
)
{
actions
.
set
Voice
RecorderElapsed
(
Math
.
max
(
0
,
Math
.
floor
((
Date
.
now
()
-
startedAtRef
.
current
)
/
1000
)));
actions
.
set
Audio
RecorderElapsed
(
Math
.
max
(
0
,
Math
.
floor
((
Date
.
now
()
-
startedAtRef
.
current
)
/
1000
)));
}
}
},
250
);
},
250
);
}
catch
(
error
)
{
}
catch
(
error
)
{
if
(
startRequestIdRef
.
current
!==
requestId
)
{
return
;
}
const
permissionDenied
=
const
permissionDenied
=
error
instanceof
DOMException
&&
(
error
.
name
===
"NotAllowedError"
||
error
.
name
===
"PermissionDeniedError"
);
error
instanceof
DOMException
&&
(
error
.
name
===
"NotAllowedError"
||
error
.
name
===
"PermissionDeniedError"
);
actions
.
set
Voice
RecorderPermission
(
permissionDenied
?
"denied"
:
"unknown"
);
actions
.
set
Audio
RecorderPermission
(
permissionDenied
?
"denied"
:
"unknown"
);
actions
.
set
Voice
RecorderStatus
(
"error"
);
actions
.
set
Audio
RecorderStatus
(
"error"
);
actions
.
set
VoiceRecorderError
(
permissionDenied
?
"Microphone permission was denied."
:
"Failed to start voice
recording."
);
actions
.
set
AudioRecorderError
(
permissionDenied
?
"Microphone permission was denied."
:
"Failed to start audio
recording."
);
resetRecorderRefs
();
resetRecorderRefs
();
}
}
};
};
...
@@ -177,11 +210,11 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => {
...
@@ -177,11 +210,11 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => {
};
};
const
resetRecording
=
()
=>
{
const
resetRecording
=
()
=>
{
startRequestIdRef
.
current
+=
1
;
resetRecorderRefs
();
resetRecorderRefs
();
actions
.
setVoiceRecorderElapsed
(
0
);
actions
.
setAudioRecorderElapsed
(
0
);
actions
.
setVoiceRecorderError
(
undefined
);
actions
.
setAudioRecorderError
(
undefined
);
actions
.
setVoiceRecording
(
undefined
);
actions
.
setAudioRecorderStatus
(
"idle"
);
actions
.
setVoiceRecorderStatus
(
"idle"
);
};
};
return
{
return
{
...
...
web/src/components/MemoEditor/hooks/useFileUpload.ts
View file @
067d7ff0
...
@@ -17,6 +17,7 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void
...
@@ -17,6 +17,7 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void
files
.
map
((
file
)
=>
({
files
.
map
((
file
)
=>
({
file
,
file
,
previewUrl
:
URL
.
createObjectURL
(
file
),
previewUrl
:
URL
.
createObjectURL
(
file
),
origin
:
"upload"
,
})),
})),
);
);
onFilesSelected
(
localFiles
);
onFilesSelected
(
localFiles
);
...
...
web/src/components/MemoEditor/index.tsx
View file @
067d7ff0
import
{
useQueryClient
}
from
"@tanstack/react-query"
;
import
{
useQueryClient
}
from
"@tanstack/react-query"
;
import
{
useMemo
,
useRef
,
useState
}
from
"react"
;
import
{
use
Effect
,
use
Memo
,
useRef
,
useState
}
from
"react"
;
import
{
toast
}
from
"react-hot-toast"
;
import
{
toast
}
from
"react-hot-toast"
;
import
{
useAuth
}
from
"@/contexts/AuthContext"
;
import
{
useAuth
}
from
"@/contexts/AuthContext"
;
import
useCurrentUser
from
"@/hooks/useCurrentUser"
;
import
useCurrentUser
from
"@/hooks/useCurrentUser"
;
...
@@ -10,17 +10,17 @@ import { cn } from "@/lib/utils";
...
@@ -10,17 +10,17 @@ import { cn } from "@/lib/utils";
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
{
convertVisibilityFromString
}
from
"@/utils/memo"
;
import
{
convertVisibilityFromString
}
from
"@/utils/memo"
;
import
{
import
{
AudioRecorderPanel
,
EditorContent
,
EditorContent
,
EditorMetadata
,
EditorMetadata
,
EditorToolbar
,
EditorToolbar
,
FocusModeExitButton
,
FocusModeExitButton
,
FocusModeOverlay
,
FocusModeOverlay
,
TimestampPopover
,
TimestampPopover
,
VoiceRecorderPanel
,
}
from
"./components"
;
}
from
"./components"
;
import
{
FOCUS_MODE_STYLES
}
from
"./constants"
;
import
{
FOCUS_MODE_STYLES
}
from
"./constants"
;
import
type
{
EditorRefActions
}
from
"./Editor"
;
import
type
{
EditorRefActions
}
from
"./Editor"
;
import
{
useAu
toSave
,
useFocusMode
,
useKeyboard
,
useMemoInit
,
useVoiceRecorder
}
from
"./hooks"
;
import
{
useAu
dioRecorder
,
useAutoSave
,
useFocusMode
,
useKeyboard
,
useMemoInit
}
from
"./hooks"
;
import
{
cacheService
,
errorService
,
memoService
,
validationService
}
from
"./services"
;
import
{
cacheService
,
errorService
,
memoService
,
validationService
}
from
"./services"
;
import
{
EditorProvider
,
useEditorContext
}
from
"./state"
;
import
{
EditorProvider
,
useEditorContext
}
from
"./state"
;
import
type
{
MemoEditorProps
}
from
"./types"
;
import
type
{
MemoEditorProps
}
from
"./types"
;
...
@@ -47,7 +47,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
...
@@ -47,7 +47,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
const
editorRef
=
useRef
<
EditorRefActions
>
(
null
);
const
editorRef
=
useRef
<
EditorRefActions
>
(
null
);
const
{
state
,
actions
,
dispatch
}
=
useEditorContext
();
const
{
state
,
actions
,
dispatch
}
=
useEditorContext
();
const
{
userGeneralSetting
}
=
useAuth
();
const
{
userGeneralSetting
}
=
useAuth
();
const
[
is
VoiceRecorderOpen
,
setIsVoice
RecorderOpen
]
=
useState
(
false
);
const
[
is
AudioRecorderOpen
,
setIsAudio
RecorderOpen
]
=
useState
(
false
);
const
memoName
=
memo
?.
name
;
const
memoName
=
memo
?.
name
;
...
@@ -62,72 +62,55 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
...
@@ -62,72 +62,55 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
// Focus mode management with body scroll lock
// Focus mode management with body scroll lock
useFocusMode
(
state
.
ui
.
isFocusMode
);
useFocusMode
(
state
.
ui
.
isFocusMode
);
const
voice
RecorderActions
=
useMemo
(
const
audio
RecorderActions
=
useMemo
(
()
=>
({
()
=>
({
setVoiceRecorderSupport
:
(
value
:
boolean
)
=>
dispatch
(
actions
.
setVoiceRecorderSupport
(
value
)),
setAudioRecorderSupport
:
(
value
:
boolean
)
=>
dispatch
(
actions
.
setAudioRecorderSupport
(
value
)),
setVoiceRecorderPermission
:
(
value
:
"unknown"
|
"granted"
|
"denied"
)
=>
dispatch
(
actions
.
setVoiceRecorderPermission
(
value
)),
setAudioRecorderPermission
:
(
value
:
"unknown"
|
"granted"
|
"denied"
)
=>
dispatch
(
actions
.
setAudioRecorderPermission
(
value
)),
setVoiceRecorderStatus
:
(
value
:
"idle"
|
"requesting_permission"
|
"recording"
|
"recorded"
|
"error"
|
"unsupported"
)
=>
setAudioRecorderStatus
:
(
value
:
"idle"
|
"requesting_permission"
|
"recording"
|
"error"
|
"unsupported"
)
=>
dispatch
(
actions
.
setVoiceRecorderStatus
(
value
)),
dispatch
(
actions
.
setAudioRecorderStatus
(
value
)),
setVoiceRecorderElapsed
:
(
value
:
number
)
=>
dispatch
(
actions
.
setVoiceRecorderElapsed
(
value
)),
setAudioRecorderElapsed
:
(
value
:
number
)
=>
dispatch
(
actions
.
setAudioRecorderElapsed
(
value
)),
setVoiceRecorderError
:
(
value
?:
string
)
=>
dispatch
(
actions
.
setVoiceRecorderError
(
value
)),
setAudioRecorderError
:
(
value
?:
string
)
=>
dispatch
(
actions
.
setAudioRecorderError
(
value
)),
setVoiceRecording
:
(
value
?:
typeof
state
.
voiceRecorder
.
recording
)
=>
dispatch
(
actions
.
setVoiceRecording
(
value
)),
onRecordingComplete
:
(
localFile
:
(
typeof
state
.
localFiles
)[
number
])
=>
{
dispatch
(
actions
.
addLocalFile
(
localFile
));
setIsAudioRecorderOpen
(
false
);
},
}),
}),
[
actions
,
dispatch
],
[
actions
,
dispatch
,
state
.
localFiles
],
);
);
const
voiceRecorder
=
useVoiceRecorder
(
voice
RecorderActions
);
const
audioRecorder
=
useAudioRecorder
(
audio
RecorderActions
);
const
handleToggleFocusMode
=
()
=>
{
useEffect
(()
=>
{
dispatch
(
actions
.
toggleFocusMode
());
if
(
!
isAudioRecorderOpen
)
{
};
const
handleStartVoiceRecording
=
async
()
=>
{
setIsVoiceRecorderOpen
(
true
);
await
voiceRecorder
.
startRecording
();
};
const
handleVoiceRecorderClick
=
()
=>
{
setIsVoiceRecorderOpen
(
true
);
if
(
state
.
voiceRecorder
.
status
===
"recording"
||
state
.
voiceRecorder
.
status
===
"requesting_permission"
||
state
.
voiceRecorder
.
status
===
"recorded"
)
{
return
;
return
;
}
}
void
handleStartVoiceRecording
();
if
(
state
.
audioRecorder
.
status
===
"error"
||
state
.
audioRecorder
.
status
===
"unsupported"
)
{
};
toast
.
error
(
state
.
audioRecorder
.
error
||
t
(
"editor.audio-recorder.error-description"
));
setIsAudioRecorderOpen
(
false
);
const
handleKeepVoiceRecording
=
()
=>
{
const
recording
=
state
.
voiceRecorder
.
recording
;
if
(
!
recording
)
{
return
;
}
}
},
[
isAudioRecorderOpen
,
state
.
audioRecorder
.
error
,
state
.
audioRecorder
.
status
,
t
]);
dispatch
(
actions
.
addLocalFile
(
recording
.
localFile
));
const
handleToggleFocusMode
=
()
=>
{
voiceRecorder
.
resetRecording
();
dispatch
(
actions
.
toggleFocusMode
());
setIsVoiceRecorderOpen
(
false
);
};
};
const
handle
DiscardVoiceRecording
=
()
=>
{
const
handle
StartAudioRecording
=
async
()
=>
{
voiceRecorder
.
resetRecording
(
);
setIsAudioRecorderOpen
(
true
);
setIsVoiceRecorderOpen
(
false
);
await
audioRecorder
.
startRecording
(
);
};
};
const
handle
CloseVoiceRecorder
=
()
=>
{
const
handle
AudioRecorderClick
=
()
=>
{
if
(
state
.
voiceRecorder
.
status
===
"recording"
||
state
.
voice
Recorder
.
status
===
"requesting_permission"
)
{
if
(
state
.
audioRecorder
.
status
===
"recording"
||
state
.
audio
Recorder
.
status
===
"requesting_permission"
)
{
return
;
return
;
}
}
voiceRecorder
.
resetRecording
();
void
handleStartAudioRecording
();
setIsVoiceRecorderOpen
(
false
);
};
};
const
handle
RecordAgain
=
async
()
=>
{
const
handle
CancelAudioRecording
=
()
=>
{
voice
Recorder
.
resetRecording
();
audio
Recorder
.
resetRecording
();
await
handleStartVoiceRecording
(
);
setIsAudioRecorderOpen
(
false
);
};
};
useKeyboard
(
editorRef
,
handleSave
);
useKeyboard
(
editorRef
,
handleSave
);
...
@@ -220,22 +203,18 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
...
@@ -220,22 +203,18 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
{
/* Editor content grows to fill available space in focus mode */
}
{
/* Editor content grows to fill available space in focus mode */
}
<
EditorContent
ref=
{
editorRef
}
placeholder=
{
placeholder
}
/>
<
EditorContent
ref=
{
editorRef
}
placeholder=
{
placeholder
}
/>
{
isVoiceRecorderOpen
&&
(
{
isAudioRecorderOpen
&&
(
state
.
audioRecorder
.
status
===
"recording"
||
state
.
audioRecorder
.
status
===
"requesting_permission"
)
&&
(
<
VoiceRecorderPanel
<
AudioRecorderPanel
voiceRecorder=
{
state
.
voiceRecorder
}
audioRecorder=
{
state
.
audioRecorder
}
onStart=
{
()
=>
void
handleStartVoiceRecording
()
}
onStop=
{
audioRecorder
.
stopRecording
}
onStop=
{
voiceRecorder
.
stopRecording
}
onCancel=
{
handleCancelAudioRecording
}
onKeep=
{
handleKeepVoiceRecording
}
onDiscard=
{
handleDiscardVoiceRecording
}
onRecordAgain=
{
()
=>
void
handleRecordAgain
()
}
onClose=
{
handleCloseVoiceRecorder
}
/>
/>
)
}
)
}
{
/* Metadata and toolbar grouped together at bottom */
}
{
/* Metadata and toolbar grouped together at bottom */
}
<
div
className=
"w-full flex flex-col gap-2"
>
<
div
className=
"w-full flex flex-col gap-2"
>
<
EditorMetadata
memoName=
{
memoName
}
/>
<
EditorMetadata
memoName=
{
memoName
}
/>
<
EditorToolbar
onSave=
{
handleSave
}
onCancel=
{
onCancel
}
memoName=
{
memoName
}
on
VoiceRecorderClick=
{
handleVoice
RecorderClick
}
/>
<
EditorToolbar
onSave=
{
handleSave
}
onCancel=
{
onCancel
}
memoName=
{
memoName
}
on
AudioRecorderClick=
{
handleAudio
RecorderClick
}
/>
</
div
>
</
div
>
</
div
>
</
div
>
</>
</>
...
...
web/src/components/MemoEditor/services/memoService.ts
View file @
067d7ff0
...
@@ -142,13 +142,12 @@ export const memoService = {
...
@@ -142,13 +142,12 @@ export const memoService = {
updateTime
:
memo
.
updateTime
?
timestampDate
(
memo
.
updateTime
)
:
undefined
,
updateTime
:
memo
.
updateTime
?
timestampDate
(
memo
.
updateTime
)
:
undefined
,
},
},
localFiles
:
[],
localFiles
:
[],
voice
Recorder
:
{
audio
Recorder
:
{
isSupported
:
true
,
isSupported
:
true
,
permission
:
"unknown"
,
permission
:
"unknown"
,
status
:
"idle"
,
status
:
"idle"
,
elapsedSeconds
:
0
,
elapsedSeconds
:
0
,
error
:
undefined
,
error
:
undefined
,
recording
:
undefined
,
},
},
};
};
},
},
...
...
web/src/components/MemoEditor/services/validationService.ts
View file @
067d7ff0
...
@@ -22,9 +22,9 @@ export const validationService = {
...
@@ -22,9 +22,9 @@ export const validationService = {
return
{
valid
:
false
,
reason
:
"Wait for upload to complete"
};
return
{
valid
:
false
,
reason
:
"Wait for upload to complete"
};
}
}
// Cannot save while
voice
recorder is active
// Cannot save while
audio
recorder is active
if
(
state
.
voiceRecorder
.
status
===
"recording"
||
state
.
voice
Recorder
.
status
===
"requesting_permission"
)
{
if
(
state
.
audioRecorder
.
status
===
"recording"
||
state
.
audio
Recorder
.
status
===
"requesting_permission"
)
{
return
{
valid
:
false
,
reason
:
"Finish
voice
recording before saving"
};
return
{
valid
:
false
,
reason
:
"Finish
audio
recording before saving"
};
}
}
// Cannot save while already saving
// Cannot save while already saving
...
...
web/src/components/MemoEditor/state/actions.ts
View file @
067d7ff0
import
type
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service_pb"
;
import
type
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service_pb"
;
import
type
{
MemoRelation
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
type
{
MemoRelation
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
type
{
LocalFile
}
from
"../types/attachment"
;
import
type
{
LocalFile
}
from
"../types/attachment"
;
import
type
{
EditorAction
,
EditorState
,
LoadingKey
,
VoiceRecorderPermission
,
VoiceRecorderStatus
,
VoiceRecordingPreview
}
from
"./types"
;
import
type
{
AudioRecorderPermission
,
AudioRecorderStatus
,
EditorAction
,
EditorState
,
LoadingKey
}
from
"./types"
;
export
const
editorActions
=
{
export
const
editorActions
=
{
initMemo
:
(
payload
:
{
content
:
string
;
metadata
:
EditorState
[
"metadata"
];
timestamps
:
EditorState
[
"timestamps"
]
}):
EditorAction
=>
({
initMemo
:
(
payload
:
{
content
:
string
;
metadata
:
EditorState
[
"metadata"
];
timestamps
:
EditorState
[
"timestamps"
]
}):
EditorAction
=>
({
...
@@ -77,33 +77,28 @@ export const editorActions = {
...
@@ -77,33 +77,28 @@ export const editorActions = {
payload
:
timestamps
,
payload
:
timestamps
,
}),
}),
set
Voice
RecorderSupport
:
(
value
:
boolean
):
EditorAction
=>
({
set
Audio
RecorderSupport
:
(
value
:
boolean
):
EditorAction
=>
({
type
:
"SET_
VOICE
_RECORDER_SUPPORT"
,
type
:
"SET_
AUDIO
_RECORDER_SUPPORT"
,
payload
:
value
,
payload
:
value
,
}),
}),
set
VoiceRecorderPermission
:
(
value
:
Voice
RecorderPermission
):
EditorAction
=>
({
set
AudioRecorderPermission
:
(
value
:
Audio
RecorderPermission
):
EditorAction
=>
({
type
:
"SET_
VOICE
_RECORDER_PERMISSION"
,
type
:
"SET_
AUDIO
_RECORDER_PERMISSION"
,
payload
:
value
,
payload
:
value
,
}),
}),
set
VoiceRecorderStatus
:
(
value
:
Voice
RecorderStatus
):
EditorAction
=>
({
set
AudioRecorderStatus
:
(
value
:
Audio
RecorderStatus
):
EditorAction
=>
({
type
:
"SET_
VOICE
_RECORDER_STATUS"
,
type
:
"SET_
AUDIO
_RECORDER_STATUS"
,
payload
:
value
,
payload
:
value
,
}),
}),
set
Voice
RecorderElapsed
:
(
value
:
number
):
EditorAction
=>
({
set
Audio
RecorderElapsed
:
(
value
:
number
):
EditorAction
=>
({
type
:
"SET_
VOICE
_RECORDER_ELAPSED"
,
type
:
"SET_
AUDIO
_RECORDER_ELAPSED"
,
payload
:
value
,
payload
:
value
,
}),
}),
setVoiceRecorderError
:
(
value
?:
string
):
EditorAction
=>
({
setAudioRecorderError
:
(
value
?:
string
):
EditorAction
=>
({
type
:
"SET_VOICE_RECORDER_ERROR"
,
type
:
"SET_AUDIO_RECORDER_ERROR"
,
payload
:
value
,
}),
setVoiceRecording
:
(
value
?:
VoiceRecordingPreview
):
EditorAction
=>
({
type
:
"SET_VOICE_RECORDING"
,
payload
:
value
,
payload
:
value
,
}),
}),
...
...
web/src/components/MemoEditor/state/reducer.ts
View file @
067d7ff0
...
@@ -125,61 +125,52 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
...
@@ -125,61 +125,52 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
},
},
};
};
case
"SET_
VOICE
_RECORDER_SUPPORT"
:
case
"SET_
AUDIO
_RECORDER_SUPPORT"
:
return
{
return
{
...
state
,
...
state
,
voice
Recorder
:
{
audio
Recorder
:
{
...
state
.
voice
Recorder
,
...
state
.
audio
Recorder
,
isSupported
:
action
.
payload
,
isSupported
:
action
.
payload
,
status
:
action
.
payload
?
state
.
voice
Recorder
.
status
:
"unsupported"
,
status
:
action
.
payload
?
state
.
audio
Recorder
.
status
:
"unsupported"
,
},
},
};
};
case
"SET_
VOICE
_RECORDER_PERMISSION"
:
case
"SET_
AUDIO
_RECORDER_PERMISSION"
:
return
{
return
{
...
state
,
...
state
,
voice
Recorder
:
{
audio
Recorder
:
{
...
state
.
voice
Recorder
,
...
state
.
audio
Recorder
,
permission
:
action
.
payload
,
permission
:
action
.
payload
,
},
},
};
};
case
"SET_
VOICE
_RECORDER_STATUS"
:
case
"SET_
AUDIO
_RECORDER_STATUS"
:
return
{
return
{
...
state
,
...
state
,
voice
Recorder
:
{
audio
Recorder
:
{
...
state
.
voice
Recorder
,
...
state
.
audio
Recorder
,
status
:
action
.
payload
,
status
:
action
.
payload
,
},
},
};
};
case
"SET_
VOICE
_RECORDER_ELAPSED"
:
case
"SET_
AUDIO
_RECORDER_ELAPSED"
:
return
{
return
{
...
state
,
...
state
,
voice
Recorder
:
{
audio
Recorder
:
{
...
state
.
voice
Recorder
,
...
state
.
audio
Recorder
,
elapsedSeconds
:
action
.
payload
,
elapsedSeconds
:
action
.
payload
,
},
},
};
};
case
"SET_
VOICE
_RECORDER_ERROR"
:
case
"SET_
AUDIO
_RECORDER_ERROR"
:
return
{
return
{
...
state
,
...
state
,
voice
Recorder
:
{
audio
Recorder
:
{
...
state
.
voice
Recorder
,
...
state
.
audio
Recorder
,
error
:
action
.
payload
,
error
:
action
.
payload
,
},
},
};
};
case
"SET_VOICE_RECORDING"
:
return
{
...
state
,
voiceRecorder
:
{
...
state
.
voiceRecorder
,
recording
:
action
.
payload
,
},
};
case
"RESET"
:
case
"RESET"
:
return
{
return
{
...
initialState
,
...
initialState
,
...
...
web/src/components/MemoEditor/state/types.ts
View file @
067d7ff0
...
@@ -4,14 +4,8 @@ import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
...
@@ -4,14 +4,8 @@ import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
import
type
{
LocalFile
}
from
"../types/attachment"
;
import
type
{
LocalFile
}
from
"../types/attachment"
;
export
type
LoadingKey
=
"saving"
|
"uploading"
|
"loading"
;
export
type
LoadingKey
=
"saving"
|
"uploading"
|
"loading"
;
export
type
VoiceRecorderPermission
=
"unknown"
|
"granted"
|
"denied"
;
export
type
AudioRecorderPermission
=
"unknown"
|
"granted"
|
"denied"
;
export
type
VoiceRecorderStatus
=
"idle"
|
"requesting_permission"
|
"recording"
|
"recorded"
|
"error"
|
"unsupported"
;
export
type
AudioRecorderStatus
=
"idle"
|
"requesting_permission"
|
"recording"
|
"error"
|
"unsupported"
;
export
interface
VoiceRecordingPreview
{
localFile
:
LocalFile
;
durationSeconds
:
number
;
mimeType
:
string
;
}
export
interface
EditorState
{
export
interface
EditorState
{
content
:
string
;
content
:
string
;
...
@@ -35,13 +29,12 @@ export interface EditorState {
...
@@ -35,13 +29,12 @@ export interface EditorState {
updateTime
?:
Date
;
updateTime
?:
Date
;
};
};
localFiles
:
LocalFile
[];
localFiles
:
LocalFile
[];
voice
Recorder
:
{
audio
Recorder
:
{
isSupported
:
boolean
;
isSupported
:
boolean
;
permission
:
Voice
RecorderPermission
;
permission
:
Audio
RecorderPermission
;
status
:
Voice
RecorderStatus
;
status
:
Audio
RecorderStatus
;
elapsedSeconds
:
number
;
elapsedSeconds
:
number
;
error
?:
string
;
error
?:
string
;
recording
?:
VoiceRecordingPreview
;
};
};
}
}
...
@@ -61,12 +54,11 @@ export type EditorAction =
...
@@ -61,12 +54,11 @@ export type EditorAction =
|
{
type
:
"SET_LOADING"
;
payload
:
{
key
:
LoadingKey
;
value
:
boolean
}
}
|
{
type
:
"SET_LOADING"
;
payload
:
{
key
:
LoadingKey
;
value
:
boolean
}
}
|
{
type
:
"SET_COMPOSING"
;
payload
:
boolean
}
|
{
type
:
"SET_COMPOSING"
;
payload
:
boolean
}
|
{
type
:
"SET_TIMESTAMPS"
;
payload
:
Partial
<
EditorState
[
"timestamps"
]
>
}
|
{
type
:
"SET_TIMESTAMPS"
;
payload
:
Partial
<
EditorState
[
"timestamps"
]
>
}
|
{
type
:
"SET_VOICE_RECORDER_SUPPORT"
;
payload
:
boolean
}
|
{
type
:
"SET_AUDIO_RECORDER_SUPPORT"
;
payload
:
boolean
}
|
{
type
:
"SET_VOICE_RECORDER_PERMISSION"
;
payload
:
VoiceRecorderPermission
}
|
{
type
:
"SET_AUDIO_RECORDER_PERMISSION"
;
payload
:
AudioRecorderPermission
}
|
{
type
:
"SET_VOICE_RECORDER_STATUS"
;
payload
:
VoiceRecorderStatus
}
|
{
type
:
"SET_AUDIO_RECORDER_STATUS"
;
payload
:
AudioRecorderStatus
}
|
{
type
:
"SET_VOICE_RECORDER_ELAPSED"
;
payload
:
number
}
|
{
type
:
"SET_AUDIO_RECORDER_ELAPSED"
;
payload
:
number
}
|
{
type
:
"SET_VOICE_RECORDER_ERROR"
;
payload
?:
string
}
|
{
type
:
"SET_AUDIO_RECORDER_ERROR"
;
payload
?:
string
}
|
{
type
:
"SET_VOICE_RECORDING"
;
payload
?:
VoiceRecordingPreview
}
|
{
type
:
"RESET"
};
|
{
type
:
"RESET"
};
export
const
initialState
:
EditorState
=
{
export
const
initialState
:
EditorState
=
{
...
@@ -91,12 +83,11 @@ export const initialState: EditorState = {
...
@@ -91,12 +83,11 @@ export const initialState: EditorState = {
updateTime
:
undefined
,
updateTime
:
undefined
,
},
},
localFiles
:
[],
localFiles
:
[],
voice
Recorder
:
{
audio
Recorder
:
{
isSupported
:
true
,
isSupported
:
true
,
permission
:
"unknown"
,
permission
:
"unknown"
,
status
:
"idle"
,
status
:
"idle"
,
elapsedSeconds
:
0
,
elapsedSeconds
:
0
,
error
:
undefined
,
error
:
undefined
,
recording
:
undefined
,
},
},
};
};
web/src/components/MemoEditor/types/attachment.ts
View file @
067d7ff0
...
@@ -15,14 +15,42 @@ export interface AttachmentItem {
...
@@ -15,14 +15,42 @@ export interface AttachmentItem {
readonly
sourceUrl
:
string
;
readonly
sourceUrl
:
string
;
readonly
size
?:
number
;
readonly
size
?:
number
;
readonly
isLocal
:
boolean
;
readonly
isLocal
:
boolean
;
readonly
isVoiceNote
:
boolean
;
readonly
audioMeta
?:
LocalFile
[
"audioMeta"
];
}
}
export
interface
LocalFile
{
export
interface
LocalFile
{
readonly
file
:
File
;
readonly
file
:
File
;
readonly
previewUrl
:
string
;
readonly
previewUrl
:
string
;
readonly
origin
?:
"audio_recording"
|
"upload"
;
readonly
audioMeta
?:
{
readonly
durationSeconds
:
number
;
};
readonly
motionMedia
?:
MotionMedia
;
readonly
motionMedia
?:
MotionMedia
;
}
}
const
AUDIO_RECORDING_FILENAME_RE
=
/^
(?:
voice-
(?:
recording|note
)
|audio-recording
)
-
(\d{8})
-
(\d{4,6})
/i
;
export
const
isAudioRecordingFilename
=
(
filename
:
string
):
boolean
=>
AUDIO_RECORDING_FILENAME_RE
.
test
(
filename
);
export
const
getAudioRecordingTimeLabel
=
(
filename
:
string
):
string
|
undefined
=>
{
const
match
=
filename
.
match
(
AUDIO_RECORDING_FILENAME_RE
);
const
timePart
=
match
?.[
2
];
if
(
!
timePart
)
{
return
undefined
;
}
if
(
timePart
.
length
===
4
)
{
return
`
${
timePart
.
slice
(
0
,
2
)}
:
${
timePart
.
slice
(
2
,
4
)}
`
;
}
if
(
timePart
.
length
===
6
)
{
return
`
${
timePart
.
slice
(
0
,
2
)}
:
${
timePart
.
slice
(
2
,
4
)}
:
${
timePart
.
slice
(
4
,
6
)}
`
;
}
return
undefined
;
};
function
categorizeFile
(
mimeType
:
string
,
motionMedia
?:
MotionMedia
):
FileCategory
{
function
categorizeFile
(
mimeType
:
string
,
motionMedia
?:
MotionMedia
):
FileCategory
{
if
(
motionMedia
)
return
"motion"
;
if
(
motionMedia
)
return
"motion"
;
if
(
mimeType
.
startsWith
(
"image/"
))
return
"image"
;
if
(
mimeType
.
startsWith
(
"image/"
))
return
"image"
;
...
@@ -45,6 +73,8 @@ function attachmentGroupToItem(attachment: Attachment): AttachmentItem {
...
@@ -45,6 +73,8 @@ function attachmentGroupToItem(attachment: Attachment): AttachmentItem {
sourceUrl
,
sourceUrl
,
size
:
Number
(
attachment
.
size
),
size
:
Number
(
attachment
.
size
),
isLocal
:
false
,
isLocal
:
false
,
isVoiceNote
:
categorizeFile
(
attachment
.
type
)
===
"audio"
&&
isAudioRecordingFilename
(
attachment
.
filename
),
audioMeta
:
undefined
,
};
};
}
}
...
@@ -59,6 +89,8 @@ function visualItemToAttachmentItem(item: ReturnType<typeof buildAttachmentVisua
...
@@ -59,6 +89,8 @@ function visualItemToAttachmentItem(item: ReturnType<typeof buildAttachmentVisua
sourceUrl
:
item
.
sourceUrl
,
sourceUrl
:
item
.
sourceUrl
,
size
:
item
.
attachments
.
reduce
((
total
,
attachment
)
=>
total
+
Number
(
attachment
.
size
),
0
),
size
:
item
.
attachments
.
reduce
((
total
,
attachment
)
=>
total
+
Number
(
attachment
.
size
),
0
),
isLocal
:
false
,
isLocal
:
false
,
isVoiceNote
:
false
,
audioMeta
:
undefined
,
};
};
}
}
...
@@ -73,6 +105,10 @@ function fileToItem(file: LocalFile): AttachmentItem {
...
@@ -73,6 +105,10 @@ function fileToItem(file: LocalFile): AttachmentItem {
sourceUrl
:
file
.
previewUrl
,
sourceUrl
:
file
.
previewUrl
,
size
:
file
.
file
.
size
,
size
:
file
.
file
.
size
,
isLocal
:
true
,
isLocal
:
true
,
isVoiceNote
:
categorizeFile
(
file
.
file
.
type
,
file
.
motionMedia
)
===
"audio"
&&
(
file
.
origin
===
"audio_recording"
||
isAudioRecordingFilename
(
file
.
file
.
name
)),
audioMeta
:
file
.
audioMeta
,
};
};
}
}
...
@@ -111,6 +147,8 @@ function toLocalMotionItems(localFiles: LocalFile[]): AttachmentItem[] {
...
@@ -111,6 +147,8 @@ function toLocalMotionItems(localFiles: LocalFile[]): AttachmentItem[] {
sourceUrl
:
video
.
previewUrl
,
sourceUrl
:
video
.
previewUrl
,
size
:
still
.
file
.
size
+
video
.
file
.
size
,
size
:
still
.
file
.
size
+
video
.
file
.
size
,
isLocal
:
true
,
isLocal
:
true
,
isVoiceNote
:
false
,
audioMeta
:
undefined
,
},
},
];
];
}
}
...
...
web/src/components/MemoEditor/types/components.ts
View file @
067d7ff0
...
@@ -23,21 +23,17 @@ export interface EditorToolbarProps {
...
@@ -23,21 +23,17 @@ export interface EditorToolbarProps {
onSave
:
()
=>
void
;
onSave
:
()
=>
void
;
onCancel
?:
()
=>
void
;
onCancel
?:
()
=>
void
;
memoName
?:
string
;
memoName
?:
string
;
on
Voice
RecorderClick
:
()
=>
void
;
on
Audio
RecorderClick
:
()
=>
void
;
}
}
export
interface
EditorMetadataProps
{
export
interface
EditorMetadataProps
{
memoName
?:
string
;
memoName
?:
string
;
}
}
export
interface
VoiceRecorderPanelProps
{
export
interface
AudioRecorderPanelProps
{
voiceRecorder
:
EditorState
[
"voiceRecorder"
];
audioRecorder
:
EditorState
[
"audioRecorder"
];
onStart
:
()
=>
void
;
onStop
:
()
=>
void
;
onStop
:
()
=>
void
;
onKeep
:
()
=>
void
;
onCancel
:
()
=>
void
;
onDiscard
:
()
=>
void
;
onRecordAgain
:
()
=>
void
;
onClose
:
()
=>
void
;
}
}
export
interface
FocusModeOverlayProps
{
export
interface
FocusModeOverlayProps
{
...
@@ -57,7 +53,7 @@ export interface InsertMenuProps {
...
@@ -57,7 +53,7 @@ export interface InsertMenuProps {
onLocationChange
:
(
location
?:
Location
)
=>
void
;
onLocationChange
:
(
location
?:
Location
)
=>
void
;
onToggleFocusMode
?:
()
=>
void
;
onToggleFocusMode
?:
()
=>
void
;
memoName
?:
string
;
memoName
?:
string
;
on
Voice
RecorderClick
?:
()
=>
void
;
on
Audio
RecorderClick
?:
()
=>
void
;
}
}
export
interface
TagSuggestionsProps
{
export
interface
TagSuggestionsProps
{
...
...
web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx
View file @
067d7ff0
import
{
ChevronDownIcon
,
ChevronUpIcon
,
File
Icon
,
Paperclip
Icon
,
XIcon
}
from
"lucide-react"
;
import
{
ChevronDownIcon
,
ChevronUpIcon
,
File
AudioIcon
,
FileIcon
,
PaperclipIcon
,
PauseIcon
,
Play
Icon
,
XIcon
}
from
"lucide-react"
;
import
type
{
FC
}
from
"react"
;
import
{
type
FC
,
type
MouseEvent
,
useMemo
,
useRef
,
useState
}
from
"react"
;
import
type
{
AttachmentItem
,
LocalFile
}
from
"@/components/MemoEditor/types/attachment"
;
import
type
{
AttachmentItem
,
LocalFile
}
from
"@/components/MemoEditor/types/attachment"
;
import
{
toAttachmentItems
}
from
"@/components/MemoEditor/types/attachment"
;
import
{
getAudioRecordingTimeLabel
,
toAttachmentItems
}
from
"@/components/MemoEditor/types/attachment"
;
import
MetadataSection
from
"@/components/MemoMetadata/MetadataSection"
;
import
MetadataSection
from
"@/components/MemoMetadata/MetadataSection"
;
import
PreviewImageDialog
from
"@/components/PreviewImageDialog"
;
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
{
formatFileSize
,
getFileTypeLabel
}
from
"@/utils/format"
;
import
{
formatFileSize
,
getFileTypeLabel
}
from
"@/utils/format"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
type
{
PreviewMediaItem
}
from
"@/utils/media-item"
;
import
{
formatAudioTime
}
from
"./attachmentHelpers"
;
interface
AttachmentListEditorProps
{
interface
AttachmentListEditorProps
{
attachments
:
Attachment
[];
attachments
:
Attachment
[];
...
@@ -15,56 +19,26 @@ interface AttachmentListEditorProps {
...
@@ -15,56 +19,26 @@ interface AttachmentListEditorProps {
onRemoveLocalFile
?:
(
previewUrl
:
string
)
=>
void
;
onRemoveLocalFile
?:
(
previewUrl
:
string
)
=>
void
;
}
}
const
AttachmentItemCard
:
FC
<
{
const
AttachmentItemActions
:
FC
<
{
item
:
AttachmentItem
;
onRemove
?:
()
=>
void
;
onRemove
?:
()
=>
void
;
onMoveUp
?:
()
=>
void
;
onMoveUp
?:
()
=>
void
;
onMoveDown
?:
()
=>
void
;
onMoveDown
?:
()
=>
void
;
canMoveUp
?:
boolean
;
canMoveUp
?:
boolean
;
canMoveDown
?:
boolean
;
canMoveDown
?:
boolean
;
}
>
=
({
item
,
onRemove
,
onMoveUp
,
onMoveDown
,
canMoveUp
=
true
,
canMoveDown
=
true
})
=>
{
}
>
=
({
onRemove
,
onMoveUp
,
onMoveDown
,
canMoveUp
=
true
,
canMoveDown
=
true
})
=>
{
const
{
category
,
filename
,
thumbnailUrl
,
mimeType
,
size
}
=
item
;
const
stopPropagation
=
(
event
:
MouseEvent
)
=>
{
const
fileTypeLabel
=
item
.
category
===
"motion"
?
"Live Photo"
:
getFileTypeLabel
(
mimeType
);
event
.
stopPropagation
();
const
fileSizeLabel
=
size
?
formatFileSize
(
size
)
:
undefined
;
};
const
displayName
=
category
===
"audio"
&&
/^voice-
(
recording|note
)
-/i
.
test
(
filename
)
?
"Voice note"
:
filename
;
return
(
return
(
<
div
className=
"relative rounded border border-transparent px-1.5 py-1 transition-all hover:border-border hover:bg-accent/20"
>
<
div
className=
"flex items-center gap-1.5"
>
<
div
className=
"relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded bg-muted/40"
>
{
(
category
===
"image"
||
category
===
"motion"
)
&&
thumbnailUrl
?
(
<
img
src=
{
thumbnailUrl
}
alt=
""
className=
"h-full w-full object-cover"
/>
)
:
(
<
FileIcon
className=
"h-3.5 w-3.5 text-muted-foreground"
/>
)
}
{
category
===
"motion"
&&
(
<
span
className=
"absolute inset-x-0 bottom-0 bg-black/70 text-center text-[7px] font-semibold uppercase tracking-wide text-white"
>
Live
</
span
>
)
}
</
div
>
<
div
className=
"min-w-0 flex-1 flex flex-col gap-0.5 sm:flex-row sm:items-baseline sm:gap-1.5"
>
<
span
className=
"truncate text-xs"
title=
{
filename
}
>
{
displayName
}
</
span
>
<
div
className=
"flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground"
>
<
span
>
{
fileTypeLabel
}
</
span
>
{
fileSizeLabel
&&
(
<>
<
span
className=
"hidden text-muted-foreground/50 sm:inline"
>
•
</
span
>
<
span
className=
"hidden sm:inline"
>
{
fileSizeLabel
}
</
span
>
</>
)
}
</
div
>
</
div
>
<
div
className=
"shrink-0 flex items-center gap-0.5"
>
<
div
className=
"shrink-0 flex items-center gap-0.5"
>
{
onMoveUp
&&
(
{
onMoveUp
&&
(
<
button
<
button
type=
"button"
type=
"button"
onClick=
{
onMoveUp
}
onClick=
{
(
event
)
=>
{
stopPropagation
(
event
);
onMoveUp
();
}
}
disabled=
{
!
canMoveUp
}
disabled=
{
!
canMoveUp
}
className=
{
cn
(
className=
{
cn
(
"touch-manipulation rounded p-0.5 transition-colors hover:bg-accent active:bg-accent"
,
"touch-manipulation rounded p-0.5 transition-colors hover:bg-accent active:bg-accent"
,
...
@@ -80,7 +54,10 @@ const AttachmentItemCard: FC<{
...
@@ -80,7 +54,10 @@ const AttachmentItemCard: FC<{
{
onMoveDown
&&
(
{
onMoveDown
&&
(
<
button
<
button
type=
"button"
type=
"button"
onClick=
{
onMoveDown
}
onClick=
{
(
event
)
=>
{
stopPropagation
(
event
);
onMoveDown
();
}
}
disabled=
{
!
canMoveDown
}
disabled=
{
!
canMoveDown
}
className=
{
cn
(
className=
{
cn
(
"touch-manipulation rounded p-0.5 transition-colors hover:bg-accent active:bg-accent"
,
"touch-manipulation rounded p-0.5 transition-colors hover:bg-accent active:bg-accent"
,
...
@@ -96,7 +73,10 @@ const AttachmentItemCard: FC<{
...
@@ -96,7 +73,10 @@ const AttachmentItemCard: FC<{
{
onRemove
&&
(
{
onRemove
&&
(
<
button
<
button
type=
"button"
type=
"button"
onClick=
{
onRemove
}
onClick=
{
(
event
)
=>
{
stopPropagation
(
event
);
onRemove
();
}
}
className=
"ml-0.5 touch-manipulation rounded p-0.5 transition-colors hover:bg-destructive/10 active:bg-destructive/10"
className=
"ml-0.5 touch-manipulation rounded p-0.5 transition-colors hover:bg-destructive/10 active:bg-destructive/10"
title=
"Remove"
title=
"Remove"
aria
-
label=
"Remove attachment"
aria
-
label=
"Remove attachment"
...
@@ -105,6 +85,139 @@ const AttachmentItemCard: FC<{
...
@@ -105,6 +85,139 @@ const AttachmentItemCard: FC<{
</
button
>
</
button
>
)
}
)
}
</
div
>
</
div
>
);
};
const
AttachmentItemCard
:
FC
<
{
item
:
AttachmentItem
;
onPreview
?:
()
=>
void
;
onRemove
?:
()
=>
void
;
onMoveUp
?:
()
=>
void
;
onMoveDown
?:
()
=>
void
;
canMoveUp
?:
boolean
;
canMoveDown
?:
boolean
;
}
>
=
({
item
,
onPreview
,
onRemove
,
onMoveUp
,
onMoveDown
,
canMoveUp
=
true
,
canMoveDown
=
true
})
=>
{
const
t
=
useTranslate
();
const
{
category
,
filename
,
thumbnailUrl
,
mimeType
,
size
,
sourceUrl
,
isVoiceNote
,
audioMeta
}
=
item
;
const
audioRef
=
useRef
<
HTMLAudioElement
>
(
null
);
const
[
isPlaying
,
setIsPlaying
]
=
useState
(
false
);
const
fileTypeLabel
=
item
.
category
===
"motion"
?
"Live Photo"
:
getFileTypeLabel
(
mimeType
);
const
isPreviewable
=
category
===
"image"
||
category
===
"video"
||
category
===
"motion"
;
const
recordingTimeLabel
=
isVoiceNote
?
getAudioRecordingTimeLabel
(
filename
)
:
undefined
;
const
titleLabel
=
isVoiceNote
&&
recordingTimeLabel
?
t
(
"editor.audio-recorder.attachment-label-with-time"
,
{
time
:
recordingTimeLabel
})
:
isVoiceNote
?
t
(
"editor.audio-recorder.attachment-label"
)
:
filename
;
const
detailParts
=
[
audioMeta
?.
durationSeconds
?
formatAudioTime
(
audioMeta
.
durationSeconds
)
:
undefined
,
fileTypeLabel
,
size
?
formatFileSize
(
size
)
:
undefined
,
].
filter
(
Boolean
);
const
handleAudioToggle
=
async
(
event
:
MouseEvent
<
HTMLButtonElement
>
)
=>
{
event
.
stopPropagation
();
const
audio
=
audioRef
.
current
;
if
(
!
audio
)
{
return
;
}
if
(
audio
.
paused
)
{
try
{
await
audio
.
play
();
}
catch
{
setIsPlaying
(
false
);
}
return
;
}
audio
.
pause
();
};
return
(
<
div
className=
"relative rounded border border-transparent px-1.5 py-1 transition-all hover:border-border hover:bg-accent/20"
>
<
div
className=
"flex items-center gap-1.5"
>
<
div
className=
"relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded bg-muted/40"
>
{
(
category
===
"image"
||
category
===
"motion"
)
&&
thumbnailUrl
?
(
<
button
type=
"button"
onClick=
{
(
event
)
=>
{
event
.
stopPropagation
();
onPreview
?.();
}
}
className=
{
cn
(
"h-full w-full overflow-hidden"
,
isPreviewable
?
"cursor-pointer"
:
"cursor-default"
)
}
aria
-
label=
{
`Preview ${filename}`
}
>
<
img
src=
{
thumbnailUrl
}
alt=
""
className=
"h-full w-full object-cover"
/>
</
button
>
)
:
isVoiceNote
?
(
<>
<
button
type=
"button"
onClick=
{
handleAudioToggle
}
className=
"flex size-full items-center justify-center rounded bg-muted/40 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria
-
label=
{
isPlaying
?
t
(
"editor.audio-recorder.pause-recording"
)
:
t
(
"editor.audio-recorder.play-recording"
)
}
>
{
isPlaying
?
<
PauseIcon
className=
"h-3.5 w-3.5"
/>
:
<
PlayIcon
className=
"h-3.5 w-3.5 translate-x-[0.5px]"
/>
}
</
button
>
<
audio
ref=
{
audioRef
}
src=
{
sourceUrl
}
preload=
"metadata"
className=
"hidden"
onPlay=
{
()
=>
setIsPlaying
(
true
)
}
onPause=
{
()
=>
setIsPlaying
(
false
)
}
onEnded=
{
()
=>
setIsPlaying
(
false
)
}
/>
</>
)
:
category
===
"video"
?
(
<
button
type=
"button"
onClick=
{
(
event
)
=>
{
event
.
stopPropagation
();
onPreview
?.();
}
}
className=
"flex size-full items-center justify-center rounded bg-muted/40 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria
-
label=
{
`Preview ${filename}`
}
>
<
PlayIcon
className=
"h-3.5 w-3.5 translate-x-[0.5px]"
/>
</
button
>
)
:
category
===
"audio"
?
(
<
FileAudioIcon
className=
"h-3.5 w-3.5 text-muted-foreground"
/>
)
:
(
<
FileIcon
className=
"h-3.5 w-3.5 text-muted-foreground"
/>
)
}
{
category
===
"motion"
&&
(
<
span
className=
"absolute inset-x-0 bottom-0 bg-black/70 text-center text-[7px] font-semibold uppercase tracking-wide text-white"
>
Live
</
span
>
)
}
</
div
>
<
div
className=
"min-w-0 flex-1 flex flex-col gap-0.5 sm:flex-row sm:items-baseline sm:gap-1.5"
>
<
span
className=
"truncate text-xs"
title=
{
filename
}
>
{
titleLabel
}
</
span
>
<
div
className=
"flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground"
>
{
detailParts
.
map
((
part
,
index
)
=>
(
<
span
key=
{
`${item.id}-${part}`
}
>
{
index
>
0
&&
<
span
className=
"hidden text-muted-foreground/50 sm:inline"
>
•
</
span
>
}
<
span
>
{
part
}
</
span
>
</
span
>
))
}
</
div
>
</
div
>
<
AttachmentItemActions
onRemove=
{
onRemove
}
onMoveUp=
{
onMoveUp
}
onMoveDown=
{
onMoveDown
}
canMoveUp=
{
canMoveUp
}
canMoveDown=
{
canMoveDown
}
/>
</
div
>
</
div
>
</
div
>
</
div
>
);
);
...
@@ -117,13 +230,32 @@ const AttachmentListEditor: FC<AttachmentListEditorProps> = ({
...
@@ -117,13 +230,32 @@ const AttachmentListEditor: FC<AttachmentListEditorProps> = ({
onLocalFilesChange
,
onLocalFilesChange
,
onRemoveLocalFile
,
onRemoveLocalFile
,
})
=>
{
})
=>
{
if
(
attachments
.
length
===
0
&&
localFiles
.
length
===
0
)
{
const
[
previewState
,
setPreviewState
]
=
useState
<
{
open
:
boolean
;
initialIndex
:
number
}
>
({
open
:
false
,
initialIndex
:
0
});
return
null
;
}
const
items
=
toAttachmentItems
(
attachments
,
localFiles
);
const
items
=
toAttachmentItems
(
attachments
,
localFiles
);
const
attachmentItems
=
items
.
filter
((
item
)
=>
!
item
.
isLocal
);
const
attachmentItems
=
items
.
filter
((
item
)
=>
!
item
.
isLocal
);
const
localItems
=
items
.
filter
((
item
)
=>
item
.
isLocal
);
const
localItems
=
items
.
filter
((
item
)
=>
item
.
isLocal
);
const
previewItems
=
useMemo
<
PreviewMediaItem
[]
>
(
()
=>
items
.
reduce
<
PreviewMediaItem
[]
>
((
acc
,
item
)
=>
{
if
(
item
.
category
===
"image"
)
{
acc
.
push
({
id
:
item
.
id
,
kind
:
"image"
,
sourceUrl
:
item
.
sourceUrl
,
posterUrl
:
item
.
thumbnailUrl
,
filename
:
item
.
filename
});
return
acc
;
}
if
(
item
.
category
===
"video"
)
{
acc
.
push
({
id
:
item
.
id
,
kind
:
"video"
,
sourceUrl
:
item
.
sourceUrl
,
posterUrl
:
item
.
thumbnailUrl
,
filename
:
item
.
filename
});
return
acc
;
}
if
(
item
.
category
===
"motion"
)
{
acc
.
push
({
id
:
item
.
id
,
kind
:
"motion"
,
motionUrl
:
item
.
sourceUrl
,
posterUrl
:
item
.
thumbnailUrl
,
filename
:
item
.
filename
});
return
acc
;
}
return
acc
;
},
[]),
[
items
],
);
const
handleMoveAttachments
=
(
itemId
:
string
,
direction
:
-
1
|
1
)
=>
{
const
handleMoveAttachments
=
(
itemId
:
string
,
direction
:
-
1
|
1
)
=>
{
if
(
!
onAttachmentsChange
)
return
;
if
(
!
onAttachmentsChange
)
return
;
...
@@ -176,8 +308,22 @@ const AttachmentListEditor: FC<AttachmentListEditorProps> = ({
...
@@ -176,8 +308,22 @@ const AttachmentListEditor: FC<AttachmentListEditorProps> = ({
}
}
};
};
const
handlePreviewItem
=
(
item
:
AttachmentItem
)
=>
{
const
previewIndex
=
previewItems
.
findIndex
((
previewItem
)
=>
previewItem
.
id
===
item
.
id
);
if
(
previewIndex
<
0
)
{
return
;
}
setPreviewState
({
open
:
true
,
initialIndex
:
previewIndex
});
};
if
(
items
.
length
===
0
)
{
return
null
;
}
return
(
return
(
<
MetadataSection
icon=
{
PaperclipIcon
}
title=
"Attachments"
count=
{
items
.
length
}
contentClassName=
"flex flex-col gap-0.5 p-1 sm:p-1.5"
>
<>
<
MetadataSection
icon=
{
PaperclipIcon
}
title=
"Attachments"
count=
{
items
.
length
}
contentClassName=
"flex flex-col gap-1 p-1 sm:p-1.5"
>
{
items
.
map
((
item
)
=>
{
{
items
.
map
((
item
)
=>
{
const
itemList
=
item
.
isLocal
?
localItems
:
attachmentItems
;
const
itemList
=
item
.
isLocal
?
localItems
:
attachmentItems
;
const
itemIndex
=
itemList
.
findIndex
((
entry
)
=>
entry
.
id
===
item
.
id
);
const
itemIndex
=
itemList
.
findIndex
((
entry
)
=>
entry
.
id
===
item
.
id
);
...
@@ -186,6 +332,11 @@ const AttachmentListEditor: FC<AttachmentListEditorProps> = ({
...
@@ -186,6 +332,11 @@ const AttachmentListEditor: FC<AttachmentListEditorProps> = ({
<
AttachmentItemCard
<
AttachmentItemCard
key=
{
item
.
id
}
key=
{
item
.
id
}
item=
{
item
}
item=
{
item
}
onPreview=
{
item
.
category
===
"image"
||
item
.
category
===
"video"
||
item
.
category
===
"motion"
?
()
=>
handlePreviewItem
(
item
)
:
undefined
}
onRemove=
{
()
=>
handleRemoveItem
(
item
)
}
onRemove=
{
()
=>
handleRemoveItem
(
item
)
}
onMoveUp=
{
item
.
isLocal
?
()
=>
handleMoveLocalFiles
(
item
.
id
,
-
1
)
:
()
=>
handleMoveAttachments
(
item
.
id
,
-
1
)
}
onMoveUp=
{
item
.
isLocal
?
()
=>
handleMoveLocalFiles
(
item
.
id
,
-
1
)
:
()
=>
handleMoveAttachments
(
item
.
id
,
-
1
)
}
onMoveDown=
{
item
.
isLocal
?
()
=>
handleMoveLocalFiles
(
item
.
id
,
1
)
:
()
=>
handleMoveAttachments
(
item
.
id
,
1
)
}
onMoveDown=
{
item
.
isLocal
?
()
=>
handleMoveLocalFiles
(
item
.
id
,
1
)
:
()
=>
handleMoveAttachments
(
item
.
id
,
1
)
}
...
@@ -195,6 +346,14 @@ const AttachmentListEditor: FC<AttachmentListEditorProps> = ({
...
@@ -195,6 +346,14 @@ const AttachmentListEditor: FC<AttachmentListEditorProps> = ({
);
);
})
}
})
}
</
MetadataSection
>
</
MetadataSection
>
<
PreviewImageDialog
open=
{
previewState
.
open
}
onOpenChange=
{
(
open
)
=>
setPreviewState
((
state
)
=>
({
...
state
,
open
}))
}
items=
{
previewItems
}
initialIndex=
{
previewState
.
initialIndex
}
/>
</>
);
);
};
};
...
...
web/src/locales/en.json
View file @
067d7ff0
...
@@ -121,29 +121,38 @@
...
@@ -121,29 +121,38 @@
"any-thoughts"
:
"Any thoughts..."
,
"any-thoughts"
:
"Any thoughts..."
,
"exit-focus-mode"
:
"Exit Focus Mode"
,
"exit-focus-mode"
:
"Exit Focus Mode"
,
"focus-mode"
:
"Focus Mode"
,
"focus-mode"
:
"Focus Mode"
,
"insert-menu"
:
{
"add-location"
:
"Add location"
,
"link-memo"
:
"Link memo"
,
"upload-file"
:
"Upload file"
},
"no-changes-detected"
:
"No changes detected"
,
"no-changes-detected"
:
"No changes detected"
,
"save"
:
"Save"
,
"save"
:
"Save"
,
"saving"
:
"Saving..."
,
"saving"
:
"Saving..."
,
"slash-commands"
:
"Type `/` for commands"
,
"slash-commands"
:
"Type `/` for commands"
,
"voice-recorder"
:
{
"audio-recorder"
:
{
"attachment-label"
:
"Audio recording"
,
"attachment-label-with-time"
:
"Audio recording {{time}}"
,
"discard"
:
"Discard"
,
"discard"
:
"Discard"
,
"error"
:
"Microphone unavailable"
,
"error"
:
"Microphone unavailable"
,
"error-description"
:
"Try again after checking microphone access for this site."
,
"error-description"
:
"Try again after checking microphone access for this site."
,
"idle-description"
:
"Start recording to add a
voice note as an audio
attachment."
,
"idle-description"
:
"Start recording to add a
n audio recording as an
attachment."
,
"keep"
:
"Keep recording"
,
"keep"
:
"Keep recording"
,
"pause-recording"
:
"Pause audio recording"
,
"play-recording"
:
"Play audio recording"
,
"ready"
:
"Recording ready"
,
"ready"
:
"Recording ready"
,
"ready-description"
:
"Preview the clip, then keep it as an audio attachment or discard it."
,
"ready-description"
:
"Preview the clip, then keep it as an audio attachment or discard it."
,
"record-again"
:
"Record again"
,
"record-again"
:
"Record again"
,
"recording"
:
"Recording
voice note
"
,
"recording"
:
"Recording
audio
"
,
"recording-description"
:
"Capture a quick audio attachment. Current length: {{duration}}"
,
"recording-description"
:
"Capture a quick audio attachment. Current length: {{duration}}"
,
"requesting"
:
"Requesting access..."
,
"requesting"
:
"Requesting access..."
,
"requesting-permission"
:
"Requesting microphone access"
,
"requesting-permission"
:
"Requesting microphone access"
,
"requesting-permission-description"
:
"Allow microphone access in your browser to start recording."
,
"requesting-permission-description"
:
"Allow microphone access in your browser to start recording."
,
"start"
:
"Start recording"
,
"start"
:
"Start recording"
,
"stop"
:
"Stop recording"
,
"stop"
:
"Stop recording"
,
"title"
:
"
Voice
recorder"
,
"title"
:
"
Audio
recorder"
,
"trigger"
:
"
Voice note
"
,
"trigger"
:
"
Record audio
"
,
"unsupported"
:
"
Voice
recording unsupported"
,
"unsupported"
:
"
Audio
recording unsupported"
,
"unsupported-description"
:
"This browser cannot record audio from the memo composer."
"unsupported-description"
:
"This browser cannot record audio from the memo composer."
}
}
},
},
...
...
web/src/locales/tr.json
View file @
067d7ff0
...
@@ -121,16 +121,25 @@
...
@@ -121,16 +121,25 @@
"any-thoughts"
:
"Düşünceleriniz..."
,
"any-thoughts"
:
"Düşünceleriniz..."
,
"exit-focus-mode"
:
"Odak modundan çık"
,
"exit-focus-mode"
:
"Odak modundan çık"
,
"focus-mode"
:
"Odak modu"
,
"focus-mode"
:
"Odak modu"
,
"insert-menu"
:
{
"add-location"
:
"Konum ekle"
,
"link-memo"
:
"Not bağla"
,
"upload-file"
:
"Dosya yükle"
},
"no-changes-detected"
:
"Değişiklik yok"
,
"no-changes-detected"
:
"Değişiklik yok"
,
"save"
:
"Kaydet"
,
"save"
:
"Kaydet"
,
"saving"
:
"Kaydediliyor..."
,
"saving"
:
"Kaydediliyor..."
,
"slash-commands"
:
"Komutlar için `/` yazın"
,
"slash-commands"
:
"Komutlar için `/` yazın"
,
"voice-recorder"
:
{
"audio-recorder"
:
{
"attachment-label"
:
"Ses kaydı"
,
"attachment-label-with-time"
:
"Ses kaydı {{time}}"
,
"discard"
:
"Sil"
,
"discard"
:
"Sil"
,
"error"
:
"Mikrofon kullanılamıyor"
,
"error"
:
"Mikrofon kullanılamıyor"
,
"error-description"
:
"Bu site için mikrofon erişimini kontrol ettikten sonra tekrar deneyin."
,
"error-description"
:
"Bu site için mikrofon erişimini kontrol ettikten sonra tekrar deneyin."
,
"idle-description"
:
"Ses eki olarak bir sesli not eklemek için kayda başlayın."
,
"idle-description"
:
"Ses eki olarak bir sesli not eklemek için kayda başlayın."
,
"keep"
:
"Sakla"
,
"keep"
:
"Sakla"
,
"pause-recording"
:
"Ses kaydını duraklat"
,
"play-recording"
:
"Ses kaydını oynat"
,
"ready"
:
"Kayıt hazır"
,
"ready"
:
"Kayıt hazır"
,
"ready-description"
:
"Klibi önizleyin, ardından ses eki olarak saklayın veya silin."
,
"ready-description"
:
"Klibi önizleyin, ardından ses eki olarak saklayın veya silin."
,
"record-again"
:
"Tekrar kaydet"
,
"record-again"
:
"Tekrar kaydet"
,
...
...
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