Files
sky_phone-sky-systems/frontend/src/views/apps/NotesApp.vue
T
Type d99be70e77 ENH - merge dev into weather app
Integrates the current dev branch while preserving the weather-app Dynamic Island, Notes deletion flow, and simplified Phone contact header. Resolves the overlapping Sky UI migration conflicts in Notes and Phone.
2026-08-16 23:37:42 +02:00

438 lines
11 KiB
Vue

<script setup lang="ts">
import {
Ellipsis,
Pin,
PinOff,
Share2,
SquarePen,
Trash2,
} from 'lucide-vue-next'
import { computed, ref } from 'vue'
import NotesRichTextEditor from '@/components/NotesRichTextEditor.vue'
import { useNotesStore } from '@/stores/notes'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePhoneStore } from '@/stores/phone'
import {
SkyActionSheet,
SkyAppPage,
SkyBlock,
SkyBlockTitle,
SkyButton,
SkyDialog,
SkyDialogButton,
SkyFab,
SkyLink,
SkyList,
SkyListButton,
SkyListItem,
SkyNavbar,
SkyNavbarBackLink,
SkyScrollArea,
SkySearchbar,
SkyToolbar,
} from '@/ui'
import type { Note } from '@/utils/notes'
import { noteBodyToPlainText } from '@/utils/noteRichText'
const phone = usePhoneStore()
const notes = useNotesStore()
const easyShare = useEasyShareStore()
const searchQuery = ref('')
const editorId = ref<string | null>(null)
const editorOpened = ref(false)
const draftBody = ref('')
const menuOpened = ref(false)
const listDeleteCandidateId = ref<string | null>(null)
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${noteBodyToPlainText(note.body)}`
.toLocaleLowerCase(phone.lang)
.includes(query)
})
.sort(
(left, right) =>
Number(right.pinned) - Number(left.pinned) ||
right.updatedAt - left.updatedAt,
)
})
const editorLabels = computed(() => ({
bold: phone.t('Apps.notes.tools.bold'),
bulletList: phone.t('Apps.notes.tools.bulletList'),
closeFormatting: phone.t('Common.close'),
decreaseText: phone.t('Apps.notes.tools.decreaseText'),
increaseText: phone.t('Apps.notes.tools.increaseText'),
italic: phone.t('Apps.notes.tools.italic'),
numberedList: phone.t('Apps.notes.tools.numberedList'),
quote: phone.t('Apps.notes.tools.quote'),
redo: phone.t('Apps.notes.tools.redo'),
strike: phone.t('Apps.notes.tools.strike'),
toolbar: phone.t('Apps.notes.tools.toolbar'),
underline: phone.t('Apps.notes.tools.underline'),
undo: phone.t('Apps.notes.tools.undo'),
}))
function noteTitle(note: Note): string {
return note.title.trim() || phone.t('Apps.notes.untitled')
}
function notePreview(note: Note): string {
return (
noteBodyToPlainText(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 titleFromDraftBody(body: string): string {
return noteBodyToPlainText(body).split('\n')[0]?.trim() ?? ''
}
function createNote(): void {
editorId.value = null
draftBody.value = ''
editorOpened.value = true
}
function editNote(note: Note): void {
editorId.value = note.id
draftBody.value = note.body
editorOpened.value = true
}
function requestListDelete(note: Note): void {
listDeleteCandidateId.value = note.id
}
function cancelListDelete(): void {
listDeleteCandidateId.value = null
}
function confirmListDelete(): void {
const noteId = listDeleteCandidateId.value
if (!noteId) return
notes.deleteNote(noteId)
listDeleteCandidateId.value = null
}
function persistDraft(): Note | undefined {
const draft = {
body: draftBody.value,
title:
titleFromDraftBody(draftBody.value) ||
currentNote.value?.title.trim() ||
'',
}
if (editorId.value) {
notes.updateNote(editorId.value, draft)
return notes.notes.find((note) => note.id === editorId.value)
}
if (!draft.title && !noteBodyToPlainText(draft.body).trim()) 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()) return
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
}
function shareNote(): void {
const note = persistDraft()
if (!note) return
menuOpened.value = false
easyShare.open({
appId: 'notes',
copyText: noteBodyToPlainText(note.body) || note.title,
id: note.id,
kind: 'note',
subtitle: notePreview(note),
title: noteTitle(note),
})
}
</script>
<template>
<sky-app-page
v-if="!editorOpened"
class="notes-list-page sky-ui-provider"
:class="{ 'sky-ui-provider--dark': phone.isDarkMode }"
:aria-label="phone.t('Apps.notes.name')"
>
<sky-navbar
variant="large"
transparent
:title="phone.t('Apps.notes.name')"
/>
<SkyScrollArea as="main" class="notes-list-scroll">
<SkyList v-if="visibleNotes.length" inset strong>
<SkyListItem
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>
<template #actions>
<SkyButton
:aria-label="phone.t('Apps.notes.deleteNote')"
clear
icon-only
rounded
@click.stop="requestListDelete(note)"
>
<Trash2 :size="18" aria-hidden="true" />
</SkyButton>
</template>
</SkyListItem>
</SkyList>
<template v-else>
<sky-block-title large>{{
phone.t(
searchQuery ? 'Apps.notes.noResults' : 'Apps.notes.emptyTitle',
)
}}</sky-block-title>
<sky-block strong inset>{{
phone.t(
searchQuery ? 'Apps.notes.noResultsBody' : 'Apps.notes.emptyBody',
)
}}</sky-block>
<sky-list v-if="!searchQuery" strong inset>
<sky-list-button link-component="button" @click="createNote">
{{ phone.t('Apps.notes.newNote') }}
</sky-list-button>
</sky-list>
</template>
</SkyScrollArea>
<SkyToolbar
class="notes-composer"
component="footer"
:aria-label="phone.t('Apps.notes.searchPlaceholder')"
>
<SkySearchbar
v-model="searchQuery"
:clear-label="phone.t('Common.clear')"
:label="phone.t('Apps.notes.searchPlaceholder')"
:placeholder="phone.t('Apps.notes.searchPlaceholder')"
/>
<SkyFab
:aria-label="phone.t('Apps.notes.newNote')"
variant="neutral"
@click="createNote"
>
<template #icon>
<SquarePen :size="21" aria-hidden="true" />
</template>
</SkyFab>
</SkyToolbar>
<SkyDialog
:opened="listDeleteCandidateId !== null"
:title="phone.t('Apps.notes.deleteTitle')"
:content="phone.t('Apps.notes.deleteBody')"
role="alertdialog"
@backdropclick="cancelListDelete"
@escape="cancelListDelete"
>
<template #buttons>
<SkyDialogButton @click="cancelListDelete">
{{ phone.t('Common.cancel') }}
</SkyDialogButton>
<SkyDialogButton
strong
class="notes-delete-confirm"
@click="confirmListDelete"
>
{{ phone.t('Common.delete') }}
</SkyDialogButton>
</template>
</SkyDialog>
</sky-app-page>
<sky-app-page v-else class="notes-editor-page !pt-[44px] !pb-0">
<sky-navbar :title="phone.t('Apps.notes.note')">
<template #left>
<sky-navbar-back-link
component="button"
:text="phone.t('Apps.notes.back')"
:aria-label="phone.t('Apps.notes.back')"
@click="saveAndClose"
/>
</template>
<template #right>
<sky-link
component="button"
icon-only
:aria-label="phone.t('Apps.notes.actions')"
@click="openMenu"
>
<Ellipsis :size="22" />
</sky-link>
</template>
</sky-navbar>
<div class="notes-editor-layout">
<NotesRichTextEditor
v-model="draftBody"
:dark="phone.isDarkMode"
:labels="editorLabels"
:placeholder="phone.t('Apps.notes.bodyPlaceholder')"
/>
</div>
<SkyActionSheet
class="notes-action-sheet sky-ui-provider"
:class="{ 'sky-ui-provider--dark': phone.isDarkMode }"
:aria-label="phone.t('Apps.notes.actions')"
:opened="menuOpened"
@backdropclick="menuOpened = false"
@escape="menuOpened = false"
>
<div class="notes-action-menu">
<div class="notes-action-menu__choices">
<SkyButton block large tonal @click="shareNote">
<Share2 :size="19" aria-hidden="true" />
{{ phone.t('Apps.easyShare.name') }}
</SkyButton>
<SkyButton block large tonal @click="togglePinned">
<PinOff v-if="currentNote?.pinned" :size="19" aria-hidden="true" />
<Pin v-else :size="19" aria-hidden="true" />
{{
phone.t(
currentNote?.pinned ? 'Apps.notes.unpin' : 'Apps.notes.pin',
)
}}
</SkyButton>
<SkyButton
block
class="notes-action-menu__danger"
large
tonal
@click="deleteNote"
>
<Trash2 :size="19" aria-hidden="true" />
{{ phone.t('Apps.notes.deleteNote') }}
</SkyButton>
</div>
<SkyButton block clear large @click="menuOpened = false">
{{ phone.t('Common.cancel') }}
</SkyButton>
</div>
</SkyActionSheet>
</sky-app-page>
</template>
<style scoped>
.notes-list-page {
padding-bottom: 0 !important;
display: flex;
flex-direction: column;
overflow: hidden;
}
.notes-delete-confirm {
color: var(--sky-danger);
background: var(--sky-danger-soft);
}
.notes-editor-page {
display: flex;
flex-direction: column;
overflow: hidden;
}
.notes-editor-layout {
min-height: 0;
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
}
.notes-action-menu {
display: grid;
gap: 8px;
padding: 14px;
border: 1px solid var(--sky-hairline);
border-radius: 28px 28px 0 0;
background: var(--sky-surface);
box-shadow: 0 -18px 50px rgb(0 0 0 / 32%);
}
.notes-action-menu__choices {
display: grid;
gap: 8px;
}
.notes-action-menu :deep(.sky-button) {
justify-content: center;
border-radius: var(--sky-radius-control);
}
.notes-action-menu__danger {
color: var(--sky-danger);
background: var(--sky-danger-soft);
}
.notes-action-sheet :deep(.sky-action-sheet__panel) {
max-height: none;
padding: 0;
}
</style>