Implement snoozing items
This commit is contained in:
parent
6530de4bf2
commit
bfd117ff20
12 changed files with 461 additions and 55 deletions
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
@ -12,5 +12,6 @@
|
||||||
<div id="app" class="dark bg-gray-100"></div>
|
<div id="app" class="dark bg-gray-100"></div>
|
||||||
<div id="modal-root" class="dark"></div>
|
<div id="modal-root" class="dark"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/flowbite/2.3.0/datepicker.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -12,6 +12,7 @@
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||||
"@fortawesome/vue-fontawesome": "latest-3",
|
"@fortawesome/vue-fontawesome": "latest-3",
|
||||||
|
"@vitejs/plugin-vue-jsx": "^3.1.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"flowbite": "^2.3.0",
|
"flowbite": "^2.3.0",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<span class="text-xl mx-4 group">
|
<div class="text-xl mx-4 m-2 group">
|
||||||
{{ name }}
|
{{ name }}
|
||||||
<RouterLink :to="`/settings/category/${id}`">
|
<RouterLink :to="`/settings/category/${id}`">
|
||||||
<FontAwesomeIcon :icon="faGear" class="hidden group-hover:inline" />
|
<FontAwesomeIcon :icon="faGear" class="hidden group-hover:inline" />
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</span>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="Object.keys(sourceItem.threads).length === 1 && '0' in sourceItem.threads"
|
<div v-if="Object.keys(sourceItem.threads).length === 1 && '0' in sourceItem.threads" class="relative">
|
||||||
class="p-4 border-2 -mt-2px cursor-pointer bg-gray-200 border-gray-800" :class="isSelected(0) ? 'selected shadow-md scale-105' : ''" @click="emits('select', 0)">
|
<div :style="style()" class="p-4 border-2 -mt-2px cursor-pointer bg-gray-200 border-gray-800 select-none relative z-20" :class="isSelected(0) ? 'selected shadow-md -mx-2' : ''" @click="select(0)" @mousedown="e => startDrag(e)" @touchstart="e => startDrag(e)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<Avatar v-if="getContact(0)" v-bind="getContact(0)!" class="mr-2" />
|
<Avatar v-if="getContact(0)" v-bind="getContact(0)!" class="mr-2" />
|
||||||
<span class="bg-gray-300 px-2 py-1 rounded-2xl block overflow-hidden text-nowrap text-ellipsis">{{ item.threads[0].preview }}</span>
|
<span class="bg-gray-300 px-2 py-1 rounded-2xl block overflow-hidden text-nowrap text-ellipsis">{{ item.threads[0].preview }}</span>
|
||||||
|
@ -12,14 +12,20 @@
|
||||||
<span>{{ source.name }}</span>
|
<span>{{ source.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<component :is="actionIndicators" />
|
||||||
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="p-4 border-2 -mt-2px bg-gray-200 border-gray-800">
|
<div class="relative">
|
||||||
|
<div class="p-4 border-2 -mt-2px bg-gray-200 border-gray-800 select-none relative z-20" :style="style()" @mousemove="drag" @mousedown="e => startDrag(e)" @mouseup="endDrag" @touchmove="drag" @touchstart="e => startDrag(e)" @touchend="endDrag">
|
||||||
<div class="flex -mb-2 text-gray-500 text-sm font-normal">
|
<div class="flex -mb-2 text-gray-500 text-sm font-normal">
|
||||||
<span class="grow">{{ sourceItem.title }}</span>
|
<span class="grow">{{ sourceItem.title }}</span>
|
||||||
<span>{{ source.name }}</span>
|
<span>{{ source.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="(thread, id) in item.threads" class="p-4 border-2 -mt-2px cursor-pointer bg-slate-300 border-gray-800" :class="isSelected(id) ? 'selected shadow-md -mx-2 thread' : ''" @click="emits('select', parseInt(id as unknown as string))">
|
<component :is="actionIndicators" />
|
||||||
|
</div>
|
||||||
|
<div v-for="(thread, id) in activeThreads" class="relative">
|
||||||
|
<div class="p-4 border-2 -mt-2px cursor-pointer bg-slate-300 border-gray-800 select-none relative z-20" :class="isSelected(id) ? 'selected shadow-md -mx-2 thread' : ''" :style="style(parseInt(id as unknown as string))" @click="select(parseInt(id as unknown as string))" @mousemove="drag" @mousedown="e => startDrag(e, id as unknown as string)" @mouseup="endDrag" @touchmove="drag" @touchstart="e => startDrag(e, id as unknown as string)" @touchend="endDrag">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<Avatar v-if="getContact(id)" v-bind="getContact(id)!" class="mr-2" />
|
<Avatar v-if="getContact(id)" v-bind="getContact(id)!" class="mr-2" />
|
||||||
<span class="bg-gray-100 px-2 py-1 rounded-2xl block overflow-hidden text-nowrap text-ellipsis">{{ thread.preview }}</span>
|
<span class="bg-gray-100 px-2 py-1 rounded-2xl block overflow-hidden text-nowrap text-ellipsis">{{ thread.preview }}</span>
|
||||||
|
@ -30,24 +36,49 @@
|
||||||
<span class="grow">{{ sourceItem.threads[id].title }}</span>
|
<span class="grow">{{ sourceItem.threads[id].title }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<component :is="actionIndicators" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="tsx">
|
||||||
import { computed } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import type { Item, ThreadRef } from "../state";
|
import type { Item, ItemThread, ThreadRef } from "../state";
|
||||||
import { sources } from '../state';
|
import { items, sources } from '../state';
|
||||||
import Avatar from './Avatar.vue';
|
import Avatar from './Avatar.vue';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||||
|
import { faCheck, faClockRotateLeft } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
item: Item,
|
item: Item;
|
||||||
selectedThread?: ThreadRef
|
selectedThread?: ThreadRef;
|
||||||
|
showSnoozed?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emits = defineEmits<{
|
const emits = defineEmits<{
|
||||||
select: [thread: number];
|
select: [thread: number];
|
||||||
|
snoozeItem: [thread: number];
|
||||||
|
deselect: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const activeThreads = computed(() => Object.keys(props.item.threads).reduce((acc, curr) => {
|
||||||
|
const thread = props.item.threads[curr as unknown as number];
|
||||||
|
if ((thread.snoozedUntil == null || Date.now() >= thread.snoozedUntil) !== (props.showSnoozed === true)) {
|
||||||
|
acc[curr as unknown as number] = thread;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<number, ItemThread>));
|
||||||
|
|
||||||
|
const dragging = ref(false);
|
||||||
|
const dragged = ref(false);
|
||||||
|
const draggingX = ref(0);
|
||||||
|
const initialMouseX = ref(0);
|
||||||
|
const draggingIndex = ref<number>();
|
||||||
|
const style = (id?: number) =>
|
||||||
|
dragging.value && (id === draggingIndex.value || draggingIndex.value == null) ?
|
||||||
|
`transform: translateX(${Math.max(-50, Math.min(50, draggingX.value))}px)` :
|
||||||
|
'';
|
||||||
|
|
||||||
const source = computed(() => sources.value[props.item.source]);
|
const source = computed(() => sources.value[props.item.source]);
|
||||||
const sourceItem = computed(() => source.value.items[props.item.sourceItem]);
|
const sourceItem = computed(() => source.value.items[props.item.sourceItem]);
|
||||||
|
|
||||||
|
@ -71,16 +102,102 @@ function isSelected(thread: number | string) {
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startDrag(e: MouseEvent | TouchEvent, index?: string) {
|
||||||
|
dragging.value = true;
|
||||||
|
dragged.value = false;
|
||||||
|
draggingX.value = 0;
|
||||||
|
draggingIndex.value = index == undefined ? undefined : parseInt(index);
|
||||||
|
initialMouseX.value = "touches" in e ? e.touches[0].clientX : e.clientX;
|
||||||
|
window.onmousemove = drag;
|
||||||
|
window.ontouchmove = drag;
|
||||||
|
window.onmouseup = endDrag;
|
||||||
|
window.ontouchend = endDrag;
|
||||||
|
window.onpointerleave = endDrag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drag(e: MouseEvent | TouchEvent) {
|
||||||
|
if (dragging.value) {
|
||||||
|
draggingX.value = ("touches" in e ? e.touches[0].clientX : e.clientX) - initialMouseX.value;
|
||||||
|
if (Math.abs(draggingX.value) > 10) {
|
||||||
|
dragged.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag(e: MouseEvent | TouchEvent) {
|
||||||
|
dragging.value = false;
|
||||||
|
if (dragged.value) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
if (draggingX.value <= -50) {
|
||||||
|
if (draggingIndex.value == null) {
|
||||||
|
deleteItem();
|
||||||
|
} else {
|
||||||
|
delete props.item.threads[draggingIndex.value];
|
||||||
|
if (Object.keys(props.item.threads).length === 0) {
|
||||||
|
deleteItem();
|
||||||
|
} else if (
|
||||||
|
props.selectedThread?.source === props.item.source &&
|
||||||
|
props.selectedThread.sourceItem === props.item.sourceItem &&
|
||||||
|
props.selectedThread.thread === draggingIndex.value) {
|
||||||
|
emits("deselect");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (draggingX.value >= 50) {
|
||||||
|
emits('snoozeItem', draggingIndex.value ?? -1);
|
||||||
|
}
|
||||||
|
window.onmousemove = null;
|
||||||
|
window.ontouchmove = null;
|
||||||
|
window.onmouseup = null;
|
||||||
|
window.ontouchend = null;
|
||||||
|
window.onpointerleave = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(thread: number) {
|
||||||
|
if (!dragged.value) {
|
||||||
|
emits("select", thread);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteItem() {
|
||||||
|
const index = items.value.findIndex(item => item.source === props.item.source && item.sourceItem === props.item.sourceItem);
|
||||||
|
const newItems = items.value.slice();
|
||||||
|
newItems.splice(index, 1);
|
||||||
|
items.value = newItems;
|
||||||
|
if (
|
||||||
|
props.selectedThread?.source === props.item.source &&
|
||||||
|
props.selectedThread.sourceItem === props.item.sourceItem) {
|
||||||
|
emits("deselect");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionIndicators = () => <div class={{
|
||||||
|
absolute: true,
|
||||||
|
"top-0": true,
|
||||||
|
"left-0": true,
|
||||||
|
"right-0": true,
|
||||||
|
"bottom-0": true,
|
||||||
|
"p-2": true,
|
||||||
|
"pointer-events-none": true,
|
||||||
|
"z-10": true,
|
||||||
|
"bg-lime-400": draggingX.value <= -50,
|
||||||
|
"bg-orange-400": draggingX.value >= 50,
|
||||||
|
"bg-slate-200": Math.abs(draggingX.value) < 50
|
||||||
|
}}>
|
||||||
|
<FontAwesomeIcon icon={faCheck} class="float-right h-full w-8" />
|
||||||
|
<FontAwesomeIcon icon={faClockRotateLeft} class="float-left h-full w-8" />
|
||||||
|
</div>
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.-mt-2px {
|
.-mt-2px {
|
||||||
margin-top: -2px;
|
margin-top: -2px;
|
||||||
}
|
}
|
||||||
.selected:not(:first-child) {
|
:not(:first-child) > .selected {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
.selected:not(:last-child) {
|
:not(:last-child) > .selected {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -1,9 +1,12 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="mb-4 item-group">
|
<div class="mb-4 item-group relative">
|
||||||
<Item v-for="item in items"
|
<Item v-for="item in items"
|
||||||
:item="item"
|
:item="item"
|
||||||
:selected-thread="selectedThread"
|
:selected-thread="selectedThread"
|
||||||
@select="thread => emits('selectItem', item.source, item.sourceItem, thread)" />
|
:show-snoozed="showSnoozed"
|
||||||
|
@select="thread => emits('selectItem', item.source, item.sourceItem, thread)"
|
||||||
|
@snoozeItem="thread => emits('snoozeItem', item.source, item.sourceItem, thread)"
|
||||||
|
@deselect="emits('deselect')" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -12,11 +15,14 @@ import type { Item as StateItem, ThreadRef } from "../state";
|
||||||
import Item from "./Item.vue";
|
import Item from "./Item.vue";
|
||||||
|
|
||||||
const emits = defineEmits<{
|
const emits = defineEmits<{
|
||||||
selectItem: [source: number, sourceItem: number, thread: number]
|
selectItem: [source: number, sourceItem: number, thread: number];
|
||||||
|
snoozeItem: [source: number, sourceItem: number, thread: number];
|
||||||
|
deselect: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
items: StateItem[],
|
items: StateItem[];
|
||||||
selectedThread?: ThreadRef
|
selectedThread?: ThreadRef;
|
||||||
|
showSnoozed?: boolean;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
122
src/components/SnoozeModal.vue
Normal file
122
src/components/SnoozeModal.vue
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
<template>
|
||||||
|
<Modal :model-value="snoozing" @update:model-value="emits('close')">
|
||||||
|
<template v-slot:header><div class="text-3xl">Snooze "{{ threadTitle }}"</div></template>
|
||||||
|
<template v-slot:body>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="justify-between flex flex-col grow">
|
||||||
|
<button class="w-full p-2 border-slate-200 border-2" v-if="new Date().getHours() < 19" @click="snoozeUntil(laterToday)">Later Today<div class="text-slate-400">{{ displayDate(laterToday) }}</div></button>
|
||||||
|
<button class="w-full p-2 border-slate-200 border-2" v-if="new Date().getDay() < 5" @click="snoozeUntil(tomorrow)">Tomorrow<div class="text-slate-400">{{ displayDate(tomorrow) }}</div></button>
|
||||||
|
<button class="w-full p-2 border-slate-200 border-2" v-if="new Date().getDay() < 4" @click="snoozeUntil(laterThisWeek)">Later This Week<div class="text-slate-400">{{ displayDate(laterThisWeek) }}</div></button>
|
||||||
|
<button class="w-full p-2 border-slate-200 border-2" v-if="![0,6].includes(new Date().getDay())" @click="snoozeUntil(weekend)">Weekend<div class="text-slate-400">{{ displayDate(weekend) }}</div></button>
|
||||||
|
<button class="w-full p-2 border-slate-200 border-2" @click="snoozeUntil(nextWeek)">Next Week<div class="text-slate-400">{{ displayDate(nextWeek) }}</div></button>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 h-full">
|
||||||
|
<div ref="datepickerRef" inline-datepicker datepicker-title="Custom" class="w-full"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, toRef } from 'vue';
|
||||||
|
import { ThreadRef, items, sources } from '../state';
|
||||||
|
import Modal from './Modal.vue';
|
||||||
|
import { initFlowbite } from 'flowbite';
|
||||||
|
|
||||||
|
const datepickerRef = ref();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initFlowbite();
|
||||||
|
datepickerRef.value.addEventListener("changeDate", (e: CustomEvent) => {
|
||||||
|
snoozeUntil(e.detail.date as Date);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
snoozing: boolean;
|
||||||
|
threadRef?: ThreadRef;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
close: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const threadTitle = computed(() => {
|
||||||
|
const selected = props.threadRef;
|
||||||
|
if (selected == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const thread = selected.thread === -1 ? 0 : selected.thread;
|
||||||
|
return sources.value[selected.source].items[selected.sourceItem].threads[thread].title;
|
||||||
|
});
|
||||||
|
|
||||||
|
const laterToday = toRef(() => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setHours(19, 0, 0, 0);
|
||||||
|
return date;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tomorrow = toRef(() => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() + 1);
|
||||||
|
date.setHours(8, 0, 0, 0);
|
||||||
|
return date;
|
||||||
|
});
|
||||||
|
|
||||||
|
const laterThisWeek = toRef(() => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() + 2);
|
||||||
|
date.setHours(8, 0, 0, 0);
|
||||||
|
return date;
|
||||||
|
});
|
||||||
|
|
||||||
|
const weekend = toRef(() => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() + (12 - date.getDay()) % 7 + 1);
|
||||||
|
date.setHours(8, 0, 0, 0);
|
||||||
|
return date;
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextWeek = toRef(() => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() + (7 - date.getDay()) % 7 + 1);
|
||||||
|
date.setHours(8, 0, 0, 0);
|
||||||
|
return date;
|
||||||
|
});
|
||||||
|
|
||||||
|
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||||
|
function displayDate(date: Date) {
|
||||||
|
const halfDay = date.getHours() >= 12 ? 'PM' : 'AM';
|
||||||
|
const actualHour = (date.getHours() % 12) + 1;
|
||||||
|
const time = `${actualHour}:${padLeft(date.getMinutes().toString())} ${halfDay}`;
|
||||||
|
if (date.getDate() === new Date().getDate()) {
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
const dayOfWeek = DAY_NAMES[date.getDay()];
|
||||||
|
return dayOfWeek + " " + time;
|
||||||
|
}
|
||||||
|
|
||||||
|
function padLeft(string: string, delim= "0", length = 2) {
|
||||||
|
return new Array(length - string.length).fill(delim).join() + string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snoozeUntil(date: Date) {
|
||||||
|
const selected = props.threadRef;
|
||||||
|
if (selected == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newItems = items.value.slice();
|
||||||
|
const item = newItems.find(item => item.source === selected.source && item.sourceItem === selected.sourceItem);
|
||||||
|
if (item == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selected.thread === -1) {
|
||||||
|
Object.values(item.threads).forEach(thread => thread.snoozedUntil = date.getTime());
|
||||||
|
} else {
|
||||||
|
item.threads[selected.thread].snoozedUntil = date.getTime();
|
||||||
|
}
|
||||||
|
items.value = newItems;
|
||||||
|
emits('close');
|
||||||
|
}
|
||||||
|
</script>
|
102
src/components/pages/Snoozed.vue
Normal file
102
src/components/pages/Snoozed.vue
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex h-full">
|
||||||
|
<div class="grow basis-6/12 p-4 overflow-x-hidden relative">
|
||||||
|
<div class="text-3xl mb-4">Snoozed</div>
|
||||||
|
<div v-if="snoozedCategories.length === 0" class="absolute center text-9xl">∅</div>
|
||||||
|
<div v-for="category in snoozedCategories">
|
||||||
|
<CategoryHeader v-bind="category" />
|
||||||
|
<ItemGroup
|
||||||
|
:items="category.snoozedItems"
|
||||||
|
:selected-thread="showThread ? selectedItem : undefined"
|
||||||
|
:show-snoozed="true"
|
||||||
|
@select-item="selectItem"
|
||||||
|
@snooze-item="snoozeItem"
|
||||||
|
@deselect="close" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hidden xl:flex bg-slate-200 ml-0 h-screen flex-col overflow-hidden" :class="showThread ? 'p-4 basis-6/12' : 'basis-0'">
|
||||||
|
<PanelTitle :title="threadTitle" />
|
||||||
|
<Thread :source="selectedItem?.source ?? 0" :sourceItem="selectedItem?.sourceItem ?? 0" :thread="selectedItem?.thread ?? 0" base-url="/snoozed" />
|
||||||
|
</div>
|
||||||
|
<Modal :model-value="showThread" class="block xl:hidden" @update:model-value="close">
|
||||||
|
<template v-slot:header><div class="text-3xl">{{ threadTitle }}</div></template>
|
||||||
|
<template v-slot:body><Thread :source="selectedItem?.source ?? 0" :sourceItem="selectedItem?.sourceItem ?? 0" :thread="selectedItem?.thread ?? 0" base-url="/snoozed" /></template>
|
||||||
|
</Modal>
|
||||||
|
<SnoozeModal :snoozing="snoozing" :threadRef="snoozingItem" @close="stopSnoozing" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { ThreadRef, sources, snoozedCategories } from '../../state';
|
||||||
|
import CategoryHeader from "../CategoryHeader.vue";
|
||||||
|
import ItemGroup from "../ItemGroup.vue";
|
||||||
|
import Modal from '../Modal.vue';
|
||||||
|
import PanelTitle from '../PanelTitle.vue';
|
||||||
|
import SnoozeModal from '../SnoozeModal.vue';
|
||||||
|
import Thread from '../Thread.vue';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const showThread = ref(false);
|
||||||
|
const selectedItem = ref<ThreadRef | undefined>();
|
||||||
|
const snoozingItem = ref<ThreadRef | undefined>();
|
||||||
|
const snoozing = ref(false);
|
||||||
|
watch([() => route.params.source, () => route.params.sourceItem, () => route.params.thread], ([source, sourceItem, thread]) => {
|
||||||
|
const sourceInt = parseInt(Array.isArray(source) ? source[0] : source);
|
||||||
|
const sourceItemInt = parseInt(Array.isArray(sourceItem) ? sourceItem[0] : sourceItem);
|
||||||
|
const threadInt = parseInt(Array.isArray(thread) ? thread[0] : thread);
|
||||||
|
if (!isNaN(sourceInt) && !isNaN(sourceItemInt) && !isNaN(threadInt)) {
|
||||||
|
selectedItem.value = { source: sourceInt, sourceItem: sourceItemInt, thread: threadInt };
|
||||||
|
showThread.value = true;
|
||||||
|
} else {
|
||||||
|
// Intentionally leave selectedItem so animations look right
|
||||||
|
showThread.value = false;
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const threadTitle = computed(() => {
|
||||||
|
const selected = selectedItem.value;
|
||||||
|
if (selected == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return sources.value[selected.source].items[selected.sourceItem].threads[selected.thread].title;
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectItem(source: number, sourceItem: number, thread: number) {
|
||||||
|
const p = route.params;
|
||||||
|
const sourceInt = parseInt(Array.isArray(p.source) ? p.source[0] : p.source);
|
||||||
|
const sourceItemInt = parseInt(Array.isArray(p.sourceItem) ? p.sourceItem[0] : p.sourceItem);
|
||||||
|
const threadInt = parseInt(Array.isArray(p.thread) ? p.thread[0] : p.thread);
|
||||||
|
if (source === sourceInt && sourceItem === sourceItemInt && thread === threadInt) {
|
||||||
|
close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
router.push(`/snoozed/${source}/${sourceItem}/${thread}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snoozeItem(source: number, sourceItem: number, thread: number) {
|
||||||
|
snoozingItem.value = { source, sourceItem, thread };
|
||||||
|
snoozing.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
console.log("closing thread")
|
||||||
|
router.push("/snoozed");
|
||||||
|
snoozing.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSnoozing() {
|
||||||
|
if (snoozingItem.value?.source === selectedItem.value?.source &&
|
||||||
|
snoozingItem.value?.sourceItem === selectedItem.value?.sourceItem &&
|
||||||
|
(snoozingItem.value?.thread === -1 || snoozingItem.value?.thread === selectedItem.value?.thread)
|
||||||
|
) {
|
||||||
|
close();
|
||||||
|
} else {
|
||||||
|
snoozing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -1,10 +1,16 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-full">
|
<div class="flex h-full">
|
||||||
<div class="grow basis-6/12 p-4 overflow-x-hidden">
|
<div class="grow basis-6/12 p-4 overflow-x-hidden relative">
|
||||||
<div class="text-3xl mb-4">Todo</div>
|
<div class="text-3xl mb-4">Todo</div>
|
||||||
|
<div v-if="todoCategories.length === 0" class="absolute center text-9xl">∅</div>
|
||||||
<div v-for="category in todoCategories">
|
<div v-for="category in todoCategories">
|
||||||
<CategoryHeader v-bind="category" />
|
<CategoryHeader v-bind="category" />
|
||||||
<ItemGroup :items="category.activeItems" :selected-thread="showThread ? selectedItem : undefined" @select-item="selectItem" />
|
<ItemGroup
|
||||||
|
:items="category.activeItems"
|
||||||
|
:selected-thread="showThread ? selectedItem : undefined"
|
||||||
|
@select-item="selectItem"
|
||||||
|
@snooze-item="snoozeItem"
|
||||||
|
@deselect="close" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden xl:flex bg-slate-200 ml-0 h-screen flex-col overflow-hidden" :class="showThread ? 'p-4 basis-6/12' : 'basis-0'">
|
<div class="hidden xl:flex bg-slate-200 ml-0 h-screen flex-col overflow-hidden" :class="showThread ? 'p-4 basis-6/12' : 'basis-0'">
|
||||||
|
@ -15,24 +21,28 @@
|
||||||
<template v-slot:header><div class="text-3xl">{{ threadTitle }}</div></template>
|
<template v-slot:header><div class="text-3xl">{{ threadTitle }}</div></template>
|
||||||
<template v-slot:body><Thread :source="selectedItem?.source ?? 0" :sourceItem="selectedItem?.sourceItem ?? 0" :thread="selectedItem?.thread ?? 0" base-url="/todo" /></template>
|
<template v-slot:body><Thread :source="selectedItem?.source ?? 0" :sourceItem="selectedItem?.sourceItem ?? 0" :thread="selectedItem?.thread ?? 0" base-url="/todo" /></template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<SnoozeModal :snoozing="snoozing" :threadRef="snoozingItem" @close="stopSnoozing" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { ThreadRef, sources, todoCategories } from '../state';
|
import { ThreadRef, sources, todoCategories } from '../../state';
|
||||||
import CategoryHeader from "./CategoryHeader.vue";
|
import CategoryHeader from "../CategoryHeader.vue";
|
||||||
import ItemGroup from "./ItemGroup.vue";
|
import ItemGroup from "../ItemGroup.vue";
|
||||||
import Modal from './Modal.vue';
|
import Modal from '../Modal.vue';
|
||||||
import PanelTitle from './PanelTitle.vue';
|
import PanelTitle from '../PanelTitle.vue';
|
||||||
import Thread from './Thread.vue';
|
import SnoozeModal from '../SnoozeModal.vue';
|
||||||
|
import Thread from '../Thread.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const showThread = ref(false);
|
const showThread = ref(false);
|
||||||
const selectedItem = ref<ThreadRef | undefined>();
|
const selectedItem = ref<ThreadRef | undefined>();
|
||||||
|
const snoozingItem = ref<ThreadRef | undefined>();
|
||||||
|
const snoozing = ref(false);
|
||||||
watch([() => route.params.source, () => route.params.sourceItem, () => route.params.thread], ([source, sourceItem, thread]) => {
|
watch([() => route.params.source, () => route.params.sourceItem, () => route.params.thread], ([source, sourceItem, thread]) => {
|
||||||
const sourceInt = parseInt(Array.isArray(source) ? source[0] : source);
|
const sourceInt = parseInt(Array.isArray(source) ? source[0] : source);
|
||||||
const sourceItemInt = parseInt(Array.isArray(sourceItem) ? sourceItem[0] : sourceItem);
|
const sourceItemInt = parseInt(Array.isArray(sourceItem) ? sourceItem[0] : sourceItem);
|
||||||
|
@ -61,13 +71,31 @@ function selectItem(source: number, sourceItem: number, thread: number) {
|
||||||
const threadInt = parseInt(Array.isArray(p.thread) ? p.thread[0] : p.thread);
|
const threadInt = parseInt(Array.isArray(p.thread) ? p.thread[0] : p.thread);
|
||||||
if (source === sourceInt && sourceItem === sourceItemInt && thread === threadInt) {
|
if (source === sourceInt && sourceItem === sourceItemInt && thread === threadInt) {
|
||||||
close();
|
close();
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
router.push(`/todo/${source}/${sourceItem}/${thread}`);
|
router.push(`/todo/${source}/${sourceItem}/${thread}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snoozeItem(source: number, sourceItem: number, thread: number) {
|
||||||
|
snoozingItem.value = { source, sourceItem, thread };
|
||||||
|
snoozing.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
console.log("closing thread")
|
console.log("closing thread")
|
||||||
router.push("/todo");
|
router.push("/todo");
|
||||||
|
snoozing.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSnoozing() {
|
||||||
|
if (snoozingItem.value?.source === selectedItem.value?.source &&
|
||||||
|
snoozingItem.value?.sourceItem === selectedItem.value?.sourceItem &&
|
||||||
|
(snoozingItem.value?.thread === -1 || snoozingItem.value?.thread === selectedItem.value?.thread)
|
||||||
|
) {
|
||||||
|
close();
|
||||||
|
} else {
|
||||||
|
snoozing.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
|
@ -3,11 +3,13 @@ import { createMemoryHistory, createRouter } from "vue-router";
|
||||||
import type { RouteRecordRaw } from "vue-router";
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
import Todo from "./components/Todo.vue";
|
import Todo from "./components/pages/Todo.vue";
|
||||||
|
import Snoozed from "./components/pages/Snoozed.vue";
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{ path: '/', component: Todo, name: 'home' },
|
{ path: '/', component: Todo, name: 'home' },
|
||||||
{ path: '/todo/:source(\\d+)?/:sourceItem(\\d+)?/:thread(\\d+)?', component: Todo, name: 'todo' },
|
{ path: '/todo/:source(\\d+)?/:sourceItem(\\d+)?/:thread(\\d+)?', component: Todo, name: 'todo' },
|
||||||
|
{ path: '/snoozed/:source(\\d+)?/:sourceItem(\\d+)?/:thread(\\d+)?', component: Snoozed, name: 'snoozed' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|
29
src/state.ts
29
src/state.ts
|
@ -86,6 +86,7 @@ export interface Category {
|
||||||
name: string;
|
name: string;
|
||||||
priority: Priority;
|
priority: Priority;
|
||||||
activeItems: Item[];
|
activeItems: Item[];
|
||||||
|
snoozedItems: Item[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThreadRef {
|
export interface ThreadRef {
|
||||||
|
@ -231,19 +232,22 @@ export const items = ref<Item[]>([
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
export const unsortedItems = ref<Item[]>([]);
|
export const unsortedItems = ref<Item[]>([]);
|
||||||
|
export const unsortedItemsSnoozed = ref<Item[]>([]);
|
||||||
export const rules = ref<{ category?: string; rules: Rule[] }[]>([]);
|
export const rules = ref<{ category?: string; rules: Rule[] }[]>([]);
|
||||||
export const categories = ref<Category[]>([
|
export const categories = ref<Category[]>([
|
||||||
{
|
{
|
||||||
id: 0,
|
id: 0,
|
||||||
name: "Urgent",
|
name: "Urgent",
|
||||||
priority: "urgent",
|
priority: "urgent",
|
||||||
activeItems: []
|
activeItems: [],
|
||||||
|
snoozedItems: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "DMs",
|
name: "DMs",
|
||||||
priority: "notify",
|
priority: "notify",
|
||||||
activeItems: []
|
activeItems: [],
|
||||||
|
snoozedItems: []
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
export const favorites = ref<ThreadRef[]>([
|
export const favorites = ref<ThreadRef[]>([
|
||||||
|
@ -260,35 +264,54 @@ export const todoItems = computed(() =>
|
||||||
todoCategories.value
|
todoCategories.value
|
||||||
.reduce((acc, curr) => mergeSortedLists(acc, curr.activeItems), [] as Item[])
|
.reduce((acc, curr) => mergeSortedLists(acc, curr.activeItems), [] as Item[])
|
||||||
);
|
);
|
||||||
export const todoCategories = computed(() => categories.value.filter(c => ["urgent", "notify", "todo"].includes(c.priority)));
|
export const todoCategories = computed(() => categories.value.filter(c => ["urgent", "notify", "todo"].includes(c.priority) && Object.keys(c.activeItems).length > 0));
|
||||||
|
export const snoozedCategories = computed(() => categories.value.filter(c => Object.keys(c.snoozedItems).length > 0));
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
items,
|
items,
|
||||||
items => {
|
items => {
|
||||||
const mappedItems: Record<number, Item[]> = {};
|
const mappedItems: Record<number, Item[]> = {};
|
||||||
|
const mappedItemsSnoozed: Record<number, Item[]> = {};
|
||||||
const unsorted: Item[] = [];
|
const unsorted: Item[] = [];
|
||||||
|
const unsortedSnoozed: Item[] = [];
|
||||||
|
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
let includeInUnsorted = false;
|
let includeInUnsorted = false;
|
||||||
|
let includeInUnsortedSnoozed = false;
|
||||||
const includeInCategories = new Set<number>();
|
const includeInCategories = new Set<number>();
|
||||||
|
const includeInCategoriesSnoozed = new Set<number>();
|
||||||
Object.values(item.threads).forEach(t => {
|
Object.values(item.threads).forEach(t => {
|
||||||
|
if (t.snoozedUntil != null && Date.now() < t.snoozedUntil) {
|
||||||
|
if (t.category == null) {
|
||||||
|
includeInUnsortedSnoozed = true;
|
||||||
|
} else {
|
||||||
|
includeInCategoriesSnoozed.add(t.category);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if (t.category == null) {
|
if (t.category == null) {
|
||||||
includeInUnsorted = true;
|
includeInUnsorted = true;
|
||||||
} else {
|
} else {
|
||||||
includeInCategories.add(t.category);
|
includeInCategories.add(t.category);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (includeInUnsorted) {
|
if (includeInUnsorted) {
|
||||||
unsorted.push(item);
|
unsorted.push(item);
|
||||||
}
|
}
|
||||||
|
if (includeInUnsortedSnoozed) {
|
||||||
|
unsortedSnoozed.push(item);
|
||||||
|
}
|
||||||
includeInCategories.forEach(cat => mappedItems[cat] = [...(mappedItems[cat] ?? []), item]);
|
includeInCategories.forEach(cat => mappedItems[cat] = [...(mappedItems[cat] ?? []), item]);
|
||||||
|
includeInCategoriesSnoozed.forEach(cat => mappedItemsSnoozed[cat] = [...(mappedItemsSnoozed[cat] ?? []), item]);
|
||||||
});
|
});
|
||||||
|
|
||||||
categories.value.forEach(cat => {
|
categories.value.forEach(cat => {
|
||||||
// Noting here that activeItems must be sorted by most recently updated
|
// Noting here that activeItems must be sorted by most recently updated
|
||||||
cat.activeItems = mappedItems[cat.id]?.sort((a, b) => a.updatedAt - b.updatedAt) ?? [];
|
cat.activeItems = mappedItems[cat.id]?.sort((a, b) => a.updatedAt - b.updatedAt) ?? [];
|
||||||
|
cat.snoozedItems = mappedItemsSnoozed[cat.id]?.sort((a, b) => a.updatedAt - b.updatedAt) ?? [];
|
||||||
});
|
});
|
||||||
unsortedItems.value = unsorted;
|
unsortedItems.value = unsorted;
|
||||||
|
unsortedItemsSnoozed.value = unsortedSnoozed;
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
vueJsx()
|
||||||
|
],
|
||||||
base: "/"
|
base: "/"
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in a new issue