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
b5de8471
Commit
b5de8471
authored
Nov 24, 2025
by
Steven
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
chore(web): add external link icon for memo attachments
parent
424e5999
Changes
1
Hide whitespace changes
Inline
Side-by-side
Showing
1 changed file
with
95 additions
and
74 deletions
+95
-74
Attachments.tsx
web/src/pages/Attachments.tsx
+95
-74
No files found.
web/src/pages/Attachments.tsx
View file @
b5de8471
import
dayjs
from
"dayjs"
;
import
dayjs
from
"dayjs"
;
import
{
includes
}
from
"lodash-es"
;
import
{
ExternalLinkIcon
,
PaperclipIcon
,
SearchIcon
,
Trash
}
from
"lucide-react"
;
import
{
PaperclipIcon
,
SearchIcon
,
Trash
}
from
"lucide-react"
;
import
{
observer
}
from
"mobx-react-lite"
;
import
{
observer
}
from
"mobx-react-lite"
;
import
{
use
Effect
,
useState
}
from
"react"
;
import
{
use
Callback
,
useEffect
,
useMemo
,
useState
}
from
"react"
;
import
{
toast
}
from
"react-hot-toast"
;
import
{
toast
}
from
"react-hot-toast"
;
import
{
Link
}
from
"react-router-dom"
;
import
AttachmentIcon
from
"@/components/AttachmentIcon"
;
import
AttachmentIcon
from
"@/components/AttachmentIcon"
;
import
ConfirmDialog
from
"@/components/ConfirmDialog"
;
import
ConfirmDialog
from
"@/components/ConfirmDialog"
;
import
Empty
from
"@/components/Empty"
;
import
Empty
from
"@/components/Empty"
;
...
@@ -17,47 +17,86 @@ import useLoading from "@/hooks/useLoading";
...
@@ -17,47 +17,86 @@ import useLoading from "@/hooks/useLoading";
import
useResponsiveWidth
from
"@/hooks/useResponsiveWidth"
;
import
useResponsiveWidth
from
"@/hooks/useResponsiveWidth"
;
import
i18n
from
"@/i18n"
;
import
i18n
from
"@/i18n"
;
import
{
attachmentStore
}
from
"@/store"
;
import
{
attachmentStore
}
from
"@/store"
;
import
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service"
;
import
type
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
function
groupAttachmentsByDate
(
attachments
:
Attachment
[])
{
const
PAGE_SIZE
=
50
;
/**
* Groups attachments by month for organized display
*/
const
groupAttachmentsByDate
=
(
attachments
:
Attachment
[]):
Map
<
string
,
Attachment
[]
>
=>
{
const
grouped
=
new
Map
<
string
,
Attachment
[]
>
();
const
grouped
=
new
Map
<
string
,
Attachment
[]
>
();
attachments
const
sorted
=
[...
attachments
].
sort
((
a
,
b
)
=>
dayjs
(
b
.
createTime
).
unix
()
-
dayjs
(
a
.
createTime
).
unix
());
.
sort
((
a
,
b
)
=>
dayjs
(
b
.
createTime
).
unix
()
-
dayjs
(
a
.
createTime
).
unix
())
.
forEach
((
item
)
=>
{
for
(
const
attachment
of
sorted
)
{
const
monthStr
=
dayjs
(
item
.
createTime
).
format
(
"YYYY-MM"
);
const
monthKey
=
dayjs
(
attachment
.
createTime
).
format
(
"YYYY-MM"
);
if
(
!
grouped
.
has
(
monthStr
))
{
const
group
=
grouped
.
get
(
monthKey
)
??
[];
grouped
.
set
(
monthStr
,
[]
);
group
.
push
(
attachment
);
}
grouped
.
set
(
monthKey
,
group
);
grouped
.
get
(
monthStr
)?.
push
(
item
);
}
});
return
grouped
;
return
grouped
;
}
};
/**
* Filters attachments based on search query
*/
const
filterAttachments
=
(
attachments
:
Attachment
[],
searchQuery
:
string
):
Attachment
[]
=>
{
if
(
!
searchQuery
.
trim
())
return
attachments
;
const
query
=
searchQuery
.
toLowerCase
();
return
attachments
.
filter
((
attachment
)
=>
attachment
.
filename
.
toLowerCase
().
includes
(
query
));
};
interface
State
{
/**
searchQuery
:
string
;
* Individual attachment item component
*/
interface
AttachmentItemProps
{
attachment
:
Attachment
;
}
}
const
AttachmentItem
=
({
attachment
}:
AttachmentItemProps
)
=>
(
<
div
className=
"w-24 sm:w-32 h-auto flex flex-col justify-start items-start"
>
<
div
className=
"w-24 h-24 flex justify-center items-center sm:w-32 sm:h-32 border border-border overflow-clip rounded-xl cursor-pointer hover:shadow hover:opacity-80"
>
<
AttachmentIcon
attachment=
{
attachment
}
strokeWidth=
{
0.5
}
/>
</
div
>
<
div
className=
"w-full max-w-full flex flex-row justify-between items-center mt-1 px-1"
>
<
p
className=
"text-xs shrink text-muted-foreground truncate"
>
{
attachment
.
filename
}
</
p
>
{
attachment
.
memo
&&
(
<
Link
to=
{
`/${attachment.memo}`
}
className=
"text-primary hover:opacity-80 transition-opacity shrink-0 ml-1"
aria
-
label=
"View memo"
>
<
ExternalLinkIcon
className=
"w-3 h-3"
/>
</
Link
>
)
}
</
div
>
</
div
>
);
const
Attachments
=
observer
(()
=>
{
const
Attachments
=
observer
(()
=>
{
const
t
=
useTranslate
();
const
t
=
useTranslate
();
const
{
md
}
=
useResponsiveWidth
();
const
{
md
}
=
useResponsiveWidth
();
const
loadingState
=
useLoading
();
const
loadingState
=
useLoading
();
const
deleteUnusedAttachmentsDialog
=
useDialog
();
const
deleteUnusedAttachmentsDialog
=
useDialog
();
const
[
state
,
setState
]
=
useState
<
State
>
({
searchQuery
:
""
,
const
[
searchQuery
,
setSearchQuery
]
=
useState
(
""
);
});
const
[
attachments
,
setAttachments
]
=
useState
<
Attachment
[]
>
([]);
const
[
attachments
,
setAttachments
]
=
useState
<
Attachment
[]
>
([]);
const
[
nextPageToken
,
setNextPageToken
]
=
useState
(
""
);
const
[
nextPageToken
,
setNextPageToken
]
=
useState
(
""
);
const
[
isLoadingMore
,
setIsLoadingMore
]
=
useState
(
false
);
const
[
isLoadingMore
,
setIsLoadingMore
]
=
useState
(
false
);
const
filteredAttachments
=
attachments
.
filter
((
attachment
)
=>
includes
(
attachment
.
filename
,
state
.
searchQuery
));
const
groupedAttachments
=
groupAttachmentsByDate
(
filteredAttachments
.
filter
((
attachment
)
=>
attachment
.
memo
));
const
unusedAttachments
=
filteredAttachments
.
filter
((
attachment
)
=>
!
attachment
.
memo
);
// Memoized computed values
const
filteredAttachments
=
useMemo
(()
=>
filterAttachments
(
attachments
,
searchQuery
),
[
attachments
,
searchQuery
]);
const
usedAttachments
=
useMemo
(()
=>
filteredAttachments
.
filter
((
attachment
)
=>
attachment
.
memo
),
[
filteredAttachments
]);
const
unusedAttachments
=
useMemo
(()
=>
filteredAttachments
.
filter
((
attachment
)
=>
!
attachment
.
memo
),
[
filteredAttachments
]);
const
groupedAttachments
=
useMemo
(()
=>
groupAttachmentsByDate
(
usedAttachments
),
[
usedAttachments
]);
// Fetch initial attachments
useEffect
(()
=>
{
useEffect
(()
=>
{
const
fetchInitialAttachments
=
async
()
=>
{
const
fetchInitialAttachments
=
async
()
=>
{
try
{
try
{
const
{
attachments
:
fetchedAttachments
,
nextPageToken
}
=
await
attachmentServiceClient
.
listAttachments
({
const
{
attachments
:
fetchedAttachments
,
nextPageToken
}
=
await
attachmentServiceClient
.
listAttachments
({
pageSize
:
50
,
pageSize
:
PAGE_SIZE
,
});
});
setAttachments
(
fetchedAttachments
);
setAttachments
(
fetchedAttachments
);
setNextPageToken
(
nextPageToken
??
""
);
setNextPageToken
(
nextPageToken
??
""
);
...
@@ -70,19 +109,20 @@ const Attachments = observer(() => {
...
@@ -70,19 +109,20 @@ const Attachments = observer(() => {
};
};
fetchInitialAttachments
();
fetchInitialAttachments
();
// eslint-disable-next-line react-hooks/exhaustive-deps
},
[]);
},
[]);
const
handleLoadMore
=
async
()
=>
{
// Load more attachments with pagination
if
(
!
nextPageToken
||
isLoadingMore
)
{
const
handleLoadMore
=
useCallback
(
async
()
=>
{
return
;
if
(
!
nextPageToken
||
isLoadingMore
)
return
;
}
setIsLoadingMore
(
true
);
setIsLoadingMore
(
true
);
try
{
try
{
const
{
attachments
:
fetchedAttachments
,
nextPageToken
:
newPageToken
}
=
await
attachmentServiceClient
.
listAttachments
({
const
{
attachments
:
fetchedAttachments
,
nextPageToken
:
newPageToken
}
=
await
attachmentServiceClient
.
listAttachments
({
pageSize
:
50
,
pageSize
:
PAGE_SIZE
,
pageToken
:
nextPageToken
,
pageToken
:
nextPageToken
,
});
});
setAttachments
((
prev
Attachments
)
=>
[...
prevAttachments
,
...
fetchedAttachments
]);
setAttachments
((
prev
)
=>
[...
prev
,
...
fetchedAttachments
]);
setNextPageToken
(
newPageToken
??
""
);
setNextPageToken
(
newPageToken
??
""
);
}
catch
(
error
)
{
}
catch
(
error
)
{
console
.
error
(
"Failed to load more attachments:"
,
error
);
console
.
error
(
"Failed to load more attachments:"
,
error
);
...
@@ -90,38 +130,42 @@ const Attachments = observer(() => {
...
@@ -90,38 +130,42 @@ const Attachments = observer(() => {
}
finally
{
}
finally
{
setIsLoadingMore
(
false
);
setIsLoadingMore
(
false
);
}
}
};
}
,
[
nextPageToken
,
isLoadingMore
])
;
const
handleRefetch
=
async
()
=>
{
// Refetch all attachments from the beginning
const
handleRefetch
=
useCallback
(
async
()
=>
{
try
{
try
{
loadingState
.
setLoading
();
loadingState
.
setLoading
();
const
{
attachments
:
fetchedAttachments
,
nextPageToken
}
=
await
attachmentServiceClient
.
listAttachments
({
const
{
attachments
:
fetchedAttachments
,
nextPageToken
}
=
await
attachmentServiceClient
.
listAttachments
({
pageSize
:
50
,
pageSize
:
PAGE_SIZE
,
});
});
setAttachments
(
fetchedAttachments
);
setAttachments
(
fetchedAttachments
);
setNextPageToken
(
nextPageToken
??
""
);
setNextPageToken
(
nextPageToken
??
""
);
loadingState
.
setFinish
();
loadingState
.
setFinish
();
}
catch
(
error
)
{
}
catch
(
error
)
{
console
.
error
(
error
);
console
.
error
(
"Failed to refetch attachments:"
,
error
);
loadingState
.
setError
();
loadingState
.
setError
();
toast
.
error
(
"Failed to refresh attachments. Please try again."
);
}
}
};
}
,
[
loadingState
])
;
const
handleDeleteUnusedAttachments
=
async
()
=>
{
// Delete all unused attachments
const
handleDeleteUnusedAttachments
=
useCallback
(
async
()
=>
{
try
{
try
{
await
Promise
.
all
(
await
Promise
.
all
(
unusedAttachments
.
map
((
attachment
)
=>
attachmentStore
.
deleteAttachment
(
attachment
.
name
)));
unusedAttachments
.
map
((
attachment
)
=>
{
return
attachmentStore
.
deleteAttachment
(
attachment
.
name
);
}),
);
toast
.
success
(
t
(
"resource.delete-all-unused-success"
));
toast
.
success
(
t
(
"resource.delete-all-unused-success"
));
}
catch
(
error
)
{
}
catch
(
error
)
{
console
.
error
(
error
);
console
.
error
(
"Failed to delete unused attachments:"
,
error
);
toast
.
error
(
t
(
"resource.delete-all-unused-error"
));
toast
.
error
(
t
(
"resource.delete-all-unused-error"
));
}
finally
{
}
finally
{
void
handleRefetch
();
await
handleRefetch
();
}
}
};
},
[
unusedAttachments
,
t
,
handleRefetch
]);
// Handle search input change
const
handleSearchChange
=
useCallback
((
e
:
React
.
ChangeEvent
<
HTMLInputElement
>
)
=>
{
setSearchQuery
(
e
.
target
.
value
);
},
[]);
return
(
return
(
<
section
className=
"@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8"
>
<
section
className=
"@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8"
>
...
@@ -136,12 +180,7 @@ const Attachments = observer(() => {
...
@@ -136,12 +180,7 @@ const Attachments = observer(() => {
<
div
>
<
div
>
<
div
className=
"relative max-w-32"
>
<
div
className=
"relative max-w-32"
>
<
SearchIcon
className=
"absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground"
/>
<
SearchIcon
className=
"absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground"
/>
<
Input
<
Input
className=
"pl-9"
placeholder=
{
t
(
"common.search"
)
}
value=
{
searchQuery
}
onChange=
{
handleSearchChange
}
/>
className=
"pl-9"
placeholder=
{
t
(
"common.search"
)
}
value=
{
state
.
searchQuery
}
onChange=
{
(
e
)
=>
setState
({
...
state
,
searchQuery
:
e
.
target
.
value
})
}
/>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
...
@@ -170,18 +209,9 @@ const Attachments = observer(() => {
...
@@ -170,18 +209,9 @@ const Attachments = observer(() => {
</
span
>
</
span
>
</
div
>
</
div
>
<
div
className=
"w-full max-w-[calc(100%-4rem)] sm:max-w-[calc(100%-6rem)] flex flex-row justify-start items-start gap-4 flex-wrap"
>
<
div
className=
"w-full max-w-[calc(100%-4rem)] sm:max-w-[calc(100%-6rem)] flex flex-row justify-start items-start gap-4 flex-wrap"
>
{
attachments
.
map
((
attachment
)
=>
{
{
attachments
.
map
((
attachment
)
=>
(
return
(
<
AttachmentItem
key=
{
attachment
.
name
}
attachment=
{
attachment
}
/>
<
div
key=
{
attachment
.
name
}
className=
"w-24 sm:w-32 h-auto flex flex-col justify-start items-start"
>
))
}
<
div
className=
"w-24 h-24 flex justify-center items-center sm:w-32 sm:h-32 border border-border overflow-clip rounded-xl cursor-pointer hover:shadow hover:opacity-80"
>
<
AttachmentIcon
attachment=
{
attachment
}
strokeWidth=
{
0.5
}
/>
</
div
>
<
div
className=
"w-full max-w-full flex flex-row justify-between items-center mt-1 px-1"
>
<
p
className=
"text-xs shrink text-muted-foreground truncate"
>
{
attachment
.
filename
}
</
p
>
</
div
>
</
div
>
);
})
}
</
div
>
</
div
>
</
div
>
</
div
>
);
);
...
@@ -205,18 +235,9 @@ const Attachments = observer(() => {
...
@@ -205,18 +235,9 @@ const Attachments = observer(() => {
</
Button
>
</
Button
>
</
div
>
</
div
>
</
div
>
</
div
>
{
unusedAttachments
.
map
((
attachment
)
=>
{
{
unusedAttachments
.
map
((
attachment
)
=>
(
return
(
<
AttachmentItem
key=
{
attachment
.
name
}
attachment=
{
attachment
}
/>
<
div
key=
{
attachment
.
name
}
className=
"w-24 sm:w-32 h-auto flex flex-col justify-start items-start"
>
))
}
<
div
className=
"w-24 h-24 flex justify-center items-center sm:w-32 sm:h-32 border border-border overflow-clip rounded-xl cursor-pointer hover:shadow hover:opacity-80"
>
<
AttachmentIcon
attachment=
{
attachment
}
strokeWidth=
{
0.5
}
/>
</
div
>
<
div
className=
"w-full max-w-full flex flex-row justify-between items-center mt-1 px-1"
>
<
p
className=
"text-xs shrink text-muted-foreground truncate"
>
{
attachment
.
filename
}
</
p
>
</
div
>
</
div
>
);
})
}
</
div
>
</
div
>
</
div
>
</
div
>
</>
</>
...
...
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