Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
15 changes: 15 additions & 0 deletions src/components/CommentView.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script lang="ts" module>
import type { HTMLAttributes } from 'svelte/elements'
</script>

<script lang="ts">
interface Props extends HTMLAttributes<HTMLAnchorElement> {
textData: string
}

let { textData, class: className }: Props = $props()
</script>

<p class={['', className]}>
{textData}
</p>
14 changes: 8 additions & 6 deletions src/components/PreviousReflections.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@

<script lang="ts">
import { reflections } from '$lib/Reflections.svelte'
import TextBox from './TextBox.svelte'
import CommentView from './CommentView.svelte'
let index = $state(Math.max(reflections.count - 1, 0))
Expand Down Expand Up @@ -85,12 +87,8 @@
✅ When you reach the beginning or the end, we hide the buttons to navigate to the next/prev step
✅ Add keyboard support for navigating to the prev / next entry with arrow left and arrow right
Note - if we add notes in the future
If the entry has a note, this could be a nice place to show the note attached to the refleciton
Maybe show first lines and then toggle to expand (which resets for each entry)
TODO: Figure out how to encode variable length string in the entries.
Maybe possible to use some special delimiter sequence so the parser can know where the next entry starts
potential solution: encode the length of the variable length content, so that the parser knows when to start and stop
Comments
✅: Encode variable length string in the entries.
Slider
✅ (Similar to the input slider in the reflection), this can be used to navigate to a specific point in time.
Expand Down Expand Up @@ -161,6 +159,10 @@

<Lifewheel data={currentReflection.data} {tweenedLifewheel} class="max-w-sm" />

{#if currentReflection.comment != null && currentReflection.comment.length > 0}
<CommentView textData={currentReflection.comment} />
{/if}

{#if reflections.entries.length > 2}
<DateRangeSlider min={0} max={reflections.entries.length - 1} bind:value={index} />
{/if}
Expand Down
20 changes: 15 additions & 5 deletions src/components/Reflection.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,22 @@
import ReflectionInputSlider from './ReflectionInputSlider.svelte'
import Lifewheel from './Lifewheel.svelte'

import { allReflectionSteps, INITIAL_LIFEWHEEL_STATE, INITIAL_LEVEL } from '$lib/constants'
import { createReflectionEntry, isLifewheelStep } from '$lib/utils'
import type { LifewheelState, LifewheelStep } from '$lib/types'
import { allReflectionSteps, INITIAL_LIFEWHEEL_STATE, INITIAL_LEVEL, INITIAL_COMMENT_STATE } from '$lib/constants'
import { createReflectionEntry, isCommentStep, isLifewheelStep } from '$lib/utils'
import type { LifewheelState, LifewheelStep, CommentState } from '$lib/types'
</script>

<script lang="ts">
import { goto } from '$app/navigation'
import { reflections } from '$lib/Reflections.svelte'
import { base } from '$app/paths'
import TextBox from './TextBox.svelte'

/**
* The actual lifewheel state.
*/
let lifewheel = $state<LifewheelState>(INITIAL_LIFEWHEEL_STATE)
let comment = $state<CommentState>(INITIAL_COMMENT_STATE)

/**
* A tweened representation of the lifewheel state. This allows smooth tweened motions when values change.
Expand All @@ -51,7 +53,7 @@

const onNext = async () => {
if (currentIndex === allReflectionSteps.length - 1) {
reflections.add(createReflectionEntry(lifewheel))
reflections.add(createReflectionEntry(lifewheel, comment))

await goto(base)
} else {
Expand All @@ -69,7 +71,7 @@
}

async function abortReflection() {
const hasUnsavedChanges = lifewheel.some((value) => value > 0 && value !== INITIAL_LEVEL)
const hasUnsavedChanges = lifewheel.some((value) => value > 0 && value !== INITIAL_LEVEL) || comment.length > 0

if (
!hasUnsavedChanges ||
Expand Down Expand Up @@ -97,6 +99,14 @@
<div class="h-40 2xs:h-48 xs:h-52">
<ReflectionTexts {reflectionStep} />
</div>


{#if isCommentStep(reflectionStep)}
<div class="flex min-w-[160px] max-w-md justify-between px-4 pb-4">
<TextBox bind:commentState={comment} />
</div>
{/if}

</div>

<ReflectionInputSlider {reflectionStep} bind:lifewheel />
Expand Down
9 changes: 7 additions & 2 deletions src/components/ReflectionTexts.svelte
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<script lang="ts" module>
import type { ReflectionStep } from '$lib/types'
import { isLifewheelStep } from '../lib/utils'
import { isLifewheelStep, isCommentStep } from '../lib/utils'
</script>

<script lang="ts">
import { colors, LIFEWHEEL_ICONS } from '$lib/constants'
import { colors, COMMENT_ICON, LIFEWHEEL_ICONS } from '$lib/constants'

type Props = {
reflectionStep: ReflectionStep
Expand All @@ -23,6 +23,11 @@
{@const Icon = LIFEWHEEL_ICONS[reflectionStep.i]}
<Icon class="size-6 -mb-0.5 {colors[reflectionStep.i].text}" />
{/if}

{#if isCommentStep(reflectionStep)}
{@const Icon = COMMENT_ICON}
<Icon class="size-6 -mb-0.5 text-emerald-400" />
{/if}
{/key}
{reflectionStep.title}
</h2>
Expand Down
15 changes: 15 additions & 0 deletions src/components/TextBox.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script lang="ts" module>
import type { CommentState } from '$lib/types'
</script>

<script lang="ts">
type Props = {
commentState: CommentState
}

let { commentState = $bindable() }: Props = $props()
</script>

<textarea rows="3" cols="50" class={['']} style="color: black; outline: black; outline-width: 1px; outline-style: dotted;"
bind:value={commentState}>
</textarea>
17 changes: 16 additions & 1 deletion src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import MaterialSymbolsGroupsRounded from '~icons/material-symbols/groups-rounded
import MdiConversation from '~icons/mdi/conversation'
import MdiUmbrellaBeach from '~icons/mdi/umbrella-beach'
import MdiDollar from '~icons/mdi/dollar'
import MdiNoteEdit from '~icons/mdi/note-edit'

import type { LifewheelState, LifewheelStep, ReflectionStep, TextStep } from './types'
import type { LifewheelState, CommentState, LifewheelStep, ReflectionStep, TextStep } from './types'

export const LIFEWHEEL_ICONS = [
MdiHeart,
Expand All @@ -20,6 +21,8 @@ export const LIFEWHEEL_ICONS = [
MdiDollar,
]

export const COMMENT_ICON = MdiNoteEdit

export const APP_NAME = 'Life Wheel'
export const APP_TAGLINE = 'Reflect on Your Life Balance'

Expand Down Expand Up @@ -130,6 +133,13 @@ export const introSteps: Partial<TextStep>[] = [
},
]

export const commentSteps: Partial<TextStep>[] = [
{
title: 'Comment',
text: 'Write down anything that will help your future self remember why you rated the dimensions the way you did.',
},
]

export const outroSteps: Partial<TextStep>[] = [
{
title: 'Well done!',
Expand All @@ -151,6 +161,10 @@ export const allReflectionSteps = [
step.i = i
return step
}),
commentSteps.map((step) => {
step.phase = 'comment'
return step
}),
outroSteps.map((step) => {
step.phase = 'outro'
return step
Expand All @@ -170,5 +184,6 @@ export const MAX_LEVEL = 10
* This is key in enabling the tweened motion.
*/
export const INITIAL_LIFEWHEEL_STATE: LifewheelState = [0, 0, 0, 0, 0, 0, 0, 0]
export const INITIAL_COMMENT_STATE: CommentState = ''

export const REPO_URL = 'https://github.com/Greenheart/lifewheel'
4 changes: 3 additions & 1 deletion src/lib/protocols/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { EncryptedSaveFile, ParsedLink, ReflectionEntry, SaveFile, UserKey

import v1 from './v1/protocol'
import v2 from './v2/protocol'
import v3 from './v3/protocol'

// IDEA: In the future, we could lazy load old protocol versions only when they are needed.
// Or if we package Lifewheel as a PWA, it doesn't matter since all modules would be offline anyway.
Expand Down Expand Up @@ -130,11 +131,12 @@ type BackwardsCompatibleProtocol = Pick<
export const PROTOCOL_VERSIONS = {
1: v1,
2: v2,
3: v3,
}

export type ProtocolVersion = keyof typeof PROTOCOL_VERSIONS

const CURRENT_PROTOCOL_VERSION = 2
const CURRENT_PROTOCOL_VERSION = 3

const PROTOCOL = PROTOCOL_VERSIONS[CURRENT_PROTOCOL_VERSION]

Expand Down
1 change: 1 addition & 0 deletions src/lib/protocols/v1/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export function decodeReflectionEntries(data: Uint8Array) {
export function reviveTimestamps(reflections: ReflectionEntry[]) {
return reflections.map<ReflectionEntry>(({ time, data }) => ({
time: new Date(time),
comment: '',
data,
}))
}
Expand Down
1 change: 1 addition & 0 deletions src/lib/protocols/v2/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export function decodeReflectionEntries(data: Uint8Array) {
export function reviveTimestamps(reflections: ReflectionEntry[]) {
return reflections.map<ReflectionEntry>(({ time, data }) => ({
time: new Date(time),
comment: '',
data,
}))
}
Expand Down
88 changes: 88 additions & 0 deletions src/lib/protocols/v3/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { UserKey } from '$lib/types'
import { decodeInt32, encodeInt32 } from '$lib/utils'

export const ITERATIONS = 2e6

export async function deriveKey(
salt: Uint8Array,
password: string,
iterations: number = ITERATIONS,
keyUsages: Iterable<KeyUsage> = ['encrypt', 'decrypt'],
): Promise<UserKey> {
const encoder = new TextEncoder()
const baseKey = await crypto.subtle.importKey(
'raw',
encoder.encode(password),
'PBKDF2',
false,
['deriveKey'],
)
return {
key: await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
keyUsages,
),
salt,
}
}

export async function deriveKeyFromData(
data: Uint8Array,
password: string,
keyUsages: Iterable<KeyUsage> = ['encrypt', 'decrypt'],
) {
const salt = data.slice(0, 32)
const iterations = data.slice(32 + 16, 32 + 16 + 4)

return deriveKey(salt, password, decodeInt32(iterations), keyUsages)
}

/**
* Encrypt a string and turn it into an encrypted payload.
*
* @param content The data to encrypt
* @param key The key used to encrypt the content.
* @param iterations The number of iterations to derive the key from the password.
*/
export async function getEncryptedPayload(
content: Uint8Array,
key: UserKey,
iterations: number = ITERATIONS,
) {
const salt = key.salt
const iv = crypto.getRandomValues(new Uint8Array(16))
const iterationsBytes = encodeInt32(iterations)
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key.key, content),
)

const totalLength = salt.length + iv.length + iterationsBytes.length + ciphertext.length
const mergedData = new Uint8Array(totalLength)
mergedData.set(salt)
mergedData.set(iv, salt.length)
mergedData.set(iterationsBytes, salt.length + iv.length)
mergedData.set(ciphertext, salt.length + iv.length + iterationsBytes.length)

return mergedData
}

/**
* Decrypt a payload and return the contents.
*
* @param bytes The payload to decrypt.
* @param key The key used for decryption.
*/
export async function getDecryptedPayload(bytes: Uint8Array, key: UserKey) {
const iv = bytes.slice(32, 32 + 16)
const ciphertext = bytes.slice(32 + 16 + 4)

const content = new Uint8Array(
await window.crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key.key, ciphertext),
)
if (!content) throw new Error('Malformed content')

return content
}
64 changes: 64 additions & 0 deletions src/lib/protocols/v3/export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { deflate } from 'pako'
import { base64url } from 'rfc4648'

import type { ProtocolVersion, ReflectionEntry } from '$lib/types'
import { encodeInt32, encodeString, mergeTypedArrays } from '$lib/utils'
import { PROTOCOL_VERSION } from './protocol'

function encodeTime(date: Date) {
const timestamp = date.getTime() / 1000
return encodeInt32(timestamp)
}

function encodeEntry(entry: ReflectionEntry) {
return mergeTypedArrays(
encodeTime(entry.time),
new Uint8Array(encodeEntryData(entry.data)),
encodeInt32(entry.comment != null ? entry.comment.length : 0),
encodeString(entry.comment != null ? entry.comment : 'null'))
}

export function encodeReflectionEntries(reflections: ReflectionEntry[]) {
console.log('encodeReflectionEntries', reflections)
const encodedEntries = reflections.map(encodeEntry)
const data = mergeTypedArrays(encodeInt32(reflections.length), ...encodedEntries)
return deflate(data, { level: 9 })
}

/**
* Encode every pair of numbers into a one byte to compress data.
*/
function encodeEntryData(data: ReflectionEntry['data']) {
return [data[0] << 4 | data[1],
data[2] << 4 | data[3],
data[4] << 4 | data[5],
data[6] << 4 | data[7]]

//const bin = data.map((number) => number.toString(2).padStart(4, '0'))
//return [bin[0] + bin[1], bin[2] + bin[3], bin[4] + bin[5], bin[6] + bin[7]].map((n) =>
// parseInt(n, 2),
//)
}


const formatHeader = ({
encrypted,
protocolVersion,
}: {
encrypted: boolean
protocolVersion: ProtocolVersion
}) => `${encrypted ? '1' : '0'}e${protocolVersion}p`

/**
* Generate a URI fragment (hash) representing user data.
* Also adds a header to make it possible to know how to parse different links.
* For example the header "0e2p" means "0e" = no encryption, and "2p" = protocol version 2.
* Similarly "1e2p" means "1e" = the data is encrypted, and "2p" = protocol version 2.
*/
export const formatLink = ({
data,
encrypted = false,
}: {
data: Uint8Array
encrypted?: boolean
}) => formatHeader({ encrypted, protocolVersion: PROTOCOL_VERSION }) + base64url.stringify(data)
Loading