Implement creating TODO items from messages
This commit is contained in:
parent
d39f752e2e
commit
d74e285447
5 changed files with 163 additions and 29 deletions
|
@ -3,7 +3,9 @@
|
||||||
<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 :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">
|
||||||
|
<component :is="parseString(item.threads[0].preview)" />
|
||||||
|
</span>
|
||||||
<span class="grow" />
|
<span class="grow" />
|
||||||
<span v-if="item.threads[0].count > 0" 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>
|
<span v-if="item.threads[0].count > 0" 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>
|
||||||
<span v-else><FontAwesomeIcon class="w-6 h-6 text-lime-600" :icon="faCheck" /></span>
|
<span v-else><FontAwesomeIcon class="w-6 h-6 text-lime-600" :icon="faCheck" /></span>
|
||||||
|
@ -30,7 +32,9 @@
|
||||||
<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="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">
|
||||||
|
<component :is="parseString(thread.preview)" />
|
||||||
|
</span>
|
||||||
<span class="grow" />
|
<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>
|
<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>
|
||||||
|
@ -45,12 +49,13 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="tsx">
|
<script setup lang="tsx">
|
||||||
|
import { faCheck, faClockRotateLeft } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import type { Item, ItemThread, ThreadRef } from "../state";
|
import type { Item, ItemThread, ThreadRef } from "../state";
|
||||||
import { items, sources } from '../state';
|
import { items, sources } from '../state';
|
||||||
|
import { parseString } from '../utils';
|
||||||
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;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full flex mb-4">
|
<div class="w-full flex mb-4">
|
||||||
<div class="text-3xl shrink-0 grow">{{ title }}</div>
|
<div class="text-3xl grow">{{ title }}</div>
|
||||||
<button @click="emits('toggleRead')">
|
<button @click="emits('toggleRead')">
|
||||||
<FontAwesomeIcon class="w-6 h-6" :icon="isRead ? faEnvelopeOpen : faCheck" />
|
<FontAwesomeIcon class="w-6 h-6" :icon="isRead ? faEnvelopeOpen : faCheck" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -8,12 +8,19 @@
|
||||||
<div v-if="i === threadItem.timeline.length - 1 || threadItem.timeline[i + 1].type !== 'message' || threadItem.timeline[i + 1].contact !== event.contact" style="max-width: 80%;" class="flex mb-2" :class="event.contact === sourceObj.selfContact ? 'self self-end flex-row-reverse' : 'other self-start'" @mousedown="start(i)" @mouseup="stop" @contextmenu="e => openmenu(e, i)">
|
<div v-if="i === threadItem.timeline.length - 1 || threadItem.timeline[i + 1].type !== 'message' || threadItem.timeline[i + 1].contact !== event.contact" style="max-width: 80%;" class="flex mb-2" :class="event.contact === sourceObj.selfContact ? 'self self-end flex-row-reverse' : 'other self-start'" @mousedown="start(i)" @mouseup="stop" @contextmenu="e => openmenu(e, i)">
|
||||||
<Avatar v-if="event.contact !== sourceObj.selfContact" v-bind="sourceObj.contacts[event.contact]" class="self-end" />
|
<Avatar v-if="event.contact !== sourceObj.selfContact" v-bind="sourceObj.contacts[event.contact]" class="self-end" />
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="message px-2 py-1 flex items-center rounded-2xl" :class="getRoundedCornersClasses(i) + (event.contact === sourceObj.selfContact ? ' ml-13' : ' ml-2')">{{ event.message }}</span>
|
<span :class="getRoundedCornersClasses(threadItem, sourceObj.selfContact, i) +
|
||||||
|
' message px-2 py-1 flex items-center rounded-2xl' +
|
||||||
|
(event.contact === sourceObj.selfContact ? ' ml-13' : ' ml-2')">
|
||||||
|
<component :is="parseMessage(threadItem, i)" />
|
||||||
|
</span>
|
||||||
<span v-if="event.contact !== sourceObj.selfContact" class="author px-2 text-sm italic mx-2">{{ sourceObj.contacts[event.contact].name }}</span>
|
<span v-if="event.contact !== sourceObj.selfContact" class="author px-2 text-sm italic mx-2">{{ sourceObj.contacts[event.contact].name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex mb-px" :class="event.contact === sourceObj.selfContact ? 'self self-end flex-row-reverse' : 'other self-start text-gray-200'" @mousedown="start(i)" @mouseup="stop" @contextmenu="e => openmenu(e, i)">
|
<div v-else class="flex mb-px" :class="event.contact === sourceObj.selfContact ? 'self self-end flex-row-reverse' : 'other self-start text-gray-200'" @mousedown="start(i)" @mouseup="stop" @contextmenu="e => openmenu(e, i)">
|
||||||
<span class="message px-2 py-1 flex items-center rounded-2xl ml-13" :class="getRoundedCornersClasses(i)">{{ event.message }}</span>
|
<span :class="getRoundedCornersClasses(threadItem, sourceObj.selfContact, i) +
|
||||||
|
' message px-2 py-1 flex items-center rounded-2xl ml-13'">
|
||||||
|
<component :is="parseMessage(threadItem, i)" />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else-if="event.type === 'create-thread'" class="self-center mb-2 italic text-gray-500 text-center" @mousedown="start(i)" @mouseup="stop" @contextmenu="e => openmenu(e, i)">
|
<div v-else-if="event.type === 'create-thread'" class="self-center mb-2 italic text-gray-500 text-center" @mousedown="start(i)" @mouseup="stop" @contextmenu="e => openmenu(e, i)">
|
||||||
|
@ -23,7 +30,7 @@
|
||||||
<button v-if="ownsMessage && event.type === 'message'" @click="startEditing">Edit</button>
|
<button v-if="ownsMessage && event.type === 'message'" @click="startEditing">Edit</button>
|
||||||
<button v-if="ownsMessage" @click="deleteMessage(i)">Delete</button>
|
<button v-if="ownsMessage" @click="deleteMessage(i)">Delete</button>
|
||||||
<button v-if="event.type === 'message'" @click="createThread(i)">Create thread</button>
|
<button v-if="event.type === 'message'" @click="createThread(i)">Create thread</button>
|
||||||
<button v-if="event.type === 'message'">Create to do</button>
|
<button v-if="event.type === 'message'" @click="createToDo(i)">Create to do</button>
|
||||||
<button v-if="event.type === 'message'">Add to garden</button>
|
<button v-if="event.type === 'message'">Add to garden</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -33,11 +40,12 @@
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="tsx">
|
||||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||||
import { items, sources } from '../state';
|
|
||||||
import Avatar from './Avatar.vue';
|
|
||||||
import { RouterLink, useRouter } from 'vue-router';
|
import { RouterLink, useRouter } from 'vue-router';
|
||||||
|
import { items, sources } from '../state';
|
||||||
|
import { getRoundedCornersClasses, parseMessage } from '../utils';
|
||||||
|
import Avatar from './Avatar.vue';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
@ -91,19 +99,6 @@ function sendMessage() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRoundedCornersClasses(index: number) {
|
|
||||||
const contact = threadItem.value.timeline[index]?.contact;
|
|
||||||
if (contact == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const side = contact === sourceObj.value.selfContact ? 'r' : 'l';
|
|
||||||
let classes = `rounded-b${side}-none`;
|
|
||||||
if (threadItem.value.timeline[index - 1] != null && threadItem.value.timeline[index - 1].type === 'message' && threadItem.value.timeline[index - 1].contact === contact) {
|
|
||||||
classes += ` rounded-t${side}-none`;
|
|
||||||
}
|
|
||||||
return classes;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeout = ref<number>();
|
const timeout = ref<number>();
|
||||||
const selectedMessageIndex = ref<number>();
|
const selectedMessageIndex = ref<number>();
|
||||||
|
|
||||||
|
@ -183,6 +178,46 @@ function createThread(event: number) {
|
||||||
});
|
});
|
||||||
router.push(`/source/${props.source}/${props.sourceItem}/${threadId}`);
|
router.push(`/source/${props.source}/${props.sourceItem}/${threadId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createToDo(event: number) {
|
||||||
|
const messageEvent = threadItem.value.timeline[event];
|
||||||
|
if (messageEvent.type !== "message") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceItem = Object.keys(sources.value[1].items).reduce((acc, curr) => Math.max(acc, parseInt(curr)), 0) + 1;
|
||||||
|
const message = `[[${props.source}/${props.sourceItem}/${props.thread}/${event}]]`;
|
||||||
|
const newItems = items.value.slice();
|
||||||
|
newItems.push({
|
||||||
|
source: 1,
|
||||||
|
sourceItem,
|
||||||
|
threads: {
|
||||||
|
0: {
|
||||||
|
count: 1,
|
||||||
|
preview: message,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
contact: 0,
|
||||||
|
category: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updatedAt: Date.now()
|
||||||
|
});
|
||||||
|
items.value = newItems;
|
||||||
|
sources.value[1].items[sourceItem] = {
|
||||||
|
id: sourceItem,
|
||||||
|
threads: {
|
||||||
|
0: {
|
||||||
|
id: 0,
|
||||||
|
timeline: [
|
||||||
|
{ type: "message", contact: 0, time: Date.now(), message }
|
||||||
|
],
|
||||||
|
title: messageEvent.message
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title: messageEvent.message
|
||||||
|
}
|
||||||
|
router.push(`/source/1/${sourceItem}/0`);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
47
src/state.ts
47
src/state.ts
|
@ -1,7 +1,7 @@
|
||||||
import { computed, ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import { mergeSortedLists } from "./utils";
|
import { mergeSortedLists } from "./utils";
|
||||||
|
|
||||||
export type SourceType = "email" | "matrix" | "xmpp" | "rss" | "site";
|
export type SourceType = "email" | "matrix" | "xmpp" | "rss" | "site" | "todo";
|
||||||
|
|
||||||
export interface Item {
|
export interface Item {
|
||||||
source: number;
|
source: number;
|
||||||
|
@ -180,6 +180,35 @@ export const sources = ref<Record<number, Source>>({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
selfContact: 1
|
selfContact: 1
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
name: "Manual To Do Items",
|
||||||
|
type: "todo",
|
||||||
|
id: 1,
|
||||||
|
selfContact: 0,
|
||||||
|
items: {
|
||||||
|
0: {
|
||||||
|
id: 0,
|
||||||
|
threads: {
|
||||||
|
0: {
|
||||||
|
id: 0,
|
||||||
|
timeline: [
|
||||||
|
{ type: "message", contact: 0, time: Date.now(), message: "In the full app I'd like to render this as a list of sub-items, with 'side threads' acting to nest sub-items" }
|
||||||
|
],
|
||||||
|
title: "Finish Mockup"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title: "Finish Mockup"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contacts: {
|
||||||
|
0: {
|
||||||
|
id: 0,
|
||||||
|
name: "TODO",
|
||||||
|
status: "unknown",
|
||||||
|
image: "https://picsum.photos/seed/avatar2/64"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
export const items = ref<Item[]>([
|
export const items = ref<Item[]>([
|
||||||
|
@ -242,6 +271,20 @@ export const items = ref<Item[]>([
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: 1,
|
||||||
|
sourceItem: 0,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
threads: {
|
||||||
|
0: {
|
||||||
|
category: 0,
|
||||||
|
count: 1,
|
||||||
|
preview: "Finish Mockup",
|
||||||
|
contact: 0,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
export const unsortedItems = ref<Item[]>([]);
|
export const unsortedItems = ref<Item[]>([]);
|
||||||
|
@ -279,8 +322,6 @@ export const todoItems = computed(() =>
|
||||||
);
|
);
|
||||||
export const todoCategories = computed(() => categories.value.filter(c => ["urgent", "notify", "todo"].includes(c.priority) && Object.keys(c.activeItems).length > 0));
|
export const todoCategories = computed(() => categories.value.filter(c => ["urgent", "notify", "todo"].includes(c.priority) && Object.keys(c.activeItems).length > 0));
|
||||||
|
|
||||||
watch(todoCategories, console.log)
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
items,
|
items,
|
||||||
items => {
|
items => {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Ref, computed, ref, watch } from "vue";
|
import { Ref, computed, ref, watch } from "vue";
|
||||||
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
import { RouteLocationNormalizedLoaded, Router, RouterLink } from "vue-router";
|
||||||
import { MessageEvent, ThreadRef, items, sources } from "./state";
|
import { MessageEvent, SourceThread, ThreadRef, items, sources } from "./state";
|
||||||
|
|
||||||
// Modified from https://www.basedash.com/blog/how-to-merge-two-sorted-lists-in-javascript
|
// Modified from https://www.basedash.com/blog/how-to-merge-two-sorted-lists-in-javascript
|
||||||
export function mergeSortedLists<T>(arr1: Array<T>, arr2: Array<T>): Array<T> {
|
export function mergeSortedLists<T>(arr1: Array<T>, arr2: Array<T>): Array<T> {
|
||||||
|
@ -152,3 +152,56 @@ export function setupSelectedThread(baseUrl: Ref<string>, route: RouteLocationNo
|
||||||
toggleRead
|
toggleRead
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRoundedCornersClasses(thread: SourceThread, selfContact: number, index: number) {
|
||||||
|
const contact = thread.timeline[index]?.contact;
|
||||||
|
if (contact == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const side = contact === selfContact ? 'r' : 'l';
|
||||||
|
let classes = `rounded-b${side}-none`;
|
||||||
|
if (thread.timeline[index - 1] != null && thread.timeline[index - 1].type === 'message' && thread.timeline[index - 1].contact === contact) {
|
||||||
|
classes += ` rounded-t${side}-none`;
|
||||||
|
}
|
||||||
|
return classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMessage(thread: SourceThread, event: number) {
|
||||||
|
const messageEvent = thread.timeline[event];
|
||||||
|
if (messageEvent.type !== "message") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = messageEvent.message;
|
||||||
|
return parseString(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseString(message: string) {
|
||||||
|
const messageRefs = message.matchAll(/\[\[[0-9]+\/[0-9]+\/[0-9]+\/[0-9]+\]\]/g);
|
||||||
|
let index = 0;
|
||||||
|
const parts = [];
|
||||||
|
for (const match of messageRefs) {
|
||||||
|
if (index !== match.index) {
|
||||||
|
parts.push(message.slice(index, match.index));
|
||||||
|
}
|
||||||
|
const numbers = match[0].slice(2, -2).split("/");
|
||||||
|
const sourceId = parseInt(numbers[0]);
|
||||||
|
const sourceItemId = parseInt(numbers[1]);
|
||||||
|
const threadId = parseInt(numbers[2]);
|
||||||
|
const messageId = parseInt(numbers[3]);
|
||||||
|
const referencedMessage = sources.value[sourceId].items[sourceItemId].threads[threadId].timeline[messageId];
|
||||||
|
if (referencedMessage.type === "message") {
|
||||||
|
parts.push(<span class="px-2 bg-emerald-200/25 rounded-2xl">
|
||||||
|
{ referencedMessage.message }
|
||||||
|
<RouterLink class="text-slate-600 pl-2 text-sm" to={`/source/${sourceId}/${sourceItemId}/${threadId}`}>
|
||||||
|
goto message
|
||||||
|
</RouterLink>
|
||||||
|
</span>);
|
||||||
|
}
|
||||||
|
index = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
if (index < message.length) {
|
||||||
|
parts.push(<span>{ message.slice(index) }</span>)
|
||||||
|
}
|
||||||
|
return <>{parts}</>;
|
||||||
|
}
|
Loading…
Reference in a new issue