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
8cdcd7b2
Commit
8cdcd7b2
authored
Apr 10, 2026
by
boojack
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
refactor(attachments): extract visual gallery layout and tile style tokens
parent
9ca71229
Changes
3
Show whitespace changes
Inline
Side-by-side
Showing
3 changed files
with
134 additions
and
48 deletions
+134
-48
AttachmentListView.tsx
...components/MemoMetadata/Attachment/AttachmentListView.tsx
+29
-48
attachmentVisualClasses.ts
...onents/MemoMetadata/Attachment/attachmentVisualClasses.ts
+33
-0
visualGalleryLayout.ts
...components/MemoMetadata/Attachment/visualGalleryLayout.ts
+72
-0
No files found.
web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx
View file @
8cdcd7b2
...
@@ -10,6 +10,18 @@ import type { AttachmentVisualItem, PreviewMediaItem } from "@/utils/media-item"
...
@@ -10,6 +10,18 @@ import type { AttachmentVisualItem, PreviewMediaItem } from "@/utils/media-item"
import
{
buildAttachmentVisualItems
}
from
"@/utils/media-item"
;
import
{
buildAttachmentVisualItems
}
from
"@/utils/media-item"
;
import
AudioAttachmentItem
from
"./AudioAttachmentItem"
;
import
AudioAttachmentItem
from
"./AudioAttachmentItem"
;
import
{
getAttachmentMetadata
,
isAudioAttachment
,
separateAttachments
}
from
"./attachmentHelpers"
;
import
{
getAttachmentMetadata
,
isAudioAttachment
,
separateAttachments
}
from
"./attachmentHelpers"
;
import
{
COLLAGE_VIDEO_PLAY_BADGE_CLASS
,
COVER_MEDIA_CLASS
,
MEDIA_HOVER_GRADIENT_CLASS
,
MEDIA_HOVER_SURFACE_CLASS
,
NATURAL_MEDIA_CLASS
,
OVERFLOW_TILE_OVERLAY_CLASS
,
SINGLE_MOTION_VIDEO_CLASS
,
SINGLE_VIDEO_CARD_WIDTH_CLASS
,
VISUAL_TILE_BUTTON_CLASS
,
}
from
"./attachmentVisualClasses"
;
import
{
resolveVisualGalleryLayout
}
from
"./visualGalleryLayout"
;
interface
AttachmentListViewProps
{
interface
AttachmentListViewProps
{
attachments
:
Attachment
[];
attachments
:
Attachment
[];
...
@@ -18,15 +30,6 @@ interface AttachmentListViewProps {
...
@@ -18,15 +30,6 @@ interface AttachmentListViewProps {
type
VisualItem
=
AttachmentVisualItem
;
type
VisualItem
=
AttachmentVisualItem
;
const
VISUAL_TILE_CLASS
=
"group relative overflow-hidden rounded-xl border border-border/70 bg-muted/30 text-left transition-colors hover:border-accent/40"
;
const
COVER_MEDIA_CLASS
=
"h-full w-full rounded-none object-cover transition-transform duration-300 group-hover:scale-[1.02]"
;
const
NATURAL_MEDIA_CLASS
=
"block h-auto max-h-[20rem] w-auto max-w-full rounded-none transition-transform duration-300 group-hover:scale-[1.02]"
;
const
SINGLE_VIDEO_CARD_WIDTH_CLASS
=
"w-full max-w-[30rem]"
;
const
TWO_ITEM_GRID_HEIGHT_CLASS
=
"h-[11rem] sm:h-[13rem] md:h-[15rem]"
;
const
MOSAIC_GRID_HEIGHT_CLASS
=
"h-[13rem] sm:h-[16rem] md:h-[18rem]"
;
const
AttachmentMeta
=
({
attachment
}:
{
attachment
:
Attachment
})
=>
{
const
AttachmentMeta
=
({
attachment
}:
{
attachment
:
Attachment
})
=>
{
const
{
fileTypeLabel
,
fileSizeLabel
}
=
getAttachmentMetadata
(
attachment
);
const
{
fileTypeLabel
,
fileSizeLabel
}
=
getAttachmentMetadata
(
attachment
);
...
@@ -74,14 +77,12 @@ const VisualTile = ({
...
@@ -74,14 +77,12 @@ const VisualTile = ({
children
,
children
,
}:
PropsWithChildren
<
{
className
?:
string
;
onPreview
?:
()
=>
void
;
overlayLabel
?:
string
}
>
)
=>
{
}:
PropsWithChildren
<
{
className
?:
string
;
onPreview
?:
()
=>
void
;
overlayLabel
?:
string
}
>
)
=>
{
return
(
return
(
<
button
type=
"button"
className=
{
cn
(
VISUAL_TILE_CLASS
,
className
)
}
onClick=
{
onPreview
}
>
<
button
type=
"button"
className=
{
cn
(
VISUAL_TILE_BUTTON_CLASS
,
className
)
}
onClick=
{
onPreview
}
>
<
div
className=
{
MEDIA_HOVER_SURFACE_CLASS
}
>
{
children
}
{
children
}
<
div
className=
"pointer-events-none absolute inset-0 bg-gradient-to-t from-foreground/15 via-transparent to-transparent opacity-0 transition-opacity group-hover:opacity-100"
/>
<
div
className=
{
MEDIA_HOVER_GRADIENT_CLASS
}
aria
-
hidden
/>
{
overlayLabel
&&
(
<
div
className=
"pointer-events-none absolute inset-0 flex items-center justify-center bg-black/45 text-2xl font-semibold text-white backdrop-blur-[2px]"
>
{
overlayLabel
}
</
div
>
</
div
>
)
}
{
overlayLabel
&&
<
div
className=
{
OVERFLOW_TILE_OVERLAY_CLASS
}
>
{
overlayLabel
}
</
div
>
}
</
button
>
</
button
>
);
);
};
};
...
@@ -116,7 +117,7 @@ const CollageVisualItem = ({
...
@@ -116,7 +117,7 @@ const CollageVisualItem = ({
<>
<>
<
video
src=
{
item
.
sourceUrl
}
className=
{
COVER_MEDIA_CLASS
}
preload=
"metadata"
/>
<
video
src=
{
item
.
sourceUrl
}
className=
{
COVER_MEDIA_CLASS
}
preload=
"metadata"
/>
{
!
overlayLabel
&&
(
{
!
overlayLabel
&&
(
<
VideoPlayBadge
className=
"bottom-2 right-2 h-7 w-7 bg-background/80 text-foreground/70"
>
<
VideoPlayBadge
className=
{
COLLAGE_VIDEO_PLAY_BADGE_CLASS
}
>
<
PlayIcon
className=
"h-3.5 w-3.5 fill-current"
/>
<
PlayIcon
className=
"h-3.5 w-3.5 fill-current"
/>
</
VideoPlayBadge
>
</
VideoPlayBadge
>
)
}
)
}
...
@@ -159,7 +160,7 @@ const SingleVisualItem = ({ item, onPreview }: { item: VisualItem; onPreview?: (
...
@@ -159,7 +160,7 @@ const SingleVisualItem = ({ item, onPreview }: { item: VisualItem; onPreview?: (
presentationTimestampUs=
{
motionPreviewProps
.
presentationTimestampUs
}
presentationTimestampUs=
{
motionPreviewProps
.
presentationTimestampUs
}
containerClassName=
"max-w-full"
containerClassName=
"max-w-full"
posterClassName=
{
cn
(
NATURAL_MEDIA_CLASS
,
"object-contain"
)
}
posterClassName=
{
cn
(
NATURAL_MEDIA_CLASS
,
"object-contain"
)
}
videoClassName=
"absolute inset-0 h-full w-full rounded-none object-contain transition-transform duration-300 group-hover:scale-[1.02]"
videoClassName=
{
SINGLE_MOTION_VIDEO_CLASS
}
badgeClassName=
"left-2 top-2 px-2 py-0.5 text-[10px]"
badgeClassName=
"left-2 top-2 px-2 py-0.5 text-[10px]"
/>
/>
</
VisualTile
>
</
VisualTile
>
...
@@ -180,48 +181,28 @@ const SingleVisualItem = ({ item, onPreview }: { item: VisualItem; onPreview?: (
...
@@ -180,48 +181,28 @@ const SingleVisualItem = ({ item, onPreview }: { item: VisualItem; onPreview?: (
};
};
const
VisualGallery
=
({
items
,
onPreview
}:
{
items
:
VisualItem
[];
onPreview
?:
(
itemId
:
string
)
=>
void
})
=>
{
const
VisualGallery
=
({
items
,
onPreview
}:
{
items
:
VisualItem
[];
onPreview
?:
(
itemId
:
string
)
=>
void
})
=>
{
if
(
items
.
length
===
0
)
{
const
layout
=
resolveVisualGalleryLayout
(
items
);
if
(
!
layout
)
{
return
null
;
return
null
;
}
}
if
(
items
.
length
===
1
)
{
if
(
layout
.
mode
===
"single"
)
{
return
(
return
(
<
div
className=
"w-full"
>
<
div
className=
"w-full"
>
<
SingleVisualItem
item=
{
items
[
0
]
}
onPreview=
{
()
=>
onPreview
?.(
items
[
0
]
.
id
)
}
/>
<
SingleVisualItem
item=
{
layout
.
item
}
onPreview=
{
()
=>
onPreview
?.(
layout
.
item
.
id
)
}
/>
</
div
>
</
div
>
);
);
}
}
if
(
items
.
length
===
2
)
{
return
(
<
div
className=
{
cn
(
"grid grid-cols-2 gap-2"
,
TWO_ITEM_GRID_HEIGHT_CLASS
)
}
>
{
items
.
map
((
item
)
=>
(
<
CollageVisualItem
key=
{
item
.
id
}
item=
{
item
}
onPreview=
{
()
=>
onPreview
?.(
item
.
id
)
}
/>
))
}
</
div
>
);
}
if
(
items
.
length
===
3
)
{
return
(
<
div
className=
{
cn
(
"grid grid-cols-2 grid-rows-2 gap-2"
,
MOSAIC_GRID_HEIGHT_CLASS
)
}
>
<
CollageVisualItem
item=
{
items
[
0
]
}
className=
"row-span-2"
onPreview=
{
()
=>
onPreview
?.(
items
[
0
].
id
)
}
/>
<
CollageVisualItem
item=
{
items
[
1
]
}
onPreview=
{
()
=>
onPreview
?.(
items
[
1
].
id
)
}
/>
<
CollageVisualItem
item=
{
items
[
2
]
}
onPreview=
{
()
=>
onPreview
?.(
items
[
2
].
id
)
}
/>
</
div
>
);
}
const
visibleItems
=
items
.
slice
(
0
,
4
);
const
remainingCount
=
items
.
length
-
visibleItems
.
length
;
return
(
return
(
<
div
className=
{
cn
(
"grid grid-cols-2 grid-rows-2 gap-2"
,
MOSAIC_GRID_HEIGHT_CLASS
)
}
>
<
div
className=
{
layout
.
containerClassName
}
>
{
visibleItems
.
map
((
item
,
index
)
=>
(
{
layout
.
cells
.
map
(({
item
,
className
,
overlayLabel
}
)
=>
(
<
CollageVisualItem
<
CollageVisualItem
key=
{
item
.
id
}
key=
{
item
.
id
}
item=
{
item
}
item=
{
item
}
overlayLabel=
{
index
===
visibleItems
.
length
-
1
&&
remainingCount
>
0
?
`+${remainingCount}`
:
undefined
}
className=
{
className
}
overlayLabel=
{
overlayLabel
}
onPreview=
{
()
=>
onPreview
?.(
item
.
id
)
}
onPreview=
{
()
=>
onPreview
?.(
item
.
id
)
}
/>
/>
))
}
))
}
...
...
web/src/components/MemoMetadata/Attachment/attachmentVisualClasses.ts
0 → 100644
View file @
8cdcd7b2
/**
* Tailwind class bundles for attachment visual tiles (`VisualTile`, collage, single image/video).
* Hover uses `group/media` on {@link MEDIA_HOVER_SURFACE_CLASS} so scale/gradient track the media surface, not the outer button chrome.
*/
export
const
VISUAL_TILE_BUTTON_CLASS
=
"relative block overflow-hidden rounded-xl border border-border/70 bg-muted/30 p-0 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/50"
;
export
const
MEDIA_HOVER_SURFACE_CLASS
=
"group/media relative h-full min-h-0 w-full overflow-hidden"
;
export
const
COVER_MEDIA_CLASS
=
"h-full w-full rounded-none object-cover transition-transform duration-300 group-hover/media:scale-[1.02]"
;
export
const
NATURAL_MEDIA_CLASS
=
"block h-auto max-h-[20rem] w-auto max-w-full rounded-none transition-transform duration-300 group-hover/media:scale-[1.02]"
;
/** Motion overlay video in single-tile layout (pairs with {@link NATURAL_MEDIA_CLASS} poster). */
export
const
SINGLE_MOTION_VIDEO_CLASS
=
"absolute inset-0 h-full w-full rounded-none object-contain transition-transform duration-300 group-hover/media:scale-[1.02]"
;
export
const
SINGLE_VIDEO_CARD_WIDTH_CLASS
=
"w-full max-w-[30rem]"
;
/** Stacking inside {@link MEDIA_HOVER_SURFACE_CLASS}: gradient < badge < overflow mask. */
export
const
VISUAL_Z
=
{
gradient
:
"z-[1]"
,
badge
:
"z-[2]"
,
overflowMask
:
"z-[3]"
,
}
as
const
;
export
const
MEDIA_HOVER_GRADIENT_CLASS
=
`pointer-events-none absolute inset-0
${
VISUAL_Z
.
gradient
}
bg-gradient-to-t from-foreground/15 via-transparent to-transparent opacity-0 transition-opacity group-hover/media:opacity-100`
;
export
const
COLLAGE_VIDEO_PLAY_BADGE_CLASS
=
`bottom-2 right-2
${
VISUAL_Z
.
badge
}
h-7 w-7 bg-background/80 text-foreground/70`
;
export
const
OVERFLOW_TILE_OVERLAY_CLASS
=
`pointer-events-none absolute inset-0
${
VISUAL_Z
.
overflowMask
}
flex items-center justify-center bg-black/45 text-2xl font-semibold text-white backdrop-blur-[2px]`
;
web/src/components/MemoMetadata/Attachment/visualGalleryLayout.ts
0 → 100644
View file @
8cdcd7b2
import
{
cn
}
from
"@/lib/utils"
;
import
type
{
AttachmentVisualItem
}
from
"@/utils/media-item"
;
export
type
VisualGalleryCell
=
{
item
:
AttachmentVisualItem
;
className
?:
string
;
overlayLabel
?:
string
;
};
/** Resolved layout for attachment visual previews — keeps grid rules in one place. */
export
type
VisualGalleryLayout
=
|
{
mode
:
"single"
;
item
:
AttachmentVisualItem
}
|
{
mode
:
"collage"
;
containerClassName
:
string
;
cells
:
VisualGalleryCell
[]
};
const
TWO_ITEM_GRID_HEIGHT_CLASS
=
"h-[11rem] sm:h-[13rem] md:h-[15rem]"
;
const
MOSAIC_GRID_HEIGHT_CLASS
=
"h-[13rem] sm:h-[16rem] md:h-[18rem]"
;
/** 2 rows × 3 columns */
const
SIX_UP_GRID_HEIGHT_CLASS
=
"h-[14rem] sm:h-[17rem] md:h-[20rem]"
;
/** Max thumbnails shown in the 2×3 collage before `+N` on the last cell. */
export
const
COLLAGE_MAX_VISIBLE_CELLS
=
6
;
/**
* Maps N visual items to a gallery layout (single, 2-up, 3-mosaic, 2×2, or 2×3 with optional +N).
*/
export
const
resolveVisualGalleryLayout
=
(
items
:
AttachmentVisualItem
[]):
VisualGalleryLayout
|
null
=>
{
const
count
=
items
.
length
;
if
(
count
===
0
)
{
return
null
;
}
if
(
count
===
1
)
{
return
{
mode
:
"single"
,
item
:
items
[
0
]
};
}
if
(
count
===
2
)
{
return
{
mode
:
"collage"
,
containerClassName
:
cn
(
"grid grid-cols-2 gap-2"
,
TWO_ITEM_GRID_HEIGHT_CLASS
),
cells
:
items
.
map
((
item
)
=>
({
item
})),
};
}
if
(
count
===
3
)
{
return
{
mode
:
"collage"
,
containerClassName
:
cn
(
"grid grid-cols-2 grid-rows-2 gap-2"
,
MOSAIC_GRID_HEIGHT_CLASS
),
cells
:
[{
item
:
items
[
0
],
className
:
"row-span-2"
},
{
item
:
items
[
1
]
},
{
item
:
items
[
2
]
}],
};
}
if
(
count
===
4
)
{
return
{
mode
:
"collage"
,
containerClassName
:
cn
(
"grid grid-cols-2 grid-rows-2 gap-2"
,
MOSAIC_GRID_HEIGHT_CLASS
),
cells
:
items
.
map
((
item
)
=>
({
item
})),
};
}
const
visible
=
items
.
slice
(
0
,
COLLAGE_MAX_VISIBLE_CELLS
);
const
overflowCount
=
items
.
length
-
visible
.
length
;
return
{
mode
:
"collage"
,
containerClassName
:
cn
(
"grid grid-cols-3 grid-rows-2 gap-2"
,
SIX_UP_GRID_HEIGHT_CLASS
),
cells
:
visible
.
map
((
item
,
index
)
=>
({
item
,
overlayLabel
:
index
===
visible
.
length
-
1
&&
overflowCount
>
0
?
`+
${
overflowCount
}
`
:
undefined
,
})),
};
};
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