Files
sky_phone/frontend/src/views/apps/NotesApp.vue
T
2026-08-04 12:25:15 +02:00

329 lines
8.4 KiB
Vue

<script setup lang="ts">
import {
kBlock,
kBlockTitle,
kLink,
kList,
kListButton,
kListInput,
kListItem,
kNavbar,
kNavbarBackLink,
kPage,
kPopover,
kSearchbar,
} from 'konsta/vue'
import { Ellipsis, Pin, PinOff, SquarePen, Trash2 } from 'lucide-vue-next'
import {
computed,
type ComponentPublicInstance,
type CSSProperties,
nextTick,
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 menuButton = ref<ComponentPublicInstance | null>(null)
const menuOpened = ref(false)
const menuTarget = computed(
() => menuButton.value?.$el as HTMLElement | undefined,
)
const menuTargetStyle = ref<CSSProperties>({})
const pinActionColors = computed(() => ({
textIos: phone.isDarkMode ? 'text-white' : 'text-black',
textMaterial: phone.isDarkMode ? 'text-white' : 'text-black',
}))
const deleteActionColors = {
textIos: 'text-red-500',
textMaterial: 'text-red-500',
}
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
}
async function openMenu(): Promise<void> {
if (!persistDraft()) return
const target = menuTarget.value
const screen = target?.closest('.phone-screen')
if (target && screen) {
const screenRect = screen.getBoundingClientRect()
menuTargetStyle.value = {
'--k-safe-area-left': `${Math.round(screenRect.left + 2)}px`,
'--k-safe-area-right': `${Math.round(
document.body.offsetWidth - screenRect.right + 2,
)}px`,
'--k-safe-area-top': `${Math.round(screenRect.top + 8)}px`,
}
await nextTick()
}
menuOpened.value = true
}
function deleteNote(): void {
const note = persistDraft()
if (!note) return
notes.deleteNote(note.id)
menuOpened.value = false
editorOpened.value = false
}
function togglePinned(): void {
const note = persistDraft()
if (!note) return
notes.togglePinned(note.id)
menuOpened.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"
href="#"
:title="noteTitle(note)"
:subtitle="noteSubtitle(note)"
:chevron="false"
strong-title="auto"
@click.prevent="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
ref="menuButton"
component="button"
icon-only
:style="menuTargetStyle"
: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>
<Teleport to="body">
<k-popover
:opened="menuOpened"
:target="menuTarget"
:class="{ dark: phone.isDarkMode }"
angle
@backdropclick="menuOpened = false"
>
<k-list nested>
<k-list-button
link-component="button"
:colors="pinActionColors"
@click="togglePinned"
>
<PinOff v-if="currentNote?.pinned" :size="18" />
<Pin v-else :size="18" />
{{
phone.t(
currentNote?.pinned ? 'Apps.notes.unpin' : 'Apps.notes.pin',
)
}}
</k-list-button>
<k-list-button
link-component="button"
:colors="deleteActionColors"
@click="deleteNote"
>
<Trash2 :size="18" />
{{ phone.t('Apps.notes.deleteNote') }}
</k-list-button>
</k-list>
</k-popover>
</Teleport>
</k-page>
</template>