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
638b22a2
Commit
638b22a2
authored
Nov 02, 2025
by
Claude
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
chore: implement InsertMenu with file upload and memo linking functionality
parent
93964827
Changes
8
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
486 additions
and
319 deletions
+486
-319
InsertMenu.tsx
web/src/components/MemoEditor/ActionButton/InsertMenu.tsx
+57
-319
LinkMemoDialog.tsx
...nts/MemoEditor/ActionButton/InsertMenu/LinkMemoDialog.tsx
+68
-0
LocationDialog.tsx
...nts/MemoEditor/ActionButton/InsertMenu/LocationDialog.tsx
+108
-0
index.ts
...rc/components/MemoEditor/ActionButton/InsertMenu/index.ts
+6
-0
types.ts
...rc/components/MemoEditor/ActionButton/InsertMenu/types.ts
+15
-0
useFileUpload.ts
...nents/MemoEditor/ActionButton/InsertMenu/useFileUpload.ts
+53
-0
useLinkMemo.tsx
...onents/MemoEditor/ActionButton/InsertMenu/useLinkMemo.tsx
+97
-0
useLocation.ts
...ponents/MemoEditor/ActionButton/InsertMenu/useLocation.ts
+82
-0
No files found.
web/src/components/MemoEditor/ActionButton/InsertMenu.tsx
View file @
638b22a2
This diff is collapsed.
Click to expand it.
web/src/components/MemoEditor/ActionButton/InsertMenu/LinkMemoDialog.tsx
0 → 100644
View file @
638b22a2
import
{
Dialog
,
DialogContent
,
DialogHeader
,
DialogTitle
}
from
"@/components/ui/dialog"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
{
Memo
}
from
"@/types/proto/api/v1/memo_service"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
interface
LinkMemoDialogProps
{
open
:
boolean
;
onOpenChange
:
(
open
:
boolean
)
=>
void
;
searchText
:
string
;
onSearchChange
:
(
text
:
string
)
=>
void
;
filteredMemos
:
Memo
[];
isFetching
:
boolean
;
onSelectMemo
:
(
memo
:
Memo
)
=>
void
;
getHighlightedContent
:
(
content
:
string
)
=>
React
.
ReactNode
;
}
export
const
LinkMemoDialog
=
({
open
,
onOpenChange
,
searchText
,
onSearchChange
,
filteredMemos
,
isFetching
,
onSelectMemo
,
getHighlightedContent
,
}:
LinkMemoDialogProps
)
=>
{
const
t
=
useTranslate
();
return
(
<
Dialog
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
DialogContent
>
<
DialogHeader
>
<
DialogTitle
>
{
t
(
"tooltip.link-memo"
)
}
</
DialogTitle
>
</
DialogHeader
>
<
div
className=
"flex flex-col gap-3"
>
<
Input
placeholder=
{
t
(
"reference.search-placeholder"
)
}
value=
{
searchText
}
onChange=
{
(
e
)
=>
onSearchChange
(
e
.
target
.
value
)
}
className=
"!text-sm"
/>
<
div
className=
"max-h-[300px] overflow-y-auto border rounded-md"
>
{
filteredMemos
.
length
===
0
?
(
<
div
className=
"py-8 text-center text-sm text-muted-foreground"
>
{
isFetching
?
"Loading..."
:
t
(
"reference.no-memos-found"
)
}
</
div
>
)
:
(
filteredMemos
.
map
((
memo
)
=>
(
<
div
key=
{
memo
.
name
}
className=
"relative flex cursor-pointer items-start gap-2 border-b last:border-b-0 px-3 py-2 hover:bg-accent hover:text-accent-foreground"
onClick=
{
()
=>
onSelectMemo
(
memo
)
}
>
<
div
className=
"w-full flex flex-col justify-start items-start"
>
<
p
className=
"text-xs text-muted-foreground select-none"
>
{
memo
.
displayTime
?.
toLocaleString
()
}
</
p
>
<
p
className=
"mt-0.5 text-sm leading-5 line-clamp-2"
>
{
searchText
?
getHighlightedContent
(
memo
.
content
)
:
memo
.
snippet
}
</
p
>
</
div
>
</
div
>
))
)
}
</
div
>
</
div
>
</
DialogContent
>
</
Dialog
>
);
};
web/src/components/MemoEditor/ActionButton/InsertMenu/LocationDialog.tsx
0 → 100644
View file @
638b22a2
import
{
LatLng
}
from
"leaflet"
;
import
LeafletMap
from
"@/components/LeafletMap"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Dialog
,
DialogClose
,
DialogContent
}
from
"@/components/ui/dialog"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
{
Label
}
from
"@/components/ui/label"
;
import
{
Textarea
}
from
"@/components/ui/textarea"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
{
LocationState
}
from
"./types"
;
interface
LocationDialogProps
{
open
:
boolean
;
onOpenChange
:
(
open
:
boolean
)
=>
void
;
state
:
LocationState
;
locationInitialized
:
boolean
;
onPositionChange
:
(
position
:
LatLng
)
=>
void
;
onLatChange
:
(
value
:
string
)
=>
void
;
onLngChange
:
(
value
:
string
)
=>
void
;
onPlaceholderChange
:
(
value
:
string
)
=>
void
;
onCancel
:
()
=>
void
;
onConfirm
:
()
=>
void
;
}
export
const
LocationDialog
=
({
open
,
onOpenChange
,
state
,
locationInitialized
,
onPositionChange
,
onLatChange
,
onLngChange
,
onPlaceholderChange
,
onCancel
,
onConfirm
,
}:
LocationDialogProps
)
=>
{
const
t
=
useTranslate
();
const
{
placeholder
,
position
,
latInput
,
lngInput
}
=
state
;
return
(
<
Dialog
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
DialogContent
className=
"max-w-[min(28rem,calc(100vw-2rem))] !p-0"
>
<
DialogClose
className=
"hidden"
></
DialogClose
>
<
div
className=
"flex flex-col"
>
<
div
className=
"w-full h-64 overflow-hidden rounded-t-md bg-muted/30"
>
<
LeafletMap
key=
{
JSON
.
stringify
(
locationInitialized
)
}
latlng=
{
position
}
onChange=
{
onPositionChange
}
/>
</
div
>
<
div
className=
"w-full flex flex-col p-3 gap-3"
>
<
div
className=
"grid grid-cols-2 gap-3"
>
<
div
className=
"grid gap-1"
>
<
Label
htmlFor=
"memo-location-lat"
className=
"text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
Lat
</
Label
>
<
Input
id=
"memo-location-lat"
placeholder=
"Lat"
type=
"number"
step=
"any"
min=
"-90"
max=
"90"
value=
{
latInput
}
onChange=
{
(
e
)
=>
onLatChange
(
e
.
target
.
value
)
}
className=
"h-9"
/>
</
div
>
<
div
className=
"grid gap-1"
>
<
Label
htmlFor=
"memo-location-lng"
className=
"text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
Lng
</
Label
>
<
Input
id=
"memo-location-lng"
placeholder=
"Lng"
type=
"number"
step=
"any"
min=
"-180"
max=
"180"
value=
{
lngInput
}
onChange=
{
(
e
)
=>
onLngChange
(
e
.
target
.
value
)
}
className=
"h-9"
/>
</
div
>
</
div
>
<
div
className=
"grid gap-1"
>
<
Label
htmlFor=
"memo-location-placeholder"
className=
"text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
{
t
(
"tooltip.select-location"
)
}
</
Label
>
<
Textarea
id=
"memo-location-placeholder"
placeholder=
"Choose a position first."
value=
{
placeholder
}
disabled=
{
!
position
}
onChange=
{
(
e
)
=>
onPlaceholderChange
(
e
.
target
.
value
)
}
className=
"min-h-16"
/>
</
div
>
<
div
className=
"w-full flex items-center justify-end gap-2"
>
<
Button
variant=
"ghost"
onClick=
{
onCancel
}
>
{
t
(
"common.close"
)
}
</
Button
>
<
Button
onClick=
{
onConfirm
}
disabled=
{
!
position
||
placeholder
.
trim
().
length
===
0
}
>
{
t
(
"common.confirm"
)
}
</
Button
>
</
div
>
</
div
>
</
div
>
</
DialogContent
>
</
Dialog
>
);
};
web/src/components/MemoEditor/ActionButton/InsertMenu/index.ts
0 → 100644
View file @
638b22a2
export
{
LinkMemoDialog
}
from
"./LinkMemoDialog"
;
export
{
LocationDialog
}
from
"./LocationDialog"
;
export
{
useFileUpload
}
from
"./useFileUpload"
;
export
{
useLinkMemo
}
from
"./useLinkMemo"
;
export
{
useLocation
}
from
"./useLocation"
;
export
type
{
LocationState
,
LinkMemoState
}
from
"./types"
;
web/src/components/MemoEditor/ActionButton/InsertMenu/types.ts
0 → 100644
View file @
638b22a2
import
{
LatLng
}
from
"leaflet"
;
import
{
Memo
}
from
"@/types/proto/api/v1/memo_service"
;
export
interface
LocationState
{
placeholder
:
string
;
position
?:
LatLng
;
latInput
:
string
;
lngInput
:
string
;
}
export
interface
LinkMemoState
{
searchText
:
string
;
isFetching
:
boolean
;
fetchedMemos
:
Memo
[];
}
web/src/components/MemoEditor/ActionButton/InsertMenu/useFileUpload.ts
0 → 100644
View file @
638b22a2
import
mime
from
"mime"
;
import
{
useRef
,
useState
}
from
"react"
;
import
{
toast
}
from
"react-hot-toast"
;
import
{
attachmentStore
}
from
"@/store"
;
import
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service"
;
export
const
useFileUpload
=
(
onUploadComplete
:
(
attachments
:
Attachment
[])
=>
void
)
=>
{
const
fileInputRef
=
useRef
<
HTMLInputElement
>
(
null
);
const
[
uploadingFlag
,
setUploadingFlag
]
=
useState
(
false
);
const
handleFileInputChange
=
async
()
=>
{
if
(
!
fileInputRef
.
current
?.
files
||
fileInputRef
.
current
.
files
.
length
===
0
||
uploadingFlag
)
{
return
;
}
setUploadingFlag
(
true
);
const
createdAttachmentList
:
Attachment
[]
=
[];
try
{
for
(
const
file
of
fileInputRef
.
current
.
files
)
{
const
{
name
:
filename
,
size
,
type
}
=
file
;
const
buffer
=
new
Uint8Array
(
await
file
.
arrayBuffer
());
const
attachment
=
await
attachmentStore
.
createAttachment
({
attachment
:
Attachment
.
fromPartial
({
filename
,
size
,
type
:
type
||
mime
.
getType
(
filename
)
||
"text/plain"
,
content
:
buffer
,
}),
attachmentId
:
""
,
});
createdAttachmentList
.
push
(
attachment
);
}
onUploadComplete
(
createdAttachmentList
);
}
catch
(
error
:
any
)
{
console
.
error
(
error
);
toast
.
error
(
error
.
details
);
}
finally
{
setUploadingFlag
(
false
);
}
};
const
handleUploadClick
=
()
=>
{
fileInputRef
.
current
?.
click
();
};
return
{
fileInputRef
,
uploadingFlag
,
handleFileInputChange
,
handleUploadClick
,
};
};
web/src/components/MemoEditor/ActionButton/InsertMenu/useLinkMemo.tsx
0 → 100644
View file @
638b22a2
import
{
useState
}
from
"react"
;
import
useDebounce
from
"react-use/lib/useDebounce"
;
import
{
memoServiceClient
}
from
"@/grpcweb"
;
import
{
DEFAULT_LIST_MEMOS_PAGE_SIZE
}
from
"@/helpers/consts"
;
import
useCurrentUser
from
"@/hooks/useCurrentUser"
;
import
{
extractUserIdFromName
}
from
"@/store/common"
;
import
{
Memo
,
MemoRelation
,
MemoRelation_Memo
,
MemoRelation_Type
}
from
"@/types/proto/api/v1/memo_service"
;
interface
UseLinkMemoParams
{
isOpen
:
boolean
;
currentMemoName
?:
string
;
existingRelations
:
MemoRelation
[];
onAddRelation
:
(
relation
:
MemoRelation
)
=>
void
;
}
export
const
useLinkMemo
=
({
isOpen
,
currentMemoName
,
existingRelations
,
onAddRelation
}:
UseLinkMemoParams
)
=>
{
const
user
=
useCurrentUser
();
const
[
searchText
,
setSearchText
]
=
useState
(
""
);
const
[
isFetching
,
setIsFetching
]
=
useState
(
true
);
const
[
fetchedMemos
,
setFetchedMemos
]
=
useState
<
Memo
[]
>
([]);
const
filteredMemos
=
fetchedMemos
.
filter
(
(
memo
)
=>
memo
.
name
!==
currentMemoName
&&
!
existingRelations
.
some
((
relation
)
=>
relation
.
relatedMemo
?.
name
===
memo
.
name
),
);
useDebounce
(
async
()
=>
{
if
(
!
isOpen
)
return
;
setIsFetching
(
true
);
try
{
const
conditions
=
[
`creator_id ==
${
extractUserIdFromName
(
user
.
name
)}
`
];
if
(
searchText
)
{
conditions
.
push
(
`content.contains("
${
searchText
}
")`
);
}
const
{
memos
}
=
await
memoServiceClient
.
listMemos
({
pageSize
:
DEFAULT_LIST_MEMOS_PAGE_SIZE
,
filter
:
conditions
.
join
(
" && "
),
});
setFetchedMemos
(
memos
);
}
catch
(
error
)
{
console
.
error
(
error
);
}
finally
{
setIsFetching
(
false
);
}
},
300
,
[
isOpen
,
searchText
],
);
const
addMemoRelation
=
(
memo
:
Memo
)
=>
{
const
relation
=
MemoRelation
.
fromPartial
({
type
:
MemoRelation_Type
.
REFERENCE
,
relatedMemo
:
MemoRelation_Memo
.
fromPartial
({
name
:
memo
.
name
,
snippet
:
memo
.
snippet
,
}),
});
onAddRelation
(
relation
);
};
const
getHighlightedContent
=
(
content
:
string
):
React
.
ReactNode
=>
{
if
(
!
searchText
)
return
content
;
const
index
=
content
.
toLowerCase
().
indexOf
(
searchText
.
toLowerCase
());
if
(
index
===
-
1
)
{
return
content
;
}
let
before
=
content
.
slice
(
0
,
index
);
if
(
before
.
length
>
20
)
{
before
=
"..."
+
before
.
slice
(
before
.
length
-
20
);
}
const
highlighted
=
content
.
slice
(
index
,
index
+
searchText
.
length
);
let
after
=
content
.
slice
(
index
+
searchText
.
length
);
if
(
after
.
length
>
20
)
{
after
=
after
.
slice
(
0
,
20
)
+
"..."
;
}
return
(
<>
{
before
}
<
mark
className=
"font-medium"
>
{
highlighted
}
</
mark
>
{
after
}
</>
);
};
return
{
searchText
,
setSearchText
,
isFetching
,
filteredMemos
,
addMemoRelation
,
getHighlightedContent
,
};
};
web/src/components/MemoEditor/ActionButton/InsertMenu/useLocation.ts
0 → 100644
View file @
638b22a2
import
{
LatLng
}
from
"leaflet"
;
import
{
useState
}
from
"react"
;
import
{
Location
}
from
"@/types/proto/api/v1/memo_service"
;
import
{
LocationState
}
from
"./types"
;
export
const
useLocation
=
(
initialLocation
?:
Location
)
=>
{
const
[
locationInitialized
,
setLocationInitialized
]
=
useState
(
false
);
const
[
state
,
setState
]
=
useState
<
LocationState
>
({
placeholder
:
initialLocation
?.
placeholder
||
""
,
position
:
initialLocation
?
new
LatLng
(
initialLocation
.
latitude
,
initialLocation
.
longitude
)
:
undefined
,
latInput
:
initialLocation
?
String
(
initialLocation
.
latitude
)
:
""
,
lngInput
:
initialLocation
?
String
(
initialLocation
.
longitude
)
:
""
,
});
const
updatePosition
=
(
position
?:
LatLng
)
=>
{
setState
((
prev
)
=>
({
...
prev
,
position
,
latInput
:
position
?
String
(
position
.
lat
)
:
""
,
lngInput
:
position
?
String
(
position
.
lng
)
:
""
,
}));
};
const
handlePositionChange
=
(
position
:
LatLng
)
=>
{
if
(
!
locationInitialized
)
{
setLocationInitialized
(
true
);
}
updatePosition
(
position
);
};
const
handleLatChange
=
(
value
:
string
)
=>
{
setState
((
prev
)
=>
({
...
prev
,
latInput
:
value
}));
const
lat
=
parseFloat
(
value
);
if
(
!
isNaN
(
lat
)
&&
lat
>=
-
90
&&
lat
<=
90
&&
state
.
position
)
{
updatePosition
(
new
LatLng
(
lat
,
state
.
position
.
lng
));
}
};
const
handleLngChange
=
(
value
:
string
)
=>
{
setState
((
prev
)
=>
({
...
prev
,
lngInput
:
value
}));
const
lng
=
parseFloat
(
value
);
if
(
!
isNaN
(
lng
)
&&
lng
>=
-
180
&&
lng
<=
180
&&
state
.
position
)
{
updatePosition
(
new
LatLng
(
state
.
position
.
lat
,
lng
));
}
};
const
setPlaceholder
=
(
placeholder
:
string
)
=>
{
setState
((
prev
)
=>
({
...
prev
,
placeholder
}));
};
const
reset
=
()
=>
{
setState
({
placeholder
:
""
,
position
:
undefined
,
latInput
:
""
,
lngInput
:
""
,
});
setLocationInitialized
(
false
);
};
const
getLocation
=
():
Location
|
undefined
=>
{
if
(
!
state
.
position
||
!
state
.
placeholder
.
trim
())
{
return
undefined
;
}
return
Location
.
fromPartial
({
latitude
:
state
.
position
.
lat
,
longitude
:
state
.
position
.
lng
,
placeholder
:
state
.
placeholder
,
});
};
return
{
state
,
locationInitialized
,
handlePositionChange
,
handleLatChange
,
handleLngChange
,
setPlaceholder
,
reset
,
getLocation
,
};
};
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