vibes
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user