initial vibes
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>skeps</title>
|
||||
|
||||
<link rel="icon" href="/favicon.png" type="image/png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#16a34a" />
|
||||
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Skeps" />
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var m = document.cookie.match(/(?:^|; )skeps-theme=([a-z-]+)/)
|
||||
if (m) document.documentElement.dataset.theme = m[1]
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1145
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "skeps-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^5.4.21"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 926 B |
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "Skeps",
|
||||
"short_name": "Skeps",
|
||||
"display": "standalone",
|
||||
"background_color": "#16a34a",
|
||||
"theme_color": "#16a34a",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:8080', ws: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user