Skip to content
Open
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
2 changes: 2 additions & 0 deletions .env.e2e
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
S3_IMPLEMENTATION=real
S3_ENDPOINT=http://localhost:9000
PUBLIC_S3_BASE_URL=http://localhost:9000/paradb-maps-e2e
PUBLIC_ASSETS_BASE_URL=http://localhost:9000/paradb-assets-e2e
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=minioadmin
S3_ACCESS_KEY_SECRET=minioadmin
S3_MAPS_BUCKET=paradb-maps-e2e
S3_ASSETS_BUCKET=paradb-assets-e2e

# Everything else stays faked/local - not under test here.
SENTRY_DSN=sentryDsn
Expand Down
6 changes: 4 additions & 2 deletions .env.localdev
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ PGDATABASE=postgres
PGPASSWORD=postgres
SENTRY_DSN=sentryDsn
SENTRY_ENV=localdev
PUBLIC_S3_BASE_URL=http://localhost:3000/api/_dev/s3
S3_ENDPOINT=http://localhost:3000/api/_dev/s3
PUBLIC_S3_BASE_URL=http://localhost:3000/api/dev/s3
S3_ENDPOINT=http://localhost:3000/api/dev/s3
S3_REGION=local
S3_ACCESS_KEY_ID=devaccesskeyneedstobeexactly32ch
S3_ACCESS_KEY_SECRET=12345678
S3_MAPS_BUCKET=paradb-maps-local
S3_ASSETS_BUCKET=paradb-assets-local
PUBLIC_ASSETS_BASE_URL=http://localhost:3000/api/dev/s3
S3_IMPLEMENTATION=dev
SUPABASE_IMPLEMENTATION=fake
AXIOM_IMPLEMENTATION=fake
Expand Down
8 changes: 7 additions & 1 deletion .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,19 @@ S3_REGION=local
S3_ACCESS_KEY_ID=accesskeyneedstobeexactly32chars
S3_ACCESS_KEY_SECRET=12345678
S3_MAPS_BUCKET=paradb-maps-test
S3_ASSETS_BUCKET=paradb-assets-test
# Points at the in-process dev S3 route so the fake-stored avatar is actually serveable (e.g. the
# browser upload test loads it). The route serves from the shared in-memory bucket in fake mode.
PUBLIC_ASSETS_BASE_URL=http://localhost:3000/api/dev/s3
S3_IMPLEMENTATION=fake
SUPABASE_IMPLEMENTATION=fake
AXIOM_IMPLEMENTATION=fake
FLAGS_IMPLEMENTATION=local
FLAGS_EDGE_CONFIG=abc
FLAGS_EDGE_CONFIG_KEY=123
NEXT_PUBLIC_SUPABASE_URL=example.com
# Must be a valid http(s) URL: the browser Supabase client validates it on construction (the fake
# never makes network calls to it). Without a scheme, client-rendered auth pages throw.
NEXT_PUBLIC_SUPABASE_URL=http://localhost:3000
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=123
SUPABASE_SECRET_KEY=123
AXIOM_API_TOKEN=123
Expand Down
15 changes: 15 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,12 @@
"next": "16.0.10",
"node-html-parser": "^7.0.2",
"pg": "^8.11.3",
"pica": "^10.0.1",
"postcss-modules-values": "^4.0.0",
"qs": "^6.11.2",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-easy-crop": "^6.0.2",
"sanitize-html": "^2.11.0",
"server-only": "^0.0.1",
"snakecase-keys": "^5.4.6",
Expand All @@ -74,6 +76,7 @@
"@swc/jest": "^0.2.39",
"@types/jest": "^29.5.5",
"@types/pg": "^8.10.2",
"@types/pica": "^9.0.5",
"@types/postcss-modules-values": "^4",
"@types/qs": "^6.9.8",
"@types/supertest": "^2.0.12",
Expand Down
59 changes: 59 additions & 0 deletions src/app/(logged-in)/settings/avatar_crop_dialog.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
.backdrop {
position: fixed;
inset: 0;
background-color: var(--colorDialogOverlay);
}

.dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);

display: flex;
flex-direction: column;
gap: calc(var(--gridBaseline) * 2);

width: calc(var(--gridBaseline) * 50);
max-width: 90vw;

background-color: var(--colorBackground);
padding: calc(var(--gridBaseline) * 3);
border: 1px dotted var(--colorForeground);
}

.cropArea {
position: relative;
width: 100%;
height: calc(var(--gridBaseline) * 40);
background-color: var(--colorDarkGrey);
}

.zoom {
display: flex;
align-items: center;
gap: var(--gridBaseline);
}

.zoom input {
flex: 1;
}

.scaling {
display: flex;
align-items: center;
gap: calc(var(--gridBaseline) * 2);
}

.scalingOption {
display: flex;
align-items: center;
gap: calc(0.5 * var(--gridBaseline));
cursor: pointer;
}

.actions {
display: flex;
justify-content: flex-end;
gap: var(--gridBaseline);
}
115 changes: 115 additions & 0 deletions src/app/(logged-in)/settings/avatar_crop_dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
'use client';

import { Dialog as BaseDialog } from '@base-ui/react/dialog';
import { useCallback, useState } from 'react';
import Cropper, { Area } from 'react-easy-crop';
import { Button } from 'ui/base/button/button';
import { FormError } from 'ui/base/form/form_error';
import { T } from 'ui/base/text/text';
import { Tooltip } from 'ui/base/tooltip/tooltip';
import styles from './avatar_crop_dialog.module.css';
import { ImageScaling } from './render_avatar';

type AvatarCropDialogProps = {
/** Object URL of the image to crop. */
source: string;
submitting: boolean;
error: string | undefined;
onSave: (area: Area, scaling: ImageScaling) => void;
onCancel: () => void;
};

export function AvatarCropDialog(props: AvatarCropDialogProps) {
const { source, submitting, error, onSave, onCancel } = props;
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [scaling, setScaling] = useState<ImageScaling>('smooth');
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);

const onCropComplete = useCallback((_area: Area, areaPixels: Area) => {
setCroppedAreaPixels(areaPixels);
}, []);

const handleSave = () => {
if (croppedAreaPixels == null) {
return;
}
onSave(croppedAreaPixels, scaling);
};

return (
<BaseDialog.Root
open
onOpenChange={(open) => {
if (!open) {
onCancel();
}
}}
>
<BaseDialog.Portal>
<BaseDialog.Backdrop className={styles.backdrop} />
<BaseDialog.Popup className={styles.dialog}>
<T.Large>Crop your profile picture</T.Large>
<div className={styles.cropArea}>
<Cropper
image={source}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="round"
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
</div>
<label className={styles.zoom}>
<T.Small>Zoom</T.Small>
<input
type="range"
min={1}
max={3}
step={0.01}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
/>
</label>
<div className={styles.scaling}>
<T.Small>Image scaling:</T.Small>
<Tooltip content="nearest-neighbour">
<label className={styles.scalingOption}>
<input
type="radio"
name="avatar-scaling"
checked={scaling === 'precise'}
onChange={() => setScaling('precise')}
/>
<T.Small>Precise</T.Small>
</label>
</Tooltip>
<Tooltip content="lanczos">
<label className={styles.scalingOption}>
<input
type="radio"
name="avatar-scaling"
checked={scaling === 'smooth'}
onChange={() => setScaling('smooth')}
/>
<T.Small>Smooth</T.Small>
</label>
</Tooltip>
</div>
<FormError error={error} />
<div className={styles.actions}>
<Button style="regular" onClick={onCancel}>
Cancel
</Button>
<Button loading={submitting} onClick={handleSave}>
Save
</Button>
</div>
</BaseDialog.Popup>
</BaseDialog.Portal>
</BaseDialog.Root>
);
}
18 changes: 18 additions & 0 deletions src/app/(logged-in)/settings/page.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,21 @@
flex-direction: column;
gap: calc(var(--gridBaseline) * 2);
}

.avatarSection {
display: flex;
align-items: center;
gap: calc(var(--gridBaseline) * 2);
}

.avatar,
.avatarPlaceholder {
width: calc(var(--gridBaseline) * 10);
height: calc(var(--gridBaseline) * 10);
border-radius: 50%;
object-fit: cover;
}

.avatarPlaceholder {
background-color: var(--colorGreyA15);
}
Loading
Loading