Skip to content

Commit c5922ea

Browse files
committed
feat: implement page creation, replace arrow/note property buttons with Select components, add numeric width/height inputs
- Implement full page creation flow in handleCreateNewPage with E2E encryption (keyring wrapping, title encryption, API call) - Add nanoid, createSymmetricKeyring, ensureSodiumReady, unwrapGroupContentSymmetricKeyring imports - Set created page link on selected note after successful creation - Replace arrow body type/style button groups with Select components in ArrowPropert
1 parent 98bd94f commit c5922ea

6 files changed

Lines changed: 451 additions & 158 deletions

File tree

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
<script setup lang="ts">
2+
import { computed } from 'vue'
3+
4+
export type ColorName =
5+
| 'grey'
6+
| 'red'
7+
| 'brown'
8+
| 'orange'
9+
| 'yellow'
10+
| 'green'
11+
| 'teal'
12+
| 'sky'
13+
| 'blue'
14+
| 'violet'
15+
| 'purple'
16+
| 'pink'
17+
18+
const props = defineProps<{
19+
type: 'notes' | 'arrows'
20+
orientation?: 'horizontal' | 'vertical'
21+
split?: number
22+
modelValue: ColorName | string | number
23+
disabled?: boolean
24+
}>()
25+
26+
const emit = defineEmits<{
27+
'update:modelValue': [value: ColorName]
28+
}>()
29+
30+
const allColors: ColorName[] = [
31+
'grey',
32+
'red',
33+
'brown',
34+
'orange',
35+
'yellow',
36+
'green',
37+
'teal',
38+
'sky',
39+
'blue',
40+
'violet',
41+
'purple',
42+
'pink',
43+
]
44+
45+
const colorMap: Record<'ui' | 'arrows' | 'notes', Record<ColorName, string>> = {
46+
ui: {
47+
grey: '#616161',
48+
red: '#AA0E0E',
49+
brown: '#7E3207',
50+
orange: '#AF5400',
51+
yellow: '#C29800',
52+
green: '#18990D',
53+
teal: '#00959E',
54+
sky: '#0284C7',
55+
blue: '#1135B6',
56+
violet: '#5E00D6',
57+
purple: '#7E22CE',
58+
pink: '#9C00B6',
59+
},
60+
arrows: {
61+
grey: '#858585',
62+
red: '#B80909',
63+
brown: '#81370E',
64+
orange: '#CC6200',
65+
yellow: '#C19700',
66+
green: '#13A906',
67+
teal: '#14B8A6',
68+
sky: '#0EA5E9',
69+
blue: '#1D4ED8',
70+
violet: '#7F2DFF',
71+
purple: '#9C29FF',
72+
pink: '#C91CDA',
73+
},
74+
notes: {
75+
grey: '#2F2F2F',
76+
red: '#6C1313',
77+
brown: '#542D11',
78+
orange: '#7B2F07',
79+
yellow: '#776109',
80+
green: '#0E5428',
81+
teal: '#08564E',
82+
sky: '#065072',
83+
blue: '#102C7A',
84+
violet: '#3E177A',
85+
purple: '#4B1972',
86+
pink: '#61116B',
87+
},
88+
}
89+
90+
const paletteType = computed<'notes' | 'arrows' | 'ui'>(() => props.type)
91+
92+
const groupLength = computed(() =>
93+
Math.ceil(allColors.length / (props.split ?? 1)),
94+
)
95+
96+
const numGroups = computed(() =>
97+
Math.ceil(allColors.length / groupLength.value),
98+
)
99+
100+
const isHorizontal = computed(() => props.orientation === 'horizontal')
101+
102+
function resolveColorName(value: ColorName | string | number): ColorName {
103+
if (typeof value === 'number') {
104+
return allColors[value % allColors.length] ?? 'grey'
105+
}
106+
const str = String(value ?? '')
107+
if (allColors.includes(str as ColorName)) {
108+
return str as ColorName
109+
}
110+
// Fallback: try to find by hex match
111+
const typeMap = colorMap[paletteType.value]
112+
const matched = Object.entries(typeMap).find(
113+
([, hex]) => hex.toLowerCase() === str.toLowerCase(),
114+
)
115+
return (matched?.[0] as ColorName) ?? 'grey'
116+
}
117+
118+
const selectedColor = computed(() => resolveColorName(props.modelValue))
119+
120+
function select(color: ColorName) {
121+
if (!props.disabled) {
122+
emit('update:modelValue', color)
123+
}
124+
}
125+
</script>
126+
127+
<template>
128+
<div
129+
class="inline-flex cursor-pointer overflow-hidden rounded"
130+
:class="[
131+
isHorizontal ? 'flex-col' : 'flex-row',
132+
]"
133+
:style="{
134+
width: isHorizontal ? '160px' : '44px',
135+
}"
136+
>
137+
<div
138+
v-for="groupIndex in numGroups"
139+
:key="groupIndex"
140+
class="flex flex-1"
141+
:class="[
142+
isHorizontal ? 'flex-row' : 'flex-col',
143+
]"
144+
>
145+
<button
146+
v-for="cellIndex in groupLength"
147+
:key="cellIndex"
148+
type="button"
149+
class="flex-1 aspect-square transition-transform hover:scale-110 focus:outline-none focus:ring-1 focus:ring-ring"
150+
:disabled="disabled"
151+
:style="{
152+
backgroundColor:
153+
allColors[(groupIndex - 1) * groupLength + cellIndex - 1] != null
154+
? colorMap[paletteType][
155+
allColors[(groupIndex - 1) * groupLength + cellIndex - 1]!
156+
]
157+
: 'transparent',
158+
opacity:
159+
allColors[(groupIndex - 1) * groupLength + cellIndex - 1] ===
160+
selectedColor
161+
? 1
162+
: 0.85,
163+
boxShadow:
164+
allColors[(groupIndex - 1) * groupLength + cellIndex - 1] ===
165+
selectedColor
166+
? 'inset 0 0 0 2px #fff'
167+
: 'none',
168+
}"
169+
@click="
170+
() => {
171+
const c = allColors[(groupIndex - 1) * groupLength + cellIndex - 1]
172+
if (c) select(c)
173+
}
174+
"
175+
/>
176+
</div>
177+
</div>
178+
</template>

new-deepnotes/apps/web/src/features/pages/PageEditorView.vue

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,19 @@ import { createPageCollabDoc } from "./page-yjs-doc";
2020
import { usePageCollabEditor } from "./usePageCollabEditor";
2121
import { usePageManagement } from "./usePageManagement";
2222
import { usePagePathAndPrefs } from "./usePagePathAndPrefs";
23-
import { base64ToBytes, bytesToBase64 } from "@deepnotes/e2ee";
23+
import { nanoid } from "nanoid";
24+
import {
25+
base64ToBytes,
26+
bytesToBase64,
27+
createSymmetricKeyring,
28+
ensureSodiumReady,
29+
} from "@deepnotes/e2ee";
2430
import {
2531
decryptPageRelativeTitle,
2632
decryptPageAbsoluteTitle,
33+
unwrapGroupContentSymmetricKeyring,
2734
} from "./page-collab-crypto";
35+
import { readSessionCrypto } from "../auth/crypto-storage";
2836
import { usePagePathRealtimeTitles } from "./usePagePathRealtimeTitles";
2937
import { buildRealtimeHset, sendRealtimeRequestBatch } from "../realtime/realtime-user-ws";
3038
import { usePageSnapshots } from "./usePageSnapshots";
@@ -344,7 +352,92 @@ async function handleSetNoteAsDefault() {
344352
}
345353
346354
async function handleCreateNewPage() {
347-
pageOpsMessage.value = 'Create new page is not yet implemented in the new UI (requires page-creation crypto: encrypted titles + keyring).';
355+
pageOpsMessage.value = null;
356+
const id = pageId.value;
357+
const gid = collabGroupId.value;
358+
const gCrypto = collabGroupCrypto.value;
359+
if (!id || !gid || !gCrypto) {
360+
pageOpsMessage.value = 'Page crypto not loaded yet.';
361+
return;
362+
}
363+
const stored = readSessionCrypto();
364+
if (!stored) {
365+
pageOpsMessage.value = 'Unlock session crypto (password login) to create pages.';
366+
return;
367+
}
368+
try {
369+
await ensureSodiumReady();
370+
const groupContentKeyring = await unwrapGroupContentSymmetricKeyring({
371+
groupId: gid,
372+
groupEncryptedContentKeyring: gCrypto.groupEncryptedContentKeyring,
373+
memberEncryptedAccessKeyring: gCrypto.memberEncryptedAccessKeyring,
374+
groupAccessKeyring: gCrypto.groupAccessKeyring,
375+
stored,
376+
});
377+
378+
const newPageId = nanoid();
379+
const newPageKeyring = createSymmetricKeyring();
380+
381+
const encryptedPageKeyring = newPageKeyring.wrapSymmetric(groupContentKeyring, {
382+
associatedData: {
383+
context: 'PageKeyring',
384+
pageId: newPageId,
385+
},
386+
});
387+
388+
const relativeTitle = 'New page';
389+
const absoluteTitle = 'New page';
390+
391+
const encryptedRelativeTitle = newPageKeyring.encrypt(
392+
new TextEncoder().encode(relativeTitle),
393+
{
394+
padding: true,
395+
associatedData: {
396+
context: 'PageRelativeTitle',
397+
pageId: newPageId,
398+
},
399+
},
400+
);
401+
402+
const encryptedAbsoluteTitle = newPageKeyring.encrypt(
403+
new TextEncoder().encode(absoluteTitle),
404+
{
405+
padding: true,
406+
associatedData: {
407+
context: 'PageAbsoluteTitle',
408+
pageId: newPageId,
409+
},
410+
},
411+
);
412+
413+
const res = await client.POST('/api/groups/{groupId}/pages', {
414+
params: { path: { groupId: gid } },
415+
body: {
416+
parentPageId: id,
417+
pageId: newPageId,
418+
pageEncryptedSymmetricKeyring: bytesToBase64(encryptedPageKeyring.wrappedValue),
419+
pageEncryptedRelativeTitle: bytesToBase64(encryptedRelativeTitle),
420+
pageEncryptedAbsoluteTitle: bytesToBase64(encryptedAbsoluteTitle),
421+
},
422+
});
423+
424+
if (res.response.status !== 200 || !res.data) {
425+
pageOpsMessage.value =
426+
res.error && typeof res.error === 'object' && 'message' in res.error
427+
? String((res.error as { message?: string }).message)
428+
: 'Could not create page.';
429+
return;
430+
}
431+
432+
// Set the note's link to the newly created page
433+
if (selectedNoteModel.value?.link != null) {
434+
selectedNoteModel.value.link.value = `/pages/${newPageId}`;
435+
}
436+
437+
pageOpsMessage.value = `Created new page ${newPageId}.`;
438+
} catch (e) {
439+
pageOpsMessage.value = e instanceof Error ? e.message : 'Could not create page.';
440+
}
348441
}
349442
350443
function handleSwapArrowheads() {

new-deepnotes/apps/web/src/features/spatial/ArrowPropertiesCard.test.ts

Lines changed: 4 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -37,47 +37,17 @@ describe("ArrowPropertiesCard", () => {
3737
expect(wrapper.find("[data-testid='arrow-properties-card']").exists()).toBe(false);
3838
});
3939

40-
it("shows body type buttons", () => {
40+
it("renders body type and body style Select components", () => {
4141
const ydoc = createPageYDoc();
4242
const model = createArrowModel(ydoc, "arrow-1");
4343

4444
wrapper = mount(ArrowPropertiesCard, {
4545
props: { arrowId: "arrow-1", arrowModel: model },
4646
});
4747

48-
const buttons = wrapper.findAll("button");
49-
const texts = buttons.map((b) => b.text());
50-
expect(texts).toContain("Curve");
51-
expect(texts).toContain("Line");
52-
});
53-
54-
it("shows body style buttons", () => {
55-
const ydoc = createPageYDoc();
56-
const model = createArrowModel(ydoc, "arrow-1");
57-
58-
wrapper = mount(ArrowPropertiesCard, {
59-
props: { arrowId: "arrow-1", arrowModel: model },
60-
});
61-
62-
const buttons = wrapper.findAll("button");
63-
const texts = buttons.map((b) => b.text());
64-
expect(texts).toContain("Solid");
65-
expect(texts).toContain("Dashed");
66-
expect(texts).toContain("Dotted");
67-
});
68-
69-
it("emits update:body-type when line button is clicked", async () => {
70-
const ydoc = createPageYDoc();
71-
const model = createArrowModel(ydoc, "arrow-1");
72-
73-
wrapper = mount(ArrowPropertiesCard, {
74-
props: { arrowId: "arrow-1", arrowModel: model },
75-
});
76-
77-
const lineButton = wrapper.findAll("button").find((b) => b.text() === "Line");
78-
expect(lineButton).toBeDefined();
79-
await lineButton!.trigger("click");
80-
expect(wrapper.emitted("update:body-type")).toHaveLength(1);
48+
const triggers = wrapper.findAllComponents({ name: "SelectTrigger" });
49+
// Body Type, Arrow Heads (2), Anchors (2), Body Style
50+
expect(triggers.length).toBeGreaterThanOrEqual(4);
8151
});
8252

8353
it("shows read-only toggle", () => {

0 commit comments

Comments
 (0)