This commit is contained in:
root
2026-09-14 20:51:40 +02:00
parent c3bf614901
commit 4195326938
4 changed files with 603 additions and 14 deletions
+26
View File
@@ -32,6 +32,14 @@ CREATE TABLE IF NOT EXISTS items (
position REAL NOT NULL DEFAULT 0, position REAL NOT NULL DEFAULT 0,
sublist_id INTEGER REFERENCES sublists(id), sublist_id INTEGER REFERENCES sublists(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS recurring_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
list_id TEXT NOT NULL REFERENCES lists(id),
name TEXT NOT NULL,
sublist_id INTEGER REFERENCES sublists(id),
position REAL NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);` );`
const mysqlSchema = ` const mysqlSchema = `
@@ -58,6 +66,16 @@ CREATE TABLE IF NOT EXISTS items (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (list_id) REFERENCES lists(id), FOREIGN KEY (list_id) REFERENCES lists(id),
FOREIGN KEY (sublist_id) REFERENCES sublists(id) FOREIGN KEY (sublist_id) REFERENCES sublists(id)
);
CREATE TABLE IF NOT EXISTS recurring_items (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
list_id VARCHAR(64) NOT NULL,
name VARCHAR(255) NOT NULL,
sublist_id INTEGER,
position DOUBLE NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (list_id) REFERENCES lists(id),
FOREIGN KEY (sublist_id) REFERENCES sublists(id)
);` );`
const postgresSchema = ` const postgresSchema = `
@@ -81,6 +99,14 @@ CREATE TABLE IF NOT EXISTS items (
position DOUBLE PRECISION NOT NULL DEFAULT 0, position DOUBLE PRECISION NOT NULL DEFAULT 0,
sublist_id INTEGER REFERENCES sublists(id), sublist_id INTEGER REFERENCES sublists(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS recurring_items (
id SERIAL PRIMARY KEY,
list_id TEXT NOT NULL REFERENCES lists(id),
name TEXT NOT NULL,
sublist_id INTEGER REFERENCES sublists(id),
position DOUBLE PRECISION NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);` );`
// querier is the subset of *sql.DB every handler uses. Postgres needs its own // querier is the subset of *sql.DB every handler uses. Postgres needs its own
+261
View File
@@ -30,6 +30,14 @@ type Sublist struct {
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
} }
type RecurringItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
SublistID *int64 `json:"sublist_id"`
Position float64 `json:"position"`
CreatedAt string `json:"created_at"`
}
type api struct { type api struct {
db querier db querier
hub *hub hub *hub
@@ -79,9 +87,58 @@ func (a *api) getList(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, l) writeJSON(w, http.StatusOK, l)
} }
// ensureGeneralSublist guarantees every item lands in a sublist: it makes
// sure the list has at least one (creating a "General" catch-all if it has
// none) and backfills any item/recurring row still missing a sublist_id
// (left over from before sublists were required) onto it.
func (a *api) ensureGeneralSublist(listID string) error {
var orphanItems, orphanRecurring int
if err := a.db.QueryRow("SELECT COUNT(*) FROM items WHERE list_id = ? AND sublist_id IS NULL", listID).Scan(&orphanItems); err != nil {
return err
}
if err := a.db.QueryRow("SELECT COUNT(*) FROM recurring_items WHERE list_id = ? AND sublist_id IS NULL", listID).Scan(&orphanRecurring); err != nil {
return err
}
var sublistCount int
if err := a.db.QueryRow("SELECT COUNT(*) FROM sublists WHERE list_id = ?", listID).Scan(&sublistCount); err != nil {
return err
}
if sublistCount > 0 && orphanItems == 0 && orphanRecurring == 0 {
return nil
}
var generalID int64
err := a.db.QueryRow("SELECT id FROM sublists WHERE list_id = ? AND name = ? ORDER BY id LIMIT 1", listID, "General").Scan(&generalID)
if err == sql.ErrNoRows {
generalID, err = a.db.insertReturningID("INSERT INTO sublists (list_id, name, color) VALUES (?, ?, ?)", listID, "General", "#9ca3af")
}
if err != nil {
return err
}
if orphanItems > 0 {
if _, err := a.db.Exec("UPDATE items SET sublist_id = ? WHERE list_id = ? AND sublist_id IS NULL", generalID, listID); err != nil {
return err
}
}
if orphanRecurring > 0 {
if _, err := a.db.Exec("UPDATE recurring_items SET sublist_id = ? WHERE list_id = ? AND sublist_id IS NULL", generalID, listID); err != nil {
return err
}
}
return nil
}
func (a *api) listSublists(w http.ResponseWriter, r *http.Request) { func (a *api) listSublists(w http.ResponseWriter, r *http.Request) {
listID := r.PathValue("listId") listID := r.PathValue("listId")
if err := a.ensureGeneralSublist(listID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
rows, err := a.db.Query("SELECT id, name, color, created_at FROM sublists WHERE list_id = ? ORDER BY id", listID) rows, err := a.db.Query("SELECT id, name, color, created_at FROM sublists WHERE list_id = ? ORDER BY id", listID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
@@ -150,6 +207,210 @@ func (a *api) createSublist(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, s) writeJSON(w, http.StatusCreated, s)
} }
func (a *api) updateSublist(w http.ResponseWriter, r *http.Request) {
listID := r.PathValue("listId")
id := r.PathValue("id")
var in struct {
Name *string `json:"name"`
Color *string `json:"color"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if in.Name != nil {
if *in.Name == "" {
writeError(w, http.StatusBadRequest, "name cannot be empty")
return
}
if _, err := a.db.Exec("UPDATE sublists SET name = ? WHERE id = ? AND list_id = ?", *in.Name, id, listID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
}
if in.Color != nil {
if *in.Color == "" {
writeError(w, http.StatusBadRequest, "color cannot be empty")
return
}
if _, err := a.db.Exec("UPDATE sublists SET color = ? WHERE id = ? AND list_id = ?", *in.Color, id, listID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
}
var s Sublist
err := a.db.QueryRow("SELECT id, name, color, created_at FROM sublists WHERE id = ? AND list_id = ?", id, listID).
Scan(&s.ID, &s.Name, &s.Color, &s.CreatedAt)
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "sublist not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
a.hub.broadcast(listID)
writeJSON(w, http.StatusOK, s)
}
func (a *api) deleteSublist(w http.ResponseWriter, r *http.Request) {
listID := r.PathValue("listId")
id := r.PathValue("id")
if _, err := a.db.Exec("DELETE FROM items WHERE sublist_id = ? AND list_id = ?", id, listID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if _, err := a.db.Exec("DELETE FROM recurring_items WHERE sublist_id = ? AND list_id = ?", id, listID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
res, err := a.db.Exec("DELETE FROM sublists WHERE id = ? AND list_id = ?", id, listID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if n, _ := res.RowsAffected(); n == 0 {
writeError(w, http.StatusNotFound, "sublist not found")
return
}
a.hub.broadcast(listID)
w.WriteHeader(http.StatusNoContent)
}
func (a *api) listRecurring(w http.ResponseWriter, r *http.Request) {
listID := r.PathValue("listId")
rows, err := a.db.Query("SELECT id, name, sublist_id, position, created_at FROM recurring_items WHERE list_id = ? ORDER BY position, id", listID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
recurring := []RecurringItem{}
for rows.Next() {
var ri RecurringItem
if err := rows.Scan(&ri.ID, &ri.Name, &ri.SublistID, &ri.Position, &ri.CreatedAt); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
recurring = append(recurring, ri)
}
writeJSON(w, http.StatusOK, recurring)
}
func (a *api) createRecurring(w http.ResponseWriter, r *http.Request) {
listID := r.PathValue("listId")
exists, err := a.listExists(listID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !exists {
writeError(w, http.StatusNotFound, "list not found")
return
}
var in struct {
Name string `json:"name"`
SublistID *int64 `json:"sublist_id"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if in.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
var maxPosition sql.NullFloat64
if err := a.db.QueryRow("SELECT MAX(position) FROM recurring_items WHERE list_id = ?", listID).Scan(&maxPosition); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
position := maxPosition.Float64 + 1
id, err := a.db.insertReturningID("INSERT INTO recurring_items (list_id, name, sublist_id, position) VALUES (?, ?, ?, ?)", listID, in.Name, in.SublistID, position)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
var ri RecurringItem
err = a.db.QueryRow("SELECT id, name, sublist_id, position, created_at FROM recurring_items WHERE id = ?", id).
Scan(&ri.ID, &ri.Name, &ri.SublistID, &ri.Position, &ri.CreatedAt)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
a.hub.broadcast(listID)
writeJSON(w, http.StatusCreated, ri)
}
func (a *api) updateRecurring(w http.ResponseWriter, r *http.Request) {
listID := r.PathValue("listId")
id := r.PathValue("id")
var in struct {
Position *float64 `json:"position"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if in.Position != nil {
if _, err := a.db.Exec("UPDATE recurring_items SET position = ? WHERE id = ? AND list_id = ?", *in.Position, id, listID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
}
var ri RecurringItem
err := a.db.QueryRow("SELECT id, name, sublist_id, position, created_at FROM recurring_items WHERE id = ? AND list_id = ?", id, listID).
Scan(&ri.ID, &ri.Name, &ri.SublistID, &ri.Position, &ri.CreatedAt)
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "recurring item not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
a.hub.broadcast(listID)
writeJSON(w, http.StatusOK, ri)
}
func (a *api) deleteRecurring(w http.ResponseWriter, r *http.Request) {
listID := r.PathValue("listId")
id := r.PathValue("id")
res, err := a.db.Exec("DELETE FROM recurring_items WHERE id = ? AND list_id = ?", id, listID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if n, _ := res.RowsAffected(); n == 0 {
writeError(w, http.StatusNotFound, "recurring item not found")
return
}
a.hub.broadcast(listID)
w.WriteHeader(http.StatusNoContent)
}
func (a *api) listSocket(w http.ResponseWriter, r *http.Request) { func (a *api) listSocket(w http.ResponseWriter, r *http.Request) {
listID := r.PathValue("listId") listID := r.PathValue("listId")
+6
View File
@@ -26,6 +26,12 @@ func main() {
mux.HandleFunc("GET /api/lists/{listId}/ws", a.listSocket) mux.HandleFunc("GET /api/lists/{listId}/ws", a.listSocket)
mux.HandleFunc("GET /api/lists/{listId}/sublists", a.listSublists) mux.HandleFunc("GET /api/lists/{listId}/sublists", a.listSublists)
mux.HandleFunc("POST /api/lists/{listId}/sublists", a.createSublist) mux.HandleFunc("POST /api/lists/{listId}/sublists", a.createSublist)
mux.HandleFunc("PATCH /api/lists/{listId}/sublists/{id}", a.updateSublist)
mux.HandleFunc("DELETE /api/lists/{listId}/sublists/{id}", a.deleteSublist)
mux.HandleFunc("GET /api/lists/{listId}/recurring", a.listRecurring)
mux.HandleFunc("POST /api/lists/{listId}/recurring", a.createRecurring)
mux.HandleFunc("PATCH /api/lists/{listId}/recurring/{id}", a.updateRecurring)
mux.HandleFunc("DELETE /api/lists/{listId}/recurring/{id}", a.deleteRecurring)
mux.HandleFunc("GET /api/lists/{listId}/items", a.listItems) mux.HandleFunc("GET /api/lists/{listId}/items", a.listItems)
mux.HandleFunc("POST /api/lists/{listId}/items", a.createItem) mux.HandleFunc("POST /api/lists/{listId}/items", a.createItem)
mux.HandleFunc("PATCH /api/lists/{listId}/items/{id}", a.updateItem) mux.HandleFunc("PATCH /api/lists/{listId}/items/{id}", a.updateItem)
+310 -14
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, nextTick } from 'vue'
const loading = ref(true) const loading = ref(true)
const notFound = ref(false) const notFound = ref(false)
@@ -19,6 +19,17 @@ const newSublistColor = ref('#16a34a')
const newItemSublistId = ref('') const newItemSublistId = ref('')
const collapsed = ref(new Set()) 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) { function groupKey(group) {
return group.sublist ? group.sublist.id : 'none' return group.sublist ? group.sublist.id : 'none'
} }
@@ -34,18 +45,14 @@ const groups = computed(() => {
return [{ sublist: null, items: items.value }] return [{ sublist: null, items: items.value }]
} }
const bySublist = new Map(sublists.value.map((s) => [s.id, { sublist: s, items: [] }])) const bySublist = new Map(sublists.value.map((s) => [s.id, { sublist: s, items: [] }]))
const none = { sublist: null, items: [] }
for (const item of items.value) { for (const item of items.value) {
const bucket = item.sublist_id != null ? bySublist.get(item.sublist_id) : null bySublist.get(item.sublist_id)?.items.push(item)
;(bucket || none).items.push(item)
} }
return [...bySublist.values(), none] return [...bySublist.values()]
}) })
const newItemSublistColor = computed(() => { const newItemSublistColor = computed(() => {
if (!newItemSublistId.value) return 'transparent' return newItemSublistId.value ? colorOf(Number(newItemSublistId.value)) : 'transparent'
const s = sublists.value.find((s) => s.id === Number(newItemSublistId.value))
return s ? s.color : 'transparent'
}) })
const openListInput = ref('') const openListInput = ref('')
@@ -138,10 +145,27 @@ async function loadItems() {
async function loadSublists() { async function loadSublists() {
const res = await fetch(`/api/lists/${listId.value}/sublists`) const res = await fetch(`/api/lists/${listId.value}/sublists`)
sublists.value = await res.json() 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() { 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() { async function addSublist() {
@@ -155,6 +179,79 @@ async function addSublist() {
newSublistName.value = '' 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() { async function addItem() {
if (!newName.value.trim()) return if (!newName.value.trim()) return
const res = await fetch(`/api/lists/${listId.value}/items`, { const res = await fetch(`/api/lists/${listId.value}/items`, {
@@ -170,6 +267,8 @@ async function addItem() {
} }
async function toggleDone(item) { async function toggleDone(item) {
if (!item.done) navigator.vibrate?.(30)
const res = await fetch(`/api/lists/${listId.value}/items/${item.id}`, { const res = await fetch(`/api/lists/${listId.value}/items/${item.id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, 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() { function openList() {
const slug = openListInput.value.trim().toLowerCase() const slug = openListInput.value.trim().toLowerCase()
if (!/^[a-z]+-[a-z]+-[a-z]+$/.test(slug)) { 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" /> <input v-model="newName" type="text" placeholder="Add an item" />
<span class="dot" :style="{ background: newItemSublistColor }"></span> <span class="dot" :style="{ background: newItemSublistColor }"></span>
<select class="item-sublist" v-model="newItemSublistId"> <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> <option v-for="s in sublists" :key="s.id" :value="s.id">{{ s.name }}</option>
</select> </select>
<button type="submit">Add</button> <button type="submit">Add</button>
@@ -556,6 +758,23 @@ onMounted(init)
<div class="settings-section"> <div class="settings-section">
<h3>Sublists</h3> <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"> <form class="add-sublist" @submit.prevent="addSublist">
<input v-model="newSublistName" type="text" placeholder="Add sublist" /> <input v-model="newSublistName" type="text" placeholder="Add sublist" />
<input v-model="newSublistColor" type="color" /> <input v-model="newSublistColor" type="color" />
@@ -563,6 +782,45 @@ onMounted(init)
</form> </form>
</div> </div>
</details> </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> </main>
</template> </template>
@@ -855,7 +1113,8 @@ ul {
.add-sublist input[type='text'] { .add-sublist input[type='text'] {
flex: 1; flex: 1;
} }
.add-sublist input[type='color'] { .add-sublist input[type='color'],
.sublist-color-input {
width: 2.5rem; width: 2.5rem;
height: 2.5rem; height: 2.5rem;
padding: 0; padding: 0;
@@ -863,15 +1122,32 @@ ul {
overflow: hidden; overflow: hidden;
flex-shrink: 0; 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; 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; 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; 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 { .settings {
margin-top: 2rem; margin-top: 2rem;
color: var(--muted); color: var(--muted);
@@ -898,6 +1174,26 @@ ul {
margin-top: 0.5rem; margin-top: 0.5rem;
margin-bottom: 0; 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 { .error {
color: var(--danger); color: var(--danger);
font-size: 0.9rem; font-size: 0.9rem;