diff --git a/README.md b/README.md
index 0bb82cc..70cf55d 100644
--- a/README.md
+++ b/README.md
@@ -93,7 +93,8 @@ one.
converted file also gets a report of what the conversion could not carry. Records you already have
are matched rather than duplicated, and a dive you deleted returns under its own identity. The
archive additionally restores the dive-computer files and c-card scans, which the bare document
- names by digest but does not carry.
+ names by digest but does not carry. The check-in details a logbook carries, and an archive's
+ portrait, are shown beside yours in the preview, and only what you choose there is saved.
- **Passwordless sign-in** — email magic links or Google; no passwords stored, ever.
- **Dark mode & responsive** — works on the boat, in the dive shop, and on your desk.
diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx
index fa4eb75..d5f68f0 100644
--- a/src/app/privacy/page.tsx
+++ b/src/app/privacy/page.tsx
@@ -241,14 +241,16 @@ export default async function PrivacyPage() {
- Both pictures are kept as the file you picked, so that you can
- change how either is cropped later, with the location and camera
- details a phone photo carries removed by this server before the
- file is stored; what the app shows is drawn from that file at the
- crop you chose. Your portrait is shown to you where you edit it
- and on your check-in page, and never where your profile picture
- appears. The full archive export carries both files, and both are
- deleted with your account.
+ Both pictures are kept as the file you picked — or, for the
+ portrait, the one a full archive carries, if you take it in the
+ import preview — so that you can change how either is
+ cropped later, with the location and camera details a phone photo
+ carries removed by this server before the file is stored; what the
+ app shows is drawn from that file at the crop you chose. Your
+ portrait is shown to you where you edit it and on your check-in
+ page, and never where your profile picture appears. The full
+ archive export carries both files, and both are deleted with your
+ account.
One thing arrives without you typing it. If you create your
@@ -389,8 +391,10 @@ export default async function PrivacyPage() {
GPS positions inside a photo you pick as your profile picture or
- portrait, which travel with the upload and are removed by this
- server before the photo is stored
+ portrait, or inside the portrait carried by a full archive you
+ import, which travel with the upload and are removed by this
+ server before the photo is stored — an archive’s
+ only if you take it in the import preview
diff --git a/src/components/settings/data-export-card.render.test.tsx b/src/components/settings/data-export-card.render.test.tsx
index ab2e93b..239ffca 100644
--- a/src/components/settings/data-export-card.render.test.tsx
+++ b/src/components/settings/data-export-card.render.test.tsx
@@ -90,8 +90,10 @@ describe("DataExportCard", () => {
expect(warning).toMatch(
/card scans and the portrait are personal documents/,
);
- // In the archive, and - until an import can restore it - not put back by one.
- expect(warning).toMatch(/portrait is in it too, though an import does not/);
+ // In the archive, and offered back by an import rather than restored silently.
+ expect(warning).toMatch(
+ /portrait is in it too, and importing the archive offers it back/,
+ );
});
it.each([
diff --git a/src/components/settings/data-export-card.tsx b/src/components/settings/data-export-card.tsx
index 7af27af..4a364cc 100644
--- a/src/components/settings/data-export-card.tsx
+++ b/src/components/settings/data-export-card.tsx
@@ -81,7 +81,7 @@ const EXPORT_ROWS: ExportRow[] = [
icon: FileArchive,
title: "Full archive",
description:
- "Everything, as a zip: the DiveJSON, the UDDF, the full CSV set, every dive-computer file you uploaded — each under the recording it belongs to — and every certification card image you uploaded. This is the one to keep as a backup — it is also the only download that can put those files back, since the import card below can restore bytes it actually carries. Your portrait is in it too, though an import does not put that back yet. The card scans and the portrait are personal documents, so treat the file as one.",
+ "Everything, as a zip: the DiveJSON, the UDDF, the full CSV set, every dive-computer file you uploaded — each under the recording it belongs to — and every certification card image you uploaded. This is the one to keep as a backup — it is also the only download that can put those files back, since the import card below can restore bytes it actually carries. Your portrait is in it too, and importing the archive offers it back beside yours. The card scans and the portrait are personal documents, so treat the file as one.",
},
];
diff --git a/src/components/settings/data-import-card.render.test.tsx b/src/components/settings/data-import-card.render.test.tsx
index fcb8deb..b369b46 100644
--- a/src/components/settings/data-import-card.render.test.tsx
+++ b/src/components/settings/data-import-card.render.test.tsx
@@ -27,7 +27,18 @@ const mocks = vi.hoisted(() => ({
}));
vi.mock("@/contexts/AuthContext", () => ({
- useAuth: () => ({ refreshUser: mocks.refreshUser }),
+ useAuth: () => ({ user: { name: "Alex" }, refreshUser: mocks.refreshUser }),
+}));
+
+// The account's portrait is fetched through this, the endpoint being owner-only.
+// Answering with a URL is what puts its `` on the page.
+vi.mock("@/hooks/useAuthedBlobUrl", () => ({
+ useAuthedBlobUrl: (fetchBlob: unknown) => ({
+ url: fetchBlob ? "blob:portrait" : null,
+ isLoading: false,
+ hasError: false,
+ error: null,
+ }),
}));
vi.mock("@/lib/api/logbook-import", async (importOriginal) => ({
@@ -63,6 +74,7 @@ function preview(overrides: Partial = {}): ImportPreview {
archive: false,
token: "tok-1",
check_in_details: [],
+ portrait: null,
...overrides,
};
}
@@ -548,3 +560,109 @@ describe("the check-in details in an import preview", () => {
expect(mocks.refreshUser).not.toHaveBeenCalled();
});
});
+
+describe("the archive's portrait in an import preview", () => {
+ const sha = "b".repeat(64);
+ const proposed = "data:image/webp;base64,UklGRg==";
+
+ const applyButton = () =>
+ screen.getByRole("button", { name: /import this logbook/i });
+
+ async function previewWith(overrides: Partial) {
+ mocks.apply.mockResolvedValue(report());
+ mocks.preview.mockResolvedValue(preview({ archive: true, ...overrides }));
+ render();
+ await choose(new File(["zip"], "backup.zip", { type: "application/zip" }));
+ await screen.findByText(/nothing has been written yet/i);
+ }
+
+ it("shows the account's beside the archive's, the archive's chosen", async () => {
+ await previewWith({ portrait: { account_sha256: sha, proposed } });
+
+ const row = screen.getByRole("group", { name: "Portrait" });
+ expect(within(row).getByAltText("Portrait of Alex")).toHaveAttribute(
+ "src",
+ "blob:portrait",
+ );
+ expect(within(row).getByAltText("Portrait from this file")).toHaveAttribute(
+ "src",
+ proposed,
+ );
+ expect(
+ within(row).getByText("This file's replaces yours when you import."),
+ ).toBeVisible();
+
+ await userEvent.click(applyButton());
+ await waitFor(() => expect(mocks.apply).toHaveBeenCalledTimes(1));
+ expect(mocks.apply.mock.calls[0][3]).toEqual({
+ choice: "take",
+ account_sha256: sha,
+ });
+ });
+
+ it("sends keep, with the digest the preview showed, after Keep mine", async () => {
+ await previewWith({ portrait: { account_sha256: sha, proposed } });
+
+ const row = screen.getByRole("group", { name: "Portrait" });
+ await userEvent.click(
+ within(row).getByRole("button", { name: "Keep mine" }),
+ );
+ expect(
+ within(row).getByText("Keeping yours; nothing from this file is saved."),
+ ).toBeVisible();
+
+ await userEvent.click(applyButton());
+ await waitFor(() => expect(mocks.apply).toHaveBeenCalledTimes(1));
+ // The digest goes on keep too: the API requires the key either way.
+ expect(mocks.apply.mock.calls[0][3]).toEqual({
+ choice: "keep",
+ account_sha256: sha,
+ });
+ });
+
+ it("sends a null digest for an account with no portrait", async () => {
+ await previewWith({ portrait: { account_sha256: null, proposed } });
+
+ const row = screen.getByRole("group", { name: "Portrait" });
+ expect(within(row).getByRole("img", { name: "No portrait" })).toBeVisible();
+ await userEvent.click(
+ within(row).getByRole("button", { name: "Leave unset" }),
+ );
+
+ await userEvent.click(applyButton());
+ await waitFor(() => expect(mocks.apply).toHaveBeenCalledTimes(1));
+ expect(mocks.apply.mock.calls[0][3]).toEqual({
+ choice: "keep",
+ account_sha256: null,
+ });
+ });
+
+ it("shows the section with the portrait alone for an archive carrying no fact", async () => {
+ await previewWith({ portrait: { account_sha256: sha, proposed } });
+
+ expect(screen.getByText("Check-in details")).toBeVisible();
+ expect(screen.getAllByRole("group")).toHaveLength(1);
+ expect(screen.getByRole("group", { name: "Portrait" })).toBeVisible();
+ });
+
+ it("shows no row, and sends no choice, when nothing is offered", async () => {
+ await previewWith({
+ check_in_details: [{ detail: "phone", account: null, proposed: "+44 2" }],
+ });
+
+ expect(screen.getByText("Check-in details")).toBeVisible();
+ expect(screen.queryByRole("group", { name: "Portrait" })).toBeNull();
+ await userEvent.click(applyButton());
+ await waitFor(() => expect(mocks.apply).toHaveBeenCalledTimes(1));
+ expect(mocks.apply.mock.calls[0][3]).toBeUndefined();
+ });
+
+ it("says in the dropzone that the portrait and the check-in details are offered", () => {
+ render();
+ expect(
+ screen.getByText(
+ /check-in details a logbook carries, and an archive.s portrait, are shown beside yours/,
+ ),
+ ).toBeVisible();
+ });
+});
diff --git a/src/components/settings/data-import-card.tsx b/src/components/settings/data-import-card.tsx
index e014964..cfd7a50 100644
--- a/src/components/settings/data-import-card.tsx
+++ b/src/components/settings/data-import-card.tsx
@@ -16,6 +16,7 @@ import { useToast } from "@/components/ui/use-toast";
import {
ImportCheckInDetails,
useImportCheckIn,
+ type ImportCheckInChoices,
} from "@/components/settings/import-check-in-details";
import { useAuth } from "@/contexts/AuthContext";
import { getApiErrorMessage } from "@/lib/api/error";
@@ -25,7 +26,6 @@ import {
LOGBOOK_IMPORT_ACCEPT,
MAX_IMPORT_ARCHIVE_SIZE,
MAX_IMPORT_DOCUMENT_SIZE,
- type ImportCheckInSubmission,
type ImportPreview,
type ImportReport,
} from "@/lib/api/logbook-import";
@@ -290,14 +290,14 @@ function PendingImport({
file: File;
preview: ImportPreview;
isApplying: boolean;
- onApply: (checkIn: ImportCheckInSubmission | undefined) => void;
+ onApply: (choices: ImportCheckInChoices) => void;
onCancel: () => void;
}) {
- const checkIn = useImportCheckIn(preview.check_in_details);
+ const checkIn = useImportCheckIn(preview.check_in_details, preview.portrait);
const handleApply = async () => {
- const submission = await checkIn.collect();
- if (submission !== null) onApply(submission);
+ const choices = await checkIn.collect();
+ if (choices !== null) onApply(choices);
};
return (
@@ -421,7 +421,7 @@ export function DataImportCard() {
}
};
- const handleApply = async (checkIn: ImportCheckInSubmission | undefined) => {
+ const handleApply = async ({ details, portrait }: ImportCheckInChoices) => {
if (!pending) return;
try {
@@ -429,11 +429,13 @@ export function DataImportCard() {
const applied = await logbookImportAPI.apply(
pending.file,
pending.preview.token,
- checkIn,
+ details,
+ portrait,
);
- // Only when a fact changed: the check-in card on this page seeds from the
- // signed-in user, and saving it from a stale copy would send the imported
- // facts back as nulls. Not otherwise, since a refresh resets every mounted
+ // Only when a fact or the portrait changed: the check-in card on this page
+ // seeds from the signed-in user, and saving it from a stale copy would send
+ // the imported facts back as nulls, and the portrait slot and "Adjust" read
+ // their digests from it. Not otherwise, since a refresh resets every mounted
// form seeded from that user.
if (checkInWasWritten(applied)) await refreshUser();
setResult(applied);
@@ -488,9 +490,11 @@ export function DataImportCard() {
and you are told what the conversion could not carry. A .zip is
either a full OpenDiving archive — which restores your
dive-computer files and certification scans as well — or a folder
- of dive-computer files, read as one logbook. Records already in
- your logbook are matched rather than duplicated, and a dive you
- deleted comes back under its own identity.
+ of dive-computer files, read as one logbook. The check-in details
+ a logbook carries, and an archive’s portrait, are shown
+ beside yours first, and you choose which are saved. Records
+ already in your logbook are matched rather than duplicated, and a
+ dive you deleted comes back under its own identity.
diff --git a/src/components/settings/import-check-in-details.tsx b/src/components/settings/import-check-in-details.tsx
index f85a805..9dee072 100644
--- a/src/components/settings/import-check-in-details.tsx
+++ b/src/components/settings/import-check-in-details.tsx
@@ -3,14 +3,19 @@
import { useMemo, useState } from "react";
import { useForm, type Resolver, type UseFormReturn } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
+import { UserSquare } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";
import { UserField } from "@/components/user/user-fields-form";
+import { PortraitFrame, PortraitImage } from "@/components/user/portrait-image";
+import { useAuth } from "@/contexts/AuthContext";
import type {
ImportCheckInDetail,
ImportCheckInDetailKey,
ImportCheckInSubmission,
+ ImportPortraitChoice,
+ ImportPortraitOffer,
} from "@/lib/api/logbook-import";
import {
CHECK_IN_DETAIL_FIELDS,
@@ -18,37 +23,51 @@ import {
checkInAccountSummary,
checkInProposalValues,
checkInSubmission,
+ portraitChoice,
} from "@/lib/import-check-in";
+import { cn } from "@/lib/utils";
import {
userFieldsSchema,
type UserFieldValues,
} from "@/lib/validations/user-fields";
+/** What the apply sends beside the file and the token; each part omitted when empty. */
+export interface ImportCheckInChoices {
+ details?: ImportCheckInSubmission;
+ portrait?: ImportPortraitChoice;
+}
+
export interface ImportCheckIn {
details: readonly ImportCheckInDetail[];
form: UseFormReturn;
kept: ReadonlySet;
toggleKept: (detail: ImportCheckInDetailKey) => void;
+ portrait: ImportPortraitOffer | null;
+ portraitKept: boolean;
+ togglePortraitKept: () => void;
/**
- * Validates what is on screen and resolves to the facts to send: `undefined` when
- * there are none, `null` when a field is invalid and now says why.
+ * Validates what is on screen and resolves to what to send, or to `null` when a
+ * field is invalid and now says why.
*/
- collect: () => Promise;
+ collect: () => Promise;
}
/**
* The editable half of an import preview: one form over the check-in facts the
- * document carries, seeded with the API's proposal.
+ * document carries, seeded with the API's proposal, and the archive's portrait,
+ * taken unless kept.
*
* Seeded once, so the host mounts it per preview (keyed on the token). The resolver
* covers only the facts not kept, so a kept fact's proposal cannot block the apply.
*/
export function useImportCheckIn(
details: readonly ImportCheckInDetail[],
+ portrait: ImportPortraitOffer | null,
): ImportCheckIn {
const [kept, setKept] = useState>(
() => new Set(),
);
+ const [portraitKept, setPortraitKept] = useState(false);
const resolver = useMemo(() => {
const fields = details
@@ -75,33 +94,127 @@ export function useImportCheckIn(
});
};
- const collect = async () => {
+ const collect = async (): Promise => {
if (!(await form.trigger())) return null;
const submission = checkInSubmission(details, kept, form.getValues());
- return Object.keys(submission).length > 0 ? submission : undefined;
+ return {
+ details: Object.keys(submission).length > 0 ? submission : undefined,
+ portrait: portraitChoice(portrait, portraitKept),
+ };
};
- return { details, form, kept, toggleKept, collect };
+ return {
+ details,
+ form,
+ kept,
+ toggleKept,
+ portrait,
+ portraitKept,
+ togglePortraitKept: () => setPortraitKept((current) => !current),
+ collect,
+ };
+}
+
+// The archive's portrait beside the account's, the archive's chosen. "Keep mine"
+// still sends a choice, `keep`: the apply is told which portrait the diver saw.
+function ImportPortraitRow({
+ offer,
+ kept,
+ onToggle,
+}: {
+ offer: ImportPortraitOffer;
+ kept: boolean;
+ onToggle: () => void;
+}) {
+ const { user } = useAuth();
+ const mine = offer.account_sha256;
+ const chosen = "ring-2 ring-primary ring-offset-2 ring-offset-background";
+
+ return (
+
+ );
}
-// Each fact the document carries, the account's value beside the proposal. What is
-// in the boxes when the diver imports is what is written; "Keep mine" takes a fact
-// out of the apply altogether, which is different from emptying it - an emptied
-// fact is cleared from the account.
+// Each fact the document carries, the account's value beside the proposal, and the
+// archive's portrait beside the account's. What is in the boxes when the diver
+// imports is what is written; "Keep mine" takes a fact out of the apply altogether,
+// which is different from emptying it - an emptied fact is cleared from the account.
export function ImportCheckInDetails({ checkIn }: { checkIn: ImportCheckIn }) {
- const { details, form, kept, toggleKept } = checkIn;
- if (details.length === 0) return null;
+ const { details, form, kept, toggleKept, portrait } = checkIn;
+ if (details.length === 0 && !portrait) return null;
return (