initial vibes

This commit is contained in:
root
2026-09-13 15:08:12 +02:00
commit 46cc687f57
24 changed files with 2754 additions and 0 deletions
+827
View File
@@ -0,0 +1,827 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
const loading = ref(true)
const notFound = ref(false)
const listId = ref('')
const listUrl = ref('')
const copied = ref(false)
const items = ref([])
const newName = ref('')
const sublists = ref([])
const newSublistName = ref('')
const newSublistColor = ref('#16a34a')
const newItemSublistId = ref('')
const collapsed = ref(new Set())
function groupKey(group) {
return group.sublist ? group.sublist.id : 'none'
}
function toggleCollapse(key) {
const next = new Set(collapsed.value)
next.has(key) ? next.delete(key) : next.add(key)
collapsed.value = next
}
const groups = computed(() => {
if (sublists.value.length === 0) {
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)
}
return [...bySublist.values(), none]
})
const newItemSublistColor = computed(() => {
if (!newItemSublistId.value) return 'transparent'
const s = sublists.value.find((s) => s.id === Number(newItemSublistId.value))
return s ? s.color : 'transparent'
})
const openListInput = ref('')
const openListError = ref('')
const themes = [
{ value: 'system', label: 'Auto' },
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'dark-blue', label: 'Dark Blue' },
{ value: 'bright-yellow', label: 'Bright Yellow' },
{ value: 'solarized-light', label: 'Solarized Light' },
{ value: 'solarized-dark', label: 'Solarized Dark' },
{ value: 'rose', label: 'Rose' },
{ value: 'mint', label: 'Mint' },
{ value: 'charcoal', label: 'Charcoal' },
{ value: 'ocean', label: 'Ocean' },
{ value: 'plum', label: 'Plum' },
{ value: 'sunset', label: 'Sunset' },
]
const themeCookieMatch = document.cookie.match(/(?:^|; )skeps-theme=([a-z-]+)/)
const theme = ref(themeCookieMatch ? themeCookieMatch[1] : 'system')
function setTheme(value) {
theme.value = value
if (value === 'system') {
document.documentElement.removeAttribute('data-theme')
document.cookie = 'skeps-theme=; path=/; max-age=0'
} else {
document.documentElement.dataset.theme = value
document.cookie = `skeps-theme=${value}; path=/; max-age=31536000; SameSite=Lax`
}
}
async function init() {
const pathId = window.location.pathname.replace(/^\/+/, '')
if (!pathId) {
const res = await fetch('/api/lists', { method: 'POST' })
const list = await res.json()
window.location.replace('/' + list.id)
return
}
const res = await fetch(`/api/lists/${pathId}`)
if (res.status === 404) {
notFound.value = true
loading.value = false
return
}
listId.value = pathId
listUrl.value = window.location.origin + '/' + pathId
await refresh()
loading.value = false
connectSocket()
}
let socket = null
function connectSocket() {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
socket = new WebSocket(`${proto}://${window.location.host}/api/lists/${listId.value}/ws`)
socket.addEventListener('message', refresh)
socket.addEventListener('close', () => setTimeout(connectSocket, 2000))
}
// Mobile browsers suspend JS and can drop the socket without firing 'close'
// while backgrounded, so re-sync and reconnect whenever the app comes back.
function resync() {
if (!listId.value) return
refresh()
if (!socket || socket.readyState === WebSocket.CLOSED) {
connectSocket()
}
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') resync()
})
window.addEventListener('pageshow', resync)
window.addEventListener('online', resync)
async function loadItems() {
const res = await fetch(`/api/lists/${listId.value}/items`)
items.value = await res.json()
}
async function loadSublists() {
const res = await fetch(`/api/lists/${listId.value}/sublists`)
sublists.value = await res.json()
}
async function refresh() {
await Promise.all([loadItems(), loadSublists()])
}
async function addSublist() {
if (!newSublistName.value.trim()) return
const res = await fetch(`/api/lists/${listId.value}/sublists`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newSublistName.value.trim(), color: newSublistColor.value }),
})
sublists.value.push(await res.json())
newSublistName.value = ''
}
async function addItem() {
if (!newName.value.trim()) return
const res = await fetch(`/api/lists/${listId.value}/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newName.value.trim(),
sublist_id: newItemSublistId.value ? Number(newItemSublistId.value) : null,
}),
})
items.value.push(await res.json())
newName.value = ''
}
async function toggleDone(item) {
const res = await fetch(`/api/lists/${listId.value}/items/${item.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ done: !item.done }),
})
Object.assign(item, await res.json())
}
async function removeItem(item) {
await fetch(`/api/lists/${listId.value}/items/${item.id}`, { method: 'DELETE' })
items.value = items.value.filter((i) => i.id !== item.id)
}
const SWIPE_THRESHOLD = 70
const SWIPE_MAX = 140
let drag = null
function onSwipeStart(e) {
drag = { startX: e.clientX, dx: 0, el: e.currentTarget }
drag.el.setPointerCapture(e.pointerId)
drag.el.style.transition = 'none'
}
function onSwipeMove(e) {
if (!drag) return
const dx = e.clientX - drag.startX
drag.dx = Math.max(-SWIPE_MAX, Math.min(SWIPE_MAX, dx))
drag.el.style.transform = `translateX(${drag.dx}px)`
}
function onSwipeEnd(e, item) {
if (!drag) return
const { dx, el } = drag
el.style.transition = 'transform 0.2s ease'
if (dx <= -SWIPE_THRESHOLD) {
el.style.transform = `translateX(-100%)`
setTimeout(() => removeItem(item), 150)
} else if (dx >= SWIPE_THRESHOLD) {
el.style.transform = 'translateX(0)'
toggleDone(item)
} else {
el.style.transform = 'translateX(0)'
}
drag = null
}
let dragReorder = null
// A flattened, visually-ordered list of every header and (visible) item on the
// page, used to find which sublist section and slot the pointer is over —
// this is what lets a drag cross from one sublist's <ul> into another's.
function buildAnchors() {
const anchors = []
for (const group of groups.value) {
const key = groupKey(group)
const headerEl = document.querySelector(`[data-group-header="${key}"]`)
if (headerEl) anchors.push({ type: 'header', key, rect: headerEl.getBoundingClientRect() })
const isCollapsed = sublists.value.length > 0 && collapsed.value.has(key)
if (isCollapsed) continue
for (const item of group.items) {
const el = document.querySelector(`[data-item-id="${item.id}"]`)
if (el) anchors.push({ type: 'item', key, item, el, rect: el.getBoundingClientRect() })
}
}
return anchors
}
function findTarget(anchors, pointerY) {
let targetKey = anchors.find((a) => a.type === 'header')?.key ?? groupKey(groups.value[0])
let insertAfterItemId = null
for (const a of anchors) {
if (a.rect.top + a.rect.height / 2 > pointerY) break
targetKey = a.key
insertAfterItemId = a.type === 'header' ? null : a.item.id
}
return { targetKey, insertAfterItemId }
}
function setDropTarget(key) {
document.querySelectorAll('.group-header').forEach((h) => h.classList.remove('drop-target'))
const headerEl = key != null ? document.querySelector(`[data-group-header="${key}"]`) : null
if (headerEl) headerEl.classList.add('drop-target')
}
function onHandleDown(e, item) {
const li = e.currentTarget.closest('li')
const height = li.getBoundingClientRect().height
const originalKey = item.sublist_id != null ? item.sublist_id : 'none'
const ownGroup = groups.value.find((g) => groupKey(g) === originalKey)
const ownIdx = ownGroup.items.findIndex((i) => i.id === item.id)
const originalPrecedingId = ownIdx > 0 ? ownGroup.items[ownIdx - 1].id : null
const anchors = buildAnchors().filter((a) => !(a.type === 'item' && a.item.id === item.id))
dragReorder = {
item,
el: li,
startY: e.clientY,
height,
anchors,
originalKey,
originalPrecedingId,
targetKey: originalKey,
insertAfterItemId: originalPrecedingId,
shiftedEls: [],
}
li.style.zIndex = 5
li.style.position = 'relative'
li.style.boxShadow = '0 4px 10px rgba(0, 0, 0, 0.2)'
e.currentTarget.setPointerCapture(e.pointerId)
}
// Shifts the sibling items around the current drop slot to visually open a
// gap: within one group when reordering, or close the old gap and open a
// new one across two groups when the drag crosses a sublist boundary.
function updateGap() {
const { anchors, originalKey, originalPrecedingId, targetKey, insertAfterItemId, height, shiftedEls } = dragReorder
shiftedEls.forEach((el) => {
el.style.transform = ''
el.style.transition = ''
})
const nextShifted = []
const shift = (el, dir) => {
el.style.transition = 'transform 0.15s ease'
el.style.transform = `translateY(${dir * height}px)`
nextShifted.push(el)
}
const indexOf = (groupAnchors, afterId) => (afterId == null ? 0 : groupAnchors.findIndex((a) => a.item.id === afterId) + 1)
if (targetKey === originalKey) {
const groupAnchors = anchors.filter((a) => a.type === 'item' && a.key === originalKey)
const origIdx = indexOf(groupAnchors, originalPrecedingId)
const newIdx = indexOf(groupAnchors, insertAfterItemId)
groupAnchors.forEach((a, i) => {
if (newIdx > origIdx && i >= origIdx && i < newIdx) shift(a.el, -1)
if (newIdx < origIdx && i >= newIdx && i < origIdx) shift(a.el, 1)
})
} else {
const origGroupAnchors = anchors.filter((a) => a.type === 'item' && a.key === originalKey)
const origIdx = indexOf(origGroupAnchors, originalPrecedingId)
origGroupAnchors.forEach((a, i) => {
if (i >= origIdx) shift(a.el, -1)
})
const targetGroupAnchors = anchors.filter((a) => a.type === 'item' && a.key === targetKey)
const newIdx = indexOf(targetGroupAnchors, insertAfterItemId)
targetGroupAnchors.forEach((a, i) => {
if (i >= newIdx) shift(a.el, 1)
})
}
dragReorder.shiftedEls = nextShifted
}
function onHandleMove(e) {
if (!dragReorder) return
const dy = e.clientY - dragReorder.startY
dragReorder.el.style.transform = `translateY(${dy}px)`
const { targetKey, insertAfterItemId } = findTarget(dragReorder.anchors, e.clientY)
dragReorder.targetKey = targetKey
dragReorder.insertAfterItemId = insertAfterItemId
setDropTarget(targetKey)
updateGap()
}
function onHandleEnd() {
if (!dragReorder) return
const { el, item, targetKey, insertAfterItemId, shiftedEls } = dragReorder
el.style.transform = ''
el.style.transition = ''
el.style.zIndex = ''
el.style.position = ''
el.style.boxShadow = ''
shiftedEls.forEach((s) => {
s.style.transform = ''
s.style.transition = ''
})
setDropTarget(null)
dragReorder = null
const targetGroup = groups.value.find((g) => groupKey(g) === targetKey)
if (!targetGroup) return
const targetSublistId = targetGroup.sublist ? targetGroup.sublist.id : null
const siblings = targetGroup.items.filter((i) => i.id !== item.id)
const insertIdx = insertAfterItemId == null ? 0 : siblings.findIndex((i) => i.id === insertAfterItemId) + 1
const prev = siblings[insertIdx - 1]
const next = siblings[insertIdx]
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
if (position === item.position && targetSublistId === item.sublist_id) return
item.position = position
item.sublist_id = targetSublistId
fetch(`/api/lists/${listId.value}/items/${item.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ position, sublist_id: targetSublistId, move_sublist: true }),
})
}
function openList() {
const slug = openListInput.value.trim().toLowerCase()
if (!/^[a-z]+-[a-z]+-[a-z]+$/.test(slug)) {
openListError.value = 'Enter the 3 words, e.g. lion-fancy-sharpness'
return
}
window.location.href = '/' + slug
}
async function copyLink() {
try {
await navigator.clipboard.writeText(listUrl.value)
copied.value = true
setTimeout(() => (copied.value = false), 1500)
} catch {
// clipboard access denied; nothing to do
}
}
onMounted(init)
</script>
<template>
<main v-if="loading"></main>
<main v-else-if="notFound">
<h1>skeps</h1>
<p>That list doesn't exist.</p>
<a href="/">Create a new list</a>
</main>
<main v-else>
<h1>skeps</h1>
<form @submit.prevent="addItem">
<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>
</form>
<template v-for="group in groups" :key="groupKey(group)">
<div
v-if="sublists.length"
class="group-header"
:data-group-header="groupKey(group)"
@click="toggleCollapse(groupKey(group))"
>
<span class="triangle" :class="{ open: !collapsed.has(groupKey(group)) }"></span>
<span class="dot" :style="{ background: group.sublist ? group.sublist.color : 'transparent' }"></span>
<span class="group-name">{{ group.sublist ? group.sublist.name : 'No sublist' }}</span>
<span class="count">{{ group.items.length }}</span>
</div>
<ul v-show="!sublists.length || !collapsed.has(groupKey(group))">
<li v-for="item in group.items" :key="item.id" class="item" :data-item-id="item.id">
<div class="swipe-bg">
<span class="swipe-check">✓ Done</span>
<span class="swipe-delete">Delete ✕</span>
</div>
<div
class="swipe-content"
:class="{ done: item.done }"
@pointerdown="onSwipeStart"
@pointermove="onSwipeMove"
@pointerup="onSwipeEnd($event, item)"
@pointercancel="onSwipeEnd($event, item)"
>
<span
class="handle"
@pointerdown.stop="onHandleDown($event, item)"
@pointermove.stop="onHandleMove"
@pointerup.stop="onHandleEnd"
@pointercancel.stop="onHandleEnd"
>⠿</span
>
<label>
<input type="checkbox" :checked="item.done" @change="toggleDone(item)" />
{{ item.name }} <span class="qty">x{{ item.quantity }}</span>
</label>
</div>
</li>
</ul>
</template>
<details class="settings">
<summary>Settings</summary>
<div class="settings-section">
<h3>Sharing</h3>
<div class="list-url">
<code>{{ listUrl }}</code>
<button type="button" @click="copyLink">{{ copied ? 'Copied!' : 'Copy link' }}</button>
</div>
<form @submit.prevent="openList">
<input v-model="openListInput" type="text" placeholder="lion-fancy-sharpness" />
<button type="submit">Open list</button>
</form>
<p v-if="openListError" class="error">{{ openListError }}</p>
</div>
<div class="settings-section">
<h3>Theme</h3>
<select class="theme-picker" v-model="theme" @change="setTheme(theme)">
<option v-for="t in themes" :key="t.value" :value="t.value">{{ t.label }}</option>
</select>
</div>
<div class="settings-section">
<h3>Sublists</h3>
<form class="add-sublist" @submit.prevent="addSublist">
<input v-model="newSublistName" type="text" placeholder="Add sublist" />
<input v-model="newSublistColor" type="color" />
<button type="submit">Add sublist</button>
</form>
</div>
</details>
</main>
</template>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
:root {
--bg: #ffffff;
--fg: #111827;
--muted: #888888;
--border: #dddddd;
--card-bg: #f3f4f6;
--danger: #b91c1c;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--bg: #16181d;
--fg: #e5e7eb;
--muted: #9ca3af;
--border: #333844;
--card-bg: #21242c;
--danger: #f87171;
color-scheme: dark;
}
}
:root[data-theme='dark'] {
--bg: #16181d;
--fg: #e5e7eb;
--muted: #9ca3af;
--border: #333844;
--card-bg: #21242c;
--danger: #f87171;
color-scheme: dark;
}
:root[data-theme='dark-blue'] {
--bg: #0b1a33;
--fg: #dbe7ff;
--muted: #7f93b8;
--border: #1f3a63;
--card-bg: #12264a;
--danger: #ff8080;
color-scheme: dark;
}
:root[data-theme='bright-yellow'] {
--bg: #fff275;
--fg: #2b2200;
--muted: #7a6a00;
--border: #e0c200;
--card-bg: #fff9c4;
--danger: #b30000;
color-scheme: light;
}
:root[data-theme='solarized-light'] {
--bg: #fdf6e3;
--fg: #073642;
--muted: #93a1a1;
--border: #eee8d5;
--card-bg: #eee8d5;
--danger: #dc322f;
color-scheme: light;
}
:root[data-theme='solarized-dark'] {
--bg: #002b36;
--fg: #93c2c9;
--muted: #586e75;
--border: #073642;
--card-bg: #073642;
--danger: #e5615c;
color-scheme: dark;
}
:root[data-theme='rose'] {
--bg: #fff0f3;
--fg: #6d0f24;
--muted: #b3718a;
--border: #ffd6e0;
--card-bg: #ffe1e8;
--danger: #c2185b;
color-scheme: light;
}
:root[data-theme='mint'] {
--bg: #f1fbf6;
--fg: #0b3d2e;
--muted: #5fa084;
--border: #cdeee0;
--card-bg: #e0f7ee;
--danger: #d32f2f;
color-scheme: light;
}
:root[data-theme='charcoal'] {
--bg: #1c1c1e;
--fg: #e5e5e7;
--muted: #8e8e93;
--border: #3a3a3c;
--card-bg: #2c2c2e;
--danger: #ff6961;
color-scheme: dark;
}
:root[data-theme='ocean'] {
--bg: #eef6fb;
--fg: #0b3350;
--muted: #5f8aa8;
--border: #cfe6f3;
--card-bg: #dcf0fb;
--danger: #c0392b;
color-scheme: light;
}
:root[data-theme='plum'] {
--bg: #1c0f24;
--fg: #eadcf5;
--muted: #9c7fb3;
--border: #3a2451;
--card-bg: #2a1638;
--danger: #ff7676;
color-scheme: dark;
}
:root[data-theme='sunset'] {
--bg: #fff3e6;
--fg: #4a2600;
--muted: #b8763b;
--border: #ffdcb0;
--card-bg: #ffe8cc;
--danger: #d84315;
color-scheme: light;
}
body {
background: var(--bg);
color: var(--fg);
overflow-x: hidden;
}
main {
max-width: 480px;
margin: 2rem auto;
padding: env(safe-area-inset-top) 1rem env(safe-area-inset-bottom);
font-family: sans-serif;
}
.theme-picker {
display: block;
font-size: 16px;
padding: 0.2rem 0.3rem;
border-radius: 0.4rem;
border: 1px solid var(--border);
background: var(--card-bg);
color: var(--fg);
}
.list-url {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.75rem 0;
padding: 0.5rem 0.75rem;
background: var(--card-bg);
border-radius: 0.5rem;
}
.list-url code {
flex: 1;
overflow-wrap: anywhere;
font-size: 0.85rem;
}
form {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
form > * {
min-width: 0;
}
form input[type='text'] {
flex: 1;
}
input,
select,
button {
font-size: 16px;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
}
ul {
list-style: none;
padding: 0;
}
.group-header {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.75rem 0.35rem;
cursor: pointer;
font-weight: 600;
font-size: 1.05rem;
border-bottom: 1px solid var(--border);
border-radius: 0.3rem;
}
.group-header.drop-target {
background: var(--card-bg);
outline: 2px dashed var(--muted);
outline-offset: -2px;
}
.triangle {
display: inline-block;
width: 0;
height: 0;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 5px solid var(--muted);
transition: transform 0.15s ease;
}
.triangle.open {
transform: rotate(90deg);
}
.group-name {
flex: 1;
}
.count {
font-weight: 400;
color: var(--muted);
font-size: 0.85rem;
}
.item {
position: relative;
overflow: hidden;
border-bottom: 1px solid var(--border);
}
.swipe-bg {
position: absolute;
inset: 0;
z-index: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0.75rem;
font-size: 0.9rem;
color: #fff;
background: linear-gradient(to right, #16a34a 50%, var(--danger) 50%);
}
.swipe-content {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0;
background: var(--bg);
touch-action: pan-y;
}
.swipe-content label {
flex: 1;
}
.swipe-content.done label {
text-decoration: line-through;
color: var(--muted);
}
.handle {
touch-action: none;
cursor: grab;
padding: 0.2rem 0.4rem;
color: var(--muted);
}
.dot {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
flex-shrink: 0;
}
.item-sublist {
min-width: 0;
max-width: 6.5rem;
flex-shrink: 1;
}
.add-sublist input[type='text'] {
flex: 1;
}
.add-sublist input[type='color'] {
width: 2.5rem;
height: 2.5rem;
padding: 0;
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
}
.add-sublist input[type='color']::-webkit-color-swatch-wrapper {
padding: 0;
}
.add-sublist input[type='color']::-webkit-color-swatch {
border: none;
}
.add-sublist input[type='color']::-moz-color-swatch {
border: none;
}
.qty {
color: var(--muted);
}
.settings {
margin-top: 2rem;
color: var(--muted);
}
.settings-section {
margin-top: 1.25rem;
padding-top: 1.25rem;
border-top: 1px solid var(--border);
}
.settings-section:first-of-type {
margin-top: 0.75rem;
padding-top: 0;
border-top: none;
}
.settings-section h3 {
margin: 0 0 0.5rem;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
color: var(--muted);
}
.settings-section form {
margin-top: 0.5rem;
margin-bottom: 0;
}
.error {
color: var(--danger);
font-size: 0.9rem;
}
</style>
+4
View File
@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')