Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ Fix Upload Size Check
- Put types, constants and validation schemas used by both the client and the server in `shared/`.
- Add or update tests for behavior changes (`test/unit/` for pure logic, `test/e2e/` for API behavior).

## Frontend Conventions

- Dialogs use `<UiModal>` (focus trap, scroll lock, Escape) and destructive confirmations use `<UiConfirmDialog>`.
- Close-on-Escape goes through `useEscapeKey(handler, isOpen)`, so only the top-most open layer reacts.
- Icons come from `<UiIcon name="…">`; add new ones to `app/utils/icons.ts` instead of inlining SVGs.
- Icon-only buttons need an `aria-label`, and inputs need a label (use `sr-only` if it shouldn't be visible).
- Note data lives in `useNotesList` (loading), `useNoteMutations` and `useNoteAttachments` (changes).

## License

By contributing, you agree that your contributions will be licensed under the [AGPL-3.0](./LICENSE) license.
2 changes: 1 addition & 1 deletion app/app.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<div>
<NuxtLoadingIndicator color="#88C0D0" />
<NuxtLoadingIndicator color="var(--nord-aurora)" />
<ClientOnly>
<NuxtRouteAnnouncer />
</ClientOnly>
Expand Down
3 changes: 3 additions & 0 deletions app/assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,6 @@
40%, 80% { transform: translateX(6px); }
}
.animate-shake { animation: shake 0.5s ease-in-out; }

/* type="search" inputs use our own clear button */
input[type="search"]::-webkit-search-cancel-button { display: none; }
6 changes: 3 additions & 3 deletions app/components/app/BackToTop.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,18 @@
>
<button
v-if="showButton"
type="button"
class="fixed bottom-6 right-6 z-40 w-11 h-11 rounded-full bg-nord-blue hover:bg-nord-frost text-white shadow-lg flex items-center justify-center transition-colors duration-150"
aria-label="Back to top"
@click="scrollToTop"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18" />
</svg>
<UiIcon name="arrow-up" class="w-5 h-5" />
</button>
</Transition>
</template>

<script setup lang="ts">
// Show the button once the user has scrolled this far down (px)
const THRESHOLD = 1000

const showButton = ref(false)
Expand Down
12 changes: 4 additions & 8 deletions app/components/app/Header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,20 @@
<!-- Left: hamburger, always w-16 -->
<div class="w-16 flex-shrink-0 flex items-center justify-center">
<button
type="button"
class="w-10 h-10 rounded-full flex items-center justify-center hover:bg-nord-graphite transition-colors"
aria-label="Toggle Menu"
aria-controls="app-sidebar"
@click="$emit('toggle-sidebar')"
>
<svg
class="w-6 h-6 text-nord-ice"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
</svg>
<UiIcon name="menu" class="w-6 h-6 text-nord-ice" />
</button>
</div>

<!-- Center: search bar, full-width on small screens, capped + centered on large -->
<div class="flex-1 min-w-0 px-4">
<AppSearchBar class="w-full max-w-[600px] mx-auto" />
</div>

<!-- Right: intentionally removed — search bar takes full remaining width -->
</header>
</template>

Expand Down
151 changes: 68 additions & 83 deletions app/components/app/Lightbox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,152 +2,137 @@
<Teleport to="body">
<Transition name="lightbox">
<div
v-if="lightboxOpen"
v-if="isOpen && current"
ref="root"
role="dialog"
aria-modal="true"
:aria-label="`Image ${index + 1} of ${items.length}: ${current.name}`"
class="fixed left-0 top-0 modal-viewport z-[200] bg-black/90 flex items-center justify-center"
@click.self="close"
>
<!-- Top bar: filename left, actions right -->
<div class="absolute top-0 left-0 right-0 flex items-center justify-between px-4 py-3 z-10 bg-gradient-to-b from-black/60 to-transparent">
<!-- Filename -->
<span class="text-sm font-medium text-nord-snow truncate max-w-[50vw] select-none block" dir="rtl">
{{ lightboxNames[lightboxIndex] || '' }}
{{ current.name }}
</span>
<!-- Actions: download, delete, close -->
<div class="flex items-center gap-2">
<a
:href="lightboxImages[lightboxIndex]"
:href="current.url"
download
class="w-10 h-10 flex items-center justify-center rounded-full bg-nord-obsidian/80 hover:bg-nord-graphite text-nord-snow transition-colors"
class="lightbox-btn hover:bg-nord-graphite"
title="Download"
@click.stop="onDownload"
aria-label="Download"
@click.stop="showSnackbar('Download Starting...')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/>
</svg>
<UiIcon name="download" class="w-5 h-5" />
</a>
<button
v-if="lightboxAttachmentIds[lightboxIndex]"
class="w-10 h-10 flex items-center justify-center rounded-full bg-nord-obsidian/80 hover:bg-nord-ember/80 text-nord-snow transition-colors"
v-if="canDelete"
type="button"
class="lightbox-btn hover:bg-nord-ember/80"
title="Delete"
aria-label="Delete Image"
@click.stop="confirmingDelete = true"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/>
</svg>
<UiIcon name="trash" class="w-5 h-5" />
</button>
<button
class="w-10 h-10 flex items-center justify-center rounded-full bg-nord-obsidian/80 hover:bg-nord-graphite text-nord-snow transition-colors"
@click="close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6 18 18 6M6 6l12 12"/>
</svg>
<button ref="closeButton" type="button" class="lightbox-btn hover:bg-nord-graphite" title="Close" aria-label="Close" @click="close">
<UiIcon name="x-mark" class="w-5 h-5" />
</button>
</div>
</div>

<!-- Prev button -->
<button
v-if="lightboxImages.length > 1 && lightboxIndex > 0"
class="absolute left-4 top-1/2 -translate-y-1/2 w-10 h-10 flex items-center justify-center rounded-full bg-nord-obsidian/80 hover:bg-nord-graphite text-nord-snow transition-colors z-10"
v-if="index > 0"
type="button"
class="lightbox-btn hover:bg-nord-graphite absolute left-4 top-1/2 -translate-y-1/2 z-10"
aria-label="Previous Image"
@click="prev"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.75 19.5 8.25 12l7.5-7.5"/>
</svg>
<UiIcon name="chevron-left" class="w-5 h-5" />
</button>

<!-- Image -->
<img
:src="lightboxImages[lightboxIndex]"
:src="current.url"
:alt="current.name"
class="max-w-[90vw] max-h-[90vh] object-contain select-none rounded-lg shadow-2xl"
@click.stop
>

<!-- Next button -->
<button
v-if="lightboxImages.length > 1 && lightboxIndex < lightboxImages.length - 1"
class="absolute right-4 top-1/2 -translate-y-1/2 w-10 h-10 flex items-center justify-center rounded-full bg-nord-obsidian/80 hover:bg-nord-graphite text-nord-snow transition-colors z-10"
v-if="index < items.length - 1"
type="button"
class="lightbox-btn hover:bg-nord-graphite absolute right-4 top-1/2 -translate-y-1/2 z-10"
aria-label="Next Image"
@click="next"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8.25 4.5l7.5 7.5-7.5 7.5"/>
</svg>
<UiIcon name="chevron-right" class="w-5 h-5" />
</button>

<!-- Counter -->
<div
v-if="lightboxImages.length > 1"
v-if="items.length > 1"
class="absolute bottom-4 left-1/2 -translate-x-1/2 text-xs text-nord-frost bg-nord-obsidian/70 px-3 py-1 rounded-full"
aria-hidden="true"
>
{{ lightboxIndex + 1 }} / {{ lightboxImages.length }}
{{ index + 1 }} / {{ items.length }}
</div>

<!-- Delete confirmation overlay -->
<Transition name="fade">
<div
v-if="confirmingDelete"
class="absolute inset-0 bg-black/60 flex items-center justify-center z-20"
@click.self="confirmingDelete = false"
>
<div class="bg-nord-obsidian rounded-2xl p-6 w-full max-w-xs mx-4 shadow-2xl">
<p class="text-nord-snow font-medium mb-1">Are You Sure?</p>
<p class="text-nord-frost text-sm mb-5">The image will be permanently deleted from this note and disk.</p>
<div class="flex gap-3 justify-end">
<button
class="px-4 py-2 text-sm text-nord-ice hover:bg-nord-graphite rounded-lg transition-colors"
@click="confirmingDelete = false"
>Cancel</button>
<button
class="px-4 py-2 text-sm bg-nord-ember text-white rounded-lg hover:bg-nord-ember/80 transition-colors"
:disabled="deleting"
@click="onDeleteConfirmed"
>{{ deleting ? 'Deleting…' : 'Delete' }}</button>
</div>
</div>
</div>
</Transition>
</div>
</Transition>

<UiConfirmDialog
v-if="confirmingDelete"
title="Delete Image?"
confirm-label="Delete"
:busy="deleting"
@cancel="confirmingDelete = false"
@confirm="onDeleteConfirmed"
>
The image will be permanently deleted from this note and disk.
</UiConfirmDialog>
</Teleport>
</template>

<script setup lang="ts">
const { lightboxOpen, lightboxImages, lightboxIndex, lightboxAttachmentIds, lightboxNames, close, prev, next, deleteCurrentImage } = useLightbox()
const { isOpen, items, index, current, canDelete, close, prev, next, deleteCurrent } = useLightbox()
const { show: showSnackbar } = useSnackbar()

useBodyScrollLock(lightboxOpen)

const root = ref<HTMLElement>()
const closeButton = ref<HTMLButtonElement>()
const confirmingDelete = ref(false)
const deleting = ref(false)
let returnFocusTo: HTMLElement | null = null

useBodyScrollLock(isOpen)
useEscapeKey(close, isOpen)
useLightboxNavigation(() => isOpen.value && !confirmingDelete.value, prev, next)

// Move focus into the lightbox while it's open and give it back afterwards
watch(isOpen, (open) => {
if (open) {
returnFocusTo = document.activeElement as HTMLElement | null
nextTick(() => closeButton.value?.focus())
} else {
restoreFocus(returnFocusTo, root.value)
}
})

const onDeleteConfirmed = async () => {
deleting.value = true
try {
await deleteCurrentImage()
await deleteCurrent()
confirmingDelete.value = false
} finally {
deleting.value = false
}
}

const onDownload = () => {
showSnackbar('Download Starting...')
}

useLightboxTouch(
lightboxOpen,
prev,
next,
close,
confirmingDelete,
() => { confirmingDelete.value = false }
)
</script>

<style scoped>
@reference "../../assets/css/main.css";

.lightbox-btn {
@apply w-10 h-10 flex items-center justify-center rounded-full bg-nord-obsidian/80 text-nord-snow transition-colors;
}
.lightbox-enter-active, .lightbox-leave-active { transition: opacity 0.2s; }
.lightbox-enter-from, .lightbox-leave-to { opacity: 0; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
21 changes: 10 additions & 11 deletions app/components/app/SearchBar.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<form class="relative flex-1 w-full" @submit.prevent="submit">
<form class="relative flex-1 w-full" role="search" @submit.prevent="submit">
<div
class="flex items-center bg-nord-graphite rounded-xl overflow-hidden transition-all"
:class="focused ? 'ring-2 ring-nord-aurora' : ''"
Expand All @@ -9,11 +9,10 @@
type="submit"
class="w-10 h-10 flex items-center justify-center flex-shrink-0 hover:bg-nord-slate transition-colors"
:title="searching ? 'Searching…' : 'Search'"
:aria-label="searching ? 'Searching…' : 'Search'"
>
<svg v-if="!searching" class="w-5 h-5 text-nord-frost" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"/>
</svg>
<svg v-else class="w-4 h-4 text-nord-aurora animate-spin" fill="none" viewBox="0 0 24 24">
<UiIcon v-if="!searching" name="search" class="w-5 h-5 text-nord-frost" />
<svg v-else class="w-4 h-4 text-nord-aurora animate-spin" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
</svg>
Expand All @@ -22,25 +21,25 @@
<input
ref="inputEl"
v-model="localQuery"
type="text"
type="search"
placeholder="Search"
aria-label="Search Notes"
class="flex-1 bg-transparent px-2 py-3 text-sm text-nord-snow outline-none placeholder-nord-frost min-w-0"
@focus="focused = true"
@blur="focused = false"
@keydown.escape="clear"
@keydown.escape.prevent="clear"
>

<!-- Clear button -->
<button
v-if="localQuery || searchResults"
type="button"
class="w-10 h-10 flex items-center justify-center hover:bg-nord-slate transition-colors flex-shrink-0"
title="Clear search"
title="Clear Search"
aria-label="Clear Search"
@click="clear"
>
<svg class="w-4 h-4 text-nord-frost" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6 18 18 6M6 6l12 12"/>
</svg>
<UiIcon name="x-mark" class="w-4 h-4 text-nord-frost" />
</button>
</div>
</form>
Expand Down
Loading
Loading