ADD - notes app

This commit is contained in:
Eichenholz
2026-08-04 11:41:56 +02:00
parent dfc358f85a
commit 8090dbd5ab
11 changed files with 482 additions and 5 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+18 -3
View File
@@ -3,6 +3,7 @@ import {
Camera,
Clock3,
Images,
NotebookPen,
Settings,
ShoppingBag,
} from 'lucide-vue-next'
@@ -12,11 +13,25 @@ import appStoreIcon from '@/assets/img/app-icons/apps.webp'
import calculatorIcon from '@/assets/img/app-icons/calculator.webp'
import cameraIcon from '@/assets/img/app-icons/camera.webp'
import clockIcon from '@/assets/img/app-icons/clock.webp'
import notesIcon from '@/assets/img/app-icons/notes.webp'
import photosIcon from '@/assets/img/app-icons/gallery.webp'
import settingsIcon from '@/assets/img/app-icons/settings.webp'
import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps'
export const PHONE_APPS: PhoneAppDefinition[] = [
{
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/NotesApp.vue')),
),
dockOrder: null,
gridOrder: 3,
icon: markRaw(NotebookPen),
iconClass: '',
iconImage: notesIcon,
id: 'notes',
labelKey: 'Apps.notes.name',
route: '/apps/notes',
},
{
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/CalculatorApp.vue')),
@@ -61,7 +76,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
defineAsyncComponent(() => import('@/views/apps/PhotosApp.vue')),
),
dockOrder: null,
gridOrder: 3,
gridOrder: 4,
icon: markRaw(Images),
iconClass: 'app-icon--photos',
iconImage: photosIcon,
@@ -74,7 +89,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
defineAsyncComponent(() => import('@/views/apps/AppStoreApp.vue')),
),
dockOrder: 0,
gridOrder: 4,
gridOrder: 5,
icon: markRaw(ShoppingBag),
iconClass: 'app-icon--store',
iconImage: appStoreIcon,
@@ -87,7 +102,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
defineAsyncComponent(() => import('@/views/apps/SettingsApp.vue')),
),
dockOrder: null,
gridOrder: 5,
gridOrder: 6,
icon: markRaw(Settings),
iconClass: 'app-icon--settings',
iconImage: settingsIcon,
+48
View File
@@ -0,0 +1,48 @@
import { defineStore } from 'pinia'
import {
type Note,
type NoteDraft,
readNotes,
writeNotes,
} from '@/utils/notes'
export const useNotesStore = defineStore('notes', {
state: () => ({
notes: readNotes(),
}),
actions: {
createNote(draft: NoteDraft): Note {
const now = Date.now()
const note: Note = {
...draft,
createdAt: now,
id: `note-${now}-${Math.random().toString(36).slice(2, 9)}`,
pinned: false,
updatedAt: now,
}
this.notes.unshift(note)
this.persist()
return note
},
deleteNote(id: string): void {
this.notes = this.notes.filter((note) => note.id !== id)
this.persist()
},
persist(): void {
writeNotes(this.notes)
},
togglePinned(id: string): void {
const note = this.notes.find((candidate) => candidate.id === id)
if (!note) return
note.pinned = !note.pinned
this.persist()
},
updateNote(id: string, draft: NoteDraft): void {
const note = this.notes.find((candidate) => candidate.id === id)
if (!note) return
Object.assign(note, draft, { updatedAt: Date.now() })
this.persist()
},
},
})
+24
View File
@@ -136,6 +136,30 @@ const defaultLocales: LocaleTree = {
ringing: 'Timer',
},
},
notes: {
name: 'Notes',
note: 'Note',
back: 'Back',
actions: 'Note actions',
newNote: 'New Note',
searchPlaceholder: 'Search Notes',
title: 'Title',
titlePlaceholder: 'Note title',
body: 'Note',
bodyPlaceholder: 'Start writing...',
untitled: 'Untitled',
noText: 'No additional text',
emptyBadge: 'ON DEVICE',
emptyTitle: 'No Notes',
emptyBody: 'Create a note to keep important details close at hand.',
noResults: 'No Results',
noResultsBody: 'Try searching for a different word or phrase.',
pin: 'Pin note',
unpin: 'Unpin note',
deleteNote: 'Delete note',
deleteTitle: 'Delete Note?',
deleteBody: 'This note will be permanently deleted.',
},
photos: {
name: 'Photos',
searchPlaceholder: 'Photos, people, places...',
+1
View File
@@ -4,6 +4,7 @@ export type PhoneAppId =
| 'calculator'
| 'camera'
| 'clock'
| 'notes'
| 'photos'
| 'app-store'
| 'settings'
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { parseNotes } from './notes'
describe('notes persistence', () => {
it('returns valid notes and ignores malformed entries', () => {
const valid = {
body: 'Meet at Mission Row.',
createdAt: 10,
id: 'note-1',
pinned: true,
title: 'Shift briefing',
updatedAt: 20,
}
expect(parseNotes(JSON.stringify([valid, { id: 'broken' }]))).toEqual([
valid,
])
})
it('recovers from missing or invalid storage', () => {
expect(parseNotes(null)).toEqual([])
expect(parseNotes('{invalid')).toEqual([])
expect(parseNotes('{}')).toEqual([])
})
})
+48
View File
@@ -0,0 +1,48 @@
export const NOTES_STORAGE_KEY = 'sky_phone.notes.v1'
export type Note = {
body: string
createdAt: number
id: string
pinned: boolean
title: string
updatedAt: number
}
export type NoteDraft = Pick<Note, 'body' | 'title'>
function isNote(value: unknown): value is Note {
if (!value || typeof value !== 'object') return false
const note = value as Partial<Note>
return (
typeof note.body === 'string' &&
typeof note.createdAt === 'number' &&
Number.isFinite(note.createdAt) &&
typeof note.id === 'string' &&
Boolean(note.id) &&
typeof note.pinned === 'boolean' &&
typeof note.title === 'string' &&
typeof note.updatedAt === 'number' &&
Number.isFinite(note.updatedAt)
)
}
export function parseNotes(raw: string | null): Note[] {
if (!raw) return []
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed.filter(isNote)
} catch {
return []
}
}
export function readNotes(): Note[] {
return parseNotes(window.localStorage.getItem(NOTES_STORAGE_KEY))
}
export function writeNotes(notes: Note[]): void {
window.localStorage.setItem(NOTES_STORAGE_KEY, JSON.stringify(notes))
}
+1
View File
@@ -50,6 +50,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
calculator: { enabled: true, sounds: true },
camera: { enabled: true, sounds: true },
clock: { enabled: true, sounds: true },
notes: { enabled: true, sounds: true },
photos: { enabled: true, sounds: true },
settings: { enabled: true, sounds: true },
}
+305
View File
@@ -0,0 +1,305 @@
<script setup lang="ts">
import {
kActions,
kActionsButton,
kActionsGroup,
kBlock,
kBlockTitle,
kDialog,
kDialogButton,
kLink,
kList,
kListButton,
kListInput,
kListItem,
kNavbar,
kNavbarBackLink,
kPage,
kSearchbar,
} from 'konsta/vue'
import { Ellipsis, Pin, SquarePen } from 'lucide-vue-next'
import { computed, type CSSProperties, ref } from 'vue'
import { useNotesStore } from '@/stores/notes'
import { usePhoneStore } from '@/stores/phone'
import type { Note } from '@/utils/notes'
const phone = usePhoneStore()
const notes = useNotesStore()
const searchQuery = ref('')
const editorId = ref<string | null>(null)
const editorOpened = ref(false)
const draftTitle = ref('')
const draftBody = ref('')
const deleteCandidate = ref<Note | null>(null)
const menuOpened = ref(false)
const noteBodyStyle: CSSProperties = {
height: 'calc(100cqh - 210px)',
resize: 'none',
}
const currentNote = computed(() =>
editorId.value
? notes.notes.find((note) => note.id === editorId.value)
: undefined,
)
const visibleNotes = computed(() => {
const query = searchQuery.value.trim().toLocaleLowerCase(phone.lang)
return [...notes.notes]
.filter((note) => {
if (!query) return true
return `${note.title}\n${note.body}`
.toLocaleLowerCase(phone.lang)
.includes(query)
})
.sort(
(left, right) =>
Number(right.pinned) - Number(left.pinned) ||
right.updatedAt - left.updatedAt,
)
})
function noteTitle(note: Note): string {
return note.title.trim() || phone.t('Apps.notes.untitled')
}
function notePreview(note: Note): string {
return note.body.trim().replace(/\s+/g, ' ') || phone.t('Apps.notes.noText')
}
function noteDate(note: Note): string {
const date = new Date(note.updatedAt)
const sameYear = date.getFullYear() === new Date().getFullYear()
return new Intl.DateTimeFormat(phone.lang, {
day: 'numeric',
month: 'short',
...(sameYear ? {} : { year: 'numeric' }),
}).format(date)
}
function noteSubtitle(note: Note): string {
return `${noteDate(note)} · ${notePreview(note)}`
}
function updateSearch(event: Event): void {
searchQuery.value = (event.target as HTMLInputElement).value
}
function updateTitle(event: Event): void {
draftTitle.value = (event.target as HTMLInputElement).value
}
function updateBody(event: Event): void {
draftBody.value = (event.target as HTMLTextAreaElement).value
}
function createNote(): void {
editorId.value = null
draftTitle.value = ''
draftBody.value = ''
editorOpened.value = true
}
function editNote(note: Note): void {
editorId.value = note.id
draftTitle.value = note.title
draftBody.value = note.body
editorOpened.value = true
}
function persistDraft(): Note | undefined {
const draft = {
body: draftBody.value.trim(),
title: draftTitle.value.trim(),
}
if (editorId.value) {
notes.updateNote(editorId.value, draft)
return notes.notes.find((note) => note.id === editorId.value)
}
if (!draft.title && !draft.body) return undefined
const note = notes.createNote(draft)
editorId.value = note.id
return note
}
function saveAndClose(): void {
persistDraft()
menuOpened.value = false
editorOpened.value = false
}
function openMenu(): void {
if (persistDraft()) menuOpened.value = true
}
function requestDelete(): void {
const note = persistDraft()
if (!note) return
menuOpened.value = false
deleteCandidate.value = note
}
function togglePinned(): void {
const note = persistDraft()
if (!note) return
notes.togglePinned(note.id)
menuOpened.value = false
}
function confirmDelete(): void {
if (!deleteCandidate.value) return
notes.deleteNote(deleteCandidate.value.id)
deleteCandidate.value = null
editorOpened.value = false
}
</script>
<template>
<k-page
v-if="!editorOpened"
class="!pt-[44px] !pb-[25px]"
:aria-label="phone.t('Apps.notes.name')"
>
<k-navbar
large
transparent
:title="phone.t('Apps.notes.name')"
>
<template #right>
<k-link
component="button"
icon-only
:aria-label="phone.t('Apps.notes.newNote')"
@click="createNote"
>
<SquarePen :size="21" />
</k-link>
</template>
<template #subnavbar>
<k-searchbar
:value="searchQuery"
:placeholder="phone.t('Apps.notes.searchPlaceholder')"
@input="updateSearch"
@clear="searchQuery = ''"
/>
</template>
</k-navbar>
<k-list v-if="visibleNotes.length" strong inset>
<k-list-item
v-for="note in visibleNotes"
:key="note.id"
link
link-component="button"
:title="noteTitle(note)"
:subtitle="noteSubtitle(note)"
:chevron="false"
strong-title="auto"
@click="editNote(note)"
>
<template v-if="note.pinned" #after>
<Pin :size="15" aria-hidden="true" />
</template>
</k-list-item>
</k-list>
<template v-else>
<k-block-title large>{{
phone.t(searchQuery ? 'Apps.notes.noResults' : 'Apps.notes.emptyTitle')
}}</k-block-title>
<k-block strong inset>{{
phone.t(
searchQuery ? 'Apps.notes.noResultsBody' : 'Apps.notes.emptyBody',
)
}}</k-block>
<k-list v-if="!searchQuery" strong inset>
<k-list-button link-component="button" @click="createNote">
{{ phone.t('Apps.notes.newNote') }}
</k-list-button>
</k-list>
</template>
</k-page>
<k-page v-else class="!pt-[44px] !pb-[25px]">
<k-navbar :title="phone.t('Apps.notes.note')">
<template #left>
<k-navbar-back-link
component="button"
:text="phone.t('Apps.notes.back')"
:aria-label="phone.t('Apps.notes.back')"
@click="saveAndClose"
/>
</template>
<template #right>
<k-link
component="button"
icon-only
:aria-label="phone.t('Apps.notes.actions')"
@click="openMenu"
>
<Ellipsis :size="22" />
</k-link>
</template>
</k-navbar>
<k-list nested :dividers="false">
<k-list-input
:value="draftTitle"
:label="phone.t('Apps.notes.title')"
:placeholder="phone.t('Apps.notes.titlePlaceholder')"
maxlength="120"
clear-button
@input="updateTitle"
@clear="draftTitle = ''"
/>
<k-list-input
type="textarea"
:value="draftBody"
:label="phone.t('Apps.notes.body')"
:placeholder="phone.t('Apps.notes.bodyPlaceholder')"
:input-style="noteBodyStyle"
maxlength="20000"
@input="updateBody"
/>
</k-list>
<k-actions
:opened="menuOpened"
@backdropclick="menuOpened = false"
>
<k-actions-group>
<k-actions-button @click="togglePinned">
{{
phone.t(currentNote?.pinned ? 'Apps.notes.unpin' : 'Apps.notes.pin')
}}
</k-actions-button>
<k-actions-button @click="requestDelete">
{{ phone.t('Apps.notes.deleteNote') }}
</k-actions-button>
</k-actions-group>
<k-actions-group>
<k-actions-button bold @click="menuOpened = false">
{{ phone.t('Common.cancel') }}
</k-actions-button>
</k-actions-group>
</k-actions>
</k-page>
<k-dialog
:opened="Boolean(deleteCandidate)"
:title="phone.t('Apps.notes.deleteTitle')"
:content="phone.t('Apps.notes.deleteBody')"
@backdropclick="deleteCandidate = null"
>
<template #buttons>
<k-dialog-button @click="deleteCandidate = null">
{{ phone.t('Common.cancel') }}
</k-dialog-button>
<k-dialog-button strong @click="confirmDelete">
{{ phone.t('Common.delete') }}
</k-dialog-button>
</template>
</k-dialog>
</template>
+9
View File
@@ -42,6 +42,15 @@ Locales["en"] = {
note = "Note", notePlaceholder = "Timer", sound = "Sound", ringing = "Timer",
},
},
notes = {
name = "Notes", note = "Note", back = "Back", actions = "Note actions",
newNote = "New Note", searchPlaceholder = "Search Notes",
title = "Title", titlePlaceholder = "Note title", body = "Note", bodyPlaceholder = "Start writing...",
untitled = "Untitled", noText = "No additional text", emptyBadge = "ON DEVICE", emptyTitle = "No Notes",
emptyBody = "Create a note to keep important details close at hand.", noResults = "No Results",
noResultsBody = "Try searching for a different word or phrase.", pin = "Pin note", unpin = "Unpin note",
deleteNote = "Delete note", deleteTitle = "Delete Note?", deleteBody = "This note will be permanently deleted.",
},
photos = {
name = "Photos", searchPlaceholder = "Photos, people, places...", recents = "Recents",
favorites = "Favorites", items = "items", memories = "Memories", featured = "City colors",
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sky Phone</title>
<script type="module" crossorigin src="./assets/sky-index-Brc5i4FT.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-DkBC7Qz5.css">
<script type="module" crossorigin src="./assets/sky-index-DPyyh5lq.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-xm7-yxvA.css">
</head>
<body>
<div id="app"></div>