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
be1b758d
Commit
be1b758d
authored
Dec 31, 2025
by
Johnny
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
refactor: simplify memo-metadata components
parent
d7284fe8
Changes
18
Hide whitespace changes
Inline
Side-by-side
Showing
18 changed files
with
506 additions
and
331 deletions
+506
-331
LeafletMap.tsx
web/src/components/LeafletMap.tsx
+2
-3
AttachmentItemCard.tsx
...c/components/MemoEditor/components/AttachmentItemCard.tsx
+116
-0
AttachmentListV2.tsx
...src/components/MemoEditor/components/AttachmentListV2.tsx
+81
-0
EditorMetadata.tsx
web/src/components/MemoEditor/components/EditorMetadata.tsx
+9
-14
LocationDisplayV2.tsx
...rc/components/MemoEditor/components/LocationDisplayV2.tsx
+48
-0
RelationItemCard.tsx
...src/components/MemoEditor/components/RelationItemCard.tsx
+64
-0
RelationListV2.tsx
web/src/components/MemoEditor/components/RelationListV2.tsx
+62
-0
index.ts
web/src/components/MemoEditor/components/index.ts
+5
-0
index.tsx
web/src/components/MemoEditor/index.tsx
+1
-1
MemoBody.tsx
web/src/components/MemoView/components/MemoBody.tsx
+3
-3
AttachmentCard.tsx
web/src/components/memo-metadata/AttachmentCard.tsx
+14
-78
AttachmentList.tsx
web/src/components/memo-metadata/AttachmentList.tsx
+6
-74
LocationDisplay.tsx
web/src/components/memo-metadata/LocationDisplay.tsx
+7
-27
RelationCard.tsx
web/src/components/memo-metadata/RelationCard.tsx
+3
-43
RelationList.tsx
web/src/components/memo-metadata/RelationList.tsx
+9
-61
SortableItem.tsx
web/src/components/memo-metadata/SortableItem.tsx
+0
-25
index.ts
web/src/components/memo-metadata/index.ts
+1
-2
format.ts
web/src/utils/format.ts
+75
-0
No files found.
web/src/components/LeafletMap.tsx
View file @
be1b758d
...
@@ -34,12 +34,11 @@ const LocationMarker = (props: MarkerProps) => {
...
@@ -34,12 +34,11 @@ const LocationMarker = (props: MarkerProps) => {
// Call the parent onChange function.
// Call the parent onChange function.
props
.
onChange
(
e
.
latlng
);
props
.
onChange
(
e
.
latlng
);
},
},
locationfound
()
{},
locationfound
()
{
},
});
});
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
!
initializedRef
.
current
)
{
if
(
!
initializedRef
.
current
)
{
map
.
attributionControl
.
setPrefix
(
""
);
map
.
locate
();
map
.
locate
();
initializedRef
.
current
=
true
;
initializedRef
.
current
=
true
;
}
}
...
@@ -247,7 +246,7 @@ const LeafletMap = (props: MapProps) => {
...
@@ -247,7 +246,7 @@ const LeafletMap = (props: MapProps) => {
isDark
?
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
:
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
isDark
?
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
:
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
}
}
/>
/>
<
LocationMarker
position=
{
position
}
readonly=
{
props
.
readonly
}
onChange=
{
props
.
onChange
?
props
.
onChange
:
()
=>
{}
}
/>
<
LocationMarker
position=
{
position
}
readonly=
{
props
.
readonly
}
onChange=
{
props
.
onChange
?
props
.
onChange
:
()
=>
{
}
}
/>
<
MapControls
position=
{
props
.
latlng
}
/>
<
MapControls
position=
{
props
.
latlng
}
/>
<
MapCleanup
/>
<
MapCleanup
/>
</
MapContainer
>
</
MapContainer
>
...
...
web/src/components/MemoEditor/components/AttachmentItemCard.tsx
0 → 100644
View file @
be1b758d
import
{
ChevronDownIcon
,
ChevronUpIcon
,
FileIcon
,
Loader2Icon
,
XIcon
}
from
"lucide-react"
;
import
type
{
FC
}
from
"react"
;
import
type
{
AttachmentItem
}
from
"@/components/memo-metadata/types"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
formatFileSize
,
getFileTypeLabel
}
from
"@/utils/format"
;
interface
AttachmentItemCardProps
{
item
:
AttachmentItem
;
onRemove
?:
()
=>
void
;
onMoveUp
?:
()
=>
void
;
onMoveDown
?:
()
=>
void
;
canMoveUp
?:
boolean
;
canMoveDown
?:
boolean
;
className
?:
string
;
}
const
AttachmentItemCard
:
FC
<
AttachmentItemCardProps
>
=
({
item
,
onRemove
,
onMoveUp
,
onMoveDown
,
canMoveUp
=
true
,
canMoveDown
=
true
,
className
,
})
=>
{
const
{
category
,
filename
,
thumbnailUrl
,
mimeType
,
size
,
isLocal
}
=
item
;
const
fileTypeLabel
=
getFileTypeLabel
(
mimeType
);
const
fileSizeLabel
=
size
?
formatFileSize
(
size
)
:
undefined
;
return
(
<
div
className=
{
cn
(
"relative flex items-center gap-1.5 px-1.5 py-1 rounded border border-transparent hover:border-border hover:bg-accent/20 transition-all"
,
className
,
)
}
>
<
div
className=
"flex-shrink-0 w-6 h-6 rounded overflow-hidden bg-muted/40 flex items-center justify-center"
>
{
category
===
"image"
&&
thumbnailUrl
?
(
<
img
src=
{
thumbnailUrl
}
alt=
""
className=
"w-full h-full object-cover"
/>
)
:
(
<
FileIcon
className=
"w-3.5 h-3.5 text-muted-foreground"
/>
)
}
</
div
>
<
div
className=
"flex-1 min-w-0 flex flex-col sm:flex-row sm:items-baseline gap-0.5 sm:gap-1.5"
>
<
span
className=
"text-xs font-medium truncate"
title=
{
filename
}
>
{
filename
}
</
span
>
<
div
className=
"flex items-center gap-1 text-[11px] text-muted-foreground shrink-0"
>
{
isLocal
&&
(
<>
<
Loader2Icon
className=
"w-2.5 h-2.5 animate-spin"
/>
<
span
className=
"text-muted-foreground/50"
>
•
</
span
>
</>
)
}
<
span
>
{
fileTypeLabel
}
</
span
>
{
fileSizeLabel
&&
(
<>
<
span
className=
"text-muted-foreground/50 hidden sm:inline"
>
•
</
span
>
<
span
className=
"hidden sm:inline"
>
{
fileSizeLabel
}
</
span
>
</>
)
}
</
div
>
</
div
>
<
div
className=
"flex-shrink-0 flex items-center gap-0.5"
>
{
onMoveUp
&&
(
<
button
type=
"button"
onClick=
{
onMoveUp
}
disabled=
{
!
canMoveUp
}
className=
{
cn
(
"p-0.5 rounded hover:bg-accent active:bg-accent transition-colors touch-manipulation"
,
!
canMoveUp
&&
"opacity-20 cursor-not-allowed hover:bg-transparent"
,
)
}
title=
"Move up"
aria
-
label=
"Move attachment up"
>
<
ChevronUpIcon
className=
"w-3 h-3 text-muted-foreground"
/>
</
button
>
)
}
{
onMoveDown
&&
(
<
button
type=
"button"
onClick=
{
onMoveDown
}
disabled=
{
!
canMoveDown
}
className=
{
cn
(
"p-0.5 rounded hover:bg-accent active:bg-accent transition-colors touch-manipulation"
,
!
canMoveDown
&&
"opacity-20 cursor-not-allowed hover:bg-transparent"
,
)
}
title=
"Move down"
aria
-
label=
"Move attachment down"
>
<
ChevronDownIcon
className=
"w-3 h-3 text-muted-foreground"
/>
</
button
>
)
}
{
onRemove
&&
(
<
button
type=
"button"
onClick=
{
onRemove
}
className=
"p-0.5 rounded hover:bg-destructive/10 active:bg-destructive/10 transition-colors ml-0.5 touch-manipulation"
title=
"Remove"
aria
-
label=
"Remove attachment"
>
<
XIcon
className=
"w-3 h-3 text-muted-foreground hover:text-destructive"
/>
</
button
>
)
}
</
div
>
</
div
>
);
};
export
default
AttachmentItemCard
;
web/src/components/MemoEditor/components/AttachmentListV2.tsx
0 → 100644
View file @
be1b758d
import
{
PaperclipIcon
}
from
"lucide-react"
;
import
type
{
FC
}
from
"react"
;
import
type
{
LocalFile
}
from
"@/components/memo-metadata/types"
;
import
{
toAttachmentItems
}
from
"@/components/memo-metadata/types"
;
import
type
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service_pb"
;
import
AttachmentItemCard
from
"./AttachmentItemCard"
;
interface
AttachmentListV2Props
{
attachments
:
Attachment
[];
localFiles
?:
LocalFile
[];
onAttachmentsChange
?:
(
attachments
:
Attachment
[])
=>
void
;
onRemoveLocalFile
?:
(
previewUrl
:
string
)
=>
void
;
}
const
AttachmentListV2
:
FC
<
AttachmentListV2Props
>
=
({
attachments
,
localFiles
=
[],
onAttachmentsChange
,
onRemoveLocalFile
})
=>
{
if
(
attachments
.
length
===
0
&&
localFiles
.
length
===
0
)
{
return
null
;
}
const
items
=
toAttachmentItems
(
attachments
,
localFiles
);
const
handleMoveUp
=
(
index
:
number
)
=>
{
if
(
index
===
0
||
!
onAttachmentsChange
)
return
;
const
newAttachments
=
[...
attachments
];
[
newAttachments
[
index
-
1
],
newAttachments
[
index
]]
=
[
newAttachments
[
index
],
newAttachments
[
index
-
1
]];
onAttachmentsChange
(
newAttachments
);
};
const
handleMoveDown
=
(
index
:
number
)
=>
{
if
(
index
===
attachments
.
length
-
1
||
!
onAttachmentsChange
)
return
;
const
newAttachments
=
[...
attachments
];
[
newAttachments
[
index
],
newAttachments
[
index
+
1
]]
=
[
newAttachments
[
index
+
1
],
newAttachments
[
index
]];
onAttachmentsChange
(
newAttachments
);
};
const
handleRemoveAttachment
=
(
name
:
string
)
=>
{
if
(
onAttachmentsChange
)
{
onAttachmentsChange
(
attachments
.
filter
((
attachment
)
=>
attachment
.
name
!==
name
));
}
};
const
handleRemoveItem
=
(
item
:
(
typeof
items
)[
0
])
=>
{
if
(
item
.
isLocal
)
{
onRemoveLocalFile
?.(
item
.
id
);
}
else
{
handleRemoveAttachment
(
item
.
id
);
}
};
return
(
<
div
className=
"w-full rounded-lg border border-border bg-muted/20 overflow-hidden"
>
<
div
className=
"flex items-center gap-1.5 px-2 py-1.5 border-b border-border bg-muted/30"
>
<
PaperclipIcon
className=
"w-3.5 h-3.5 text-muted-foreground"
/>
<
span
className=
"text-xs font-medium text-muted-foreground"
>
Attachments (
{
items
.
length
}
)
</
span
>
</
div
>
<
div
className=
"p-1 sm:p-1.5 flex flex-col gap-0.5"
>
{
items
.
map
((
item
)
=>
{
const
isLocalFile
=
item
.
isLocal
;
const
attachmentIndex
=
isLocalFile
?
-
1
:
attachments
.
findIndex
((
a
)
=>
a
.
name
===
item
.
id
);
return
(
<
AttachmentItemCard
key=
{
item
.
id
}
item=
{
item
}
onRemove=
{
()
=>
handleRemoveItem
(
item
)
}
onMoveUp=
{
!
isLocalFile
?
()
=>
handleMoveUp
(
attachmentIndex
)
:
undefined
}
onMoveDown=
{
!
isLocalFile
?
()
=>
handleMoveDown
(
attachmentIndex
)
:
undefined
}
canMoveUp=
{
!
isLocalFile
&&
attachmentIndex
>
0
}
canMoveDown=
{
!
isLocalFile
&&
attachmentIndex
<
attachments
.
length
-
1
}
/>
);
})
}
</
div
>
</
div
>
);
};
export
default
AttachmentListV2
;
web/src/components/MemoEditor/components/EditorMetadata.tsx
View file @
be1b758d
import
type
{
FC
}
from
"react"
;
import
type
{
FC
}
from
"react"
;
import
{
AttachmentList
,
LocationDisplay
,
RelationList
}
from
"@/components/memo-metadata"
;
import
{
useEditorContext
}
from
"../state"
;
import
{
useEditorContext
}
from
"../state"
;
import
type
{
EditorMetadataProps
}
from
"../types"
;
import
type
{
EditorMetadataProps
}
from
"../types"
;
import
AttachmentListV2
from
"./AttachmentListV2"
;
import
LocationDisplayV2
from
"./LocationDisplayV2"
;
import
RelationListV2
from
"./RelationListV2"
;
export
const
EditorMetadata
:
FC
<
EditorMetadataProps
>
=
()
=>
{
export
const
EditorMetadata
:
FC
<
EditorMetadataProps
>
=
()
=>
{
const
{
state
,
actions
,
dispatch
}
=
useEditorContext
();
const
{
state
,
actions
,
dispatch
}
=
useEditorContext
();
return
(
return
(
<
div
className=
"w-full flex flex-col gap-2"
>
<
div
className=
"w-full flex flex-col gap-2"
>
{
state
.
metadata
.
location
&&
(
<
AttachmentListV2
<
LocationDisplay
mode=
"edit"
location=
{
state
.
metadata
.
location
}
onRemove=
{
()
=>
dispatch
(
actions
.
setMetadata
({
location
:
undefined
}))
}
/>
)
}
<
AttachmentList
mode=
"edit"
attachments=
{
state
.
metadata
.
attachments
}
attachments=
{
state
.
metadata
.
attachments
}
localFiles=
{
state
.
localFiles
}
localFiles=
{
state
.
localFiles
}
onAttachmentsChange=
{
(
attachments
)
=>
dispatch
(
actions
.
setMetadata
({
attachments
}))
}
onAttachmentsChange=
{
(
attachments
)
=>
dispatch
(
actions
.
setMetadata
({
attachments
}))
}
onRemoveLocalFile=
{
(
previewUrl
)
=>
dispatch
(
actions
.
removeLocalFile
(
previewUrl
))
}
onRemoveLocalFile=
{
(
previewUrl
)
=>
dispatch
(
actions
.
removeLocalFile
(
previewUrl
))
}
/>
/>
<
RelationList
<
RelationListV2
mode=
"edit"
relations=
{
state
.
metadata
.
relations
}
relations=
{
state
.
metadata
.
relations
}
currentMemoName=
""
onRelationsChange=
{
(
relations
)
=>
dispatch
(
actions
.
setMetadata
({
relations
}))
}
onRelationsChange=
{
(
relations
)
=>
dispatch
(
actions
.
setMetadata
({
relations
}))
}
/>
/>
{
state
.
metadata
.
location
&&
(
<
LocationDisplayV2
location=
{
state
.
metadata
.
location
}
onRemove=
{
()
=>
dispatch
(
actions
.
setMetadata
({
location
:
undefined
}))
}
/>
)
}
</
div
>
</
div
>
);
);
};
};
web/src/components/MemoEditor/components/LocationDisplayV2.tsx
0 → 100644
View file @
be1b758d
import
{
MapPinIcon
,
XIcon
}
from
"lucide-react"
;
import
type
{
FC
}
from
"react"
;
import
{
cn
}
from
"@/lib/utils"
;
import
type
{
Location
}
from
"@/types/proto/api/v1/memo_service_pb"
;
interface
LocationDisplayV2Props
{
location
:
Location
;
onRemove
?:
()
=>
void
;
className
?:
string
;
}
const
LocationDisplayV2
:
FC
<
LocationDisplayV2Props
>
=
({
location
,
onRemove
,
className
})
=>
{
const
displayText
=
location
.
placeholder
||
`
${
location
.
latitude
.
toFixed
(
6
)}
,
${
location
.
longitude
.
toFixed
(
6
)}
`
;
return
(
<
div
className=
{
cn
(
"relative flex items-center gap-1.5 px-1.5 py-1 rounded border border-border bg-background hover:bg-accent/20 transition-all w-full"
,
className
,
)
}
>
<
MapPinIcon
className=
"w-3.5 h-3.5 shrink-0 text-muted-foreground"
/>
<
div
className=
"flex items-center gap-1.5 min-w-0 flex-1"
>
<
span
className=
"text-xs font-medium truncate"
title=
{
displayText
}
>
{
displayText
}
</
span
>
<
span
className=
"text-[11px] text-muted-foreground shrink-0 hidden sm:inline"
>
{
location
.
latitude
.
toFixed
(
4
)
}
°,
{
location
.
longitude
.
toFixed
(
4
)
}
°
</
span
>
</
div
>
{
onRemove
&&
(
<
button
type=
"button"
onClick=
{
onRemove
}
className=
"p-0.5 rounded hover:bg-destructive/10 active:bg-destructive/10 transition-colors touch-manipulation shrink-0 ml-auto"
title=
"Remove"
aria
-
label=
"Remove location"
>
<
XIcon
className=
"w-3 h-3 text-muted-foreground hover:text-destructive"
/>
</
button
>
)
}
</
div
>
);
};
export
default
LocationDisplayV2
;
web/src/components/MemoEditor/components/RelationItemCard.tsx
0 → 100644
View file @
be1b758d
import
{
LinkIcon
,
XIcon
}
from
"lucide-react"
;
import
type
{
FC
}
from
"react"
;
import
{
Link
}
from
"react-router-dom"
;
import
{
extractMemoIdFromName
}
from
"@/helpers/resource-names"
;
import
{
cn
}
from
"@/lib/utils"
;
import
type
{
MemoRelation_Memo
}
from
"@/types/proto/api/v1/memo_service_pb"
;
interface
RelationItemCardProps
{
memo
:
MemoRelation_Memo
;
onRemove
?:
()
=>
void
;
parentPage
?:
string
;
className
?:
string
;
}
const
RelationItemCard
:
FC
<
RelationItemCardProps
>
=
({
memo
,
onRemove
,
parentPage
,
className
})
=>
{
const
memoId
=
extractMemoIdFromName
(
memo
.
name
);
if
(
onRemove
)
{
return
(
<
div
className=
{
cn
(
"relative flex items-center gap-1.5 px-1.5 py-1 rounded border border-transparent hover:border-border hover:bg-accent/20 transition-all"
,
className
,
)
}
>
<
LinkIcon
className=
"w-3.5 h-3.5 shrink-0 text-muted-foreground"
/>
<
span
className=
"text-xs font-medium truncate flex-1"
title=
{
memo
.
snippet
}
>
{
memo
.
snippet
}
</
span
>
<
div
className=
"flex-shrink-0 flex items-center gap-0.5"
>
<
button
type=
"button"
onClick=
{
onRemove
}
className=
"p-0.5 rounded hover:bg-destructive/10 active:bg-destructive/10 transition-colors touch-manipulation"
title=
"Remove"
aria
-
label=
"Remove relation"
>
<
XIcon
className=
"w-3 h-3 text-muted-foreground hover:text-destructive"
/>
</
button
>
</
div
>
</
div
>
);
}
return
(
<
Link
className=
{
cn
(
"relative flex items-center gap-1.5 px-1.5 py-1 rounded border border-transparent hover:border-border hover:bg-accent/20 transition-all"
,
className
,
)
}
to=
{
`/${memo.name}`
}
viewTransition
state=
{
{
from
:
parentPage
}
}
>
<
span
className=
"text-[10px] font-mono px-1 py-0.5 rounded bg-muted/50 text-muted-foreground shrink-0"
>
{
memoId
.
slice
(
0
,
6
)
}
</
span
>
<
span
className=
"text-xs truncate flex-1"
title=
{
memo
.
snippet
}
>
{
memo
.
snippet
}
</
span
>
</
Link
>
);
};
export
default
RelationItemCard
;
web/src/components/MemoEditor/components/RelationListV2.tsx
0 → 100644
View file @
be1b758d
import
{
create
}
from
"@bufbuild/protobuf"
;
import
{
LinkIcon
}
from
"lucide-react"
;
import
type
{
FC
}
from
"react"
;
import
{
useEffect
,
useState
}
from
"react"
;
import
{
memoServiceClient
}
from
"@/connect"
;
import
type
{
Memo
,
MemoRelation
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
{
MemoRelation_MemoSchema
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
RelationItemCard
from
"./RelationItemCard"
;
interface
RelationListV2Props
{
relations
:
MemoRelation
[];
onRelationsChange
?:
(
relations
:
MemoRelation
[])
=>
void
;
}
const
RelationListV2
:
FC
<
RelationListV2Props
>
=
({
relations
,
onRelationsChange
})
=>
{
const
[
referencingMemos
,
setReferencingMemos
]
=
useState
<
Memo
[]
>
([]);
useEffect
(()
=>
{
(
async
()
=>
{
if
(
relations
.
length
>
0
)
{
const
requests
=
relations
.
map
(
async
(
relation
)
=>
{
return
await
memoServiceClient
.
getMemo
({
name
:
relation
.
relatedMemo
!
.
name
});
});
const
list
=
await
Promise
.
all
(
requests
);
setReferencingMemos
(
list
);
}
else
{
setReferencingMemos
([]);
}
})();
},
[
relations
]);
const
handleDeleteRelation
=
(
memoName
:
string
)
=>
{
if
(
onRelationsChange
)
{
onRelationsChange
(
relations
.
filter
((
relation
)
=>
relation
.
relatedMemo
?.
name
!==
memoName
));
}
};
if
(
referencingMemos
.
length
===
0
)
{
return
null
;
}
return
(
<
div
className=
"w-full rounded-lg border border-border bg-muted/20 overflow-hidden"
>
<
div
className=
"flex items-center gap-1.5 px-2 py-1.5 border-b border-border bg-muted/30"
>
<
LinkIcon
className=
"w-3.5 h-3.5 text-muted-foreground"
/>
<
span
className=
"text-xs font-medium text-muted-foreground"
>
Relations (
{
referencingMemos
.
length
}
)
</
span
>
</
div
>
<
div
className=
"p-1 sm:p-1.5 flex flex-col gap-0.5"
>
{
referencingMemos
.
map
((
memo
)
=>
(
<
RelationItemCard
key=
{
memo
.
name
}
memo=
{
create
(
MemoRelation_MemoSchema
,
{
name
:
memo
.
name
,
snippet
:
memo
.
snippet
})
}
onRemove=
{
()
=>
handleDeleteRelation
(
memo
.
name
)
}
/>
))
}
</
div
>
</
div
>
);
};
export
default
RelationListV2
;
web/src/components/MemoEditor/components/index.ts
View file @
be1b758d
// UI components for MemoEditor
// UI components for MemoEditor
export
{
default
as
AttachmentItemCard
}
from
"./AttachmentItemCard"
;
export
{
default
as
AttachmentListV2
}
from
"./AttachmentListV2"
;
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
{
LinkMemoDialog
}
from
"./LinkMemoDialog"
;
export
{
LinkMemoDialog
}
from
"./LinkMemoDialog"
;
export
{
LocationDialog
}
from
"./LocationDialog"
;
export
{
LocationDialog
}
from
"./LocationDialog"
;
export
{
default
as
LocationDisplayV2
}
from
"./LocationDisplayV2"
;
export
{
default
as
RelationItemCard
}
from
"./RelationItemCard"
;
export
{
default
as
RelationListV2
}
from
"./RelationListV2"
;
web/src/components/MemoEditor/index.tsx
View file @
be1b758d
...
@@ -127,7 +127,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
...
@@ -127,7 +127,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
*/
}
*/
}
<
div
<
div
className=
{
cn
(
className=
{
cn
(
"group relative w-full flex flex-col justify-between items-start bg-card px-4 pt-3 pb-1 rounded-lg border border-border"
,
"group relative w-full flex flex-col justify-between items-start bg-card px-4 pt-3 pb-1 rounded-lg border border-border
gap-2
"
,
FOCUS_MODE_STYLES
.
transition
,
FOCUS_MODE_STYLES
.
transition
,
state
.
ui
.
isFocusMode
&&
cn
(
FOCUS_MODE_STYLES
.
container
.
base
,
FOCUS_MODE_STYLES
.
container
.
spacing
),
state
.
ui
.
isFocusMode
&&
cn
(
FOCUS_MODE_STYLES
.
container
.
base
,
FOCUS_MODE_STYLES
.
container
.
spacing
),
className
,
className
,
...
...
web/src/components/MemoView/components/MemoBody.tsx
View file @
be1b758d
...
@@ -29,9 +29,9 @@ const MemoBody: React.FC<MemoBodyProps> = ({ compact, onContentClick, onContentD
...
@@ -29,9 +29,9 @@ const MemoBody: React.FC<MemoBodyProps> = ({ compact, onContentClick, onContentD
onDoubleClick=
{
onContentDoubleClick
}
onDoubleClick=
{
onContentDoubleClick
}
compact=
{
memo
.
pinned
?
false
:
compact
}
// Always show full content when pinned
compact=
{
memo
.
pinned
?
false
:
compact
}
// Always show full content when pinned
/>
/>
{
memo
.
location
&&
<
LocationDisplay
mode=
"view"
location=
{
memo
.
location
}
/>
}
<
AttachmentList
attachments=
{
memo
.
attachments
}
/>
<
AttachmentList
mode=
"view"
attachments=
{
memo
.
attachments
}
/>
<
RelationList
relations=
{
referencedMemos
}
currentMemoName=
{
memo
.
name
}
parentPage=
{
parentPage
}
/>
<
RelationList
mode=
"view"
relations=
{
referencedMemos
}
currentMemoName=
{
memo
.
name
}
parentPage=
{
parentPage
}
/>
{
memo
.
location
&&
<
LocationDisplay
location=
{
memo
.
location
}
/>
}
<
MemoReactionListView
memo=
{
memo
}
reactions=
{
memo
.
reactions
}
/>
<
MemoReactionListView
memo=
{
memo
}
reactions=
{
memo
.
reactions
}
/>
</
div
>
</
div
>
...
...
web/src/components/memo-metadata/AttachmentCard.tsx
View file @
be1b758d
import
{
FileIcon
,
XIcon
}
from
"lucide-react"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
cn
}
from
"@/lib/utils"
;
import
type
{
AttachmentItem
,
DisplayMode
}
from
"./types"
;
import
type
{
AttachmentItem
}
from
"./types"
;
interface
AttachmentCardProps
{
interface
AttachmentCardProps
{
item
:
AttachmentItem
;
item
:
AttachmentItem
;
mode
:
DisplayMode
;
mode
:
"view"
;
onRemove
?:
()
=>
void
;
onClick
?:
()
=>
void
;
onClick
?:
()
=>
void
;
className
?:
string
;
className
?:
string
;
showThumbnail
?:
boolean
;
}
}
const
AttachmentCard
=
({
item
,
mode
,
onRemove
,
onClick
,
className
,
showThumbnail
=
true
}:
AttachmentCardProps
)
=>
{
const
AttachmentCard
=
({
item
,
onClick
,
className
}:
AttachmentCardProps
)
=>
{
const
{
category
,
filename
,
thumbnailUrl
,
sourceUrl
}
=
item
;
const
{
category
,
filename
,
sourceUrl
}
=
item
;
const
isMedia
=
category
===
"image"
||
category
===
"video"
;
// Editor mode - compact badge style with optional thumbnail
if
(
category
===
"image"
)
{
if
(
mode
===
"edit"
)
{
return
(
return
(
<
div
<
img
className=
{
cn
(
src=
{
sourceUrl
}
"relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border text-secondary-foreground text-xs transition-colors"
,
alt=
{
filename
}
"border-border bg-background hover:bg-accent"
,
className=
{
cn
(
"w-full h-full object-cover rounded-lg cursor-pointer"
,
className
)
}
className
,
onClick=
{
onClick
}
)
}
loading=
"lazy"
>
/>
{
showThumbnail
&&
category
===
"image"
&&
thumbnailUrl
?
(
<
img
src=
{
thumbnailUrl
}
alt=
{
filename
}
className=
"w-5 h-5 shrink-0 object-cover rounded"
/>
)
:
(
<
FileIcon
className=
"w-3.5 h-3.5 shrink-0 text-muted-foreground"
/>
)
}
<
span
className=
"truncate max-w-40"
>
{
filename
}
</
span
>
{
onRemove
&&
(
<
button
type=
"button"
className=
"shrink-0 rounded hover:bg-accent transition-colors p-0.5"
onPointerDown=
{
(
e
)
=>
{
e
.
stopPropagation
();
}
}
onMouseDown=
{
(
e
)
=>
{
e
.
stopPropagation
();
}
}
onClick=
{
(
e
)
=>
{
e
.
preventDefault
();
e
.
stopPropagation
();
onRemove
();
}
}
>
<
XIcon
className=
"w-3 h-3 text-muted-foreground hover:text-foreground"
/>
</
button
>
)
}
</
div
>
);
);
}
}
// View mode - specialized rendering for media
if
(
category
===
"video"
)
{
if
(
isMedia
)
{
return
<
video
src=
{
sourceUrl
}
className=
{
cn
(
"w-full h-full object-cover rounded-lg"
,
className
)
}
controls
preload=
"metadata"
/>;
if
(
category
===
"image"
)
{
return
(
<
img
className=
{
cn
(
"cursor-pointer h-full w-auto rounded-lg border border-border/60 object-contain transition-colors"
,
className
)
}
src=
{
thumbnailUrl
}
onError=
{
(
e
)
=>
{
const
target
=
e
.
target
as
HTMLImageElement
;
// Fallback to source URL if thumbnail fails
if
(
target
.
src
.
includes
(
"?thumbnail=true"
))
{
target
.
src
=
sourceUrl
;
}
}
}
onClick=
{
onClick
}
decoding=
"async"
loading=
"lazy"
alt=
{
filename
}
/>
);
}
else
if
(
category
===
"video"
)
{
return
(
<
video
className=
{
cn
(
"cursor-pointer h-full w-auto rounded-lg border border-border/60 object-contain bg-muted transition-colors"
,
className
,
)
}
preload=
"metadata"
crossOrigin=
"anonymous"
src=
{
sourceUrl
}
controls
/>
);
}
}
}
// View mode - non-media files (will be handled by parent component for proper file card display)
return
null
;
return
null
;
};
};
...
...
web/src/components/memo-metadata/AttachmentList.tsx
View file @
be1b758d
import
{
closestCenter
,
DndContext
,
type
DragEndEvent
,
MouseSensor
,
TouchSensor
,
useSensor
,
useSensors
}
from
"@dnd-kit/core"
;
import
{
arrayMove
,
SortableContext
,
verticalListSortingStrategy
}
from
"@dnd-kit/sortable"
;
import
{
useState
}
from
"react"
;
import
{
useState
}
from
"react"
;
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
{
getAttachmentType
,
getAttachmentUrl
}
from
"@/utils/attachment"
;
import
MemoAttachment
from
"../MemoAttachment"
;
import
MemoAttachment
from
"../MemoAttachment"
;
import
PreviewImageDialog
from
"../PreviewImageDialog"
;
import
PreviewImageDialog
from
"../PreviewImageDialog"
;
import
AttachmentCard
from
"./AttachmentCard"
;
import
AttachmentCard
from
"./AttachmentCard"
;
import
SortableItem
from
"./SortableItem"
;
import
type
{
AttachmentItem
,
BaseMetadataProps
,
LocalFile
}
from
"./types"
;
import
{
separateMediaAndDocs
,
toAttachmentItems
}
from
"./types"
;
import
{
separateMediaAndDocs
,
toAttachmentItems
}
from
"./types"
;
interface
AttachmentListProps
extends
BaseMetadataProps
{
interface
AttachmentListProps
{
attachments
:
Attachment
[];
attachments
:
Attachment
[];
onAttachmentsChange
?:
(
attachments
:
Attachment
[])
=>
void
;
localFiles
?:
LocalFile
[];
onRemoveLocalFile
?:
(
previewUrl
:
string
)
=>
void
;
}
}
const
AttachmentList
=
({
attachments
,
mode
,
onAttachmentsChange
,
localFiles
=
[],
onRemoveLocalFile
}:
AttachmentListProps
)
=>
{
const
AttachmentList
=
({
attachments
}:
AttachmentListProps
)
=>
{
const
sensors
=
useSensors
(
useSensor
(
MouseSensor
),
useSensor
(
TouchSensor
));
const
[
previewImage
,
setPreviewImage
]
=
useState
<
{
open
:
boolean
;
urls
:
string
[];
index
:
number
}
>
({
const
[
previewImage
,
setPreviewImage
]
=
useState
<
{
open
:
boolean
;
urls
:
string
[];
index
:
number
}
>
({
open
:
false
,
open
:
false
,
urls
:
[],
urls
:
[],
index
:
0
,
index
:
0
,
});
});
const
handleDeleteAttachment
=
(
name
:
string
)
=>
{
if
(
onAttachmentsChange
)
{
onAttachmentsChange
(
attachments
.
filter
((
attachment
)
=>
attachment
.
name
!==
name
));
}
};
const
handleDragEnd
=
(
event
:
DragEndEvent
)
=>
{
const
{
active
,
over
}
=
event
;
if
(
over
&&
active
.
id
!==
over
.
id
&&
onAttachmentsChange
)
{
const
oldIndex
=
attachments
.
findIndex
((
attachment
)
=>
attachment
.
name
===
active
.
id
);
const
newIndex
=
attachments
.
findIndex
((
attachment
)
=>
attachment
.
name
===
over
.
id
);
onAttachmentsChange
(
arrayMove
(
attachments
,
oldIndex
,
newIndex
));
}
};
const
handleImageClick
=
(
imgUrl
:
string
,
mediaAttachments
:
Attachment
[])
=>
{
const
handleImageClick
=
(
imgUrl
:
string
,
mediaAttachments
:
Attachment
[])
=>
{
const
imgUrls
=
mediaAttachments
const
imgUrls
=
mediaAttachments
.
filter
((
attachment
)
=>
getAttachmentType
(
attachment
)
===
"image/*"
)
.
filter
((
attachment
)
=>
getAttachmentType
(
attachment
)
===
"image/*"
)
...
@@ -49,56 +25,15 @@ const AttachmentList = ({ attachments, mode, onAttachmentsChange, localFiles = [
...
@@ -49,56 +25,15 @@ const AttachmentList = ({ attachments, mode, onAttachmentsChange, localFiles = [
setPreviewImage
({
open
:
true
,
urls
:
imgUrls
,
index
});
setPreviewImage
({
open
:
true
,
urls
:
imgUrls
,
index
});
};
};
// Editor mode: Display all items as compact badges with drag-and-drop
if
(
mode
===
"edit"
)
{
if
(
attachments
.
length
===
0
&&
localFiles
.
length
===
0
)
{
return
null
;
}
const
items
=
toAttachmentItems
(
attachments
,
localFiles
);
// Only uploaded attachments support reordering (stable server IDs)
const
sortableIds
=
attachments
.
map
((
a
)
=>
a
.
name
);
const
handleRemoveItem
=
(
item
:
AttachmentItem
)
=>
{
if
(
item
.
isLocal
)
{
onRemoveLocalFile
?.(
item
.
id
);
}
else
{
handleDeleteAttachment
(
item
.
id
);
}
};
return
(
<
DndContext
sensors=
{
sensors
}
collisionDetection=
{
closestCenter
}
onDragEnd=
{
handleDragEnd
}
>
<
SortableContext
items=
{
sortableIds
}
strategy=
{
verticalListSortingStrategy
}
>
<
div
className=
"w-full flex flex-row justify-start flex-wrap gap-2 max-h-[50vh] overflow-y-auto"
>
{
items
.
map
((
item
)
=>
(
<
div
key=
{
item
.
id
}
>
{
/* Uploaded items are wrapped in SortableItem for drag-and-drop */
}
{
!
item
.
isLocal
?
(
<
SortableItem
id=
{
item
.
id
}
className=
"flex items-center gap-1.5 min-w-0"
>
<
AttachmentCard
item=
{
item
}
mode=
"edit"
onRemove=
{
()
=>
handleRemoveItem
(
item
)
}
showThumbnail
/>
</
SortableItem
>
)
:
(
/* Local items render directly without sorting capability */
<
div
className=
"flex items-center gap-1.5 min-w-0"
>
<
AttachmentCard
item=
{
item
}
mode=
"edit"
onRemove=
{
()
=>
handleRemoveItem
(
item
)
}
showThumbnail
/>
</
div
>
)
}
</
div
>
))
}
</
div
>
</
SortableContext
>
</
DndContext
>
);
}
// View mode: Split items into media gallery and document list
const
items
=
toAttachmentItems
(
attachments
,
[]);
const
items
=
toAttachmentItems
(
attachments
,
[]);
const
{
media
:
mediaItems
,
docs
:
docItems
}
=
separateMediaAndDocs
(
items
);
const
{
media
:
mediaItems
,
docs
:
docItems
}
=
separateMediaAndDocs
(
items
);
if
(
attachments
.
length
===
0
)
{
return
null
;
}
return
(
return
(
<>
<>
{
/* Media Gallery */
}
{
mediaItems
.
length
>
0
&&
(
{
mediaItems
.
length
>
0
&&
(
<
div
className=
"w-full flex flex-row justify-start overflow-auto gap-2"
>
<
div
className=
"w-full flex flex-row justify-start overflow-auto gap-2"
>
{
mediaItems
.
map
((
item
)
=>
(
{
mediaItems
.
map
((
item
)
=>
(
...
@@ -116,18 +51,15 @@ const AttachmentList = ({ attachments, mode, onAttachmentsChange, localFiles = [
...
@@ -116,18 +51,15 @@ const AttachmentList = ({ attachments, mode, onAttachmentsChange, localFiles = [
</
div
>
</
div
>
)
}
)
}
{
/* Document Files */
}
{
docItems
.
length
>
0
&&
(
{
docItems
.
length
>
0
&&
(
<
div
className=
"w-full flex flex-row justify-start overflow-auto gap-2"
>
<
div
className=
"w-full flex flex-row justify-start overflow-auto gap-2"
>
{
docItems
.
map
((
item
)
=>
{
{
docItems
.
map
((
item
)
=>
{
// Find original attachment for MemoAttachment component
const
attachment
=
attachments
.
find
((
a
)
=>
a
.
name
===
item
.
id
);
const
attachment
=
attachments
.
find
((
a
)
=>
a
.
name
===
item
.
id
);
return
attachment
?
<
MemoAttachment
key=
{
item
.
id
}
attachment=
{
attachment
}
/>
:
null
;
return
attachment
?
<
MemoAttachment
key=
{
item
.
id
}
attachment=
{
attachment
}
/>
:
null
;
})
}
})
}
</
div
>
</
div
>
)
}
)
}
{
/* Image Preview Dialog */
}
<
PreviewImageDialog
<
PreviewImageDialog
open=
{
previewImage
.
open
}
open=
{
previewImage
.
open
}
onOpenChange=
{
(
open
)
=>
setPreviewImage
((
prev
)
=>
({
...
prev
,
open
}))
}
onOpenChange=
{
(
open
)
=>
setPreviewImage
((
prev
)
=>
({
...
prev
,
open
}))
}
...
...
web/src/components/memo-metadata/LocationDisplay.tsx
View file @
be1b758d
import
{
LatLng
}
from
"leaflet"
;
import
{
LatLng
}
from
"leaflet"
;
import
{
MapPinIcon
,
XIcon
}
from
"lucide-react"
;
import
{
MapPinIcon
}
from
"lucide-react"
;
import
{
useState
}
from
"react"
;
import
{
useState
}
from
"react"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
Location
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
type
{
Location
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
LeafletMap
from
"../LeafletMap"
;
import
LeafletMap
from
"../LeafletMap"
;
import
{
Popover
,
PopoverContent
,
PopoverTrigger
}
from
"../ui/popover"
;
import
{
Popover
,
PopoverContent
,
PopoverTrigger
}
from
"../ui/popover"
;
import
{
BaseMetadataProps
}
from
"./types"
;
interface
LocationDisplayProps
extends
BaseMetadataProps
{
interface
LocationDisplayProps
{
location
?:
Location
;
location
?:
Location
;
onRemove
?:
()
=>
void
;
className
?:
string
;
}
}
const
LocationDisplay
=
({
location
,
mode
,
onRemove
,
className
}:
LocationDisplayProps
)
=>
{
const
LocationDisplay
=
({
location
,
className
}:
LocationDisplayProps
)
=>
{
const
[
popoverOpen
,
setPopoverOpen
]
=
useState
<
boolean
>
(
false
);
const
[
popoverOpen
,
setPopoverOpen
]
=
useState
<
boolean
>
(
false
);
if
(
!
location
)
{
if
(
!
location
)
{
...
@@ -26,12 +25,11 @@ const LocationDisplay = ({ location, mode, onRemove, className }: LocationDispla
...
@@ -26,12 +25,11 @@ const LocationDisplay = ({ location, mode, onRemove, className }: LocationDispla
<
PopoverTrigger
asChild
>
<
PopoverTrigger
asChild
>
<
div
<
div
className=
{
cn
(
className=
{
cn
(
"w-auto max-w-full flex flex-row gap-2"
,
"w-auto max-w-full flex flex-row gap-2
cursor-pointer
"
,
"relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border border-border bg-background hover:bg-accent text-secondary-foreground text-xs transition-colors"
,
"relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border border-border bg-background hover:bg-accent text-secondary-foreground text-xs transition-colors"
,
mode
===
"view"
&&
"cursor-pointer"
,
className
,
className
,
)
}
)
}
onClick=
{
mode
===
"view"
?
()
=>
setPopoverOpen
(
true
)
:
undefined
}
onClick=
{
()
=>
setPopoverOpen
(
true
)
}
>
>
<
span
className=
"shrink-0 text-muted-foreground"
>
<
span
className=
"shrink-0 text-muted-foreground"
>
<
MapPinIcon
className=
"w-3.5 h-3.5"
/>
<
MapPinIcon
className=
"w-3.5 h-3.5"
/>
...
@@ -40,24 +38,6 @@ const LocationDisplay = ({ location, mode, onRemove, className }: LocationDispla
...
@@ -40,24 +38,6 @@ const LocationDisplay = ({ location, mode, onRemove, className }: LocationDispla
[
{
location
.
latitude
.
toFixed
(
2
)
}
°,
{
location
.
longitude
.
toFixed
(
2
)
}
°]
[
{
location
.
latitude
.
toFixed
(
2
)
}
°,
{
location
.
longitude
.
toFixed
(
2
)
}
°]
</
span
>
</
span
>
<
span
className=
"text-nowrap truncate"
>
{
displayText
}
</
span
>
<
span
className=
"text-nowrap truncate"
>
{
displayText
}
</
span
>
{
onRemove
&&
(
<
button
className=
"shrink-0 rounded hover:bg-accent transition-colors p-0.5"
onPointerDown=
{
(
e
)
=>
{
e
.
stopPropagation
();
}
}
onMouseDown=
{
(
e
)
=>
{
e
.
stopPropagation
();
}
}
onClick=
{
(
e
)
=>
{
e
.
preventDefault
();
e
.
stopPropagation
();
onRemove
();
}
}
>
<
XIcon
className=
"w-3 h-3 text-muted-foreground hover:text-foreground"
/>
</
button
>
)
}
</
div
>
</
div
>
</
PopoverTrigger
>
</
PopoverTrigger
>
<
PopoverContent
align=
"start"
>
<
PopoverContent
align=
"start"
>
...
...
web/src/components/memo-metadata/RelationCard.tsx
View file @
be1b758d
import
{
LinkIcon
,
XIcon
}
from
"lucide-react"
;
import
{
Link
}
from
"react-router-dom"
;
import
{
Link
}
from
"react-router-dom"
;
import
{
extractMemoIdFromName
}
from
"@/helpers/resource-names"
;
import
{
extractMemoIdFromName
}
from
"@/helpers/resource-names"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
MemoRelation_Memo
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
type
{
MemoRelation_Memo
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
{
DisplayMode
}
from
"./types"
;
interface
RelationCardProps
{
interface
RelationCardProps
{
memo
:
MemoRelation_Memo
;
memo
:
MemoRelation_Memo
;
mode
:
DisplayMode
;
onRemove
?:
()
=>
void
;
parentPage
?:
string
;
parentPage
?:
string
;
className
?:
string
;
className
?:
string
;
}
}
const
RelationCard
=
({
memo
,
mode
,
onRemove
,
parentPage
,
className
}:
RelationCardProps
)
=>
{
const
RelationCard
=
({
memo
,
parentPage
,
className
}:
RelationCardProps
)
=>
{
const
memoId
=
extractMemoIdFromName
(
memo
.
name
);
const
memoId
=
extractMemoIdFromName
(
memo
.
name
);
// Editor mode: Badge with remove
if
(
mode
===
"edit"
)
{
return
(
<
div
className=
{
cn
(
"relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border border-border bg-background text-secondary-foreground text-xs transition-colors hover:bg-accent"
,
className
,
)
}
>
<
LinkIcon
className=
"w-3.5 h-3.5 shrink-0 text-muted-foreground"
/>
<
span
className=
"truncate max-w-[160px]"
>
{
memo
.
snippet
}
</
span
>
{
onRemove
&&
(
<
button
className=
"shrink-0 rounded hover:bg-accent transition-colors p-0.5"
onPointerDown=
{
(
e
)
=>
{
e
.
stopPropagation
();
}
}
onMouseDown=
{
(
e
)
=>
{
e
.
stopPropagation
();
}
}
onClick=
{
(
e
)
=>
{
e
.
preventDefault
();
e
.
stopPropagation
();
onRemove
();
}
}
>
<
XIcon
className=
"w-3 h-3 text-muted-foreground hover:text-foreground"
/>
</
button
>
)
}
</
div
>
);
}
// View mode: Navigable link with ID and snippet
return
(
return
(
<
Link
<
Link
className=
{
cn
(
className=
{
cn
(
...
@@ -58,9 +20,7 @@ const RelationCard = ({ memo, mode, onRemove, parentPage, className }: RelationC
...
@@ -58,9 +20,7 @@ const RelationCard = ({ memo, mode, onRemove, parentPage, className }: RelationC
)
}
)
}
to=
{
`/${memo.name}`
}
to=
{
`/${memo.name}`
}
viewTransition
viewTransition
state=
{
{
state=
{
{
from
:
parentPage
}
}
from
:
parentPage
,
}
}
>
>
<
span
className=
"text-[10px] opacity-60 leading-4 border border-border font-mono px-1 rounded-full mr-1"
>
{
memoId
.
slice
(
0
,
6
)
}
</
span
>
<
span
className=
"text-[10px] opacity-60 leading-4 border border-border font-mono px-1 rounded-full mr-1"
>
{
memoId
.
slice
(
0
,
6
)
}
</
span
>
<
span
className=
"truncate"
>
{
memo
.
snippet
}
</
span
>
<
span
className=
"truncate"
>
{
memo
.
snippet
}
</
span
>
...
...
web/src/components/memo-metadata/RelationList.tsx
View file @
be1b758d
import
{
create
}
from
"@bufbuild/protobuf"
;
import
{
LinkIcon
,
MilestoneIcon
}
from
"lucide-react"
;
import
{
LinkIcon
,
MilestoneIcon
}
from
"lucide-react"
;
import
{
useEffect
,
useState
}
from
"react"
;
import
{
useState
}
from
"react"
;
import
{
memoServiceClient
}
from
"@/connect"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
Memo
,
MemoRelation
,
MemoRelation_MemoSchema
,
MemoRelation_Type
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
type
{
MemoRelation
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
{
MemoRelation_Type
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
MetadataCard
from
"./MetadataCard"
;
import
MetadataCard
from
"./MetadataCard"
;
import
RelationCard
from
"./RelationCard"
;
import
RelationCard
from
"./RelationCard"
;
import
{
BaseMetadataProps
}
from
"./types"
;
interface
RelationListProps
extends
BaseMetadataProps
{
interface
RelationListProps
{
relations
:
MemoRelation
[];
relations
:
MemoRelation
[];
currentMemoName
?:
string
;
currentMemoName
?:
string
;
onRelationsChange
?:
(
relations
:
MemoRelation
[])
=>
void
;
parentPage
?:
string
;
parentPage
?:
string
;
className
?:
string
;
}
}
function
RelationList
({
relations
,
currentMemoName
,
mode
,
onRelationsChange
,
parentPage
,
className
}:
RelationListProps
)
{
function
RelationList
({
relations
,
currentMemoName
,
parentPage
,
className
}:
RelationListProps
)
{
const
t
=
useTranslate
();
const
t
=
useTranslate
();
const
[
referencingMemos
,
setReferencingMemos
]
=
useState
<
Memo
[]
>
([]);
const
[
selectedTab
,
setSelectedTab
]
=
useState
<
"referencing"
|
"referenced"
>
(
"referencing"
);
const
[
selectedTab
,
setSelectedTab
]
=
useState
<
"referencing"
|
"referenced"
>
(
"referencing"
);
// Get referencing and referenced relations
const
referencingRelations
=
relations
.
filter
(
const
referencingRelations
=
relations
.
filter
(
(
relation
)
=>
(
relation
)
=>
relation
.
type
===
MemoRelation_Type
.
REFERENCE
&&
relation
.
type
===
MemoRelation_Type
.
REFERENCE
&&
(
mode
===
"edit"
||
relation
.
memo
?.
name
===
currentMemoName
)
&&
relation
.
memo
?.
name
===
currentMemoName
&&
relation
.
relatedMemo
?.
name
!==
currentMemoName
,
relation
.
relatedMemo
?.
name
!==
currentMemoName
,
);
);
...
@@ -36,60 +32,14 @@ function RelationList({ relations, currentMemoName, mode, onRelationsChange, par
...
@@ -36,60 +32,14 @@ function RelationList({ relations, currentMemoName, mode, onRelationsChange, par
relation
.
relatedMemo
?.
name
===
currentMemoName
,
relation
.
relatedMemo
?.
name
===
currentMemoName
,
);
);
// Fetch full memo details for editor mode
useEffect
(()
=>
{
if
(
mode
===
"edit"
)
{
(
async
()
=>
{
if
(
referencingRelations
.
length
>
0
)
{
const
requests
=
referencingRelations
.
map
(
async
(
relation
)
=>
{
return
await
memoServiceClient
.
getMemo
({
name
:
relation
.
relatedMemo
!
.
name
});
});
const
list
=
await
Promise
.
all
(
requests
);
setReferencingMemos
(
list
);
}
else
{
setReferencingMemos
([]);
}
})();
}
},
[
mode
,
relations
]);
const
handleDeleteRelation
=
(
memoName
:
string
)
=>
{
if
(
onRelationsChange
)
{
onRelationsChange
(
relations
.
filter
((
relation
)
=>
relation
.
relatedMemo
?.
name
!==
memoName
));
}
};
// Editor mode: Simple badge list
if
(
mode
===
"edit"
)
{
if
(
referencingMemos
.
length
===
0
)
{
return
null
;
}
return
(
<
div
className=
"w-full flex flex-row gap-2 flex-wrap"
>
{
referencingMemos
.
map
((
memo
)
=>
(
<
RelationCard
key=
{
memo
.
name
}
memo=
{
create
(
MemoRelation_MemoSchema
,
{
name
:
memo
.
name
,
snippet
:
memo
.
snippet
})
}
mode=
"edit"
onRemove=
{
()
=>
handleDeleteRelation
(
memo
.
name
)
}
/>
))
}
</
div
>
);
}
// View mode: Tabbed card with bidirectional relations
if
(
referencingRelations
.
length
===
0
&&
referencedRelations
.
length
===
0
)
{
if
(
referencingRelations
.
length
===
0
&&
referencedRelations
.
length
===
0
)
{
return
null
;
return
null
;
}
}
// Auto-select tab based on which has content
const
activeTab
=
referencingRelations
.
length
===
0
?
"referenced"
:
selectedTab
;
const
activeTab
=
referencingRelations
.
length
===
0
?
"referenced"
:
selectedTab
;
return
(
return
(
<
MetadataCard
className=
{
className
}
>
<
MetadataCard
className=
{
className
}
>
{
/* Tabs */
}
<
div
className=
"w-full flex flex-row justify-start items-center mb-1 gap-3 opacity-60"
>
<
div
className=
"w-full flex flex-row justify-start items-center mb-1 gap-3 opacity-60"
>
{
referencingRelations
.
length
>
0
&&
(
{
referencingRelations
.
length
>
0
&&
(
<
button
<
button
...
@@ -119,20 +69,18 @@ function RelationList({ relations, currentMemoName, mode, onRelationsChange, par
...
@@ -119,20 +69,18 @@ function RelationList({ relations, currentMemoName, mode, onRelationsChange, par
)
}
)
}
</
div
>
</
div
>
{
/* Referencing List */
}
{
activeTab
===
"referencing"
&&
referencingRelations
.
length
>
0
&&
(
{
activeTab
===
"referencing"
&&
referencingRelations
.
length
>
0
&&
(
<
div
className=
"w-full flex flex-col justify-start items-start"
>
<
div
className=
"w-full flex flex-col justify-start items-start"
>
{
referencingRelations
.
map
((
relation
)
=>
(
{
referencingRelations
.
map
((
relation
)
=>
(
<
RelationCard
key=
{
relation
.
relatedMemo
!
.
name
}
memo=
{
relation
.
relatedMemo
!
}
mode=
"view"
parentPage=
{
parentPage
}
/>
<
RelationCard
key=
{
relation
.
relatedMemo
!
.
name
}
memo=
{
relation
.
relatedMemo
!
}
parentPage=
{
parentPage
}
/>
))
}
))
}
</
div
>
</
div
>
)
}
)
}
{
/* Referenced List */
}
{
activeTab
===
"referenced"
&&
referencedRelations
.
length
>
0
&&
(
{
activeTab
===
"referenced"
&&
referencedRelations
.
length
>
0
&&
(
<
div
className=
"w-full flex flex-col justify-start items-start"
>
<
div
className=
"w-full flex flex-col justify-start items-start"
>
{
referencedRelations
.
map
((
relation
)
=>
(
{
referencedRelations
.
map
((
relation
)
=>
(
<
RelationCard
key=
{
relation
.
memo
!
.
name
}
memo=
{
relation
.
memo
!
}
mode=
"view"
parentPage=
{
parentPage
}
/>
<
RelationCard
key=
{
relation
.
memo
!
.
name
}
memo=
{
relation
.
memo
!
}
parentPage=
{
parentPage
}
/>
))
}
))
}
</
div
>
</
div
>
)
}
)
}
...
...
web/src/components/memo-metadata/SortableItem.tsx
deleted
100644 → 0
View file @
d7284fe8
import
{
useSortable
}
from
"@dnd-kit/sortable"
;
import
{
CSS
}
from
"@dnd-kit/utilities"
;
interface
Props
{
id
:
string
;
className
:
string
;
children
:
React
.
ReactNode
;
}
const
SortableItem
:
React
.
FC
<
Props
>
=
({
id
,
className
,
children
}:
Props
)
=>
{
const
{
attributes
,
listeners
,
setNodeRef
,
transform
,
transition
}
=
useSortable
({
id
});
const
style
=
{
transform
:
CSS
.
Transform
.
toString
(
transform
),
transition
,
};
return
(
<
div
ref=
{
setNodeRef
}
style=
{
style
}
{
...
attributes
}
{
...
listeners
}
className=
{
className
}
>
{
children
}
</
div
>
);
};
export
default
SortableItem
;
web/src/components/memo-metadata/index.ts
View file @
be1b758d
export
{
default
as
AttachmentCard
}
from
"./AttachmentCard"
;
export
{
default
as
AttachmentList
}
from
"./AttachmentList"
;
export
{
default
as
AttachmentList
}
from
"./AttachmentList"
;
export
{
default
as
LocationDisplay
}
from
"./LocationDisplay"
;
export
{
default
as
LocationDisplay
}
from
"./LocationDisplay"
;
...
@@ -8,5 +7,5 @@ export { default as RelationCard } from "./RelationCard";
...
@@ -8,5 +7,5 @@ export { default as RelationCard } from "./RelationCard";
export
{
default
as
RelationList
}
from
"./RelationList"
;
export
{
default
as
RelationList
}
from
"./RelationList"
;
// Types
// Types
export
type
{
AttachmentItem
,
BaseMetadataProps
,
DisplayMode
,
FileCategory
,
LocalFile
}
from
"./types"
;
export
type
{
AttachmentItem
,
FileCategory
,
LocalFile
}
from
"./types"
;
export
{
attachmentToItem
,
fileToItem
,
filterByCategory
,
separateMediaAndDocs
,
toAttachmentItems
}
from
"./types"
;
export
{
attachmentToItem
,
fileToItem
,
filterByCategory
,
separateMediaAndDocs
,
toAttachmentItems
}
from
"./types"
;
web/src/utils/format.ts
0 → 100644
View file @
be1b758d
export
function
formatFileSize
(
bytes
:
number
):
string
{
if
(
bytes
===
0
)
return
"0 B"
;
if
(
bytes
<
0
)
return
"Invalid size"
;
const
units
=
[
"B"
,
"KB"
,
"MB"
,
"GB"
,
"TB"
];
const
k
=
1024
;
const
i
=
Math
.
floor
(
Math
.
log
(
bytes
)
/
Math
.
log
(
k
));
const
size
=
bytes
/
Math
.
pow
(
k
,
i
);
const
formatted
=
i
===
0
?
size
.
toString
()
:
size
.
toFixed
(
1
);
return
`
${
formatted
}
${
units
[
i
]}
`
;
}
export
function
getFileTypeLabel
(
mimeType
:
string
):
string
{
if
(
!
mimeType
)
return
"File"
;
const
[
category
,
subtype
]
=
mimeType
.
split
(
"/"
);
const
specialCases
:
Record
<
string
,
string
>
=
{
"application/pdf"
:
"PDF"
,
"application/zip"
:
"ZIP"
,
"application/x-zip-compressed"
:
"ZIP"
,
"application/json"
:
"JSON"
,
"application/xml"
:
"XML"
,
"text/plain"
:
"TXT"
,
"text/html"
:
"HTML"
,
"text/css"
:
"CSS"
,
"text/javascript"
:
"JS"
,
"application/javascript"
:
"JS"
,
};
if
(
specialCases
[
mimeType
])
{
return
specialCases
[
mimeType
];
}
if
(
category
===
"image"
)
{
const
imageTypes
:
Record
<
string
,
string
>
=
{
jpeg
:
"JPEG"
,
jpg
:
"JPEG"
,
png
:
"PNG"
,
gif
:
"GIF"
,
webp
:
"WebP"
,
svg
:
"SVG"
,
"svg+xml"
:
"SVG"
,
bmp
:
"BMP"
,
ico
:
"ICO"
,
};
return
imageTypes
[
subtype
]
||
subtype
.
toUpperCase
();
}
if
(
category
===
"video"
)
{
const
videoTypes
:
Record
<
string
,
string
>
=
{
mp4
:
"MP4"
,
webm
:
"WebM"
,
ogg
:
"OGG"
,
avi
:
"AVI"
,
mov
:
"MOV"
,
quicktime
:
"MOV"
,
};
return
videoTypes
[
subtype
]
||
subtype
.
toUpperCase
();
}
if
(
category
===
"audio"
)
{
const
audioTypes
:
Record
<
string
,
string
>
=
{
mp3
:
"MP3"
,
mpeg
:
"MP3"
,
wav
:
"WAV"
,
ogg
:
"OGG"
,
webm
:
"WebM"
,
};
return
audioTypes
[
subtype
]
||
subtype
.
toUpperCase
();
}
return
subtype
?
subtype
.
toUpperCase
()
:
category
.
toUpperCase
();
}
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