mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 02:01:40 +00:00
ADD - notes app
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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...',
|
||||
|
||||
@@ -4,6 +4,7 @@ export type PhoneAppId =
|
||||
| 'calculator'
|
||||
| 'camera'
|
||||
| 'clock'
|
||||
| 'notes'
|
||||
| 'photos'
|
||||
| 'app-store'
|
||||
| 'settings'
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user