feat: add thumbnails and attachment viewer
This commit is contained in:
@@ -30,23 +30,50 @@ const config = {
|
|||||||
typescript: { ignoreBuildErrors: true },
|
typescript: { ignoreBuildErrors: true },
|
||||||
|
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: (() => {
|
||||||
{
|
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */
|
||||||
protocol: "https",
|
const patterns = [
|
||||||
hostname:
|
{
|
||||||
env("S3_FORCE_PATH_STYLE") === "true"
|
protocol: "https",
|
||||||
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
|
hostname:
|
||||||
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
env("S3_FORCE_PATH_STYLE") === "true"
|
||||||
},
|
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
|
||||||
{
|
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||||
protocol: "http",
|
},
|
||||||
hostname: "localhost",
|
{
|
||||||
},
|
protocol: "http",
|
||||||
{
|
hostname: "localhost",
|
||||||
protocol: "https",
|
},
|
||||||
hostname: "*.googleusercontent.com",
|
{
|
||||||
},
|
protocol: "https",
|
||||||
],
|
hostname: "*.googleusercontent.com",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Extract root domain from S3_ENDPOINT and add wildcard pattern
|
||||||
|
const s3Endpoint = env("S3_ENDPOINT");
|
||||||
|
if (s3Endpoint) {
|
||||||
|
try {
|
||||||
|
const url = new URL(s3Endpoint);
|
||||||
|
const hostname = url.hostname;
|
||||||
|
const protocol = url.protocol.replace(":", "");
|
||||||
|
|
||||||
|
// Extract root domain (last 2 parts: e.g. cloudflarestorage.com)
|
||||||
|
const parts = hostname.split(".");
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const rootDomain = parts.slice(-2).join(".");
|
||||||
|
patterns.push({
|
||||||
|
protocol: protocol === "http" ? "http" : "https",
|
||||||
|
hostname: `*.${rootDomain}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If S3_ENDPOINT is not a valid URL, ignore it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return patterns;
|
||||||
|
})(),
|
||||||
},
|
},
|
||||||
webpack(config) {
|
webpack(config) {
|
||||||
config.module.rules.push({
|
config.module.rules.push({
|
||||||
|
|||||||
227
apps/web/src/views/card/components/AttachmentThumbnails.tsx
Normal file
227
apps/web/src/views/card/components/AttachmentThumbnails.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
|
import { Fragment, useEffect, useState } from "react";
|
||||||
|
import { HiChevronLeft, HiChevronRight, HiXMark } from "react-icons/hi2";
|
||||||
|
|
||||||
|
interface Attachment {
|
||||||
|
publicId: string;
|
||||||
|
contentType: string;
|
||||||
|
url: string | null;
|
||||||
|
originalFilename: string | null;
|
||||||
|
s3Key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentThumbnails({
|
||||||
|
attachments,
|
||||||
|
}: {
|
||||||
|
attachments?: Attachment[];
|
||||||
|
}) {
|
||||||
|
const imageAttachments =
|
||||||
|
attachments?.filter(
|
||||||
|
(attachment) =>
|
||||||
|
attachment.contentType.startsWith("image/") && attachment.url,
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedIndex === null) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setSelectedIndex(null);
|
||||||
|
} else if (e.key === "ArrowLeft") {
|
||||||
|
setSelectedIndex((prev) => {
|
||||||
|
if (prev === null) return null;
|
||||||
|
return prev === 0 ? imageAttachments.length - 1 : prev - 1;
|
||||||
|
});
|
||||||
|
} else if (e.key === "ArrowRight") {
|
||||||
|
setSelectedIndex((prev) => {
|
||||||
|
if (prev === null) return null;
|
||||||
|
return prev === imageAttachments.length - 1 ? 0 : prev + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [selectedIndex, imageAttachments.length]);
|
||||||
|
|
||||||
|
if (imageAttachments.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openViewer = (index: number) => {
|
||||||
|
setSelectedIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeViewer = () => {
|
||||||
|
setSelectedIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToPrevious = () => {
|
||||||
|
if (selectedIndex === null) return;
|
||||||
|
const newIndex =
|
||||||
|
selectedIndex === 0 ? imageAttachments.length - 1 : selectedIndex - 1;
|
||||||
|
setSelectedIndex(newIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToNext = () => {
|
||||||
|
if (selectedIndex === null) return;
|
||||||
|
const newIndex =
|
||||||
|
selectedIndex === imageAttachments.length - 1 ? 0 : selectedIndex + 1;
|
||||||
|
setSelectedIndex(newIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedAttachment =
|
||||||
|
selectedIndex !== null ? imageAttachments[selectedIndex] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-3 flex flex-wrap gap-2">
|
||||||
|
{imageAttachments.map((attachment, index) => {
|
||||||
|
if (!attachment.url) return null;
|
||||||
|
return (
|
||||||
|
<AttachmentThumbnail
|
||||||
|
key={attachment.publicId}
|
||||||
|
attachment={{
|
||||||
|
publicId: attachment.publicId,
|
||||||
|
url: attachment.url,
|
||||||
|
originalFilename: attachment.originalFilename ?? "",
|
||||||
|
}}
|
||||||
|
onClick={() => openViewer(index)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition.Root show={selectedIndex !== null} as={Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-50" onClose={() => {}} static>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-light-50 transition-opacity dark:bg-dark-50"
|
||||||
|
onClick={closeViewer}
|
||||||
|
/>
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||||
|
<div className="fixed right-4 top-4 z-20 flex gap-2">
|
||||||
|
{imageAttachments.length > 1 && selectedIndex !== null && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goToPrevious();
|
||||||
|
}}
|
||||||
|
className="rounded-full bg-light-50 p-1.5 text-light-1000 transition-colors hover:bg-light-100 focus:outline-none dark:bg-dark-50 dark:text-dark-1000 dark:hover:bg-dark-100"
|
||||||
|
aria-label="Previous image"
|
||||||
|
>
|
||||||
|
<HiChevronLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{imageAttachments.length > 1 && selectedIndex !== null && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goToNext();
|
||||||
|
}}
|
||||||
|
className="rounded-full bg-light-50 p-1.5 text-light-1000 transition-colors hover:bg-light-100 focus:outline-none dark:bg-dark-50 dark:text-dark-1000 dark:hover:bg-dark-100"
|
||||||
|
aria-label="Next image"
|
||||||
|
>
|
||||||
|
<HiChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedIndex !== null && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeViewer();
|
||||||
|
}}
|
||||||
|
className="rounded-full bg-light-50 p-1.5 text-light-1000 transition-colors hover:bg-light-100 focus:outline-none dark:bg-dark-50 dark:text-dark-1000 dark:hover:bg-dark-100"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<HiXMark className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<Dialog.Panel
|
||||||
|
className="relative w-full max-w-7xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{selectedAttachment?.url && (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="relative mx-auto max-h-[90vh] w-full">
|
||||||
|
<Image
|
||||||
|
src={selectedAttachment.url}
|
||||||
|
alt={
|
||||||
|
selectedAttachment.originalFilename ?? "Attachment"
|
||||||
|
}
|
||||||
|
width={1920}
|
||||||
|
height={1080}
|
||||||
|
className="mx-auto max-h-[90vh] w-auto object-contain"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{imageAttachments.length > 1 && (
|
||||||
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-3 py-1.5 text-[10px] text-white">
|
||||||
|
{selectedIndex !== null && selectedIndex + 1} /{" "}
|
||||||
|
{imageAttachments.length}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition.Root>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentThumbnail({
|
||||||
|
attachment,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
attachment: { publicId: string; url: string; originalFilename: string };
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="relative h-16 w-16 overflow-hidden rounded-xl border border-light-300 transition-transform hover:scale-105 dark:border-dark-300"
|
||||||
|
aria-label={`View ${attachment.originalFilename}`}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={attachment.url}
|
||||||
|
alt={attachment.originalFilename}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="64px"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { api } from "~/utils/api";
|
|||||||
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
|
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
|
||||||
import { DeleteLabelConfirmation } from "../../components/DeleteLabelConfirmation";
|
import { DeleteLabelConfirmation } from "../../components/DeleteLabelConfirmation";
|
||||||
import ActivityList from "./components/ActivityList";
|
import ActivityList from "./components/ActivityList";
|
||||||
|
import { AttachmentThumbnails } from "./components/AttachmentThumbnails";
|
||||||
import { AttachmentUpload } from "./components/AttachmentUpload";
|
import { AttachmentUpload } from "./components/AttachmentUpload";
|
||||||
import Checklists from "./components/Checklists";
|
import Checklists from "./components/Checklists";
|
||||||
import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation";
|
import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation";
|
||||||
@@ -288,9 +289,18 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
setActiveChecklistForm={setActiveChecklistForm}
|
setActiveChecklistForm={setActiveChecklistForm}
|
||||||
/>
|
/>
|
||||||
{!isTemplate && (
|
{!isTemplate && (
|
||||||
<div className="mt-6">
|
<>
|
||||||
<AttachmentUpload cardPublicId={cardId ?? ""} />
|
{card?.attachments.length > 0 && (
|
||||||
</div>
|
<div className="mt-6">
|
||||||
|
<AttachmentThumbnails
|
||||||
|
attachments={card?.attachments}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-6">
|
||||||
|
<AttachmentUpload cardPublicId={cardId} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">
|
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">
|
||||||
<h2 className="text-md pb-4 font-medium text-light-1000 dark:text-dark-1000">
|
<h2 className="text-md pb-4 font-medium text-light-1000 dark:text-dark-1000">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
|||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
import { assertUserInWorkspace } from "../utils/auth";
|
import { assertUserInWorkspace } from "../utils/auth";
|
||||||
|
import { generateDownloadUrl } from "../utils/s3";
|
||||||
|
|
||||||
export const cardRouter = createTRPCRouter({
|
export const cardRouter = createTRPCRouter({
|
||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
@@ -574,7 +575,20 @@ export const cardRouter = createTRPCRouter({
|
|||||||
.input(z.object({ cardPublicId: z.string().min(12) }))
|
.input(z.object({ cardPublicId: z.string().min(12) }))
|
||||||
.output(
|
.output(
|
||||||
z.custom<
|
z.custom<
|
||||||
Awaited<ReturnType<typeof cardRepo.getWithListAndMembersByPublicId>>
|
Omit<
|
||||||
|
NonNullable<
|
||||||
|
Awaited<ReturnType<typeof cardRepo.getWithListAndMembersByPublicId>>
|
||||||
|
>,
|
||||||
|
"attachments"
|
||||||
|
> & {
|
||||||
|
attachments: {
|
||||||
|
publicId: string;
|
||||||
|
contentType: string;
|
||||||
|
s3Key: string;
|
||||||
|
originalFilename: string | null;
|
||||||
|
url: string | null;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
>(),
|
>(),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
@@ -612,7 +626,44 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
// Generate URLs for all attachments
|
||||||
|
const bucket = process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
||||||
|
if (result.attachments && Array.isArray(result.attachments)) {
|
||||||
|
const attachments = result.attachments as {
|
||||||
|
publicId: string;
|
||||||
|
contentType: string;
|
||||||
|
s3Key: string;
|
||||||
|
originalFilename: string | null;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
const attachmentsWithUrls = await Promise.all(
|
||||||
|
attachments.map(async (attachment) => {
|
||||||
|
const base = {
|
||||||
|
publicId: attachment.publicId,
|
||||||
|
contentType: attachment.contentType,
|
||||||
|
s3Key: attachment.s3Key,
|
||||||
|
originalFilename: attachment.originalFilename,
|
||||||
|
};
|
||||||
|
if (!bucket || !attachment.s3Key) {
|
||||||
|
return { ...base, url: null };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = await generateDownloadUrl(
|
||||||
|
bucket,
|
||||||
|
attachment.s3Key,
|
||||||
|
3600, // 1 hour expiration
|
||||||
|
);
|
||||||
|
return { ...base, url };
|
||||||
|
} catch {
|
||||||
|
// If URL generation fails, return attachment with url: null
|
||||||
|
return { ...base, url: null };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { ...result, attachments: attachmentsWithUrls };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...result, attachments: [] };
|
||||||
}),
|
}),
|
||||||
update: protectedProcedure
|
update: protectedProcedure
|
||||||
.meta({
|
.meta({
|
||||||
|
|||||||
@@ -409,6 +409,14 @@ export const getWithListAndMembersByPublicId = async (
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
attachments: {
|
||||||
|
columns: {
|
||||||
|
publicId: true,
|
||||||
|
contentType: true,
|
||||||
|
s3Key: true,
|
||||||
|
originalFilename: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
checklists: {
|
checklists: {
|
||||||
columns: {
|
columns: {
|
||||||
publicId: true,
|
publicId: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user