Sending messages and styling pass

This commit is contained in:
thepaperpilot 2024-04-08 10:10:10 -05:00
parent a8e3eaea46
commit 5635b12ca4
8 changed files with 162 additions and 66 deletions

View file

@ -1,13 +1,11 @@
<script setup lang="ts">
import Nav from './components/Nav.vue'
import { useRoute, RouterView } from 'vue-router'
const route = useRoute()
import { RouterView } from 'vue-router'
</script>
<template>
<Nav />
<div class="p-4 md:ml-64 min-h-screen">
<div class="md:ml-64 h-screen overflow-hidden">
<RouterView />
</div>
</template>

View file

@ -1,9 +1,10 @@
<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">
<Avatar v-if="contact" v-bind="contact" class="mr-2" />
<span class="grow">{{ item.threads[0].preview }}</span>
<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">
@ -18,10 +19,11 @@
<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 scale-105 thread' : ''" @click="emits('select', parseInt(id as unknown as string))">
<div class="flex">
<Avatar v-if="contact" v-bind="contact" class="mr-2" />
<span class="grow">{{ thread.preview }}</span>
<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>
</div>
<div class="flex -mb-2 text-gray-500 text-sm font-normal">
@ -48,7 +50,11 @@ const emits = defineEmits<{
const source = computed(() => sources.value[props.item.source]);
const sourceItem = computed(() => source.value.items[props.item.sourceItem]);
const contact = computed(() => props.item.threads[0].contact == null ? null : sources.value[props.item.source].contacts[props.item.threads[0].contact])
function getContact(thread: number) {
const contact = props.item.threads[thread].contact;
return contact == null ? undefined : sources.value[props.item.source].contacts[contact];
}
function isSelected(thread: number | string) {
if (typeof thread === "string") {

View file

@ -8,13 +8,13 @@
<div class="h-full px-3 py-4 overflow-y-auto bg-gray-50 dark:bg-gray-800 flex flex-col">
<ul class="space-x-2 py-2 flex flex-row overflow-x-hidden hover:overflow-x-auto">
<li v-for="contact in urgentContacts" class="shrink-0">
<RouterLink :to="`source/${contact.source}/${contact.sourceItem}/${contact.thread}`" class="flex items-center text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group relative">
<RouterLink :to="`source/${contact.source}/${contact.sourceItem}/${contact.thread}`" class="flex items-center text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group relative" active-class="bg-gray-100 dark:bg-gray-700">
<Avatar :image="contact.image" :name="contact.name" :status="contact.status ?? 'unknown'" />
<span class="absolute bg-gray-800 bottom-0 right-0 h-4 w-4 text-center rounded-full">{{ Math.min(contact.count, 9) }}</span>
</RouterLink>
</li>
<li v-for="contact in pinned" class="shrink-0">
<RouterLink :to="`source/${contact.source}/${contact.id}/${contact.thread}`" class="flex items-center text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group relative">
<RouterLink :to="`source/${contact.source}/${contact.id}/${contact.thread}`" class="flex items-center text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group relative" active-class="bg-gray-100 dark:bg-gray-700">
<Avatar :image="contact.image" :name="contact.name" :status="contact.status ?? 'unknown'" />
<span v-if="contact.count > 0" class="absolute bg-gray-800 bottom-0 right-0 h-4 w-4 text-center rounded-full">{{ Math.min(contact.count, 9) }}</span>
<FontAwesomeIcon v-else :icon="faThumbTack" class="star absolute drop-shadow-2xl bottom-0 right-0 h-4 w-4 text-center rounded-full" />
@ -24,7 +24,7 @@
<hr/>
<ul class="space-y-2 font-medium pt-2">
<li>
<RouterLink to="/todo" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<RouterLink to="/todo" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group" active-class="bg-gray-100 dark:bg-gray-700" :class="route.name === 'home' ? 'bg-gray-100 dark:bg-gray-700' : ''">
<FontAwesomeIcon :icon="faListCheck" class="w-5 h-5 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" />
<span class="ms-3">To Do</span>
<span class="flex-grow" />
@ -32,19 +32,19 @@
</RouterLink>
</li>
<li>
<RouterLink to="/low" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<RouterLink to="/low" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group" active-class="bg-gray-100 dark:bg-gray-700">
<FontAwesomeIcon :icon="faList" class="w-5 h-5 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" />
<span class="ms-3">Low Priority</span>
</RouterLink>
</li>
<li>
<RouterLink to="/garden" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<RouterLink to="/garden" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group" active-class="bg-gray-100 dark:bg-gray-700">
<FontAwesomeIcon :icon="faLeaf" class="w-5 h-5 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" />
<span class="ms-3">Garden</span>
</RouterLink>
</li>
<li>
<RouterLink to="/me" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<RouterLink to="/me" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group" active-class="bg-gray-100 dark:bg-gray-700">
<FontAwesomeIcon :icon="faFaceSmile" class="w-5 h-5 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" />
<span class="ms-3">My messages</span>
</RouterLink>
@ -55,7 +55,7 @@
<div>Sources</div>
<ul class="space-y-2 p-2 text-sm">
<li v-for="source in sources">
<RouterLink :to="`/source/${source.id}`" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<RouterLink :to="`/source/${source.id}`" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group" active-class="bg-gray-100 dark:bg-gray-700">
{{ source.name }}
</RouterLink>
</li>
@ -67,19 +67,19 @@
<ul class="space-y-2 font-medium">
<li><hr/></li>
<li>
<RouterLink to="/snoozed" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<RouterLink to="/snoozed" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group" active-class="bg-gray-100 dark:bg-gray-700">
<FontAwesomeIcon :icon="faClock" class="w-5 h-5 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" />
<span class="ms-3">Snoozed</span>
</RouterLink>
</li>
<li>
<RouterLink to="/unsorted" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<RouterLink to="/unsorted" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group" active-class="bg-gray-100 dark:bg-gray-700">
<FontAwesomeIcon :icon="faShuffle" class="w-5 h-5 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" />
<span class="ms-3">Unsorted</span>
</RouterLink>
</li>
<li>
<RouterLink to="/settings" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<RouterLink to="/settings" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group" active-class="bg-gray-100 dark:bg-gray-700">
<FontAwesomeIcon :icon="faGear" class="w-5 h-5 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" />
<span class="ms-3">Settings</span>
</RouterLink>
@ -94,11 +94,13 @@ import { faBars, faClock, faFaceSmile, faGear, faLeaf, faList, faListCheck, faSh
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { initFlowbite } from 'flowbite'
import { computed, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { RouterLink, useRoute } from 'vue-router'
import type { ContactStatus } from "../state"
import { categories, favorites, sources, todoItems, urgentItems } from '../state'
import Avatar from "./Avatar.vue"
const route = useRoute();
onMounted(() => {
initFlowbite();
});

View file

@ -0,0 +1,18 @@
<template>
<div class="text-3xl mb-4 shrink-0">{{ title }}</div>
</template>
<script setup lang="ts">
defineProps<{
title: string;
}>();
</script>
<style scoped>
div {
width: calc((100vw - 23rem) / 2);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>

View file

@ -1,31 +1,31 @@
<template>
<div class="flex flex-col grow">
<div class="flex flex-col grow shrink">
<template v-for="(event, i) in threadItem.timeline">
<template v-if="event.type === 'message'">
<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'">
<Avatar v-bind="sourceObj.contacts[event.contact]" class="self-end" />
<div class="flex flex-col">
<span class="message mx-2 px-2 py-1 flex items-center rounded-full">{{ event.message }}</span>
<span class="author px-2 text-sm italic mx-2">{{ sourceObj.contacts[event.contact].name }}</span>
</div>
<div class="flex flex-col grow shrink thread overflow-y-auto mb-4 -mr-4 pr-4">
<template v-for="(event, i) in threadItem.timeline">
<template v-if="event.type === 'message'">
<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'">
<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 v-if="event.contact !== sourceObj.selfContact" class="author px-2 text-sm italic mx-2">{{ sourceObj.contacts[event.contact].name }}</span>
</div>
<div v-else style="max-width: 80%;" class="flex mb-2" :class="event.contact === sourceObj.selfContact ? 'self self-end flex-row-reverse' : 'other self-start text-gray-200'">
<span class="message px-2 py-1 flex items-center rounded-full" :class="event.contact === sourceObj.selfContact ? 'mr-13' : 'ml-13'">{{ event.message }}</span>
</div>
</template>
<div v-else-if="event.type === 'create-thread'" class="self-center mb-2 italic text-gray-500">
{{ sourceObj.contacts[event.contact].name }} created a new thread called "<RouterLink :to="`${baseUrl}/${source}/${sourceItem}/${event.thread}`" class="text-blue-500">{{ item.threads[event.thread].title }}</RouterLink>"
</div>
<div v-else style="max-width: 80%;" class="flex mb-px" :class="event.contact === sourceObj.selfContact ? 'self self-end flex-row-reverse' : 'other self-start text-gray-200'">
<span class="message px-2 py-1 flex items-center rounded-2xl ml-13" :class="getRoundedCornersClasses(i)">{{ event.message }}</span>
</div>
</template>
</div>
<input type="text" />
<div v-else-if="event.type === 'create-thread'" class="self-center mb-2 italic text-gray-500 text-center">
{{ sourceObj.contacts[event.contact].name }} created a new thread called "<RouterLink :to="`${baseUrl}/${source}/${sourceItem}/${event.thread}`" class="text-blue-500">{{ item.threads[event.thread].title }}</RouterLink>"
</div>
</template>
</div>
<form @submit="sendMessage" class="shrink-0">
<input id="message_input" class="w-full" ref="messageInput" autofocus type="text" v-model="message" />
</form>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { sources } from '../state';
import { computed, ref } from 'vue';
import { items, sources } from '../state';
import Avatar from './Avatar.vue';
import { RouterLink } from 'vue-router';
@ -39,9 +39,65 @@ const props = defineProps<{
const sourceObj = computed(() => sources.value[props.source]);
const item = computed(() => sourceObj.value.items[props.sourceItem]);
const threadItem = computed(() => item.value.threads[props.thread]);
const message = ref("");
const messageInput = ref();
function sendMessage() {
if (message.value.trim() === "") {
return;
}
threadItem.value.timeline.push({
type: "message",
contact: sourceObj.value.selfContact,
message: message.value,
time: Date.now()
});
let item = items.value.find(item => item.source === props.source && item.sourceItem === props.sourceItem);
if (item == null) {
item = { source: props.source, sourceItem: props.sourceItem, threads: {}, updatedAt: Date.now() };
items.value.push(item);
}
if (item.threads[props.thread] == null) {
item.threads[props.thread] = {
count: 1,
preview: message.value,
updatedAt: Date.now(),
contact: sourceObj.value.selfContact
}
} else {
item.threads[props.thread].count++;
item.threads[props.thread].preview = message.value;
item.threads[props.thread].updatedAt = Date.now();
item.threads[props.thread].contact = sourceObj.value.selfContact;
}
message.value = "";
messageInput.value?.focus();
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;
}
</script>
<style scoped>
.thread > * {
min-width: calc((100vw - 23rem) / 2);
}
.other .message {
background-color: gray;
color: rgb(229 231 235);

View file

@ -1,45 +1,50 @@
<template>
<div class="flex gap-x-4">
<div class="grow basis-6/12">
<div class="flex h-full">
<div class="grow basis-6/12 p-4 overflow-x-hidden">
<div class="text-3xl mb-4">Todo</div>
<div v-for="category in todoCategories">
<CategoryHeader v-bind="category" />
<ItemGroup :items="category.activeItems" :selected-thread="selectedItem" @select-item="selectItem" />
<ItemGroup :items="category.activeItems" :selected-thread="showThread ? selectedItem : undefined" @select-item="selectItem" />
</div>
</div>
<div class="hidden xl:flex grow basis-6/12 bg-slate-200 p-4 -m-4 ml-0 min-h-screen flex-col" v-if="selectedItem">
<div class="text-3xl mb-4">{{ threadTitle }}</div>
<Thread :source="selectedItem.source" :sourceItem="selectedItem.sourceItem" :thread="selectedItem.thread" base-url="/todo" />
<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="/todo" />
</div>
<Modal :model-value="true" class="block xl:hidden" v-if="selectedItem" @update:model-value="close">
<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" :sourceItem="selectedItem.sourceItem" :thread="selectedItem.thread" 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>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
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';
const route = useRoute();
const router = useRouter();
const selectedItem = computed<ThreadRef | undefined>(() => {
const { source, sourceItem, thread } = route.params;
const showThread = ref(false);
const selectedItem = ref<ThreadRef | undefined>();
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)) {
return { source: sourceInt, sourceItem: sourceItemInt, thread: threadInt };
selectedItem.value = { source: sourceInt, sourceItem: sourceItemInt, thread: threadInt };
showThread.value = true;
} else {
// Intentionally leave selectedItem so animations look right
showThread.value = false;
}
return undefined;
});
}, { immediate: true });
const threadTitle = computed(() => {
const selected = selectedItem.value;
@ -50,18 +55,19 @@ const threadTitle = computed(() => {
});
function selectItem(source: number, sourceItem: number, thread: number) {
console.log("!!", selectedItem.value, source, sourceItem, thread)
if (selectedItem.value != null) {
const s = selectedItem.value;
if (source === s.source && sourceItem === s.sourceItem && thread === s.thread) {
close();
return;
}
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;
}
router.push(`/todo/${source}/${sourceItem}/${thread}`);
}
function close() {
console.log("closing thread")
router.push("/todo");
}
</script>

View file

@ -6,8 +6,8 @@ import App from "./App.vue";
import Todo from "./components/Todo.vue";
const routes: RouteRecordRaw[] = [
{ path: '/', component: Todo },
{ path: '/todo/:source(\\d+)?/:sourceItem(\\d+)?/:thread(\\d+)?', component: Todo },
{ path: '/', component: Todo, name: 'home' },
{ path: '/todo/:source(\\d+)?/:sourceItem(\\d+)?/:thread(\\d+)?', component: Todo, name: 'todo' },
];
const router = createRouter({
@ -16,3 +16,7 @@ const router = createRouter({
});
createApp(App).use(router).mount("#app");
document.onkeypress = function() {
document.getElementById("message_input")?.focus();
};

View file

@ -12,4 +12,10 @@ export default {
require('flowbite/plugin')
],
darkMode: 'selector',
safelist: [
'rounded-tr-none',
'rounded-tl-none',
'rounded-br-none',
'rounded-bl-none'
]
}