Skip to content

Commit 60ea75f

Browse files
authored
feat: image upload (#288)
mostly taken from https://github.com/acdh-oeaw/template-app-next/tree/variant/with-image-upload - add image selector component - use list box for image grid - use component in country dashboard view (admin)
1 parent 772a24d commit 60ea75f

22 files changed

+1051
-4
lines changed

.github/workflows/build-deploy.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,18 @@ jobs:
122122
"EMAIL_SMTP_SERVER=${{ vars.K8S_SECRET_EMAIL_SMTP_SERVER }}"
123123
"DATABASE_DIRECT_URL=${{ secrets.K8S_SECRET_DATABASE_DIRECT_URL }}"
124124
"DATABASE_URL=${{ secrets.K8S_SECRET_DATABASE_URL }}"
125+
"IMGPROXY_BASE_URL=${{ vars.K8S_SECRET_IMGPROXY_BASE_URL }}"
126+
"IMGPROXY_KEY=${{ secrets.K8S_SECRET_IMGPROXY_KEY }}"
127+
"IMGPROXY_SALT=${{ secrets.K8S_SECRET_IMGPROXY_SALT }}"
125128
"KEYSTATIC_GITHUB_CLIENT_ID=${{ secrets.K8S_SECRET_KEYSTATIC_GITHUB_CLIENT_ID }}"
126129
"KEYSTATIC_GITHUB_CLIENT_SECRET=${{ secrets.K8S_SECRET_KEYSTATIC_GITHUB_CLIENT_SECRET }}"
127130
"KEYSTATIC_SECRET=${{ secrets.K8S_SECRET_KEYSTATIC_SECRET }}"
131+
"S3_ACCESS_KEY=${{ secrets.K8S_SECRET_S3_ACCESS_KEY }}"
132+
"S3_SECRET_KEY=${{ secrets.K8S_SECRET_S3_SECRET_KEY }}"
133+
"S3_BUCKET=${{ vars.K8S_SECRET_S3_BUCKET }}"
134+
"S3_HOST=${{ vars.K8S_SECRET_S3_HOST }}"
135+
"S3_PORT=${{ vars.K8S_SECRET_S3_PORT }}"
136+
"S3_PROTOCOL=${{ vars.K8S_SECRET_S3_PROTOCOL }}"
128137
"SSHOC_MARKETPLACE_API_BASE_URL=${{ vars.K8S_SECRET_SSHOC_MARKETPLACE_API_BASE_URL }}"
129138
"SSHOC_MARKETPLACE_BASE_URL=${{ vars.K8S_SECRET_SSHOC_MARKETPLACE_BASE_URL }}"
130139
"SSHOC_MARKETPLACE_PASSWORD=${{ secrets.K8S_SECRET_SSHOC_MARKETPLACE_PASSWORD }}"

.github/workflows/validate.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,18 @@ jobs:
102102
EMAIL_SMTP_PORT: "${{ vars.K8S_SECRET_TEST_EMAIL_SMTP_PORT }}"
103103
EMAIL_SMTP_SERVER: "${{ vars.K8S_SECRET_TEST_EMAIL_SMTP_SERVER }}"
104104
EMAIL_USER_NAME: "${{ secrets.K8S_SECRET_TEST_EMAIL_USER_NAME }}"
105+
IMGPROXY_BASE_URL: "${{ vars.K8S_SECRET_IMGPROXY_BASE_URL }}"
106+
IMGPROXY_KEY: "${{ secrets.K8S_SECRET_IMGPROXY_KEY }}"
107+
IMGPROXY_SALT: "${{ secrets.K8S_SECRET_IMGPROXY_SALT }}"
105108
NEXT_PUBLIC_APP_BASE_URL: "http://localhost:3000"
106109
NEXT_PUBLIC_MATOMO_BASE_URL: "${{ vars.NEXT_PUBLIC_MATOMO_BASE_URL }}"
107110
NEXT_PUBLIC_REDMINE_ID: "${{ vars.SERVICE_ID }}"
111+
S3_ACCESS_KEY: "${{ secrets.K8S_SECRET_S3_ACCESS_KEY }}"
112+
S3_SECRET_KEY: "${{ secrets.K8S_SECRET_S3_SECRET_KEY }}"
113+
S3_BUCKET: "${{ vars.K8S_SECRET_S3_BUCKET }}"
114+
S3_HOST: "${{ vars.K8S_SECRET_S3_HOST }}"
115+
S3_PORT: "${{ vars.K8S_SECRET_S3_PORT }}"
116+
S3_PROTOCOL: "${{ vars.K8S_SECRET_S3_PROTOCOL }}"
108117
SSHOC_MARKETPLACE_API_BASE_URL: "${{ vars.K8S_SECRET_SSHOC_MARKETPLACE_API_BASE_URL }}"
109118
SSHOC_MARKETPLACE_BASE_URL: "${{ vars.K8S_SECRET_SSHOC_MARKETPLACE_BASE_URL }}"
110119
SSHOC_MARKETPLACE_PASSWORD: "${{ secrets.K8S_SECRET_SSHOC_MARKETPLACE_PASSWORD }}"
@@ -126,9 +135,18 @@ jobs:
126135
EMAIL_SMTP_PORT: "${{ vars.K8S_SECRET_TEST_EMAIL_SMTP_PORT }}"
127136
EMAIL_SMTP_SERVER: "${{ vars.K8S_SECRET_TEST_EMAIL_SMTP_SERVER }}"
128137
EMAIL_USER_NAME: "${{ secrets.K8S_SECRET_TEST_EMAIL_USER_NAME }}"
138+
IMGPROXY_BASE_URL: "${{ vars.K8S_SECRET_IMGPROXY_BASE_URL }}"
139+
IMGPROXY_KEY: "${{ secrets.K8S_SECRET_IMGPROXY_KEY }}"
140+
IMGPROXY_SALT: "${{ secrets.K8S_SECRET_IMGPROXY_SALT }}"
129141
NEXT_PUBLIC_APP_BASE_URL: "http://localhost:3000"
130142
NEXT_PUBLIC_MATOMO_BASE_URL: "${{ vars.NEXT_PUBLIC_MATOMO_BASE_URL }}"
131143
NEXT_PUBLIC_REDMINE_ID: "${{ vars.SERVICE_ID }}"
144+
S3_ACCESS_KEY: "${{ secrets.K8S_SECRET_S3_ACCESS_KEY }}"
145+
S3_SECRET_KEY: "${{ secrets.K8S_SECRET_S3_SECRET_KEY }}"
146+
S3_BUCKET: "${{ vars.K8S_SECRET_S3_BUCKET }}"
147+
S3_HOST: "${{ vars.K8S_SECRET_S3_HOST }}"
148+
S3_PORT: "${{ vars.K8S_SECRET_S3_PORT }}"
149+
S3_PROTOCOL: "${{ vars.K8S_SECRET_S3_PROTOCOL }}"
132150
SSHOC_MARKETPLACE_API_BASE_URL: "${{ vars.K8S_SECRET_SSHOC_MARKETPLACE_API_BASE_URL }}"
133151
SSHOC_MARKETPLACE_BASE_URL: "${{ vars.K8S_SECRET_SSHOC_MARKETPLACE_BASE_URL }}"
134152
SSHOC_MARKETPLACE_PASSWORD: "${{ secrets.K8S_SECRET_SSHOC_MARKETPLACE_PASSWORD }}"

Dockerfile

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,18 @@ RUN --mount=type=secret,id=AUTH_SECRET,uid=1000 \
5555
--mount=type=secret,id=EMAIL_SMTP_SERVER,uid=1000 \
5656
--mount=type=secret,id=DATABASE_DIRECT_URL,uid=1000 \
5757
--mount=type=secret,id=DATABASE_URL,uid=1000 \
58+
--mount=type=secret,id=IMGPROXY_BASE_URL,uid=1000 \
59+
--mount=type=secret,id=IMGPROXY_KEY,uid=1000 \
60+
--mount=type=secret,id=IMGPROXY_SALT,uid=1000 \
5861
--mount=type=secret,id=KEYSTATIC_GITHUB_CLIENT_ID,uid=1000 \
5962
--mount=type=secret,id=KEYSTATIC_GITHUB_CLIENT_SECRET,uid=1000 \
6063
--mount=type=secret,id=KEYSTATIC_SECRET,uid=1000 \
64+
--mount=type=secret,id=S3_ACCESS_KEY,uid=1000 \
65+
--mount=type=secret,id=S3_SECRET_KEY,uid=1000 \
66+
--mount=type=secret,id=S3_BUCKET,uid=1000 \
67+
--mount=type=secret,id=S3_HOST,uid=1000 \
68+
--mount=type=secret,id=S3_PORT,uid=1000 \
69+
--mount=type=secret,id=S3_PROTOCOL,uid=1000 \
6170
--mount=type=secret,id=SSHOC_MARKETPLACE_API_BASE_URL,uid=1000 \
6271
--mount=type=secret,id=SSHOC_MARKETPLACE_BASE_URL,uid=1000 \
6372
--mount=type=secret,id=SSHOC_MARKETPLACE_PASSWORD,uid=1000 \
@@ -68,9 +77,18 @@ RUN --mount=type=secret,id=AUTH_SECRET,uid=1000 \
6877
EMAIL_SMTP_SERVER=$(cat /run/secrets/EMAIL_SMTP_SERVER) \
6978
DATABASE_DIRECT_URL=$(cat /run/secrets/DATABASE_DIRECT_URL) \
7079
DATABASE_URL=$(cat /run/secrets/DATABASE_URL) \
80+
IMGPROXY_BASE_URL=$(cat /run/secrets/IMGPROXY_BASE_URL) \
81+
IMGPROXY_KEY=$(cat /run/secrets/IMGPROXY_KEY) \
82+
IMGPROXY_SALT=$(cat /run/secrets/IMGPROXY_SALT) \
7183
KEYSTATIC_GITHUB_CLIENT_ID=$(cat /run/secrets/KEYSTATIC_GITHUB_CLIENT_ID) \
7284
KEYSTATIC_GITHUB_CLIENT_SECRET=$(cat /run/secrets/KEYSTATIC_GITHUB_CLIENT_SECRET) \
7385
KEYSTATIC_SECRET=$(cat /run/secrets/KEYSTATIC_SECRET) \
86+
S3_ACCESS_KEY=$(cat /run/secrets/S3_ACCESS_KEY) \
87+
S3_SECRET_KEY=$(cat /run/secrets/S3_SECRET_KEY) \
88+
S3_BUCKET=$(cat /run/secrets/S3_BUCKET) \
89+
S3_HOST=$(cat /run/secrets/S3_HOST) \
90+
S3_PORT=$(cat /run/secrets/S3_PORT) \
91+
S3_PROTOCOL=$(cat /run/secrets/S3_PROTOCOL) \
7492
SSHOC_MARKETPLACE_API_BASE_URL=$(cat /run/secrets/SSHOC_MARKETPLACE_API_BASE_URL) \
7593
SSHOC_MARKETPLACE_BASE_URL=$(cat /run/secrets/SSHOC_MARKETPLACE_BASE_URL) \
7694
SSHOC_MARKETPLACE_PASSWORD=$(cat /run/secrets/SSHOC_MARKETPLACE_PASSWORD) \

components/admin/country-dashboard-content.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { AdminServicesTableContent } from "@/components/admin/services-table-con
1818
import { AdminSoftwareTableContent } from "@/components/admin/software-table-content";
1919
import { SubmitButton } from "@/components/submit-button";
2020
import { DateInputField } from "@/components/ui/blocks/date-input-field";
21+
import { ImageSelector } from "@/components/ui/blocks/image-selector";
2122
import { NumberInputField } from "@/components/ui/blocks/number-input-field";
2223
import { SelectField, SelectItem } from "@/components/ui/blocks/select-field";
2324
import { TextInputField } from "@/components/ui/blocks/text-input-field";
@@ -251,7 +252,7 @@ function CountryEditForm(props: CountryEditFormProps) {
251252
})}
252253
</SelectField>
253254

254-
<TextInputField defaultValue={country.logo ?? undefined} label="Logo" name="logo" />
255+
<ImageSelector defaultValue={country.logo ?? undefined} name="logo" />
255256

256257
<NumberInputField
257258
defaultValue={country.marketplaceId ?? undefined}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
"use client";
2+
3+
import { type AsyncListData, useAsyncList } from "@react-stately/data";
4+
import type { Key, Selection } from "@react-types/shared";
5+
import { CameraIcon } from "lucide-react";
6+
import { Fragment, type ReactNode, useCallback, useState } from "react";
7+
import { ListBox, ListBoxItem } from "react-aria-components";
8+
9+
import { UploadImageForm } from "@/components/ui/blocks/upload-image-form";
10+
import { Button } from "@/components/ui/button";
11+
import {
12+
Dialog,
13+
DialogCancelButton,
14+
DialogFooter,
15+
DialogHeader,
16+
DialogTitle,
17+
DialogTrigger,
18+
} from "@/components/ui/dialog";
19+
import { IconButton } from "@/components/ui/icon-button";
20+
import { Label } from "@/components/ui/label";
21+
import { Modal, ModalOverlay } from "@/components/ui/modal";
22+
import { getImageUrls } from "@/lib/actions/images";
23+
import { cn } from "@/lib/styles";
24+
25+
interface ImageSelectorProps {
26+
defaultValue: string | undefined;
27+
name: string;
28+
}
29+
30+
export function ImageSelector(props: ImageSelectorProps): ReactNode {
31+
const { defaultValue, name } = props;
32+
33+
const [imageUrl, setImageUrl] = useState(defaultValue);
34+
35+
return (
36+
<div className="group grid content-start gap-y-1.5">
37+
<Label>Logo</Label>
38+
{imageUrl ? (
39+
<figure className="w-1/2">
40+
{
41+
// eslint-disable-next-line @next/next/no-img-element
42+
<img alt="" src={imageUrl} />
43+
}
44+
</figure>
45+
) : undefined}
46+
<input name={name} type="hidden" value={imageUrl} />
47+
<DialogTrigger>
48+
<IconButton variant="outline">
49+
<CameraIcon aria-hidden={true} className="size-5 shrink-0" />
50+
<span className="sr-only">select logo</span>
51+
</IconButton>
52+
<ImageDialog handleUpdate={setImageUrl} />
53+
</DialogTrigger>
54+
</div>
55+
);
56+
}
57+
58+
interface ImagesDialogProps {
59+
handleUpdate: (value: string) => void;
60+
}
61+
62+
export function ImageDialog(props: ImagesDialogProps): ReactNode {
63+
const { handleUpdate } = props;
64+
65+
const [selectedImage, setSelectedImage] = useState<Key | null>(null);
66+
67+
const imagesList = useAsyncList<{ objectName: string } & { url: string }>({
68+
async load(_signal) {
69+
const data = await getImageUrls();
70+
return { items: data };
71+
},
72+
getKey: (item) => {
73+
return item.objectName;
74+
},
75+
});
76+
77+
const onSelectionChange = (keys: Selection) => {
78+
const image = Array.from(keys)[0] ?? null;
79+
if (image === "all") {
80+
setSelectedImage(null);
81+
} else {
82+
setSelectedImage(image);
83+
}
84+
};
85+
86+
const handleUploadSuccess = useCallback(() => {
87+
imagesList.reload();
88+
// eslint-disable-next-line react-hooks/exhaustive-deps
89+
}, []);
90+
91+
return (
92+
<ModalOverlay>
93+
<Modal isDismissable={true}>
94+
<Dialog>
95+
{({ close }) => {
96+
const update = () => {
97+
if (selectedImage) {
98+
handleUpdate(selectedImage.toString());
99+
}
100+
close();
101+
};
102+
103+
return (
104+
<Fragment>
105+
<DialogHeader>
106+
<DialogTitle>Choose Image</DialogTitle>
107+
</DialogHeader>
108+
<UploadImageForm onUploadSuccess={handleUploadSuccess} />
109+
<ImageGrid imagesList={imagesList} onSelectionChange={onSelectionChange} />
110+
<DialogFooter>
111+
<DialogCancelButton>Cancel</DialogCancelButton>
112+
<Button onPress={update}>Update</Button>
113+
</DialogFooter>
114+
</Fragment>
115+
);
116+
}}
117+
</Dialog>
118+
</Modal>
119+
</ModalOverlay>
120+
);
121+
}
122+
123+
interface ImageGridProps {
124+
onSelectionChange: (key: Selection) => void;
125+
imagesList: AsyncListData<{ objectName: string; url: string }>;
126+
}
127+
128+
export function ImageGrid(props: ImageGridProps): ReactNode {
129+
const { imagesList, onSelectionChange } = props;
130+
131+
return (
132+
<ListBox
133+
aria-label="Logo Images"
134+
className="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-8"
135+
items={imagesList.items}
136+
layout="grid"
137+
onSelectionChange={onSelectionChange}
138+
selectionMode="single"
139+
>
140+
{(item) => {
141+
const { objectName, url } = item;
142+
return (
143+
<ListBoxItem id={url}>
144+
{({ isSelected }) => {
145+
return (
146+
<figure
147+
className={cn(
148+
"grid grid-rows-[12rem_auto] gap-y-2",
149+
isSelected ? "ring-4" : undefined,
150+
)}
151+
>
152+
{/* FIXME: add a custom loader for `next/image` if possible, otherwise create our own wrapper which generates a `srcset`. */}
153+
{/* eslint-disable-next-line @next/next/no-img-element */}
154+
<img
155+
alt=""
156+
className={cn("size-full object-cover", isSelected ? undefined : "border")}
157+
src={url}
158+
/>
159+
<figcaption className="truncate">{objectName}</figcaption>
160+
</figure>
161+
);
162+
}}
163+
</ListBoxItem>
164+
);
165+
}}
166+
</ListBox>
167+
);
168+
}

components/ui/blocks/tiptap-editor.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"use client";
22

3-
import { usePreventScroll } from "@react-aria/overlays";
43
import { Bold } from "@tiptap/extension-bold";
54
import { Document } from "@tiptap/extension-document";
65
import { Link } from "@tiptap/extension-link";
@@ -75,8 +74,6 @@ export function TiptapEditor(props: TipTapEditorProps): ReactNode {
7574
}
7675
};
7776

78-
usePreventScroll({ isDisabled: isOpen });
79-
8077
const [content, setContent] = useState(defaultContent);
8178
const editor = useEditor({
8279
editorProps: {
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"use client";
2+
3+
import { type ReactNode, useActionState, useEffect } from "react";
4+
5+
import { SubmitButton } from "@/components/submit-button";
6+
import { Form } from "@/components/ui/form";
7+
import { createImageAction } from "@/lib/actions/create-image-action";
8+
9+
interface UploadImageFormProps {
10+
onUploadSuccess: () => void;
11+
}
12+
13+
export function UploadImageForm(props: UploadImageFormProps): ReactNode {
14+
const { onUploadSuccess } = props;
15+
const [formState, action] = useActionState(createImageAction, undefined);
16+
17+
useEffect(() => {
18+
if (formState?.status === "success") {
19+
onUploadSuccess();
20+
}
21+
}, [formState?.status, onUploadSuccess]);
22+
23+
return (
24+
<Form
25+
action={action}
26+
className="grid gap-y-8"
27+
validationErrors={formState?.status === "error" ? formState.fieldErrors : undefined}
28+
>
29+
{/* <FormStatus state={state} />*/}
30+
31+
<label className="border border-dashed">
32+
<div>Select an image to upload</div>
33+
<input accept="image/png, image/jpeg" name="file" required={true} type="file" />
34+
</label>
35+
36+
<div>
37+
<SubmitButton>Upload image</SubmitButton>
38+
</div>
39+
</Form>
40+
);
41+
}

config/env.config.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ export const env = createEnv({
3939
KEYSTATIC_GITHUB_CLIENT_SECRET: z.string().min(1).optional(),
4040
KEYSTATIC_SECRET: z.string().min(1).optional(),
4141
NEXT_RUNTIME: z.enum(["edge", "nodejs"]).optional(),
42+
IMGPROXY_BASE_URL: z.string().url(),
43+
IMGPROXY_KEY: z.string().min(1),
44+
IMGPROXY_SALT: z.string().min(1),
45+
S3_ACCESS_KEY: z.string().min(1),
46+
S3_BUCKET: z.string().min(1),
47+
S3_HOST: z.string().min(1),
48+
S3_PORT: z.string().transform(Number).pipe(z.number().int().min(1)),
49+
S3_PROTOCOL: z.enum(["http", "https"]).optional().default("https"),
50+
S3_SECRET_KEY: z.string().min(1),
4251
SENTRY_AUTH_TOKEN: z.string().min(1).optional(),
4352
SSHOC_MARKETPLACE_API_BASE_URL: z.string().url(),
4453
SSHOC_MARKETPLACE_BASE_URL: z.string().url(),
@@ -87,6 +96,9 @@ export const env = createEnv({
8796
EMAIL_SMTP_PORT: process.env.EMAIL_SMTP_PORT,
8897
EMAIL_SMTP_SERVER: process.env.EMAIL_SMTP_SERVER,
8998
EMAIL_USER_NAME: process.env.EMAIL_USER_NAME,
99+
IMGPROXY_BASE_URL: process.env.IMGPROXY_BASE_URL,
100+
IMGPROXY_KEY: process.env.IMGPROXY_KEY,
101+
IMGPROXY_SALT: process.env.IMGPROXY_SALT,
90102
KEYSTATIC_GITHUB_CLIENT_ID: process.env.KEYSTATIC_GITHUB_CLIENT_ID,
91103
KEYSTATIC_GITHUB_CLIENT_SECRET: process.env.KEYSTATIC_GITHUB_CLIENT_SECRET,
92104
KEYSTATIC_SECRET: process.env.KEYSTATIC_SECRET,
@@ -105,6 +117,12 @@ export const env = createEnv({
105117
NEXT_PUBLIC_SENTRY_ORG: process.env.NEXT_PUBLIC_SENTRY_ORG,
106118
NEXT_PUBLIC_SENTRY_PROJECT: process.env.NEXT_PUBLIC_SENTRY_PROJECT,
107119
NEXT_RUNTIME: process.env.NEXT_RUNTIME,
120+
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
121+
S3_BUCKET: process.env.S3_BUCKET,
122+
S3_HOST: process.env.S3_HOST,
123+
S3_PORT: process.env.S3_PORT,
124+
S3_PROTOCOL: process.env.S3_PROTOCOL,
125+
S3_SECRET_KEY: process.env.S3_SECRET_KEY,
108126
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
109127
SSHOC_MARKETPLACE_API_BASE_URL: process.env.SSHOC_MARKETPLACE_API_BASE_URL,
110128
SSHOC_MARKETPLACE_BASE_URL: process.env.SSHOC_MARKETPLACE_BASE_URL,

config/images.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const imageMimeTypes = ["image/jpeg", "image/png"] as const;
2+
3+
export const imageSizeLimit = 4 * 1024 * 1024; /** 4 MB */

0 commit comments

Comments
 (0)