Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/build-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,18 @@ jobs:
"EMAIL_SMTP_SERVER=${{ vars.K8S_SECRET_EMAIL_SMTP_SERVER }}"
"DATABASE_DIRECT_URL=${{ secrets.K8S_SECRET_DATABASE_DIRECT_URL }}"
"DATABASE_URL=${{ secrets.K8S_SECRET_DATABASE_URL }}"
"IMGPROXY_BASE_URL=${{ vars.K8S_SECRET_IMGPROXY_BASE_URL }}"
"IMGPROXY_KEY=${{ secrets.K8S_SECRET_IMGPROXY_KEY }}"
"IMGPROXY_SALT=${{ secrets.K8S_SECRET_IMGPROXY_SALT }}"
"KEYSTATIC_GITHUB_CLIENT_ID=${{ secrets.K8S_SECRET_KEYSTATIC_GITHUB_CLIENT_ID }}"
"KEYSTATIC_GITHUB_CLIENT_SECRET=${{ secrets.K8S_SECRET_KEYSTATIC_GITHUB_CLIENT_SECRET }}"
"KEYSTATIC_SECRET=${{ secrets.K8S_SECRET_KEYSTATIC_SECRET }}"
"S3_ACCESS_KEY=${{ secrets.K8S_SECRET_S3_ACCESS_KEY }}"
"S3_SECRET_KEY=${{ secrets.K8S_SECRET_S3_SECRET_KEY }}"
"S3_BUCKET=${{ vars.K8S_SECRET_S3_BUCKET }}"
"S3_HOST=${{ vars.K8S_SECRET_S3_HOST }}"
"S3_PORT=${{ vars.K8S_SECRET_S3_PORT }}"
"S3_PROTOCOL=${{ vars.K8S_SECRET_S3_PROTOCOL }}"
"SSHOC_MARKETPLACE_API_BASE_URL=${{ vars.K8S_SECRET_SSHOC_MARKETPLACE_API_BASE_URL }}"
"SSHOC_MARKETPLACE_BASE_URL=${{ vars.K8S_SECRET_SSHOC_MARKETPLACE_BASE_URL }}"
"SSHOC_MARKETPLACE_PASSWORD=${{ secrets.K8S_SECRET_SSHOC_MARKETPLACE_PASSWORD }}"
Expand Down
18 changes: 18 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,18 @@ jobs:
EMAIL_SMTP_PORT: "${{ vars.K8S_SECRET_TEST_EMAIL_SMTP_PORT }}"
EMAIL_SMTP_SERVER: "${{ vars.K8S_SECRET_TEST_EMAIL_SMTP_SERVER }}"
EMAIL_USER_NAME: "${{ secrets.K8S_SECRET_TEST_EMAIL_USER_NAME }}"
IMGPROXY_BASE_URL: "${{ vars.K8S_SECRET_IMGPROXY_BASE_URL }}"
IMGPROXY_KEY: "${{ secrets.K8S_SECRET_IMGPROXY_KEY }}"
IMGPROXY_SALT: "${{ secrets.K8S_SECRET_IMGPROXY_SALT }}"
NEXT_PUBLIC_APP_BASE_URL: "http://localhost:3000"
NEXT_PUBLIC_MATOMO_BASE_URL: "${{ vars.NEXT_PUBLIC_MATOMO_BASE_URL }}"
NEXT_PUBLIC_REDMINE_ID: "${{ vars.SERVICE_ID }}"
S3_ACCESS_KEY: "${{ secrets.K8S_SECRET_S3_ACCESS_KEY }}"
S3_SECRET_KEY: "${{ secrets.K8S_SECRET_S3_SECRET_KEY }}"
S3_BUCKET: "${{ vars.K8S_SECRET_S3_BUCKET }}"
S3_HOST: "${{ vars.K8S_SECRET_S3_HOST }}"
S3_PORT: "${{ vars.K8S_SECRET_S3_PORT }}"
S3_PROTOCOL: "${{ vars.K8S_SECRET_S3_PROTOCOL }}"
SSHOC_MARKETPLACE_API_BASE_URL: "${{ vars.K8S_SECRET_SSHOC_MARKETPLACE_API_BASE_URL }}"
SSHOC_MARKETPLACE_BASE_URL: "${{ vars.K8S_SECRET_SSHOC_MARKETPLACE_BASE_URL }}"
SSHOC_MARKETPLACE_PASSWORD: "${{ secrets.K8S_SECRET_SSHOC_MARKETPLACE_PASSWORD }}"
Expand All @@ -126,9 +135,18 @@ jobs:
EMAIL_SMTP_PORT: "${{ vars.K8S_SECRET_TEST_EMAIL_SMTP_PORT }}"
EMAIL_SMTP_SERVER: "${{ vars.K8S_SECRET_TEST_EMAIL_SMTP_SERVER }}"
EMAIL_USER_NAME: "${{ secrets.K8S_SECRET_TEST_EMAIL_USER_NAME }}"
IMGPROXY_BASE_URL: "${{ vars.K8S_SECRET_IMGPROXY_BASE_URL }}"
IMGPROXY_KEY: "${{ secrets.K8S_SECRET_IMGPROXY_KEY }}"
IMGPROXY_SALT: "${{ secrets.K8S_SECRET_IMGPROXY_SALT }}"
NEXT_PUBLIC_APP_BASE_URL: "http://localhost:3000"
NEXT_PUBLIC_MATOMO_BASE_URL: "${{ vars.NEXT_PUBLIC_MATOMO_BASE_URL }}"
NEXT_PUBLIC_REDMINE_ID: "${{ vars.SERVICE_ID }}"
S3_ACCESS_KEY: "${{ secrets.K8S_SECRET_S3_ACCESS_KEY }}"
S3_SECRET_KEY: "${{ secrets.K8S_SECRET_S3_SECRET_KEY }}"
S3_BUCKET: "${{ vars.K8S_SECRET_S3_BUCKET }}"
S3_HOST: "${{ vars.K8S_SECRET_S3_HOST }}"
S3_PORT: "${{ vars.K8S_SECRET_S3_PORT }}"
S3_PROTOCOL: "${{ vars.K8S_SECRET_S3_PROTOCOL }}"
SSHOC_MARKETPLACE_API_BASE_URL: "${{ vars.K8S_SECRET_SSHOC_MARKETPLACE_API_BASE_URL }}"
SSHOC_MARKETPLACE_BASE_URL: "${{ vars.K8S_SECRET_SSHOC_MARKETPLACE_BASE_URL }}"
SSHOC_MARKETPLACE_PASSWORD: "${{ secrets.K8S_SECRET_SSHOC_MARKETPLACE_PASSWORD }}"
Expand Down
18 changes: 18 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,18 @@ RUN --mount=type=secret,id=AUTH_SECRET,uid=1000 \
--mount=type=secret,id=EMAIL_SMTP_SERVER,uid=1000 \
--mount=type=secret,id=DATABASE_DIRECT_URL,uid=1000 \
--mount=type=secret,id=DATABASE_URL,uid=1000 \
--mount=type=secret,id=IMGPROXY_BASE_URL,uid=1000 \
--mount=type=secret,id=IMGPROXY_KEY,uid=1000 \
--mount=type=secret,id=IMGPROXY_SALT,uid=1000 \
--mount=type=secret,id=KEYSTATIC_GITHUB_CLIENT_ID,uid=1000 \
--mount=type=secret,id=KEYSTATIC_GITHUB_CLIENT_SECRET,uid=1000 \
--mount=type=secret,id=KEYSTATIC_SECRET,uid=1000 \
--mount=type=secret,id=S3_ACCESS_KEY,uid=1000 \
--mount=type=secret,id=S3_SECRET_KEY,uid=1000 \
--mount=type=secret,id=S3_BUCKET,uid=1000 \
--mount=type=secret,id=S3_HOST,uid=1000 \
--mount=type=secret,id=S3_PORT,uid=1000 \
--mount=type=secret,id=S3_PROTOCOL,uid=1000 \
--mount=type=secret,id=SSHOC_MARKETPLACE_API_BASE_URL,uid=1000 \
--mount=type=secret,id=SSHOC_MARKETPLACE_BASE_URL,uid=1000 \
--mount=type=secret,id=SSHOC_MARKETPLACE_PASSWORD,uid=1000 \
Expand All @@ -68,9 +77,18 @@ RUN --mount=type=secret,id=AUTH_SECRET,uid=1000 \
EMAIL_SMTP_SERVER=$(cat /run/secrets/EMAIL_SMTP_SERVER) \
DATABASE_DIRECT_URL=$(cat /run/secrets/DATABASE_DIRECT_URL) \
DATABASE_URL=$(cat /run/secrets/DATABASE_URL) \
IMGPROXY_BASE_URL=$(cat /run/secrets/IMGPROXY_BASE_URL) \
IMGPROXY_KEY=$(cat /run/secrets/IMGPROXY_KEY) \
IMGPROXY_SALT=$(cat /run/secrets/IMGPROXY_SALT) \
KEYSTATIC_GITHUB_CLIENT_ID=$(cat /run/secrets/KEYSTATIC_GITHUB_CLIENT_ID) \
KEYSTATIC_GITHUB_CLIENT_SECRET=$(cat /run/secrets/KEYSTATIC_GITHUB_CLIENT_SECRET) \
KEYSTATIC_SECRET=$(cat /run/secrets/KEYSTATIC_SECRET) \
S3_ACCESS_KEY=$(cat /run/secrets/S3_ACCESS_KEY) \
S3_SECRET_KEY=$(cat /run/secrets/S3_SECRET_KEY) \
S3_BUCKET=$(cat /run/secrets/S3_BUCKET) \
S3_HOST=$(cat /run/secrets/S3_HOST) \
S3_PORT=$(cat /run/secrets/S3_PORT) \
S3_PROTOCOL=$(cat /run/secrets/S3_PROTOCOL) \
SSHOC_MARKETPLACE_API_BASE_URL=$(cat /run/secrets/SSHOC_MARKETPLACE_API_BASE_URL) \
SSHOC_MARKETPLACE_BASE_URL=$(cat /run/secrets/SSHOC_MARKETPLACE_BASE_URL) \
SSHOC_MARKETPLACE_PASSWORD=$(cat /run/secrets/SSHOC_MARKETPLACE_PASSWORD) \
Expand Down
3 changes: 2 additions & 1 deletion components/admin/country-dashboard-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { AdminServicesTableContent } from "@/components/admin/services-table-con
import { AdminSoftwareTableContent } from "@/components/admin/software-table-content";
import { SubmitButton } from "@/components/submit-button";
import { DateInputField } from "@/components/ui/blocks/date-input-field";
import { ImageSelector } from "@/components/ui/blocks/image-selector";
import { NumberInputField } from "@/components/ui/blocks/number-input-field";
import { SelectField, SelectItem } from "@/components/ui/blocks/select-field";
import { TextInputField } from "@/components/ui/blocks/text-input-field";
Expand Down Expand Up @@ -251,7 +252,7 @@ function CountryEditForm(props: CountryEditFormProps) {
})}
</SelectField>

<TextInputField defaultValue={country.logo ?? undefined} label="Logo" name="logo" />
<ImageSelector defaultValue={country.logo ?? undefined} name="logo" />

<NumberInputField
defaultValue={country.marketplaceId ?? undefined}
Expand Down
168 changes: 168 additions & 0 deletions components/ui/blocks/image-selector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"use client";

import { type AsyncListData, useAsyncList } from "@react-stately/data";
import type { Key, Selection } from "@react-types/shared";
import { CameraIcon } from "lucide-react";
import { Fragment, type ReactNode, useCallback, useState } from "react";
import { ListBox, ListBoxItem } from "react-aria-components";

import { UploadImageForm } from "@/components/ui/blocks/upload-image-form";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogCancelButton,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { IconButton } from "@/components/ui/icon-button";
import { Label } from "@/components/ui/label";
import { Modal, ModalOverlay } from "@/components/ui/modal";
import { getImageUrls } from "@/lib/actions/images";
import { cn } from "@/lib/styles";

interface ImageSelectorProps {
defaultValue: string | undefined;
name: string;
}

export function ImageSelector(props: ImageSelectorProps): ReactNode {
const { defaultValue, name } = props;

const [imageUrl, setImageUrl] = useState(defaultValue);

return (
<div className="group grid content-start gap-y-1.5">
<Label>Logo</Label>
{imageUrl ? (
<figure className="w-1/2">
{
// eslint-disable-next-line @next/next/no-img-element
<img alt="" src={imageUrl} />
}
</figure>
) : undefined}
<input name={name} type="hidden" value={imageUrl} />
<DialogTrigger>
<IconButton variant="outline">
<CameraIcon aria-hidden={true} className="size-5 shrink-0" />
<span className="sr-only">select logo</span>
</IconButton>
<ImageDialog handleUpdate={setImageUrl} />
</DialogTrigger>
</div>
);
}

interface ImagesDialogProps {
handleUpdate: (value: string) => void;
}

export function ImageDialog(props: ImagesDialogProps): ReactNode {
const { handleUpdate } = props;

const [selectedImage, setSelectedImage] = useState<Key | null>(null);

const imagesList = useAsyncList<{ objectName: string } & { url: string }>({
async load(_signal) {
const data = await getImageUrls();
return { items: data };
},
getKey: (item) => {
return item.objectName;
},
});

const onSelectionChange = (keys: Selection) => {
const image = Array.from(keys)[0] ?? null;
if (image === "all") {
setSelectedImage(null);
} else {
setSelectedImage(image);
}
};

const handleUploadSuccess = useCallback(() => {
imagesList.reload();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return (
<ModalOverlay>
<Modal isDismissable={true}>
<Dialog>
{({ close }) => {
const update = () => {
if (selectedImage) {
handleUpdate(selectedImage.toString());
}
close();
};

return (
<Fragment>
<DialogHeader>
<DialogTitle>Choose Image</DialogTitle>
</DialogHeader>
<UploadImageForm onUploadSuccess={handleUploadSuccess} />
<ImageGrid imagesList={imagesList} onSelectionChange={onSelectionChange} />
<DialogFooter>
<DialogCancelButton>Cancel</DialogCancelButton>
<Button onPress={update}>Update</Button>
</DialogFooter>
</Fragment>
);
}}
</Dialog>
</Modal>
</ModalOverlay>
);
}

interface ImageGridProps {
onSelectionChange: (key: Selection) => void;
imagesList: AsyncListData<{ objectName: string; url: string }>;
}

export function ImageGrid(props: ImageGridProps): ReactNode {
const { imagesList, onSelectionChange } = props;

return (
<ListBox
aria-label="Logo Images"
className="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-8"
items={imagesList.items}
layout="grid"
onSelectionChange={onSelectionChange}
selectionMode="single"
>
{(item) => {
const { objectName, url } = item;
return (
<ListBoxItem id={url}>
{({ isSelected }) => {
return (
<figure
className={cn(
"grid grid-rows-[12rem_auto] gap-y-2",
isSelected ? "ring-4" : undefined,
)}
>
{/* FIXME: add a custom loader for `next/image` if possible, otherwise create our own wrapper which generates a `srcset`. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt=""
className={cn("size-full object-cover", isSelected ? undefined : "border")}
src={url}
/>
<figcaption className="truncate">{objectName}</figcaption>
</figure>
);
}}
</ListBoxItem>
);
}}
</ListBox>
);
}
3 changes: 0 additions & 3 deletions components/ui/blocks/tiptap-editor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"use client";

import { usePreventScroll } from "@react-aria/overlays";
import { Bold } from "@tiptap/extension-bold";
import { Document } from "@tiptap/extension-document";
import { Link } from "@tiptap/extension-link";
Expand All @@ -8,7 +7,7 @@
import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extensions";
import { type Editor, EditorContent, useEditor, useEditorState } from "@tiptap/react";
import DOMPurify from "dompurify";

Check warning on line 10 in components/ui/blocks/tiptap-editor.tsx

View workflow job for this annotation

GitHub Actions / Validate (22.x, ubuntu-24.04)

Using exported name 'DOMPurify' as identifier for default export
import { BoldIcon, LinkIcon, UnlinkIcon } from "lucide-react";
import { Fragment, type ReactNode, useState } from "react";
import { DialogTrigger, Group } from "react-aria-components";
Expand Down Expand Up @@ -75,8 +74,6 @@
}
};

usePreventScroll({ isDisabled: isOpen });

const [content, setContent] = useState(defaultContent);
const editor = useEditor({
editorProps: {
Expand Down
41 changes: 41 additions & 0 deletions components/ui/blocks/upload-image-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"use client";

import { type ReactNode, useActionState, useEffect } from "react";

import { SubmitButton } from "@/components/submit-button";
import { Form } from "@/components/ui/form";
import { createImageAction } from "@/lib/actions/create-image-action";

interface UploadImageFormProps {
onUploadSuccess: () => void;
}

export function UploadImageForm(props: UploadImageFormProps): ReactNode {
const { onUploadSuccess } = props;
const [formState, action] = useActionState(createImageAction, undefined);

useEffect(() => {
if (formState?.status === "success") {
onUploadSuccess();
}
}, [formState?.status, onUploadSuccess]);

return (
<Form
action={action}
className="grid gap-y-8"
validationErrors={formState?.status === "error" ? formState.fieldErrors : undefined}
>
{/* <FormStatus state={state} />*/}

<label className="border border-dashed">
<div>Select an image to upload</div>
<input accept="image/png, image/jpeg" name="file" required={true} type="file" />
</label>

<div>
<SubmitButton>Upload image</SubmitButton>
</div>
</Form>
);
}
18 changes: 18 additions & 0 deletions config/env.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ export const env = createEnv({
KEYSTATIC_GITHUB_CLIENT_SECRET: z.string().min(1).optional(),
KEYSTATIC_SECRET: z.string().min(1).optional(),
NEXT_RUNTIME: z.enum(["edge", "nodejs"]).optional(),
IMGPROXY_BASE_URL: z.string().url(),
IMGPROXY_KEY: z.string().min(1),
IMGPROXY_SALT: z.string().min(1),
S3_ACCESS_KEY: z.string().min(1),
S3_BUCKET: z.string().min(1),
S3_HOST: z.string().min(1),
S3_PORT: z.string().transform(Number).pipe(z.number().int().min(1)),
S3_PROTOCOL: z.enum(["http", "https"]).optional().default("https"),
S3_SECRET_KEY: z.string().min(1),
SENTRY_AUTH_TOKEN: z.string().min(1).optional(),
SSHOC_MARKETPLACE_API_BASE_URL: z.string().url(),
SSHOC_MARKETPLACE_BASE_URL: z.string().url(),
Expand Down Expand Up @@ -87,6 +96,9 @@ export const env = createEnv({
EMAIL_SMTP_PORT: process.env.EMAIL_SMTP_PORT,
EMAIL_SMTP_SERVER: process.env.EMAIL_SMTP_SERVER,
EMAIL_USER_NAME: process.env.EMAIL_USER_NAME,
IMGPROXY_BASE_URL: process.env.IMGPROXY_BASE_URL,
IMGPROXY_KEY: process.env.IMGPROXY_KEY,
IMGPROXY_SALT: process.env.IMGPROXY_SALT,
KEYSTATIC_GITHUB_CLIENT_ID: process.env.KEYSTATIC_GITHUB_CLIENT_ID,
KEYSTATIC_GITHUB_CLIENT_SECRET: process.env.KEYSTATIC_GITHUB_CLIENT_SECRET,
KEYSTATIC_SECRET: process.env.KEYSTATIC_SECRET,
Expand All @@ -105,6 +117,12 @@ export const env = createEnv({
NEXT_PUBLIC_SENTRY_ORG: process.env.NEXT_PUBLIC_SENTRY_ORG,
NEXT_PUBLIC_SENTRY_PROJECT: process.env.NEXT_PUBLIC_SENTRY_PROJECT,
NEXT_RUNTIME: process.env.NEXT_RUNTIME,
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
S3_BUCKET: process.env.S3_BUCKET,
S3_HOST: process.env.S3_HOST,
S3_PORT: process.env.S3_PORT,
S3_PROTOCOL: process.env.S3_PROTOCOL,
S3_SECRET_KEY: process.env.S3_SECRET_KEY,
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
SSHOC_MARKETPLACE_API_BASE_URL: process.env.SSHOC_MARKETPLACE_API_BASE_URL,
SSHOC_MARKETPLACE_BASE_URL: process.env.SSHOC_MARKETPLACE_BASE_URL,
Expand Down
3 changes: 3 additions & 0 deletions config/images.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const imageMimeTypes = ["image/jpeg", "image/png"] as const;

export const imageSizeLimit = 4 * 1024 * 1024; /** 4 MB */
Loading
Loading