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 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="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 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>
|
||||
|
@ -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="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="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 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>
|
||||
|
@ -45,12 +49,13 @@
|
|||
</template>
|
||||
|
||||
<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 type { Item, ItemThread, ThreadRef } from "../state";
|
||||
import { items, sources } from '../state';
|
||||
import { parseString } from '../utils';
|
||||
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;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<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')">
|
||||
<FontAwesomeIcon class="w-6 h-6" :icon="isRead ? faEnvelopeOpen : faCheck" />
|
||||
</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)">
|
||||
<Avatar v-if="event.contact !== sourceObj.selfContact" v-bind="sourceObj.contacts[event.contact]" class="self-end" />
|
||||
<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>
|
||||
</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)">
|
||||
<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>
|
||||
</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)">
|
||||
|
@ -23,7 +30,7 @@
|
|||
<button v-if="ownsMessage && event.type === 'message'" @click="startEditing">Edit</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'">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>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -33,11 +40,12 @@
|
|||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script setup lang="tsx">
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
import { items, sources } from '../state';
|
||||
import Avatar from './Avatar.vue';
|
||||
import { RouterLink, useRouter } from 'vue-router';
|
||||
import { items, sources } from '../state';
|
||||
import { getRoundedCornersClasses, parseMessage } from '../utils';
|
||||
import Avatar from './Avatar.vue';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
|
@ -91,19 +99,6 @@ function sendMessage() {
|
|||
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 selectedMessageIndex = ref<number>();
|
||||
|
||||
|
@ -183,6 +178,46 @@ function createThread(event: number) {
|
|||
});
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
|
47
src/state.ts
47
src/state.ts
|
@ -1,7 +1,7 @@
|
|||
import { computed, ref, watch } from "vue";
|
||||
import { mergeSortedLists } from "./utils";
|
||||
|
||||
export type SourceType = "email" | "matrix" | "xmpp" | "rss" | "site";
|
||||
export type SourceType = "email" | "matrix" | "xmpp" | "rss" | "site" | "todo";
|
||||
|
||||
export interface Item {
|
||||
source: number;
|
||||
|
@ -180,6 +180,35 @@ export const sources = ref<Record<number, Source>>({
|
|||
}
|
||||
},
|
||||
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[]>([
|
||||
|
@ -242,6 +271,20 @@ export const items = ref<Item[]>([
|
|||
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[]>([]);
|
||||
|
@ -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));
|
||||
|
||||
watch(todoCategories, console.log)
|
||||
|
||||
watch(
|
||||
items,
|
||||
items => {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Ref, computed, ref, watch } from "vue";
|
||||
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||
import { MessageEvent, ThreadRef, items, sources } from "./state";
|
||||
import { RouteLocationNormalizedLoaded, Router, RouterLink } from "vue-router";
|
||||
import { MessageEvent, SourceThread, ThreadRef, items, sources } from "./state";
|
||||
|
||||
// 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> {
|
||||
|
@ -152,3 +152,56 @@ export function setupSelectedThread(baseUrl: Ref<string>, route: RouteLocationNo
|
|||
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