Compare commits
29 Commits
886cf4b5c4
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d439e81236 | |||
| 615f4afc2d | |||
| e5f5c7b82e | |||
| e16b88ba7e | |||
| 83ddd2724f | |||
| 7e87cbc3ae | |||
| 521aa7d05c | |||
| 532f93d896 | |||
| 9ebb515032 | |||
| cd59766f92 | |||
| ecbb76312b | |||
| 82933dedf8 | |||
| c4002ebb4f | |||
| a31cf4deec | |||
| 5b686ad87c | |||
| 43242c3bed | |||
| 7a06d42d20 | |||
| eeb7d6b4a6 | |||
| eb13328f9a | |||
| bfb0b46a37 | |||
| c7ed458c8e | |||
| 49cafe7161 | |||
| 58eae96791 | |||
| 1b0ffd41a2 | |||
| 3e520f6abb | |||
| 4d54947a91 | |||
| f121443e52 | |||
| df4526cabd | |||
| bf36a40bb5 |
@@ -24,6 +24,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
build-args: |
|
||||||
|
PB_PUBLIC_URL=${{ vars.PB_PUBLIC_URL }}
|
||||||
tags: |
|
tags: |
|
||||||
docker.allmy.work/${{ gitea.repository }}:latest
|
docker.allmy.work/${{ gitea.repository }}:latest
|
||||||
docker.allmy.work/${{ gitea.repository }}:${{ gitea.sha }}
|
docker.allmy.work/${{ gitea.repository }}:${{ gitea.sha }}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ RUN yarn install --immutable
|
|||||||
FROM node:22-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
ARG PB_PUBLIC_URL
|
||||||
|
ENV PB_PUBLIC_URL=$PB_PUBLIC_URL
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
@@ -22,6 +24,7 @@ RUN addgroup -S -g 1001 nodejs && adduser -S -u 1001 -G nodejs nextjs
|
|||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
RUN mkdir -p .next/cache && chown -R nextjs:nodejs .next/cache
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
@@ -19,9 +19,9 @@ export async function generateStaticParams() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
params: Promise<{ slug?: string[] }>;
|
params: Promise<{ slug?: string[] }>;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Portfolio page — one route per section, sections list always visible.
|
* Portfolio page — one route per section, sections list always visible.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
+1
-1
@@ -14,7 +14,7 @@ export const metadata: Metadata = {
|
|||||||
*/
|
*/
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" data-scroll-behavior="smooth">
|
||||||
<body className={`${fraunces.variable} ${publicSans.variable} pb-footer md:pb-footer-wide`}>
|
<body className={`${fraunces.variable} ${publicSans.variable} pb-footer md:pb-footer-wide`}>
|
||||||
{children}
|
{children}
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
+7
-7
@@ -1,9 +1,10 @@
|
|||||||
import type { NextConfig } from 'next';
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
/* PocketBase origin — used to allowlist remote images.
|
/* Public PocketBase host for the image optimizer allowlist.
|
||||||
* PB_HOSTNAME and PB_PORT are server-only env vars; safe to read here. */
|
* Derived from PB_PUBLIC_URL (e.g. https://cms.allmy.work) at BUILD time —
|
||||||
const pbHostname = process.env.PB_HOSTNAME ?? '127.0.0.1';
|
* remotePatterns is frozen into the build, so PB_PUBLIC_URL must be present
|
||||||
const pbPort = process.env.PB_PORT ?? '8090';
|
* during `next build` in CI (via build-arg), not just at runtime. */
|
||||||
|
const pbPublicHost = process.env.PB_PUBLIC_URL ? new URL(process.env.PB_PUBLIC_URL).hostname : '127.0.0.1';
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
@@ -11,9 +12,8 @@ const nextConfig: NextConfig = {
|
|||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: 'http',
|
protocol: 'https',
|
||||||
hostname: pbHostname,
|
hostname: pbPublicHost,
|
||||||
port: pbPort,
|
|
||||||
pathname: '/api/files/**',
|
pathname: '/api/files/**',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000" fill="none" xmlns:v="https://vecta.io/nano"><path d="M1000 500c0 276.142-223.858 500-500 500S0 776.142 0 500 223.858 0 500 0s500 223.858 500 500z" fill="#f4f0e8"/><path d="M548.399 188.863l5.08 2.98 5.31 3.14 10.86 6.36 37.64 22.31 24.31 14.34 22.14 13.01 21.68 12.74 27.59 16.33 11.89 7.04 17.82 10.6 10.15 5.97 2.29 1.32 4.34 2.48c12.01 6.94 19.11 15.63 22.82 29.08 2.58 8.48 6.64 16.47 10.39 24.48l13.44 29.44 1.19 2.65 15.58 35.08 8.47 18.57 10.13 22.46 9.25 20.24 9.32 20.38.9 1.99 14.33 32.09 11.12 24.38c5.32 11.45 7.13 21.43 2.9 33.66-4.87 11.08-12.12 15.85-22.4 21.67l-13.85 8.17-8.82 5.23-1.77 1.04-11.03 6.52-25.76 15.31-33.84 20.14-16.66 9.86-3.34 1.98-18.26 10.84-39.33 23.24-1.83 1.07-3.6 2.11-46.12 27.36-23.39 13.9-17.5 10.44-28.56 16.88-20.14 11.89c-18.05 10.75-34.67 20.4-56.43 15.11-9.12-2.9-17.13-7.74-25.31-12.63l-2.94-1.75-15.97-9.6-15.4-9.02c-7.83-4.5-15.55-9.17-23.25-13.88-10.2-6.23-20.48-12.28-30.85-18.22l-28.56-16.9-17.22-10.26-2.7-1.59-5.38-3.17-42.96-25.56-28.22-16.69-23.55-13.96-16.62-9.9-1.74-1.03-25.73-15.14-33.78-20.03-4.23-2.56c-11.94-7.23-18.46-13.09-22.47-26.69-1.71-8.23-.74-15.3 2.59-22.88.15-.37.15-.37.94-2.22 3.26-7.6 6.71-15.11 10.2-22.61l5.64-12.2 1.12-2.43 8.46-18.9 14.81-33.18 1.24-2.74 17.94-39.37 16.42-36.34 9.38-20.84 5.25-11.62 4.32-9.55 4.95-10.98 1.49-3.29c2.47-5.5 4.58-10.9 6.14-16.74 4.02-12.73 15.77-19.9 26.68-26.16l5.74-3.31 9.92-5.71 22.65-13.35 27.07-16.07 47.41-28 10.09-6 2.03-1.2 32.84-19.35 19.12-11.26 8.05-4.74 10.1-6.08c9.76-5.92 18.87-10.15 29.98-12.93l1.88-.54c22.28-4.83 45.18 1.99 64.12 13.29zm-91.34 25.69c-3.68 2.68-7.6 4.77-11.61 6.91-5.64 3.05-11.15 6.34-16.67 9.59l-3.84 2.25-27.29 16.28-25.52 14.9-25.46 15.06-30.61 18.02-19.66 11.55-2.62 1.54-14.1 8.34-9.84 5.74-2.07 1.2-5.37 3.08c-4.44 3.11-7.64 6.09-8.62 11.6.77 4.33 2.45 6.6 5.62 9.5 3.56 2.44 7.29 4.53 11.06 6.63l6.56 3.73 3.29 1.87c4.3 2.46 8.54 5.01 12.78 7.58l25.12 14.88 21.69 12.81 21.25 12.56 54.94 32.57 33.61 19.8 9.7 5.69 6.17 3.62 7.6 4.58c12.1 7.39 25.07 9.88 39.23 7.12 13.28-4.14 25.32-12.74 37.09-19.87 4.27-2.59 8.56-5.12 12.91-7.57 6.26-3.53 12.41-7.23 18.56-10.94l27.82-16.5 32.29-19.03 17.95-10.59 20.13-12 26.09-15.48 5.51-3.24 17.27-10.01 3.09-1.78 5.68-3.23c7.71-4.47 7.71-4.47 9.61-9.2.65-4.46.51-6.29-2.14-9.98-2.67-3.03-5.58-4.96-9.09-6.97l-1.79-1.04-5.73-3.26-3.97-2.29-7.81-4.47-12.41-7.24-3.95-2.32-9-5.32-5.36-3.17-2.74-1.62-22.04-12.95-30.97-18.37-51.53-30.48-22.52-13.16-5.62-3.29-15.63-9.27-2.33-1.39-4.01-2.46c-23.38-14.06-50.24-5.72-70.7 9.49zm-214.66 153.56a627.1 627.1 0 0 0-13.62 28.56l-1.83 4.07-5.55 12.37-1.67 3.72-1.6 3.58-1.59 3.55-3.17 7.09-12.47 27.56-22.07 49.01-18.69 41.38-3.11 6.81-1.99 4.35-.97 2.12-5.46 11.84-2.21 4.8-.97 2.04c-1.76 3.88-2.75 6.91-2.03 11.15 3.81 6.22 9.69 9.08 15.94 12.56l6.5 3.71 3.32 1.88 14.68 8.6 5.44 3.22 2.61 1.55 11.45 6.73 17.4 10.36 16.35 9.67 13.06 7.78 15.18 9 1.9 1.11 9.48 5.54 20.72 12.47c4.07 2.49 8.2 4.89 12.35 7.26l16.93 9.93 18.25 10.88 2.86 1.68 8.58 5.07 40.42 23.94 22.83 13.5 20.32 12.11 17.75 10.5 22.28 13.48 2.87 1.76 2.57 1.58c1.96 1.13 1.96 1.13 2.96 1.13v-292l-12-3c-12.22-4.97-23.51-12.32-34.79-19.09l-16.24-9.55-27.3-16.16-32.83-19.39-1.85-1.08-1.86-1.1-3.75-2.19-1.9-1.11-24.79-14.73-22.5-13.29-25.46-15.07-17.72-10.49c-6.31-3.72-12.56-7.53-18.74-11.46-2.27-1.29-2.27-1.29-4.27-1.29zm512.06.85l-2.2 1.36-2.47 1.51-2.64 1.65-5.52 3.39-2.73 1.69-9.44 5.74-1.71 1.02-32.06 18.86-54.01 31.92-37.08 21.85-24.87 14.77-22.59 13.3-13.69 8.06c-10.31 6.2-19.16 10.39-31.05 13.03v292l18.81-10.56 2.15-1.28 4.62-2.73 7.53-4.44 32.91-19.6 25.98-15.39 26.56-15.75 22.13-13.06 28.08-16.64 41.77-24.75 38.46-22.8 26.08-15.5 19.73-11.69 22.44-13.25 5.82-3.43 4.02-2.35 6.22-3.65 1.87-1.09c4.04-2.39 7.68-4.76 9.82-9.04.43-5.52-.56-9.03-2.96-13.98l-.94-1.96-3.04-6.25-2.09-4.36-4.12-8.59c-2.26-4.69-4.42-9.42-6.54-14.17l-1.04-2.34-5.92-13.33-2.91-6.58-1.52-3.42-13.69-30.18-15.17-33.71-18.88-41.69-10.04-22.18-.91-2.08-4.21-9.66-1.48-3.38-1.28-2.95c-1.64-2.85-3.07-3.71-6.2-2.34z" fill="#1334da"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000" fill="none" xmlns:v="https://vecta.io/nano"><path d="M1000 500c0 276.142-223.858 500-500 500S0 776.142 0 500 223.858 0 500 0s500 223.858 500 500z" fill="#f4f0e8"/><path d="M483.953 210.023c-12.558 3.37-26.493 11.531-155.686 92.422-49.373 30.867-48.34 29.98-55.566 40.446C263.928 355.486 170 572.083 170 579.711c0 9.934 6.365 15.611 40.083 36.543l113.023 69.361 119.56 73.619c59.866 37.075 56.081 36.011 79.305 21.464l269.398-165.863c18.923-11.53 33.545-21.819 35.61-24.835 7.053-10.289 11.526 1.419-75.177-197.084-19.611-45.058-24.428-53.573-33.546-60.846-10.493-8.16-177.018-111.581-188.715-117.08-8.946-4.257-13.935-5.322-25.805-5.854-8.085-.355-16.859 0-19.783.887zm69.156 60.491l90.487 56.411c27.352 17.03 49.028 31.576 48.168 32.286-1.72 1.774-29.245 18.981-120.592 75.215l-70.876 43.816-95.821-59.072-95.648-60.846c0-1.597 163.772-104.662 176.846-111.048 4.989-2.484 9.634-3.371 16.687-2.839 9.118.533 12.558 2.306 50.749 26.077zM386.584 450.569l96.509 59.604v115.483c0 91.535-.516 115.129-2.065 114.419-1.204-.355-22.019-12.95-46.103-27.851l-113.54-69.715-111.818-70.426c0-.709 10.321-24.835 22.88-53.395l39.222-89.761c8.774-20.755 16.687-37.785 17.375-37.785s44.556 26.786 97.54 59.427zm327.888-55.879c.688 2.128 14.278 33.882 30.449 70.602 45.588 104.308 46.62 106.969 45.071 108.388-.86.887-28.556 18.094-61.758 38.317L593.707 694.84l-75.176 45.767c-.688 0-.86-51.976-.688-115.66l.516-115.661 88.595-54.637 95.476-58.895c8.429-5.499 10.665-5.677 12.042-1.064z" fill="#041cf3"/></svg>
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -55,10 +55,5 @@ describe('SectionAccordion', () => {
|
|||||||
render(<SectionAccordion {...activeProps} />);
|
render(<SectionAccordion {...activeProps} />);
|
||||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('content wrapper has section-content class', () => {
|
|
||||||
const { container } = render(<SectionAccordion {...activeProps} />);
|
|
||||||
expect(container.querySelector('.section-content')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,13 +39,13 @@ export function SectionAccordion({ number, title, id, isActive, href, children }
|
|||||||
<section id={id} className="scroll-mt-8">
|
<section id={id} className="scroll-mt-8">
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
<div className="mb-6 sm:mb-12">
|
<div className="mb-6 sm:mb-12">
|
||||||
<ViewTransitionWrapper name="section-content">
|
<ViewTransitionWrapper name="section-title">
|
||||||
<div className="mb-6 sm:mb-16">
|
<div className="mb-6 sm:mb-12">
|
||||||
<h1 className="font-heading font-black text-xl sm:text-section-title leading-[1.2] mb-0">{heading}</h1>
|
<h1 className="font-heading font-black text-xl sm:text-section-title leading-[1.2] mb-0">{heading}</h1>
|
||||||
</div>
|
</div>
|
||||||
</ViewTransitionWrapper>
|
</ViewTransitionWrapper>
|
||||||
<ViewTransitionWrapper name="section-body">
|
<ViewTransitionWrapper name="section-body">
|
||||||
<div className="section-content">{children}</div>
|
<div>{children}</div>
|
||||||
</ViewTransitionWrapper>
|
</ViewTransitionWrapper>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Badge, Card, CardSidebar, CardTitle, RichText } from '$shared/ui';
|
import { Badge, Card, CardSidebar, CardTitle, RichText } from '$shared/ui';
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* Job title
|
* Job title
|
||||||
*/
|
*/
|
||||||
@@ -25,7 +25,7 @@ type Props = {
|
|||||||
* Additional CSS classes forwarded to the card
|
* Additional CSS classes forwarded to the card
|
||||||
*/
|
*/
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Work experience card with sidebar layout.
|
* Work experience card with sidebar layout.
|
||||||
@@ -37,9 +37,9 @@ export function ExperienceCard({ title, company, period, description, stack, cla
|
|||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<CardSidebar
|
<CardSidebar
|
||||||
sidebar={
|
sidebar={
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-3 sm:gap-4">
|
||||||
<p className="text-sm font-medium brutal-border-left pl-3">{period}</p>
|
<p className="text-sm font-medium brutal-border-left pl-3">{period}</p>
|
||||||
<p className="text-base font-medium">{company}</p>
|
<p className="text-lg font-black">{company}</p>
|
||||||
{stack.length > 0 && (
|
{stack.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{stack.map((tech) => (
|
{stack.map((tech) => (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Image from 'next/image';
|
|||||||
import { Card, RichText } from '$shared/ui';
|
import { Card, RichText } from '$shared/ui';
|
||||||
import { ProjectMetadata } from '../ProjectMetadata/ProjectMetadata';
|
import { ProjectMetadata } from '../ProjectMetadata/ProjectMetadata';
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* Project name
|
* Project name
|
||||||
*/
|
*/
|
||||||
@@ -36,7 +36,7 @@ type Props = {
|
|||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
reverse?: boolean;
|
reverse?: boolean;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-width detailed project card with metadata sidebar.
|
* Full-width detailed project card with metadata sidebar.
|
||||||
|
|||||||
@@ -124,5 +124,11 @@ describe('ProjectCard', () => {
|
|||||||
const imgWrapper = container.querySelector('img')?.parentElement;
|
const imgWrapper = container.querySelector('img')?.parentElement;
|
||||||
expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border');
|
expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('image is wrapped in a lightbox button with cursor-zoom-in', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />);
|
||||||
|
const btn = screen.getByRole('button', { name: DEFAULT_PROPS.title });
|
||||||
|
expect(btn).toHaveClass('cursor-zoom-in');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import Image from 'next/image';
|
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
import { Badge, Button, Card, CardSidebar, CardTitle, RichText } from '$shared/ui';
|
import { Badge, Button, Card, CardSidebar, CardTitle, ImageLightbox, RichText } from '$shared/ui';
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* Project name
|
* Project name
|
||||||
*/
|
*/
|
||||||
@@ -27,14 +26,20 @@ type Props = {
|
|||||||
* Optional preview image URL
|
* Optional preview image URL
|
||||||
*/
|
*/
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
};
|
/**
|
||||||
|
* Skip lazy-loading the preview image. Set true for above-the-fold cards
|
||||||
|
* (typically the first card in the list) to improve LCP.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
priority?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Project card with sidebar layout.
|
* Project card with sidebar layout.
|
||||||
* Sidebar: year badge, stack tags, View Project button.
|
* Sidebar: year badge, stack tags, View Project button.
|
||||||
* Main: title, optional image, description.
|
* Main: title, optional image, description.
|
||||||
*/
|
*/
|
||||||
export function ProjectCard({ title, year, description, tags, url, imageUrl }: Props) {
|
export function ProjectCard({ title, year, description, tags, url, imageUrl, priority = false }: Props) {
|
||||||
return (
|
return (
|
||||||
<Card className={cn('group hover:shadow-brutal-xl transition-shadow duration-300')}>
|
<Card className={cn('group hover:shadow-brutal-xl transition-shadow duration-300')}>
|
||||||
<CardSidebar
|
<CardSidebar
|
||||||
@@ -50,7 +55,7 @@ export function ProjectCard({ title, year, description, tags, url, imageUrl }: P
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button href={url} variant="primary" size="sm" className="w-full">
|
<Button href={url} variant="primary" size="sm" className="self-start lg:w-full lg:self-auto text-center">
|
||||||
View Project
|
View Project
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,9 +64,7 @@ export function ProjectCard({ title, year, description, tags, url, imageUrl }: P
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<CardTitle className="font-heading">{title}</CardTitle>
|
<CardTitle className="font-heading">{title}</CardTitle>
|
||||||
{imageUrl && (
|
{imageUrl && (
|
||||||
<div className="brutal-border aspect-video bg-blue overflow-hidden relative">
|
<ImageLightbox src={imageUrl} alt={title} priority={priority} className="brutal-border bg-blue" />
|
||||||
<Image src={imageUrl} alt={title} fill className="object-cover" />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<RichText html={description} />
|
<RichText html={description} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* Project year
|
* Project year
|
||||||
*/
|
*/
|
||||||
@@ -17,7 +17,7 @@ type Props = {
|
|||||||
* Additional CSS classes
|
* Additional CSS classes
|
||||||
*/
|
*/
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sidebar metadata display for a project: year, role, and stack.
|
* Sidebar metadata display for a project: year, role, and stack.
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export interface Props {
|
||||||
|
/**
|
||||||
|
* CSS classes on the svg element
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close / X icon (Lucide).
|
||||||
|
*/
|
||||||
|
export function CloseIcon({ className }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden={true}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M18 6 6 18" />
|
||||||
|
<path d="m6 6 12 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export interface Props {
|
||||||
|
/**
|
||||||
|
* CSS classes on the svg element
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Magnify / search icon (Lucide).
|
||||||
|
*/
|
||||||
|
export function MagnifyIcon({ className }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden={true}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="m21 21-4.34-4.34" />
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { CloseIcon } from './CloseIcon';
|
||||||
|
export { MagnifyIcon } from './MagnifyIcon';
|
||||||
@@ -5,7 +5,7 @@ export function buildFileUrl(
|
|||||||
collectionId: string,
|
collectionId: string,
|
||||||
recordId: string,
|
recordId: string,
|
||||||
filename: string,
|
filename: string,
|
||||||
baseUrl: string = process.env.NEXT_PUBLIC_PB_URL ?? 'http://127.0.0.1:8090',
|
baseUrl: string = process.env.PB_PUBLIC_URL ?? 'http://127.0.0.1:8090',
|
||||||
): string {
|
): string {
|
||||||
return `${baseUrl}/api/files/${collectionId}/${recordId}/${filename}`;
|
return `${baseUrl}/api/files/${collectionId}/${recordId}/${filename}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+117
-43
@@ -95,6 +95,7 @@
|
|||||||
--duration-slow: 350ms;
|
--duration-slow: 350ms;
|
||||||
--duration-spring: 220ms;
|
--duration-spring: 220ms;
|
||||||
--delay-normal: 200ms;
|
--delay-normal: 200ms;
|
||||||
|
--slide-section-body-in: clamp(1.25rem, 5vw, 3rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -152,6 +153,10 @@
|
|||||||
|
|
||||||
html {
|
html {
|
||||||
font-size: var(--font-size);
|
font-size: var(--font-size);
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
/* Reserve scrollbar gutter so locking body scroll (e.g. when a modal
|
||||||
|
* opens) doesn't widen the viewport and shift fixed elements. */
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -162,30 +167,16 @@
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subtle blue-tinted grain on parchment */
|
/* Page-wide blue dot grain overlay. z-index 100 puts it above the footer
|
||||||
|
* (z-50) so the grain reads as continuous across the entire viewport;
|
||||||
|
* pointer-events: none keeps everything clickable through it. */
|
||||||
body::before {
|
body::before {
|
||||||
|
@apply grain-pattern;
|
||||||
content: "";
|
content: "";
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-image:
|
|
||||||
repeating-linear-gradient(
|
|
||||||
0deg,
|
|
||||||
transparent,
|
|
||||||
transparent 2px,
|
|
||||||
rgba(4, 28, 243, 0.015) 2px,
|
|
||||||
rgba(4, 28, 243, 0.015) 4px
|
|
||||||
),
|
|
||||||
repeating-linear-gradient(
|
|
||||||
90deg,
|
|
||||||
transparent,
|
|
||||||
transparent 2px,
|
|
||||||
rgba(4, 28, 243, 0.015) 2px,
|
|
||||||
rgba(4, 28, 243, 0.015) 4px
|
|
||||||
);
|
|
||||||
opacity: 0.6;
|
|
||||||
display: block;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
@@ -270,6 +261,18 @@
|
|||||||
@utility brutal-border-right {
|
@utility brutal-border-right {
|
||||||
border-right: var(--border-width) solid var(--blue);
|
border-right: var(--border-width) solid var(--blue);
|
||||||
}
|
}
|
||||||
|
/* Border drawn as an outline — painted after children, so an image's
|
||||||
|
* subpixel paint bleed can't cover it. Doesn't take layout space; the
|
||||||
|
* ancestor must not have overflow:hidden or the outline gets clipped. */
|
||||||
|
@utility brutal-outline {
|
||||||
|
outline: var(--border-width) solid var(--blue);
|
||||||
|
}
|
||||||
|
/* Tiled blue dot pattern — applied to body::before (page-wide) and reusable
|
||||||
|
* on any surface that should share the same paper-grain texture. The SVG
|
||||||
|
* tile is rasterized once and composited cheaply via repeating background. */
|
||||||
|
@utility grain-pattern {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Ccircle cx='1' cy='1' r='1' fill='%23041cf3' opacity='0.10'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
/* Apply Fraunces variable axes to non-heading elements using the heading font */
|
/* Apply Fraunces variable axes to non-heading elements using the heading font */
|
||||||
.font-wonk {
|
.font-wonk {
|
||||||
font-variation-settings:
|
font-variation-settings:
|
||||||
@@ -313,15 +316,12 @@
|
|||||||
|
|
||||||
.rich-text ul {
|
.rich-text ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding-left: 0;
|
padding-left: 1.5em;
|
||||||
margin: 1em 0;
|
margin: 1em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rich-text ul li {
|
.rich-text ul li {
|
||||||
display: grid;
|
text-indent: -1.5em;
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
gap: 0.65em;
|
|
||||||
align-items: start;
|
|
||||||
margin-top: 0.5em;
|
margin-top: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,6 +331,10 @@
|
|||||||
|
|
||||||
.rich-text ul li::before {
|
.rich-text ul li::before {
|
||||||
content: "◆";
|
content: "◆";
|
||||||
|
display: inline-block;
|
||||||
|
width: calc(1.5em / 0.55);
|
||||||
|
/* reset inherited text-indent so glyph isn't shifted inside the ::before box */
|
||||||
|
text-indent: 0;
|
||||||
color: var(--blue);
|
color: var(--blue);
|
||||||
font-size: 0.55em;
|
font-size: 0.55em;
|
||||||
/* line-height matches parent so diamond centers within the first line box */
|
/* line-height matches parent so diamond centers within the first line box */
|
||||||
@@ -338,14 +342,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Cross-section view transition (navigation between sections) */
|
/* Cross-section view transition (navigation between sections) */
|
||||||
::view-transition-old(section-content) {
|
::view-transition-old(section-title) {
|
||||||
animation-name: section-fade-out;
|
animation-name: section-fade-out;
|
||||||
animation-duration: var(--duration-normal);
|
animation-duration: var(--duration-normal);
|
||||||
animation-timing-function: var(--ease-default);
|
animation-timing-function: var(--ease-default);
|
||||||
animation-fill-mode: both;
|
animation-fill-mode: both;
|
||||||
}
|
}
|
||||||
|
|
||||||
::view-transition-new(section-content) {
|
::view-transition-new(section-title) {
|
||||||
animation-name: section-fade-in;
|
animation-name: section-fade-in;
|
||||||
animation-duration: var(--duration-spring);
|
animation-duration: var(--duration-spring);
|
||||||
animation-timing-function: var(--ease-spring);
|
animation-timing-function: var(--ease-spring);
|
||||||
@@ -374,12 +378,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Section body slide-in from right */
|
/* Disable group geometry interpolation — OLD and NEW live at different scroll
|
||||||
|
* positions, so morphing the container drags the slide-in across the viewport.
|
||||||
|
* Let old/new each animate at their own positions instead. */
|
||||||
|
::view-transition-group(section-body) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section body — snap OLD out and slide NEW in. Running an explicit OLD
|
||||||
|
* animation left a visible ghost of the previous section's content sitting
|
||||||
|
* behind the incoming slide; hiding it immediately keeps the transition
|
||||||
|
* clean and prevents the two-content overlap. */
|
||||||
::view-transition-old(section-body) {
|
::view-transition-old(section-body) {
|
||||||
animation-name: section-body-out;
|
animation: none;
|
||||||
animation-duration: var(--duration-normal);
|
opacity: 0;
|
||||||
animation-timing-function: var(--ease-default);
|
|
||||||
animation-fill-mode: both;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
::view-transition-new(section-body) {
|
::view-transition-new(section-body) {
|
||||||
@@ -387,27 +399,89 @@
|
|||||||
animation-duration: var(--duration-spring);
|
animation-duration: var(--duration-spring);
|
||||||
animation-timing-function: var(--ease-spring);
|
animation-timing-function: var(--ease-spring);
|
||||||
animation-fill-mode: both;
|
animation-fill-mode: both;
|
||||||
|
/* Hold the start-state for this delay before the slide-in begins — gives
|
||||||
|
* the snap-out a beat to register visually before new content arrives. */
|
||||||
animation-delay: var(--delay-normal);
|
animation-delay: var(--delay-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes section-body-out {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0) scale(1);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-12px) scale(0.98);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes section-body-in {
|
@keyframes section-body-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(48px) scale(0.98);
|
transform: translateX(var(--slide-section-body-in)) scale(0.98);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(0) scale(1);
|
transform: translateX(0) scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Page-load entry animation for the footer. Previously also carried
|
||||||
|
* `view-transition-name: site-footer` to layer above section-body's slide
|
||||||
|
* group, but the section-body group now has `animation: none` (purely
|
||||||
|
* horizontal slide of OLD/NEW snapshots), so the footer can live in the root
|
||||||
|
* snapshot during transitions — which also lets the lightbox dialog group
|
||||||
|
* stack cleanly above it. */
|
||||||
|
.footer-vt {
|
||||||
|
animation: footer-enter var(--duration-slow) var(--ease-spring) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes footer-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lightbox dialog backdrop — flat cream wash. No filter, no gradient.
|
||||||
|
* Cheapest possible; Firefox-friendly during view transitions. */
|
||||||
|
dialog.lightbox::backdrop {
|
||||||
|
background-color: color-mix(in srgb, var(--cream) 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Give the lightbox dialog its own view-transition group so it can stack
|
||||||
|
* above the footer's named group (z=10) during the open/close transition.
|
||||||
|
* Closed dialogs have `display: none` (UA) and don't get snapshotted, so
|
||||||
|
* sharing the name across all `.lightbox` dialogs at rest is harmless —
|
||||||
|
* only the open one participates in any given transition. */
|
||||||
|
dialog.lightbox {
|
||||||
|
view-transition-name: lightbox-dialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-group(lightbox-dialog) {
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The image wrapper (and the thumb during the OLD snapshot of an open
|
||||||
|
* transition) shares this static name — imperatively assigned only during
|
||||||
|
* the morph, so there's never more than one element holding it at a time.
|
||||||
|
* z=30 keeps it above the dialog (z=20) and the footer (z=10) during VT. */
|
||||||
|
::view-transition-group(lightbox-frame) {
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lightbox image sizing — leaves vertical headroom for the fixed close button
|
||||||
|
* (sits at top-3, ~2.5rem tall). 8rem total = ~4rem top/bottom after centering,
|
||||||
|
* so the button never overlaps the image.
|
||||||
|
* box-sizing: content-box so max-w/max-h apply to the image pixels and the
|
||||||
|
* 3px brutal-border sits outside them — avoids subpixel clipping of the
|
||||||
|
* border by the dialog's overflow:hidden when both have border-box. */
|
||||||
|
@utility lightbox-image {
|
||||||
|
box-sizing: content-box;
|
||||||
|
max-width: calc(100vw - 2rem);
|
||||||
|
max-height: calc(100vh - 10rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable transition animation for Firefox
|
||||||
|
* since it isn't supported yet */
|
||||||
|
@supports (-moz-appearance: none) {
|
||||||
|
::view-transition-group(lightbox-frame),
|
||||||
|
::view-transition-old(lightbox-frame),
|
||||||
|
::view-transition-new(lightbox-frame) {
|
||||||
|
animation-duration: 0s !important;
|
||||||
|
animation-delay: 0s !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ describe('Button', () => {
|
|||||||
});
|
});
|
||||||
it('applies outline variant', () => {
|
it('applies outline variant', () => {
|
||||||
render(<Button variant="outline">Go</Button>);
|
render(<Button variant="outline">Go</Button>);
|
||||||
expect(screen.getByRole('button')).toHaveClass('bg-transparent');
|
expect(screen.getByRole('button')).toHaveClass('bg-cream');
|
||||||
});
|
});
|
||||||
it('applies ghost variant', () => {
|
it('applies ghost variant', () => {
|
||||||
render(<Button variant="ghost">Go</Button>);
|
render(<Button variant="ghost">Go</Button>);
|
||||||
expect(screen.getByRole('button')).toHaveClass('bg-cream');
|
expect(screen.getByRole('button')).toHaveClass('bg-transparent');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('sizes', () => {
|
describe('sizes', () => {
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ const VARIANTS = {
|
|||||||
secondary:
|
secondary:
|
||||||
'brutal-border bg-blue text-cream shadow-brutal hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-md active:translate-x-0.5 active:translate-y-0.5 active:shadow-brutal-xs',
|
'brutal-border bg-blue text-cream shadow-brutal hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-md active:translate-x-0.5 active:translate-y-0.5 active:shadow-brutal-xs',
|
||||||
outline:
|
outline:
|
||||||
'brutal-border bg-transparent text-blue shadow-brutal hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-md active:translate-x-0.5 active:translate-y-0.5 active:shadow-brutal-xs',
|
|
||||||
ghost:
|
|
||||||
'brutal-border border-blue/35 bg-cream text-blue hover:border-blue hover:bg-blue/10 active:bg-blue active:text-cream',
|
'brutal-border border-blue/35 bg-cream text-blue hover:border-blue hover:bg-blue/10 active:bg-blue active:text-cream',
|
||||||
|
ghost:
|
||||||
|
'brutal-border bg-transparent text-blue hover:-translate-x-0.5 hover:-translate-y-0.5 active:translate-x-0.5 active:translate-y-0.5',
|
||||||
} as const satisfies Record<ButtonVariant, string>;
|
} as const satisfies Record<ButtonVariant, string>;
|
||||||
|
|
||||||
const SIZES = {
|
const SIZES = {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ImageLightbox } from './ui/ImageLightbox';
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
|
import { ImageLightbox } from './ImageLightbox';
|
||||||
|
|
||||||
|
const meta: Meta<typeof ImageLightbox> = {
|
||||||
|
title: 'Shared/ImageLightbox',
|
||||||
|
component: ImageLightbox,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof ImageLightbox>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
src: 'https://picsum.photos/800/450',
|
||||||
|
alt: 'Sample project image',
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<div className="p-8 max-w-2xl">
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { ImageLightbox } from './ImageLightbox';
|
||||||
|
|
||||||
|
// jsdom does not implement dialog methods — mock them
|
||||||
|
beforeAll(() => {
|
||||||
|
HTMLDialogElement.prototype.showModal = vi.fn();
|
||||||
|
HTMLDialogElement.prototype.close = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEFAULT_PROPS = { src: '/project.jpg', alt: 'My Project' };
|
||||||
|
|
||||||
|
describe('ImageLightbox', () => {
|
||||||
|
describe('thumbnail', () => {
|
||||||
|
it('renders a thumbnail image', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByRole('img', { name: 'My Project' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('thumbnail button has cursor-zoom-in', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
const btn = screen.getByRole('button', { name: 'My Project' });
|
||||||
|
expect(btn).toHaveClass('cursor-zoom-in');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards className to the thumbnail button', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} className="extra-class" />);
|
||||||
|
expect(screen.getByRole('button', { name: 'My Project' })).toHaveClass('extra-class');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dialog', () => {
|
||||||
|
it('clicking the thumbnail opens the dialog', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'My Project' }));
|
||||||
|
expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking the close button closes the dialog', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'My Project' })); // open first
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /close/i, hidden: true }));
|
||||||
|
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking the backdrop (dialog element itself) closes the dialog', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
fireEvent.click(dialog, { target: dialog });
|
||||||
|
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking inside the dialog (not backdrop) does not close', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
const inner = dialog.querySelector('img') as HTMLImageElement;
|
||||||
|
fireEvent.click(inner);
|
||||||
|
expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dialog has accessible label matching alt text', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
expect(document.querySelector('dialog')).toHaveAttribute('aria-label', 'My Project');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opening the lightbox blocks body scroll', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'My Project' }));
|
||||||
|
expect(document.body.style.overflow).toBe('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closing the lightbox restores body scroll', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'My Project' }));
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
fireEvent(dialog, new Event('close'));
|
||||||
|
expect(document.body.style.overflow).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('close button is positioned fixed', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
const closeBtn = screen.getByRole('button', { name: /close/i, hidden: true });
|
||||||
|
expect(closeBtn).toHaveClass('fixed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { type SyntheticEvent, useRef } from 'react';
|
||||||
|
import { CloseIcon } from '$shared/assets/icons';
|
||||||
|
import { cn } from '$shared/lib';
|
||||||
|
import { Button } from '$shared/ui/Button';
|
||||||
|
import { Modal, type ModalHandle } from '$shared/ui/Modal';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
/**
|
||||||
|
* Image source URL
|
||||||
|
*/
|
||||||
|
src: string;
|
||||||
|
/**
|
||||||
|
* Image alt text, also used as the dialog accessible label
|
||||||
|
*/
|
||||||
|
alt: string;
|
||||||
|
/**
|
||||||
|
* CSS classes forwarded to the thumbnail button wrapper
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
/**
|
||||||
|
* Skip lazy-loading and preload the thumbnail. Set true for above-the-fold
|
||||||
|
* images to improve LCP.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
priority?: boolean;
|
||||||
|
/**
|
||||||
|
* Responsive `sizes` attribute for the thumbnail. Without this, next/image
|
||||||
|
* `fill` defaults to `100vw` and the browser fetches the largest srcset
|
||||||
|
* variant. Tune to match the actual rendered width at each breakpoint.
|
||||||
|
* @default '(min-width: 1024px) 56rem, 100vw'
|
||||||
|
*/
|
||||||
|
sizes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clickable image thumbnail that opens a fullscreen brutalist dialog on click.
|
||||||
|
*
|
||||||
|
* Uses the View Transitions API to morph the thumbnail's rect into the dialog
|
||||||
|
* frame's rect (and back on close). The view-transition-name is seated
|
||||||
|
* just-in-time around each transition rather than living on the thumb at rest
|
||||||
|
* — a persistent name would isolate the thumb from any parent transition
|
||||||
|
* (e.g. the section-body slide-in), causing the image to snap into place
|
||||||
|
* while the rest of the section animates.
|
||||||
|
*/
|
||||||
|
export function ImageLightbox({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
className,
|
||||||
|
priority = false,
|
||||||
|
sizes = '(min-width: 1024px) 56rem, 100vw',
|
||||||
|
}: Props) {
|
||||||
|
const modalRef = useRef<ModalHandle>(null);
|
||||||
|
const thumbRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const dialogFrameRef = useRef<HTMLDivElement>(null);
|
||||||
|
/* Shared static name across all instances. Only one dialog can be open at a
|
||||||
|
* time (showModal is browser-exclusive), and we set the name imperatively
|
||||||
|
* only during a transition — so at any snapshot, exactly one element has it. */
|
||||||
|
const vtName = 'lightbox-frame';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops the view-transition-name from both thumb and dialog frame. Called
|
||||||
|
* after the lightbox close transition settles (and as a safety net on any
|
||||||
|
* unexpected close path) so the thumb rejoins parent transitions.
|
||||||
|
*/
|
||||||
|
function clearVtNames() {
|
||||||
|
if (thumbRef.current) {
|
||||||
|
thumbRef.current.style.viewTransitionName = '';
|
||||||
|
}
|
||||||
|
if (dialogFrameRef.current) {
|
||||||
|
dialogFrameRef.current.style.viewTransitionName = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `mutate` inside a view transition when supported; falls back to a
|
||||||
|
* plain synchronous call otherwise (Firefox without VT support, jsdom).
|
||||||
|
* Returns the transition handle so callers can await `finished` for cleanup.
|
||||||
|
*/
|
||||||
|
function withTransition(mutate: () => void): { finished: Promise<void> } | null {
|
||||||
|
const doc = document as Document & {
|
||||||
|
startViewTransition?: (cb: () => void) => { finished: Promise<void> };
|
||||||
|
};
|
||||||
|
if (typeof doc.startViewTransition === 'function') {
|
||||||
|
return doc.startViewTransition(mutate);
|
||||||
|
}
|
||||||
|
mutate();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
/* Seat the name on the thumb *before* startViewTransition so it's
|
||||||
|
* captured in the OLD snapshot. The thumb otherwise carries no vt-name. */
|
||||||
|
if (thumbRef.current) {
|
||||||
|
thumbRef.current.style.viewTransitionName = vtName;
|
||||||
|
}
|
||||||
|
withTransition(() => {
|
||||||
|
if (thumbRef.current) {
|
||||||
|
thumbRef.current.style.viewTransitionName = '';
|
||||||
|
}
|
||||||
|
if (dialogFrameRef.current) {
|
||||||
|
dialogFrameRef.current.style.viewTransitionName = vtName;
|
||||||
|
}
|
||||||
|
modalRef.current?.open();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
const transition = withTransition(() => {
|
||||||
|
if (dialogFrameRef.current) {
|
||||||
|
dialogFrameRef.current.style.viewTransitionName = '';
|
||||||
|
}
|
||||||
|
if (thumbRef.current) {
|
||||||
|
thumbRef.current.style.viewTransitionName = vtName;
|
||||||
|
}
|
||||||
|
modalRef.current?.close();
|
||||||
|
});
|
||||||
|
/* Drop the name from the thumb once the transition settles. Otherwise the
|
||||||
|
* thumb stays its own snapshot until the next open, isolated from any
|
||||||
|
* parent transition that runs in the meantime. */
|
||||||
|
if (transition) {
|
||||||
|
transition.finished.finally(clearVtNames);
|
||||||
|
} else {
|
||||||
|
clearVtNames();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercept ESC so it also runs through our view-transition-wrapped close.
|
||||||
|
* Without this, ESC would snap the dialog away without the morph.
|
||||||
|
*/
|
||||||
|
function handleCancel(e: SyntheticEvent<HTMLDialogElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={thumbRef}
|
||||||
|
type="button"
|
||||||
|
onClick={open}
|
||||||
|
aria-label={alt}
|
||||||
|
className={cn('relative block w-full aspect-video overflow-hidden cursor-zoom-in', className)}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
fill
|
||||||
|
loading={priority ? 'eager' : undefined}
|
||||||
|
priority={priority}
|
||||||
|
sizes={sizes}
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
ref={modalRef}
|
||||||
|
aria-label={alt}
|
||||||
|
className="lightbox bg-cream overflow-visible"
|
||||||
|
onClose={clearVtNames}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onBackdropClose={close}
|
||||||
|
>
|
||||||
|
{/* Wrapper carries the border as an `outline` (not `border`) — paint
|
||||||
|
* order is border→children→outline, so an `outline` is drawn ON TOP
|
||||||
|
* of any subpixel image bleed and stays fully visible. The dialog
|
||||||
|
* uses `overflow-visible` because outline can be clipped by an
|
||||||
|
* ancestor's overflow:hidden.
|
||||||
|
* The wrapper is the named VT element so the brutalist frame
|
||||||
|
* participates in the morph.
|
||||||
|
* aria-hidden: the dialog element itself carries the accessible label. */}
|
||||||
|
<div ref={dialogFrameRef} className="brutal-outline block w-fit">
|
||||||
|
{/* Explicit width/height are placeholders next/image requires when not using `fill`; CSS
|
||||||
|
* (`lightbox-image` max-w/max-h + `w-auto h-auto`) drives the actual rendered size, so
|
||||||
|
* the dialog still hugs the image's intrinsic dimensions (capped at viewport bounds).
|
||||||
|
* `sizes="100vw"` hints the browser to fetch the srcset variant matching viewport width. */}
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
width={2000}
|
||||||
|
height={2000}
|
||||||
|
loading="lazy"
|
||||||
|
sizes="100vw"
|
||||||
|
aria-hidden={true}
|
||||||
|
className="lightbox-image block w-auto h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={close} aria-label="Close image" className="fixed top-3 right-3">
|
||||||
|
<CloseIcon />
|
||||||
|
</Button>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import parse from 'html-react-parser';
|
import parse from 'html-react-parser';
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* SVG markup string to inline as React elements
|
* SVG markup string to inline as React elements
|
||||||
*/
|
*/
|
||||||
@@ -10,7 +10,7 @@ type Props = {
|
|||||||
* Additional CSS classes on the wrapper span
|
* Additional CSS classes on the wrapper span
|
||||||
*/
|
*/
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses an SVG markup string into React elements.
|
* Parses an SVG markup string into React elements.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { Modal, type ModalHandle } from './ui/Modal';
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import { Button } from '../../Button';
|
||||||
|
import { Modal, type ModalHandle } from './Modal';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Modal> = {
|
||||||
|
title: 'Shared/Modal',
|
||||||
|
component: Modal,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof Modal>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imperative trigger — Storybook can't drive a ref-based API directly, so each
|
||||||
|
* story renders its own trigger button that calls `modalRef.current?.open()`.
|
||||||
|
*/
|
||||||
|
function TriggerWrapper({ className, children }: { className?: string; children: React.ReactNode }) {
|
||||||
|
const modalRef = useRef<ModalHandle>(null);
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<Button onClick={() => modalRef.current?.open()}>Open modal</Button>
|
||||||
|
<Modal ref={modalRef} aria-label="Story modal" className={className}>
|
||||||
|
<div className="p-8 max-w-md">
|
||||||
|
{children}
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => modalRef.current?.close()}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => (
|
||||||
|
<TriggerWrapper className="bg-cream brutal-border">
|
||||||
|
<h3 className="mb-2">Default modal</h3>
|
||||||
|
<p>Native dialog with scroll lock, backdrop-click close, and ESC close.</p>
|
||||||
|
</TriggerWrapper>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Brutalist: Story = {
|
||||||
|
render: () => (
|
||||||
|
<TriggerWrapper className="bg-blue text-cream brutal-border">
|
||||||
|
<h3 className="mb-2 text-cream">Brutalist modal</h3>
|
||||||
|
<p className="text-cream">
|
||||||
|
Blue background, hard border. Style is fully driven by the className you pass — Modal stays neutral.
|
||||||
|
</p>
|
||||||
|
</TriggerWrapper>
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { createRef } from 'react';
|
||||||
|
import { Modal, type ModalHandle } from './Modal';
|
||||||
|
|
||||||
|
// jsdom does not implement dialog methods — mock them
|
||||||
|
beforeAll(() => {
|
||||||
|
HTMLDialogElement.prototype.showModal = vi.fn();
|
||||||
|
HTMLDialogElement.prototype.close = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Modal', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('renders children', () => {
|
||||||
|
render(<Modal aria-label="Test">child content</Modal>);
|
||||||
|
expect(screen.getByText('child content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards className to dialog element', () => {
|
||||||
|
render(
|
||||||
|
<Modal aria-label="Test" className="extra-class">
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
expect(document.querySelector('dialog')).toHaveClass('extra-class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets aria-label on dialog', () => {
|
||||||
|
render(<Modal aria-label="My modal">x</Modal>);
|
||||||
|
expect(document.querySelector('dialog')).toHaveAttribute('aria-label', 'My modal');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('imperative API', () => {
|
||||||
|
it('open() calls showModal', () => {
|
||||||
|
const ref = createRef<ModalHandle>();
|
||||||
|
render(
|
||||||
|
<Modal ref={ref} aria-label="Test">
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
ref.current?.open();
|
||||||
|
expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('close() calls dialog.close', () => {
|
||||||
|
const ref = createRef<ModalHandle>();
|
||||||
|
render(
|
||||||
|
<Modal ref={ref} aria-label="Test">
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
ref.current?.close();
|
||||||
|
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scroll lock', () => {
|
||||||
|
it('locks body scroll on open()', () => {
|
||||||
|
const ref = createRef<ModalHandle>();
|
||||||
|
render(
|
||||||
|
<Modal ref={ref} aria-label="Test">
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
ref.current?.open();
|
||||||
|
expect(document.body.style.overflow).toBe('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores body scroll when the dialog close event fires', () => {
|
||||||
|
const ref = createRef<ModalHandle>();
|
||||||
|
render(
|
||||||
|
<Modal ref={ref} aria-label="Test">
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
ref.current?.open();
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
fireEvent(dialog, new Event('close'));
|
||||||
|
expect(document.body.style.overflow).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('backdrop interaction', () => {
|
||||||
|
it('closes on backdrop click (dialog element itself)', () => {
|
||||||
|
render(<Modal aria-label="Test">x</Modal>);
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
fireEvent.click(dialog, { target: dialog });
|
||||||
|
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not close on click inside content', () => {
|
||||||
|
render(
|
||||||
|
<Modal aria-label="Test">
|
||||||
|
<span>inner</span>
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('inner'));
|
||||||
|
expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes backdrop click through onBackdropClose when provided (skips default close)', () => {
|
||||||
|
const onBackdropClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<Modal aria-label="Test" onBackdropClose={onBackdropClose}>
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
fireEvent.click(dialog, { target: dialog });
|
||||||
|
expect(onBackdropClose).toHaveBeenCalledOnce();
|
||||||
|
expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('callbacks', () => {
|
||||||
|
it('invokes onClose when close event fires', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<Modal aria-label="Test" onClose={onClose}>
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
fireEvent(dialog, new Event('close'));
|
||||||
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes onCancel when cancel event fires', () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<Modal aria-label="Test" onCancel={onCancel}>
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
fireEvent(dialog, new Event('cancel'));
|
||||||
|
expect(onCancel).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type DialogHTMLAttributes,
|
||||||
|
forwardRef,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type MouseEvent,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
|
export type ModalHandle = {
|
||||||
|
/**
|
||||||
|
* Opens the dialog as a modal and locks body scroll.
|
||||||
|
*/
|
||||||
|
open: () => void;
|
||||||
|
/**
|
||||||
|
* Closes the dialog. Body scroll is restored on the native `close` event,
|
||||||
|
* so any close path (ESC, backdrop, this method) restores it.
|
||||||
|
*/
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Props extends DialogHTMLAttributes<HTMLDialogElement> {
|
||||||
|
/**
|
||||||
|
* Called when the user activates the backdrop (click or Enter/Space).
|
||||||
|
* Replaces the default close — useful when the close path must be wrapped
|
||||||
|
* (e.g. in a view transition). When omitted, the dialog closes itself.
|
||||||
|
*/
|
||||||
|
onBackdropClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper over native `<dialog>` with `showModal()`. Locks body scroll
|
||||||
|
* while open, restores it on any close path, and treats backdrop clicks /
|
||||||
|
* Enter|Space on the backdrop as a close intent. Style the backdrop externally
|
||||||
|
* via a className on this component + a `::backdrop` selector.
|
||||||
|
*
|
||||||
|
* All standard dialog attributes pass through — `aria-label`, `onClose`,
|
||||||
|
* `onCancel`, etc. Use `onCancel` with `e.preventDefault()` to intercept ESC
|
||||||
|
* (e.g. to route close through a view transition wrapper).
|
||||||
|
*/
|
||||||
|
export const Modal = forwardRef<ModalHandle, Props>(function Modal(
|
||||||
|
{ className, onClick, onKeyUp, onBackdropClose, children, ...rest },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
/* Either run consumer-supplied close path (e.g. view-transition-wrapped) or
|
||||||
|
* fall back to closing the dialog directly. Shared between click and keyboard
|
||||||
|
* backdrop activation. */
|
||||||
|
function triggerBackdropClose() {
|
||||||
|
if (onBackdropClose) {
|
||||||
|
onBackdropClose();
|
||||||
|
} else {
|
||||||
|
dialogRef.current?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
open: () => {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
dialogRef.current?.showModal();
|
||||||
|
},
|
||||||
|
close: () => {
|
||||||
|
dialogRef.current?.close();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = dialogRef.current;
|
||||||
|
if (!el) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* Scroll restore lives on the native event listener (not a React onClose
|
||||||
|
* prop) so it can't be unintentionally overridden by a caller that passes
|
||||||
|
* their own onClose. Both fire for the same close event. */
|
||||||
|
const handleClose = () => {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
el.addEventListener('close', handleClose);
|
||||||
|
return () => el.removeEventListener('close', handleClose);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the dialog when the user clicks the backdrop area directly.
|
||||||
|
* Target===currentTarget distinguishes the <dialog> element itself (the
|
||||||
|
* backdrop hit-area) from its content children. Caller's onClick still runs.
|
||||||
|
*/
|
||||||
|
function handleClick(e: MouseEvent<HTMLDialogElement>) {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
triggerBackdropClose();
|
||||||
|
}
|
||||||
|
onClick?.(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyboard equivalent of backdrop click — Enter/Space on the backdrop area
|
||||||
|
* closes the dialog. ESC is handled natively by `showModal()`.
|
||||||
|
*/
|
||||||
|
function handleKeyUp(e: KeyboardEvent<HTMLDialogElement>) {
|
||||||
|
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
|
triggerBackdropClose();
|
||||||
|
}
|
||||||
|
onKeyUp?.(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
{...rest}
|
||||||
|
className={cn('fixed inset-0 m-auto p-0 overflow-hidden', className)}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import parse from 'html-react-parser';
|
import parse from 'html-react-parser';
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* HTML string from PocketBase rich-text editor
|
* HTML string from PocketBase rich-text editor
|
||||||
*/
|
*/
|
||||||
@@ -10,7 +10,7 @@ type Props = {
|
|||||||
* Additional CSS classes merged onto the wrapper div
|
* Additional CSS classes merged onto the wrapper div
|
||||||
*/
|
*/
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a PocketBase rich-text HTML string as React elements.
|
* Renders a PocketBase rich-text HTML string as React elements.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Fragment, type ReactNode, ViewTransition as VT } from 'react';
|
|||||||
*/
|
*/
|
||||||
const Transition = (VT ?? Fragment) as typeof VT;
|
const Transition = (VT ?? Fragment) as typeof VT;
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* Maps to the view-transition-name CSS property
|
* Maps to the view-transition-name CSS property
|
||||||
*/
|
*/
|
||||||
@@ -15,7 +15,7 @@ type Props = {
|
|||||||
* Content to animate
|
* Content to animate
|
||||||
*/
|
*/
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps children in React's ViewTransition when available,
|
* Wraps children in React's ViewTransition when available,
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ export type { ButtonSize, ButtonVariant } from './Button';
|
|||||||
export { Button } from './Button';
|
export { Button } from './Button';
|
||||||
export type { CardBackground } from './Card';
|
export type { CardBackground } from './Card';
|
||||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './Card';
|
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './Card';
|
||||||
|
export { ImageLightbox } from './ImageLightbox';
|
||||||
export { InlineSvg } from './InlineSvg';
|
export { InlineSvg } from './InlineSvg';
|
||||||
export { Input, Textarea } from './Input';
|
export { Input, Textarea } from './Input';
|
||||||
export type { LinkVariant } from './Link';
|
export type { LinkVariant } from './Link';
|
||||||
export { Link } from './Link';
|
export { Link } from './Link';
|
||||||
|
export { Modal, type ModalHandle } from './Modal';
|
||||||
export { RichText } from './RichText';
|
export { RichText } from './RichText';
|
||||||
export type { ContainerSize, SectionBackground } from './Section';
|
export type { ContainerSize, SectionBackground } from './Section';
|
||||||
export { Container, Section } from './Section';
|
export { Container, Section } from './Section';
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export async function Footer() {
|
|||||||
const socials = contacts?.expand?.socials ?? [];
|
const socials = contacts?.expand?.socials ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="fixed bottom-0 left-0 right-0 z-50 h-footer md:h-footer-wide brutal-border-top bg-background px-4 sm:px-8 lg:px-16 flex items-center">
|
<footer className="footer-vt fixed bottom-0 left-0 right-0 z-50 h-footer md:h-footer-wide brutal-border-top bg-background px-4 sm:px-8 lg:px-16 flex items-center">
|
||||||
<div className="w-full flex flex-row justify-between gap-4">
|
<div className="w-full flex flex-row justify-between gap-4">
|
||||||
<div className="flex flex-wrap items-center gap-6 sm:gap-4">
|
<div className="flex flex-wrap items-center gap-6 sm:gap-4">
|
||||||
{email && (
|
{email && (
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
export type { NavItem } from './model/types';
|
|
||||||
export { MobileNav } from './ui/MobileNav';
|
|
||||||
export { SidebarNav } from './ui/SidebarNav';
|
|
||||||
export { UtilityBar } from './ui/UtilityBar';
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
export type NavItem = {
|
|
||||||
/**
|
|
||||||
* Section HTML id for anchor scrolling
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
/**
|
|
||||||
* Display label
|
|
||||||
*/
|
|
||||||
label: string;
|
|
||||||
/**
|
|
||||||
* Display number prefix (e.g. "01")
|
|
||||||
*/
|
|
||||||
number: string;
|
|
||||||
};
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
|
||||||
import { MobileNav } from './MobileNav';
|
|
||||||
|
|
||||||
// MobileNav is lg:hidden — it renders only on mobile viewports.
|
|
||||||
// Use the viewport toolbar in Storybook to switch to a mobile size to see it.
|
|
||||||
const meta: Meta<typeof MobileNav> = {
|
|
||||||
title: 'Widgets/MobileNav',
|
|
||||||
component: MobileNav,
|
|
||||||
parameters: {
|
|
||||||
viewport: {
|
|
||||||
defaultViewport: 'mobile1',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof MobileNav>;
|
|
||||||
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {
|
|
||||||
items: [
|
|
||||||
{ id: 'bio', label: 'Bio', number: '01' },
|
|
||||||
{ id: 'work', label: 'Work', number: '02' },
|
|
||||||
{ id: 'contact', label: 'Contact', number: '03' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import type { NavItem } from '../model/types';
|
|
||||||
import { MobileNav } from './MobileNav';
|
|
||||||
|
|
||||||
vi.mock('next/navigation', () => ({ usePathname: vi.fn(() => '/') }));
|
|
||||||
|
|
||||||
const ITEMS: NavItem[] = [
|
|
||||||
{ id: 'intro', label: 'Intro', number: '01' },
|
|
||||||
{ id: 'bio', label: 'Bio', number: '02' },
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('MobileNav', () => {
|
|
||||||
describe('rendering', () => {
|
|
||||||
it('renders title "allmy.work"', () => {
|
|
||||||
render(<MobileNav items={ITEMS} />);
|
|
||||||
expect(screen.getByText('allmy.work')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders toggle button with text "Menu" initially', () => {
|
|
||||||
render(<MobileNav items={ITEMS} />);
|
|
||||||
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('menu items are hidden initially', () => {
|
|
||||||
render(<MobileNav items={ITEMS} />);
|
|
||||||
expect(screen.queryByRole('link', { name: /intro/i })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('navigation items', () => {
|
|
||||||
it('shows items as links with correct hrefs when open', async () => {
|
|
||||||
render(<MobileNav items={ITEMS} />);
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
|
|
||||||
expect(screen.getByRole('link', { name: /01.*Intro/i })).toHaveAttribute('href', '/intro');
|
|
||||||
expect(screen.getByRole('link', { name: /02.*Bio/i })).toHaveAttribute('href', '/bio');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('interactions', () => {
|
|
||||||
it('click toggle shows links and changes label to "Close"', async () => {
|
|
||||||
render(<MobileNav items={ITEMS} />);
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
|
|
||||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Intro')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('closes menu when pathname changes', async () => {
|
|
||||||
const { usePathname } = await import('next/navigation');
|
|
||||||
vi.mocked(usePathname).mockReturnValue('/');
|
|
||||||
const { rerender } = render(<MobileNav items={ITEMS} />);
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
|
|
||||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
|
||||||
|
|
||||||
vi.mocked(usePathname).mockReturnValue('/bio');
|
|
||||||
rerender(<MobileNav items={ITEMS} />);
|
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { cn } from '$shared/lib';
|
|
||||||
import type { NavItem } from '../model/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for MobileNav.
|
|
||||||
*/
|
|
||||||
interface Props {
|
|
||||||
/**
|
|
||||||
* Navigation items to render
|
|
||||||
*/
|
|
||||||
items: NavItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mobile navigation overlay, hidden on lg+ screens.
|
|
||||||
* Closes automatically when the URL pathname changes after navigation.
|
|
||||||
*/
|
|
||||||
export function MobileNav({ items }: Props) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: pathname is the trigger, not a value used inside the callback
|
|
||||||
useEffect(() => {
|
|
||||||
setIsOpen(false);
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="lg:hidden fixed top-0 left-0 right-0 bg-cream brutal-border-bottom z-50">
|
|
||||||
<div className="px-6 py-4 flex items-center justify-between">
|
|
||||||
<h4>allmy.work</h4>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsOpen((prev) => !prev)}
|
|
||||||
className="brutal-border px-4 py-2 bg-blue text-cream"
|
|
||||||
>
|
|
||||||
{isOpen ? 'Close' : 'Menu'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{isOpen && (
|
|
||||||
<div className="px-6 py-6 brutal-border-top space-y-2 max-h-[80vh] overflow-y-auto">
|
|
||||||
{items.map((item) => (
|
|
||||||
<Link key={item.id} href={`/${item.id}`} className="block w-full brutal-border bg-cream px-4 py-3">
|
|
||||||
<div className={cn('flex items-baseline gap-3')}>
|
|
||||||
<span className="text-sm opacity-60 font-body">{item.number}</span>
|
|
||||||
<span
|
|
||||||
className="font-heading text-lg font-black"
|
|
||||||
style={{ fontVariationSettings: '"WONK" 1, "SOFT" 0' }}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
|
||||||
import { SidebarNav } from './SidebarNav';
|
|
||||||
|
|
||||||
// SidebarNav is hidden lg:block — it renders only on desktop viewports.
|
|
||||||
// Use the viewport toolbar in Storybook to switch to a desktop size to see it.
|
|
||||||
const meta: Meta<typeof SidebarNav> = {
|
|
||||||
title: 'Widgets/SidebarNav',
|
|
||||||
component: SidebarNav,
|
|
||||||
parameters: {
|
|
||||||
layout: 'fullscreen',
|
|
||||||
viewport: {
|
|
||||||
defaultViewport: 'desktop',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof SidebarNav>;
|
|
||||||
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {
|
|
||||||
items: [
|
|
||||||
{ id: 'bio', label: 'Bio', number: '01' },
|
|
||||||
{ id: 'work', label: 'Work', number: '02' },
|
|
||||||
{ id: 'contact', label: 'Contact', number: '03' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import type { NavItem } from '../model/types';
|
|
||||||
import { SidebarNav } from './SidebarNav';
|
|
||||||
|
|
||||||
vi.mock('next/navigation', () => ({
|
|
||||||
usePathname: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
|
|
||||||
const ITEMS: NavItem[] = [
|
|
||||||
{ id: 'bio', label: 'Bio', number: '01' },
|
|
||||||
{ id: 'work', label: 'Work', number: '02' },
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('SidebarNav', () => {
|
|
||||||
describe('rendering', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(usePathname).mockReturnValue('/bio');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a nav element', () => {
|
|
||||||
render(<SidebarNav items={ITEMS} />);
|
|
||||||
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders "Index" heading', () => {
|
|
||||||
render(<SidebarNav items={ITEMS} />);
|
|
||||||
expect(screen.getByText('Index')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders "Digital Monograph" subtitle', () => {
|
|
||||||
render(<SidebarNav items={ITEMS} />);
|
|
||||||
expect(screen.getByText('Digital Monograph')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders each item label and number', () => {
|
|
||||||
render(<SidebarNav items={ITEMS} />);
|
|
||||||
expect(screen.getByText('Bio')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('01')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Work')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('02')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders "Quick Links" section', () => {
|
|
||||||
render(<SidebarNav items={ITEMS} />);
|
|
||||||
expect(screen.getByText('Quick Links')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders Email quick link', () => {
|
|
||||||
render(<SidebarNav items={ITEMS} />);
|
|
||||||
expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a link for each nav item', () => {
|
|
||||||
render(<SidebarNav items={ITEMS} />);
|
|
||||||
expect(screen.getByRole('link', { name: /Bio/i })).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('link', { name: /Work/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('active state', () => {
|
|
||||||
it('marks matching pathname item as active (no opacity-40)', () => {
|
|
||||||
vi.mocked(usePathname).mockReturnValue('/bio');
|
|
||||||
render(<SidebarNav items={ITEMS} />);
|
|
||||||
const activeLink = screen.getByRole('link', { name: /Bio/i });
|
|
||||||
expect(activeLink).not.toHaveClass('opacity-40');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks non-matching item as inactive (opacity-40)', () => {
|
|
||||||
vi.mocked(usePathname).mockReturnValue('/bio');
|
|
||||||
render(<SidebarNav items={ITEMS} />);
|
|
||||||
const inactiveLink = screen.getByRole('link', { name: /Work/i });
|
|
||||||
expect(inactiveLink).toHaveClass('opacity-40');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks first item active at root path', () => {
|
|
||||||
vi.mocked(usePathname).mockReturnValue('/');
|
|
||||||
render(<SidebarNav items={ITEMS} />);
|
|
||||||
const firstLink = screen.getByRole('link', { name: /Bio/i });
|
|
||||||
expect(firstLink).not.toHaveClass('opacity-40');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import { CONTACT_LINKS, cn } from '$shared/lib';
|
|
||||||
import type { NavItem } from '../model/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for SidebarNav.
|
|
||||||
*/
|
|
||||||
interface Props {
|
|
||||||
/**
|
|
||||||
* Navigation items to render
|
|
||||||
*/
|
|
||||||
items: NavItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fixed sidebar navigation, visible on lg+ screens.
|
|
||||||
* Active section determined by current URL pathname.
|
|
||||||
*/
|
|
||||||
export function SidebarNav({ items }: Props) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An item is active when its slug matches the current pathname,
|
|
||||||
* or when the pathname is root and it is the first item.
|
|
||||||
*/
|
|
||||||
function isActive(item: NavItem): boolean {
|
|
||||||
if (pathname === `/${item.id}`) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (pathname === '/' && items[0]?.id === item.id) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className="fixed top-0 left-0 h-screen w-full lg:w-1/3 bg-cream brutal-border-right hidden lg:block overflow-y-auto z-50">
|
|
||||||
<div className="px-8 py-12 space-y-2">
|
|
||||||
<div className="mb-12">
|
|
||||||
<h2>Index</h2>
|
|
||||||
<div className="brutal-border-top pt-4">
|
|
||||||
<p className="text-sm opacity-60">Digital Monograph</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{items.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.id}
|
|
||||||
href={`/${item.id}`}
|
|
||||||
className={cn(
|
|
||||||
'block w-full text-left brutal-border bg-cream px-6 py-4 transition-all duration-300',
|
|
||||||
isActive(item)
|
|
||||||
? 'shadow-brutal-2xl opacity-100 translate-x-0'
|
|
||||||
: 'opacity-40 shadow-none hover:opacity-60',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-baseline gap-4">
|
|
||||||
<span className="text-sm opacity-60">{item.number}</span>
|
|
||||||
<span className="font-heading text-xl font-black">{item.label}</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="mt-12 pt-12 brutal-border-top">
|
|
||||||
<p className="text-sm uppercase tracking-wider mb-4 opacity-60">Quick Links</p>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<a href={`mailto:${CONTACT_LINKS.email}`} className="block">
|
|
||||||
Email
|
|
||||||
</a>
|
|
||||||
<a href={CONTACT_LINKS.linkedin} className="block">
|
|
||||||
LinkedIn
|
|
||||||
</a>
|
|
||||||
<a href={CONTACT_LINKS.instagram} className="block">
|
|
||||||
Instagram
|
|
||||||
</a>
|
|
||||||
<a href={CONTACT_LINKS.arena} className="block">
|
|
||||||
Are.na
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
|
||||||
import { UtilityBar } from './UtilityBar';
|
|
||||||
|
|
||||||
const meta: Meta<typeof UtilityBar> = {
|
|
||||||
title: 'Widgets/UtilityBar',
|
|
||||||
component: UtilityBar,
|
|
||||||
decorators: [
|
|
||||||
(Story) => (
|
|
||||||
<div className="relative h-24 bg-ochre-clay">
|
|
||||||
<Story />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof UtilityBar>;
|
|
||||||
|
|
||||||
export const Default: Story = {};
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import { UtilityBar } from './UtilityBar';
|
|
||||||
|
|
||||||
describe('UtilityBar', () => {
|
|
||||||
describe('rendering', () => {
|
|
||||||
it('renders "Contact" label', () => {
|
|
||||||
render(<UtilityBar />);
|
|
||||||
expect(screen.getByText('Contact')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders email link with correct href', () => {
|
|
||||||
render(<UtilityBar />);
|
|
||||||
const link = screen.getByRole('link', { name: 'hello@allmy.work' });
|
|
||||||
expect(link).toBeInTheDocument();
|
|
||||||
expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders "Download CV" button', () => {
|
|
||||||
render(<UtilityBar />);
|
|
||||||
expect(screen.getByRole('button', { name: /download cv/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Download CV button has primary variant class', () => {
|
|
||||||
render(<UtilityBar />);
|
|
||||||
const btn = screen.getByRole('button', { name: /download cv/i });
|
|
||||||
expect(btn).toHaveClass('bg-blue');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { CONTACT_LINKS } from '$shared/lib';
|
|
||||||
import { Button } from '$shared/ui';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fixed bottom utility bar with contact info and CV download.
|
|
||||||
*/
|
|
||||||
export function UtilityBar() {
|
|
||||||
/**
|
|
||||||
* Handles CV download action.
|
|
||||||
*/
|
|
||||||
function handleDownloadCV() {
|
|
||||||
console.log('Downloading CV...');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed bottom-0 left-0 right-0 bg-cream brutal-border-top z-40">
|
|
||||||
<div className="max-w-[2560px] mx-auto px-6 md:px-12 lg:px-16 py-4 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-sm uppercase tracking-wider">Contact</span>
|
|
||||||
<a href={`mailto:${CONTACT_LINKS.email}`} className="text-base hover:opacity-60 transition-opacity">
|
|
||||||
{CONTACT_LINKS.email}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<Button variant="primary" size="sm" onClick={handleDownloadCV}>
|
|
||||||
Download CV
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -13,9 +13,14 @@ export default async function ProjectsSection() {
|
|||||||
tags: ['projects'],
|
tags: ['projects'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* Mark the first project that actually has an image as LCP-priority.
|
||||||
|
* Using `index === 0` alone misses the case where the first card has no
|
||||||
|
* image and the LCP candidate ends up being the next card's image. */
|
||||||
|
const lcpIndex = items.findIndex((project) => project.image);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-section">
|
<div className="space-y-6 max-w-section">
|
||||||
{items.map((project) => (
|
{items.map((project, index) => (
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
key={project.id}
|
key={project.id}
|
||||||
title={project.title}
|
title={project.title}
|
||||||
@@ -24,6 +29,7 @@ export default async function ProjectsSection() {
|
|||||||
tags={project.stack}
|
tags={project.stack}
|
||||||
url={project.url}
|
url={project.url}
|
||||||
imageUrl={project.image ? buildFileUrl(project.collectionId, project.id, project.image) : undefined}
|
imageUrl={project.image ? buildFileUrl(project.collectionId, project.id, project.image) : undefined}
|
||||||
|
priority={index === lcpIndex}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
import type { ErrorInfo, ReactNode } from 'react';
|
import type { ErrorInfo, ReactNode } from 'react';
|
||||||
import { Component } from 'react';
|
import { Component } from 'react';
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* Section content to render
|
* Section content to render
|
||||||
*/
|
*/
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
}
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Children } from 'react';
|
|||||||
import type { SectionRecord } from '$entities/Section';
|
import type { SectionRecord } from '$entities/Section';
|
||||||
import { SectionAccordion } from '$entities/Section';
|
import { SectionAccordion } from '$entities/Section';
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* Ordered section metadata — drives navigation labels and IDs
|
* Ordered section metadata — drives navigation labels and IDs
|
||||||
*/
|
*/
|
||||||
@@ -17,7 +17,7 @@ type Props = {
|
|||||||
* Pre-rendered RSC content slots, one per section, matched by index
|
* Pre-rendered RSC content slots, one per section, matched by index
|
||||||
*/
|
*/
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders all portfolio sections as an accordion list.
|
* Renders all portfolio sections as an accordion list.
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
export * from './Footer';
|
export * from './Footer';
|
||||||
export * from './Navigation';
|
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
|
import path from 'node:path';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||
Reference in New Issue
Block a user