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="modal-root" class="dark"></div>
|
||||
<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>
|
||||
</html>
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||
"@fortawesome/vue-fontawesome": "latest-3",
|
||||
"@vitejs/plugin-vue-jsx": "^3.1.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"flowbite": "^2.3.0",
|
||||
"postcss": "^8.4.38",
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<template>
|
||||
<span class="text-xl mx-4 group">
|
||||
<div class="text-xl mx-4 m-2 group">
|
||||
{{ name }}
|
||||
<RouterLink :to="`/settings/category/${id}`">
|
||||
<FontAwesomeIcon :icon="faGear" class="hidden group-hover:inline" />
|
||||
</RouterLink>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
|
|
@ -1,53 +1,84 @@
|
|||
<template>
|
||||
<div v-if="Object.keys(sourceItem.threads).length === 1 && '0' in sourceItem.threads"
|
||||
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 class="flex items-center">
|
||||
<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="grow" />
|
||||
<span v-if="item.threads[0].count > 1" class="inline-flex items-center justify-center w-3 h-3 p-3 ms-3 text-sm font-medium text-blue-800 bg-blue-100 rounded-full dark:bg-blue-900 dark:text-blue-300">{{ item.threads[0].count }}</span>
|
||||
</div>
|
||||
<div class="flex -mb-2 text-gray-500 text-sm font-normal">
|
||||
<span class="grow">{{ sourceItem.title }}</span>
|
||||
<span>{{ source.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="p-4 border-2 -mt-2px bg-gray-200 border-gray-800">
|
||||
<div v-if="Object.keys(sourceItem.threads).length === 1 && '0' in sourceItem.threads" class="relative">
|
||||
<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">
|
||||
<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="grow" />
|
||||
<span v-if="item.threads[0].count > 1" class="inline-flex items-center justify-center w-3 h-3 p-3 ms-3 text-sm font-medium text-blue-800 bg-blue-100 rounded-full dark:bg-blue-900 dark:text-blue-300">{{ item.threads[0].count }}</span>
|
||||
</div>
|
||||
<div class="flex -mb-2 text-gray-500 text-sm font-normal">
|
||||
<span class="grow">{{ sourceItem.title }}</span>
|
||||
<span>{{ source.name }}</span>
|
||||
</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))">
|
||||
<div class="flex items-center">
|
||||
<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="grow" />
|
||||
<span v-if="thread.count > 1" class="inline-flex items-center justify-center w-3 h-3 p-3 ms-3 text-sm font-medium text-blue-800 bg-blue-100 rounded-full dark:bg-blue-900 dark:text-blue-300">{{ thread.count }}</span>
|
||||
<component :is="actionIndicators" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<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">
|
||||
<span class="grow">{{ sourceItem.title }}</span>
|
||||
<span>{{ source.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex -mb-2 text-gray-500 text-sm font-normal">
|
||||
<span class="grow">{{ sourceItem.threads[id].title }}</span>
|
||||
<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">
|
||||
<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="grow" />
|
||||
<span v-if="thread.count > 1" class="inline-flex items-center justify-center w-3 h-3 p-3 ms-3 text-sm font-medium text-blue-800 bg-blue-100 rounded-full dark:bg-blue-900 dark:text-blue-300">{{ thread.count }}</span>
|
||||
</div>
|
||||
<div class="flex -mb-2 text-gray-500 text-sm font-normal">
|
||||
<span class="grow">{{ sourceItem.threads[id].title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<component :is="actionIndicators" />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Item, ThreadRef } from "../state";
|
||||
import { sources } from '../state';
|
||||
<script setup lang="tsx">
|
||||
import { computed, ref } from 'vue';
|
||||
import type { Item, ItemThread, ThreadRef } from "../state";
|
||||
import { items, sources } from '../state';
|
||||
import Avatar from './Avatar.vue';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import { faCheck, faClockRotateLeft } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
const props = defineProps<{
|
||||
item: Item,
|
||||
selectedThread?: ThreadRef
|
||||
item: Item;
|
||||
selectedThread?: ThreadRef;
|
||||
showSnoozed?: boolean;
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
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 sourceItem = computed(() => source.value.items[props.item.sourceItem]);
|
||||
|
||||
|
@ -71,16 +102,102 @@ function isSelected(thread: number | string) {
|
|||
}
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.-mt-2px {
|
||||
margin-top: -2px;
|
||||
}
|
||||
.selected:not(:first-child) {
|
||||
:not(:first-child) > .selected {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.selected:not(:last-child) {
|
||||
:not(:last-child) > .selected {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
|
@ -1,9 +1,12 @@
|
|||
<template>
|
||||
<div class="mb-4 item-group">
|
||||
<div class="mb-4 item-group relative">
|
||||
<Item v-for="item in items"
|
||||
:item="item"
|
||||
: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>
|
||||
</template>
|
||||
|
||||
|
@ -12,11 +15,14 @@ import type { Item as StateItem, ThreadRef } from "../state";
|
|||
import Item from "./Item.vue";
|
||||
|
||||
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<{
|
||||
items: StateItem[],
|
||||
selectedThread?: ThreadRef
|
||||
items: StateItem[];
|
||||
selectedThread?: ThreadRef;
|
||||
showSnoozed?: boolean;
|
||||
}>();
|
||||
</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>
|
||||
<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 v-if="todoCategories.length === 0" class="absolute center text-9xl">∅</div>
|
||||
<div v-for="category in todoCategories">
|
||||
<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 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:body><Thread :source="selectedItem?.source ?? 0" :sourceItem="selectedItem?.sourceItem ?? 0" :thread="selectedItem?.thread ?? 0" base-url="/todo" /></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, todoCategories } from '../state';
|
||||
import CategoryHeader from "./CategoryHeader.vue";
|
||||
import ItemGroup from "./ItemGroup.vue";
|
||||
import Modal from './Modal.vue';
|
||||
import PanelTitle from './PanelTitle.vue';
|
||||
import Thread from './Thread.vue';
|
||||
import { ThreadRef, sources, todoCategories } 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);
|
||||
|
@ -61,13 +71,31 @@ function selectItem(source: number, sourceItem: number, thread: number) {
|
|||
const threadInt = parseInt(Array.isArray(p.thread) ? p.thread[0] : p.thread);
|
||||
if (source === sourceInt && sourceItem === sourceItemInt && thread === threadInt) {
|
||||
close();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
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() {
|
||||
console.log("closing thread")
|
||||
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>
|
|
@ -3,11 +3,13 @@ import { createMemoryHistory, createRouter } from "vue-router";
|
|||
import type { RouteRecordRaw } from "vue-router";
|
||||
import "./style.css";
|
||||
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[] = [
|
||||
{ path: '/', component: Todo, name: 'home' },
|
||||
{ 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({
|
||||
|
|
35
src/state.ts
35
src/state.ts
|
@ -86,6 +86,7 @@ export interface Category {
|
|||
name: string;
|
||||
priority: Priority;
|
||||
activeItems: Item[];
|
||||
snoozedItems: Item[];
|
||||
}
|
||||
|
||||
export interface ThreadRef {
|
||||
|
@ -231,19 +232,22 @@ export const items = ref<Item[]>([
|
|||
}
|
||||
]);
|
||||
export const unsortedItems = ref<Item[]>([]);
|
||||
export const unsortedItemsSnoozed = ref<Item[]>([]);
|
||||
export const rules = ref<{ category?: string; rules: Rule[] }[]>([]);
|
||||
export const categories = ref<Category[]>([
|
||||
{
|
||||
id: 0,
|
||||
name: "Urgent",
|
||||
priority: "urgent",
|
||||
activeItems: []
|
||||
activeItems: [],
|
||||
snoozedItems: []
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: "DMs",
|
||||
priority: "notify",
|
||||
activeItems: []
|
||||
activeItems: [],
|
||||
snoozedItems: []
|
||||
}
|
||||
]);
|
||||
export const favorites = ref<ThreadRef[]>([
|
||||
|
@ -260,35 +264,54 @@ export const todoItems = computed(() =>
|
|||
todoCategories.value
|
||||
.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(
|
||||
items,
|
||||
items => {
|
||||
const mappedItems: Record<number, Item[]> = {};
|
||||
const mappedItemsSnoozed: Record<number, Item[]> = {};
|
||||
const unsorted: Item[] = [];
|
||||
const unsortedSnoozed: Item[] = [];
|
||||
|
||||
items.forEach(item => {
|
||||
let includeInUnsorted = false;
|
||||
let includeInUnsortedSnoozed = false;
|
||||
const includeInCategories = new Set<number>();
|
||||
const includeInCategoriesSnoozed = new Set<number>();
|
||||
Object.values(item.threads).forEach(t => {
|
||||
if (t.category == null) {
|
||||
includeInUnsorted = true;
|
||||
if (t.snoozedUntil != null && Date.now() < t.snoozedUntil) {
|
||||
if (t.category == null) {
|
||||
includeInUnsortedSnoozed = true;
|
||||
} else {
|
||||
includeInCategoriesSnoozed.add(t.category);
|
||||
}
|
||||
} else {
|
||||
includeInCategories.add(t.category);
|
||||
if (t.category == null) {
|
||||
includeInUnsorted = true;
|
||||
} else {
|
||||
includeInCategories.add(t.category);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (includeInUnsorted) {
|
||||
unsorted.push(item);
|
||||
}
|
||||
if (includeInUnsortedSnoozed) {
|
||||
unsortedSnoozed.push(item);
|
||||
}
|
||||
includeInCategories.forEach(cat => mappedItems[cat] = [...(mappedItems[cat] ?? []), item]);
|
||||
includeInCategoriesSnoozed.forEach(cat => mappedItemsSnoozed[cat] = [...(mappedItemsSnoozed[cat] ?? []), item]);
|
||||
});
|
||||
|
||||
categories.value.forEach(cat => {
|
||||
// Noting here that activeItems must be sorted by most recently updated
|
||||
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;
|
||||
unsortedItemsSnoozed.value = unsortedSnoozed;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
plugins: [
|
||||
vue(),
|
||||
vueJsx()
|
||||
],
|
||||
base: "/"
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue