vibes
This commit is contained in:
+310
-14
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
|
||||
const loading = ref(true)
|
||||
const notFound = ref(false)
|
||||
@@ -19,6 +19,17 @@ const newSublistColor = ref('#16a34a')
|
||||
const newItemSublistId = ref('')
|
||||
const collapsed = ref(new Set())
|
||||
|
||||
const recurring = ref([])
|
||||
const newRecurringName = ref('')
|
||||
const newRecurringSublistId = ref('')
|
||||
const recurringPanel = ref(null)
|
||||
|
||||
function colorOf(sublistId) {
|
||||
if (sublistId == null) return 'transparent'
|
||||
const s = sublists.value.find((s) => s.id === sublistId)
|
||||
return s ? s.color : 'transparent'
|
||||
}
|
||||
|
||||
function groupKey(group) {
|
||||
return group.sublist ? group.sublist.id : 'none'
|
||||
}
|
||||
@@ -34,18 +45,14 @@ const groups = computed(() => {
|
||||
return [{ sublist: null, items: items.value }]
|
||||
}
|
||||
const bySublist = new Map(sublists.value.map((s) => [s.id, { sublist: s, items: [] }]))
|
||||
const none = { sublist: null, items: [] }
|
||||
for (const item of items.value) {
|
||||
const bucket = item.sublist_id != null ? bySublist.get(item.sublist_id) : null
|
||||
;(bucket || none).items.push(item)
|
||||
bySublist.get(item.sublist_id)?.items.push(item)
|
||||
}
|
||||
return [...bySublist.values(), none]
|
||||
return [...bySublist.values()]
|
||||
})
|
||||
|
||||
const newItemSublistColor = computed(() => {
|
||||
if (!newItemSublistId.value) return 'transparent'
|
||||
const s = sublists.value.find((s) => s.id === Number(newItemSublistId.value))
|
||||
return s ? s.color : 'transparent'
|
||||
return newItemSublistId.value ? colorOf(Number(newItemSublistId.value)) : 'transparent'
|
||||
})
|
||||
|
||||
const openListInput = ref('')
|
||||
@@ -138,10 +145,27 @@ async function loadItems() {
|
||||
async function loadSublists() {
|
||||
const res = await fetch(`/api/lists/${listId.value}/sublists`)
|
||||
sublists.value = await res.json()
|
||||
|
||||
const defaultId = sublists.value[0]?.id ?? ''
|
||||
if (!sublists.value.some((s) => s.id === newItemSublistId.value)) {
|
||||
newItemSublistId.value = defaultId
|
||||
}
|
||||
if (!sublists.value.some((s) => s.id === newRecurringSublistId.value)) {
|
||||
newRecurringSublistId.value = defaultId
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecurring() {
|
||||
const res = await fetch(`/api/lists/${listId.value}/recurring`)
|
||||
recurring.value = await res.json()
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await Promise.all([loadItems(), loadSublists()])
|
||||
// Fetching sublists also backfills any item still missing one server-side
|
||||
// (see ensureGeneralSublist), so it must resolve before items are fetched
|
||||
// or the freshly-backfilled items would briefly render as ungrouped.
|
||||
await loadSublists()
|
||||
await Promise.all([loadItems(), loadRecurring()])
|
||||
}
|
||||
|
||||
async function addSublist() {
|
||||
@@ -155,6 +179,79 @@ async function addSublist() {
|
||||
newSublistName.value = ''
|
||||
}
|
||||
|
||||
async function renameSublist(sublist, name) {
|
||||
name = name.trim()
|
||||
if (!name || name === sublist.name) return
|
||||
const res = await fetch(`/api/lists/${listId.value}/sublists/${sublist.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
Object.assign(sublist, await res.json())
|
||||
}
|
||||
|
||||
async function recolorSublist(sublist, color) {
|
||||
const res = await fetch(`/api/lists/${listId.value}/sublists/${sublist.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ color }),
|
||||
})
|
||||
Object.assign(sublist, await res.json())
|
||||
}
|
||||
|
||||
async function deleteSublist(sublist) {
|
||||
if (!window.confirm(`Delete "${sublist.name}"? This also deletes all its items.`)) return
|
||||
|
||||
await fetch(`/api/lists/${listId.value}/sublists/${sublist.id}`, { method: 'DELETE' })
|
||||
sublists.value = sublists.value.filter((s) => s.id !== sublist.id)
|
||||
items.value = items.value.filter((i) => i.sublist_id !== sublist.id)
|
||||
recurring.value = recurring.value.filter((r) => r.sublist_id !== sublist.id)
|
||||
}
|
||||
|
||||
async function addRecurring() {
|
||||
if (!newRecurringName.value.trim()) return
|
||||
const res = await fetch(`/api/lists/${listId.value}/recurring`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newRecurringName.value.trim(),
|
||||
sublist_id: newRecurringSublistId.value ? Number(newRecurringSublistId.value) : null,
|
||||
}),
|
||||
})
|
||||
recurring.value.push(await res.json())
|
||||
newRecurringName.value = ''
|
||||
}
|
||||
|
||||
async function addRecurringToList(recur) {
|
||||
// Adding the item grows the sublist above this panel, which would
|
||||
// otherwise shove the page (and the button just clicked) down the
|
||||
// screen — compensate by scrolling to cancel out that shift.
|
||||
const before = recurringPanel.value?.getBoundingClientRect().top
|
||||
|
||||
const res = await fetch(`/api/lists/${listId.value}/items`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: recur.name, sublist_id: recur.sublist_id }),
|
||||
})
|
||||
items.value.push(await res.json())
|
||||
|
||||
await nextTick()
|
||||
if (recurringPanel.value && before != null) {
|
||||
const after = recurringPanel.value.getBoundingClientRect().top
|
||||
window.scrollBy(0, after - before)
|
||||
}
|
||||
}
|
||||
|
||||
function alreadyAdded(recur) {
|
||||
const name = recur.name.trim().toLowerCase()
|
||||
return items.value.some((i) => i.sublist_id === recur.sublist_id && i.name.trim().toLowerCase() === name)
|
||||
}
|
||||
|
||||
async function removeRecurring(recur) {
|
||||
await fetch(`/api/lists/${listId.value}/recurring/${recur.id}`, { method: 'DELETE' })
|
||||
recurring.value = recurring.value.filter((r) => r.id !== recur.id)
|
||||
}
|
||||
|
||||
async function addItem() {
|
||||
if (!newName.value.trim()) return
|
||||
const res = await fetch(`/api/lists/${listId.value}/items`, {
|
||||
@@ -170,6 +267,8 @@ async function addItem() {
|
||||
}
|
||||
|
||||
async function toggleDone(item) {
|
||||
if (!item.done) navigator.vibrate?.(30)
|
||||
|
||||
const res = await fetch(`/api/lists/${listId.value}/items/${item.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -432,6 +531,110 @@ function onHandleEnd() {
|
||||
})
|
||||
}
|
||||
|
||||
// Recurring items are a single flat list (no sublist grouping to drag
|
||||
// across), so this is the simpler plain-reorder version of the drag above.
|
||||
let recurDrag = null
|
||||
|
||||
function onRecurHandleDown(e, index) {
|
||||
const li = e.currentTarget.closest('li')
|
||||
const els = Array.from(li.parentElement.children)
|
||||
const rects = els.map((el) => el.getBoundingClientRect())
|
||||
recurDrag = { index, target: index, startY: e.clientY, rects, els, height: rects[index].height }
|
||||
els[index].style.zIndex = 5
|
||||
els[index].style.position = 'relative'
|
||||
els[index].style.boxShadow = '0 4px 10px rgba(0, 0, 0, 0.2)'
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
function onRecurHandleMove(e) {
|
||||
if (!recurDrag) return
|
||||
const { index, rects, els, height, startY } = recurDrag
|
||||
const dy = e.clientY - startY
|
||||
els[index].style.transform = `translateY(${dy}px)`
|
||||
|
||||
const draggedCenter = rects[index].top + height / 2 + dy
|
||||
let target = index
|
||||
rects.forEach((rect, i) => {
|
||||
if (i === index) return
|
||||
const mid = rect.top + rect.height / 2
|
||||
if (i < index && draggedCenter < mid) target = Math.min(target, i)
|
||||
if (i > index && draggedCenter > mid) target = Math.max(target, i)
|
||||
})
|
||||
recurDrag.target = target
|
||||
|
||||
els.forEach((el, i) => {
|
||||
if (i === index) return
|
||||
let shift = 0
|
||||
if (target < index && i >= target && i < index) shift = height
|
||||
if (target > index && i <= target && i > index) shift = -height
|
||||
el.style.transition = 'transform 0.15s ease'
|
||||
el.style.transform = shift ? `translateY(${shift}px)` : ''
|
||||
})
|
||||
}
|
||||
|
||||
function onRecurHandleEnd() {
|
||||
if (!recurDrag) return
|
||||
const { index, target, els } = recurDrag
|
||||
els.forEach((el) => {
|
||||
el.style.transform = ''
|
||||
el.style.transition = ''
|
||||
el.style.position = ''
|
||||
el.style.zIndex = ''
|
||||
el.style.boxShadow = ''
|
||||
})
|
||||
recurDrag = null
|
||||
if (target === index) return
|
||||
|
||||
const moved = recurring.value.splice(index, 1)[0]
|
||||
recurring.value.splice(target, 0, moved)
|
||||
|
||||
const prev = recurring.value[target - 1]
|
||||
const next = recurring.value[target + 1]
|
||||
let position
|
||||
if (prev && next) position = (prev.position + next.position) / 2
|
||||
else if (prev) position = prev.position + 1
|
||||
else if (next) position = next.position - 1
|
||||
else position = 0
|
||||
moved.position = position
|
||||
|
||||
fetch(`/api/lists/${listId.value}/recurring/${moved.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ position }),
|
||||
})
|
||||
}
|
||||
|
||||
// Recurring items have no "done" state, so unlike the shopping-list swipe
|
||||
// this only swipes left (delete) — dx is clamped to never go positive.
|
||||
let recurSwipeDrag = null
|
||||
|
||||
function onRecurSwipeStart(e) {
|
||||
recurSwipeDrag = { startX: e.clientX, dx: 0, el: e.currentTarget }
|
||||
recurSwipeDrag.el.setPointerCapture(e.pointerId)
|
||||
recurSwipeDrag.el.style.transition = 'none'
|
||||
}
|
||||
|
||||
function onRecurSwipeMove(e) {
|
||||
if (!recurSwipeDrag) return
|
||||
const dx = e.clientX - recurSwipeDrag.startX
|
||||
recurSwipeDrag.dx = Math.max(-SWIPE_MAX, Math.min(0, dx))
|
||||
recurSwipeDrag.el.style.transform = `translateX(${recurSwipeDrag.dx}px)`
|
||||
}
|
||||
|
||||
function onRecurSwipeEnd(e, recur) {
|
||||
if (!recurSwipeDrag) return
|
||||
const { dx, el } = recurSwipeDrag
|
||||
el.style.transition = 'transform 0.2s ease'
|
||||
|
||||
if (dx <= -SWIPE_THRESHOLD) {
|
||||
el.style.transform = 'translateX(-100%)'
|
||||
setTimeout(() => removeRecurring(recur), 150)
|
||||
} else {
|
||||
el.style.transform = 'translateX(0)'
|
||||
}
|
||||
recurSwipeDrag = null
|
||||
}
|
||||
|
||||
function openList() {
|
||||
const slug = openListInput.value.trim().toLowerCase()
|
||||
if (!/^[a-z]+-[a-z]+-[a-z]+$/.test(slug)) {
|
||||
@@ -470,7 +673,6 @@ onMounted(init)
|
||||
<input v-model="newName" type="text" placeholder="Add an item" />
|
||||
<span class="dot" :style="{ background: newItemSublistColor }"></span>
|
||||
<select class="item-sublist" v-model="newItemSublistId">
|
||||
<option value="">No sublist</option>
|
||||
<option v-for="s in sublists" :key="s.id" :value="s.id">{{ s.name }}</option>
|
||||
</select>
|
||||
<button type="submit">Add</button>
|
||||
@@ -556,6 +758,23 @@ onMounted(init)
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Sublists</h3>
|
||||
<ul class="sublist-manage-list">
|
||||
<li v-for="s in sublists" :key="s.id" class="sublist-manage-item">
|
||||
<input
|
||||
type="color"
|
||||
class="sublist-color-input"
|
||||
:value="s.color"
|
||||
@change="recolorSublist(s, $event.target.value)"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
class="sublist-name-input"
|
||||
:value="s.name"
|
||||
@change="renameSublist(s, $event.target.value)"
|
||||
/>
|
||||
<button type="button" @click="deleteSublist(s)">Delete</button>
|
||||
</li>
|
||||
</ul>
|
||||
<form class="add-sublist" @submit.prevent="addSublist">
|
||||
<input v-model="newSublistName" type="text" placeholder="Add sublist" />
|
||||
<input v-model="newSublistColor" type="color" />
|
||||
@@ -563,6 +782,45 @@ onMounted(init)
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="settings" ref="recurringPanel">
|
||||
<summary>Recurring</summary>
|
||||
|
||||
<form @submit.prevent="addRecurring">
|
||||
<input v-model="newRecurringName" type="text" placeholder="Add recurring item" />
|
||||
<select class="item-sublist" v-model="newRecurringSublistId">
|
||||
<option v-for="s in sublists" :key="s.id" :value="s.id">{{ s.name }}</option>
|
||||
</select>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
|
||||
<ul class="recurring-list">
|
||||
<li v-for="(r, index) in recurring" :key="r.id" class="item">
|
||||
<div class="swipe-bg recurring-swipe-bg">
|
||||
<span class="swipe-delete">Delete ✕</span>
|
||||
</div>
|
||||
<div
|
||||
class="recurring-item"
|
||||
@pointerdown="onRecurSwipeStart"
|
||||
@pointermove="onRecurSwipeMove"
|
||||
@pointerup="onRecurSwipeEnd($event, r)"
|
||||
@pointercancel="onRecurSwipeEnd($event, r)"
|
||||
>
|
||||
<span
|
||||
class="handle"
|
||||
@pointerdown.stop="onRecurHandleDown($event, index)"
|
||||
@pointermove.stop="onRecurHandleMove"
|
||||
@pointerup.stop="onRecurHandleEnd"
|
||||
@pointercancel.stop="onRecurHandleEnd"
|
||||
>⠿</span
|
||||
>
|
||||
<span class="dot" :style="{ background: colorOf(r.sublist_id) }"></span>
|
||||
<span class="recurring-name">{{ r.name }}</span>
|
||||
<button v-if="!alreadyAdded(r)" type="button" @click="addRecurringToList(r)">Add</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -855,7 +1113,8 @@ ul {
|
||||
.add-sublist input[type='text'] {
|
||||
flex: 1;
|
||||
}
|
||||
.add-sublist input[type='color'] {
|
||||
.add-sublist input[type='color'],
|
||||
.sublist-color-input {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0;
|
||||
@@ -863,15 +1122,32 @@ ul {
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.add-sublist input[type='color']::-webkit-color-swatch-wrapper {
|
||||
.add-sublist input[type='color']::-webkit-color-swatch-wrapper,
|
||||
.sublist-color-input::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
.add-sublist input[type='color']::-webkit-color-swatch {
|
||||
.add-sublist input[type='color']::-webkit-color-swatch,
|
||||
.sublist-color-input::-webkit-color-swatch {
|
||||
border: none;
|
||||
}
|
||||
.add-sublist input[type='color']::-moz-color-swatch {
|
||||
.add-sublist input[type='color']::-moz-color-swatch,
|
||||
.sublist-color-input::-moz-color-swatch {
|
||||
border: none;
|
||||
}
|
||||
.sublist-manage-list {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.sublist-manage-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sublist-name-input {
|
||||
flex: 1;
|
||||
}
|
||||
.settings {
|
||||
margin-top: 2rem;
|
||||
color: var(--muted);
|
||||
@@ -898,6 +1174,26 @@ ul {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.recurring-list {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.recurring-swipe-bg {
|
||||
justify-content: flex-end;
|
||||
background: var(--danger);
|
||||
}
|
||||
.recurring-item {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0;
|
||||
background: var(--bg);
|
||||
touch-action: pan-y;
|
||||
}
|
||||
.recurring-name {
|
||||
flex: 1;
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
font-size: 0.9rem;
|
||||
|
||||
Reference in New Issue
Block a user