feat(gallery): refine image-sampled border animation
This commit is contained in:
@@ -77,7 +77,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| Gallery delete does not remove public item | `src/app/api/gallery/route.ts`, admin UI route using it | DELETE unpublishes by setting `is_public = false`, not hard delete. |
|
||||
| Search/filter/sort wrong | `src/app/api/gallery/route.ts`, `src/app/gallery/page.tsx` | Query params `type`, `limit`, `offset`, `sort`, `q/search`; SQL where/order. |
|
||||
| Gallery search box looks inconsistent with the rest of the UI | `src/app/gallery/page.tsx` | The search field is a custom glass panel with an inner focused input surface; avoid reverting it to a plain transparent input row. |
|
||||
| Gallery hover makes images muddy, covers the image with prompt text, shows a rotating glow block/perimeter frame, or action buttons disappear on dark/light images | `src/app/gallery/page.tsx`, `src/app/globals.css` | Gallery cards should not use a full-image dark hover overlay, center prompt text, broad square glow under the card, or rotating border frame. Keep hover feedback on the card container with scale plus a sampled-color static highlight, and keep like/download buttons legible through sampled image brightness inversion. |
|
||||
| Gallery hover makes images muddy, covers the image with prompt text, shows only a single-color/static glow, has transparent gaps, does not match image colors, misses the card corners, moves too fast, looks too hard-edged, or action buttons disappear on dark/light images | `src/app/gallery/page.tsx`, `src/app/globals.css` | Gallery cards should not use a full-image dark hover overlay, center prompt text, transparent border gaps, generated unrelated colors, broad square glow under the card, or a separate outer halo layer. Keep hover feedback on the card container with scale plus a real `gallery-card-border-frame` wrapper using 3-5 sampled image colors in a single blurred 3px continuous clockwise border around the full work-card container, including all four corners and the prompt/footer area, and keep like/download buttons legible through sampled image brightness inversion. |
|
||||
|
||||
## Canvas
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ Use this document to jump directly to code before broad searching.
|
||||
|
||||
| Feature | Files | Notes |
|
||||
| --- | --- | --- |
|
||||
| Public gallery page | `src/app/gallery/page.tsx`, `src/app/globals.css` | Lists public works, search/sort/filter, preview/download. The search box is custom styled in-page to match the glass UI; gallery cards use hover scale plus a sampled-color static highlight instead of an image-covering dark overlay, broad glow block, or rotating perimeter frame, and hover like/download buttons invert against sampled image brightness. Gallery detail image previews use `ImageMetadataBadge` for actual ratio/resolution. |
|
||||
| Public gallery page | `src/app/gallery/page.tsx`, `src/app/globals.css` | Lists public works, search/sort/filter, preview/download. The search box is custom styled in-page to match the glass UI; gallery cards sample 3-5 distinct colors from the image and use a real `gallery-card-border-frame` wrapper with a single 3px blurred, continuous clockwise multicolor border around the full work-card container, including all four corners and the prompt/footer area. Avoid image-covering dark overlays, broad square glow blocks, or a separate outer halo layer. Hover like/download buttons invert against sampled image brightness. Gallery detail image previews use `ImageMetadataBadge` for actual ratio/resolution. |
|
||||
| Public gallery API | `src/app/api/gallery/route.ts` | GET public works, admin DELETE unpublishes. |
|
||||
| Publish API | `src/app/api/gallery/publish/route.ts` | Copies media into gallery folders and inserts public work. |
|
||||
| History persistence | `src/app/api/creation-history/route.ts`, `src/lib/creation-history-store.ts` | User-private completed works and published state. Single-record deletion is server-first when logged in; detail dialogs call the same store path and then refresh local history. |
|
||||
|
||||
@@ -153,6 +153,8 @@ type GalleryCardPalette = {
|
||||
accent1: string;
|
||||
accent2: string;
|
||||
accent3: string;
|
||||
accent4: string;
|
||||
accent5: string;
|
||||
actionBg: string;
|
||||
actionFg: string;
|
||||
actionBorder: string;
|
||||
@@ -160,6 +162,7 @@ type GalleryCardPalette = {
|
||||
};
|
||||
|
||||
type RgbColor = { r: number; g: number; b: number };
|
||||
type ScoredColor = { color: RgbColor; score: number };
|
||||
|
||||
function clampColor(value: number): number {
|
||||
return Math.max(0, Math.min(255, Math.round(value)));
|
||||
@@ -190,6 +193,18 @@ function getSaturation(color: RgbColor): number {
|
||||
return max === 0 ? 0 : (max - min) / max;
|
||||
}
|
||||
|
||||
function getRgbDistance(a: RgbColor, b: RgbColor): number {
|
||||
const dr = a.r - b.r;
|
||||
const dg = a.g - b.g;
|
||||
const db = a.b - b.b;
|
||||
return Math.sqrt(dr * dr + dg * dg + db * db);
|
||||
}
|
||||
|
||||
function getHueDistance(a: number, b: number): number {
|
||||
const diff = Math.abs(a - b);
|
||||
return Math.min(diff, 1 - diff);
|
||||
}
|
||||
|
||||
function rgbToHsl({ r, g, b }: RgbColor) {
|
||||
const nr = r / 255, ng = g / 255, nb = b / 255;
|
||||
const max = Math.max(nr, ng, nb), min = Math.min(nr, ng, nb);
|
||||
@@ -242,6 +257,43 @@ function makeVivid(color: RgbColor): RgbColor {
|
||||
return hslToRgb(hsl);
|
||||
}
|
||||
|
||||
function selectImageAccentColors(candidates: ScoredColor[], average: RgbColor, strongest: RgbColor): RgbColor[] {
|
||||
const sorted = [...candidates].sort((a, b) => b.score - a.score);
|
||||
const selected: RgbColor[] = [];
|
||||
const passes = [
|
||||
{ hueDistance: 0.13, rgbDistance: 86 },
|
||||
{ hueDistance: 0.08, rgbDistance: 58 },
|
||||
{ hueDistance: 0.035, rgbDistance: 34 },
|
||||
];
|
||||
|
||||
for (const pass of passes) {
|
||||
for (const candidate of sorted) {
|
||||
if (selected.length >= 5) break;
|
||||
const vivid = makeVivid(candidate.color);
|
||||
const vividHue = rgbToHsl(vivid).h;
|
||||
const isDistinct = selected.every((color) => {
|
||||
const colorHue = rgbToHsl(color).h;
|
||||
return getHueDistance(vividHue, colorHue) >= pass.hueDistance || getRgbDistance(vivid, color) >= pass.rgbDistance;
|
||||
});
|
||||
if (isDistinct) {
|
||||
selected.push(vivid);
|
||||
}
|
||||
}
|
||||
if (selected.length >= 5) break;
|
||||
}
|
||||
|
||||
for (const fallback of [strongest, average]) {
|
||||
const vivid = makeVivid(fallback);
|
||||
if (selected.length === 0 || selected.every(color => getRgbDistance(vivid, color) >= 24)) {
|
||||
selected.push(vivid);
|
||||
}
|
||||
if (selected.length >= 3) break;
|
||||
}
|
||||
|
||||
const byHue = selected.slice(0, 5).sort((a, b) => rgbToHsl(a).h - rgbToHsl(b).h);
|
||||
return byHue.length > 0 ? byHue : [makeVivid(strongest)];
|
||||
}
|
||||
|
||||
function getImagePalette(img: HTMLImageElement): GalleryCardPalette | null {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
@@ -257,6 +309,7 @@ function getImagePalette(img: HTMLImageElement): GalleryCardPalette | null {
|
||||
const average: RgbColor = { r: 0, g: 0, b: 0 };
|
||||
let strongest: RgbColor = { r: 245, g: 166, b: 35 };
|
||||
let strongestScore = -1;
|
||||
const candidates: ScoredColor[] = [];
|
||||
|
||||
for (let index = 0; index < data.length; index += 4) {
|
||||
const alpha = data[index + 3];
|
||||
@@ -272,6 +325,7 @@ function getImagePalette(img: HTMLImageElement): GalleryCardPalette | null {
|
||||
|
||||
const saturation = getSaturation(color);
|
||||
const score = saturation * 0.7 + (1 - Math.abs(luminance - 0.55)) * 0.3;
|
||||
candidates.push({ color, score });
|
||||
if (score > strongestScore) {
|
||||
strongestScore = score;
|
||||
strongest = color;
|
||||
@@ -283,15 +337,16 @@ function getImagePalette(img: HTMLImageElement): GalleryCardPalette | null {
|
||||
average.r /= total;
|
||||
average.g /= total;
|
||||
average.b /= total;
|
||||
const vividAccent = makeVivid(strongest);
|
||||
const accents = selectImageAccentColors(candidates, average, strongest);
|
||||
const imageIsDark = getLuminance(average) < 0.48;
|
||||
const softAccent = mixRgb(vividAccent, imageIsDark ? { r: 255, g: 255, b: 255 } : { r: 0, g: 0, b: 0 }, imageIsDark ? 0.26 : 0.18);
|
||||
const warmAccent = mixRgb(vividAccent, { r: 245, g: 166, b: 35 }, 0.38);
|
||||
const colorAt = (index: number) => accents[index % accents.length] || makeVivid(strongest);
|
||||
|
||||
return {
|
||||
accent1: rgbToCss(vividAccent),
|
||||
accent2: rgbToCss(softAccent),
|
||||
accent3: rgbToCss(warmAccent),
|
||||
accent1: rgbToCss(colorAt(0)),
|
||||
accent2: rgbToCss(colorAt(1)),
|
||||
accent3: rgbToCss(colorAt(2)),
|
||||
accent4: rgbToCss(colorAt(3)),
|
||||
accent5: rgbToCss(colorAt(4)),
|
||||
actionBg: imageIsDark ? 'rgb(255 255 255 / 0.92)' : 'rgb(13 18 28 / 0.86)',
|
||||
actionFg: imageIsDark ? 'rgb(17 24 39)' : 'rgb(255 255 255)',
|
||||
actionBorder: imageIsDark ? 'rgb(255 255 255 / 0.72)' : 'rgb(255 255 255 / 0.22)',
|
||||
@@ -309,6 +364,8 @@ function getGalleryCardStyle(palette?: GalleryCardPalette): CSSProperties {
|
||||
'--gallery-accent-1': palette?.accent1 || 'rgb(245 166 35)',
|
||||
'--gallery-accent-2': palette?.accent2 || 'rgb(56 189 248)',
|
||||
'--gallery-accent-3': palette?.accent3 || 'rgb(244 114 182)',
|
||||
'--gallery-accent-4': palette?.accent4 || palette?.accent2 || 'rgb(34 197 94)',
|
||||
'--gallery-accent-5': palette?.accent5 || palette?.accent1 || 'rgb(168 85 247)',
|
||||
'--gallery-action-bg': palette?.actionBg || 'rgb(255 255 255 / 0.92)',
|
||||
'--gallery-action-fg': palette?.actionFg || 'rgb(17 24 39)',
|
||||
'--gallery-action-border': palette?.actionBorder || 'rgb(255 255 255 / 0.58)',
|
||||
@@ -712,10 +769,11 @@ export default function GalleryPage() {
|
||||
className="gallery-work-shell group"
|
||||
style={getGalleryCardStyle(cardPalettes[work.id])}
|
||||
>
|
||||
<Card
|
||||
className={`${galleryGlassCard} gallery-work-card w-full overflow-hidden cursor-pointer !rounded-2xl !py-0`}
|
||||
onClick={() => setSelectedWork(work)}
|
||||
>
|
||||
<div className="gallery-card-border-frame">
|
||||
<Card
|
||||
className={`${galleryGlassCard} gallery-work-card w-full overflow-hidden cursor-pointer !rounded-[14px] !py-0`}
|
||||
onClick={() => setSelectedWork(work)}
|
||||
>
|
||||
<div className="relative overflow-hidden bg-black/25">
|
||||
{mediaPreviewUrl ? (
|
||||
<img
|
||||
@@ -745,11 +803,11 @@ export default function GalleryPage() {
|
||||
</button>
|
||||
)}
|
||||
{(work.type === 'video' || work.type === 'text2video' || work.type === 'img2video') && (
|
||||
<Badge className={`absolute left-2 ${isAdmin && apiWorkIds.has(work.id) ? 'top-11' : 'top-2'}`} variant="secondary">
|
||||
<Badge className={`absolute left-2 z-20 ${isAdmin && apiWorkIds.has(work.id) ? 'top-11' : 'top-2'}`} variant="secondary">
|
||||
<Film className="h-3 w-3 mr-1" />视频
|
||||
</Badge>
|
||||
)}
|
||||
<Badge className="absolute top-2 right-2" variant="secondary">
|
||||
<Badge className="absolute top-2 right-2 z-20" variant="secondary">
|
||||
{getCategoryLabel(work)}
|
||||
</Badge>
|
||||
<div className="pointer-events-none absolute inset-x-3 bottom-3 z-20 flex translate-y-2 justify-center gap-2 opacity-0 transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100">
|
||||
@@ -812,7 +870,8 @@ export default function GalleryPage() {
|
||||
{work.prompt}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@property --gallery-border-angle {
|
||||
syntax: "<angle>";
|
||||
inherits: false;
|
||||
initial-value: 0deg;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
@@ -227,7 +233,7 @@
|
||||
.gallery-work-shell:hover {
|
||||
z-index: 10;
|
||||
transform: translateY(-6px) scale(1.02);
|
||||
filter: saturate(1.03);
|
||||
filter: saturate(1.12);
|
||||
}
|
||||
|
||||
.gallery-work-card {
|
||||
@@ -239,12 +245,63 @@
|
||||
background-color 300ms ease;
|
||||
}
|
||||
|
||||
.gallery-card-border-frame {
|
||||
--gallery-border-angle: 0deg;
|
||||
position: relative;
|
||||
border-radius: 1rem;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.gallery-card-border-frame::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
conic-gradient(
|
||||
from var(--gallery-border-angle),
|
||||
var(--gallery-accent-1) 0deg,
|
||||
var(--gallery-accent-2) 72deg,
|
||||
var(--gallery-accent-3) 144deg,
|
||||
var(--gallery-accent-4) 216deg,
|
||||
var(--gallery-accent-5) 288deg,
|
||||
var(--gallery-accent-1) 360deg
|
||||
);
|
||||
opacity: 0;
|
||||
transition: opacity 220ms ease;
|
||||
animation: gallery-clockwise-flow 5.2s linear infinite;
|
||||
animation-play-state: paused;
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
mask-composite: exclude;
|
||||
}
|
||||
|
||||
.gallery-card-border-frame::before {
|
||||
inset: -3px;
|
||||
border-radius: calc(1rem + 3px);
|
||||
padding: 3px;
|
||||
filter: blur(1.2px) saturate(1.75) brightness(1.08);
|
||||
}
|
||||
|
||||
.gallery-card-border-frame .gallery-work-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.gallery-work-shell:hover .gallery-card-border-frame::before {
|
||||
opacity: 0.96;
|
||||
animation-play-state: running;
|
||||
}
|
||||
|
||||
.gallery-work-shell:hover .gallery-work-card {
|
||||
border-color: color-mix(in srgb, var(--gallery-accent-1) 48%, white 24%);
|
||||
border-color: color-mix(in srgb, var(--gallery-accent-2) 28%, white 10%);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--gallery-accent-1) 42%, transparent),
|
||||
0 16px 34px rgb(0 0 0 / 0.18),
|
||||
0 0 24px color-mix(in srgb, var(--gallery-accent-2) 22%, transparent),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.12);
|
||||
}
|
||||
|
||||
@@ -273,8 +330,18 @@
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
@keyframes gallery-clockwise-flow {
|
||||
from {
|
||||
--gallery-border-angle: 0deg;
|
||||
}
|
||||
to {
|
||||
--gallery-border-angle: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gallery-work-shell,
|
||||
.gallery-card-border-frame::before,
|
||||
.gallery-work-action-button {
|
||||
animation: none !important;
|
||||
transition-duration: 1ms !important;
|
||||
|
||||
Reference in New Issue
Block a user