This commit is contained in:
thepaperpilot 2024-04-07 17:02:19 -05:00
commit a8e3eaea46
29 changed files with 1160 additions and 0 deletions

1
.eslintignore Normal file
View file

@ -0,0 +1 @@
.eslintrc.cjs

56
.eslintrc.cjs Normal file
View file

@ -0,0 +1,56 @@
require("@rushstack/eslint-patch/modern-module-resolution");
module.exports = {
root: true,
env: {
node: true
},
parser: '@typescript-eslint/parser',
plugins: ["@typescript-eslint"],
overrides: [
{
files: ['*.ts', '*.tsx'],
extends: [
"plugin:vue/vue3-essential",
"@vue/eslint-config-typescript/recommended",
"@vue/eslint-config-prettier"
],
parserOptions: [
{
ecmaVersion: 2020,
project: "./tsconfig.json"
},
{
ecmaVersion: 2020,
project: "./tsconfig.node.json"
},
],
}
],
ignorePatterns: ["src/lib"],
rules: {
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
"vue/script-setup-uses-vars": "warn",
"vue/no-mutating-props": "off",
"vue/multi-word-component-names": "off",
"@typescript-eslint/strict-boolean-expressions": [
"error",
{
allowNullableObject: true,
allowNullableBoolean: true
}
],
"eqeqeq": [
"error",
"always",
{
"null": "never"
}
]
},
globals: {
defineProps: "readonly",
defineEmits: "readonly"
}
};

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules

7
.prettierrc.json Normal file
View file

@ -0,0 +1,7 @@
{
"arrowParens": "avoid",
"endOfLine": "auto",
"printWidth": 100,
"tabWidth": 4,
"trailingComma": "none"
}

9
README.md Normal file
View file

@ -0,0 +1,9 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended Setup
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (previously Volar) and disable Vetur
- Use [vue-tsc](https://github.com/vuejs/language-tools/tree/master/packages/tsc) for performing the same type checking from the command line, or for generating d.ts files for SFCs.

BIN
bun.lockb Normal file

Binary file not shown.

16
index.html Normal file
View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>
My Digital Garden Mockup
</title>
</head>
<body>
<div id="app" class="dark bg-gray-100"></div>
<div id="modal-root" class="dark"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

34
package.json Normal file
View file

@ -0,0 +1,34 @@
{
"name": "commune-mock",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "latest-3",
"autoprefixer": "^10.4.19",
"flowbite": "^2.3.0",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"vue": "^3.4.21",
"vue-router": "4"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.1.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"eslint": "^8.6.0",
"typescript": "^5.2.2",
"prettier": "^3.2.5",
"vite": "^5.2.0",
"vue-tsc": "^2.0.6"
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

16
src/App.vue Normal file
View file

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

1
src/assets/vue.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

32
src/components/Avatar.vue Normal file
View file

@ -0,0 +1,32 @@
<template>
<div :class="statusClass">
<img v-if="image != null" :src="image" class="w-10 h-10 rounded-full bg-gray-50" />
<div v-else class="w-10 h-10 rounded-full bg-gray-50 relative">
<div class="text-gray-900 center absolute">{{ name.slice(0, 1) }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ContactStatus } from "../state";
import { computed } from "vue";
const props = defineProps<{
image?: string;
name: string;
status: ContactStatus;
}>();
const statusClass = computed(() => {
switch (props.status) {
default:case "unknown":
return "rounded-full shrink-0 border-2 border-transparent";
case "online":
return "rounded-full shrink-0 border-2 border-emerald-400";
case "away":
return "rounded-full shrink-0 border-2 border-yellow-400";
case "offline":
return "rounded-full shrink-0 border-2 border-stone-400";
};
});
</script>

View file

@ -0,0 +1,17 @@
<template>
<span class="text-xl mx-4 group">
{{ name }}
<RouterLink :to="`/settings/category/${id}`">
<FontAwesomeIcon :icon="faGear" class="hidden group-hover:inline" />
</RouterLink>
</span>
</template>
<script setup lang="ts">
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { faGear } from '@fortawesome/free-solid-svg-icons';
import { RouterLink } from 'vue-router';
import type { Category } from "../state";
defineProps<Category>();
</script>

80
src/components/Item.vue Normal file
View file

@ -0,0 +1,80 @@
<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>
<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 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 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>
<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>
</template>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { Item, ThreadRef } from "../state";
import { sources } from '../state';
import Avatar from './Avatar.vue';
const props = defineProps<{
item: Item,
selectedThread?: ThreadRef
}>();
const emits = defineEmits<{
select: [thread: number];
}>();
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 isSelected(thread: number | string) {
if (typeof thread === "string") {
thread = parseInt(thread);
}
if (props.selectedThread?.source !== props.item.source) {
return false;
}
if (props.selectedThread.sourceItem !== props.item.sourceItem) {
return false;
}
if (props.selectedThread.thread !== thread) {
return false;
}
return true;
}
</script>
<style scoped>
.-mt-2px {
margin-top: -2px;
}
.selected:not(:first-child) {
margin-top: 1rem;
}
.selected:not(:last-child) {
margin-bottom: 1rem;
}
</style>

View file

@ -0,0 +1,22 @@
<template>
<div class="mb-4 item-group">
<Item v-for="item in items"
:item="item"
:selected-thread="selectedThread"
@select="thread => emits('selectItem', item.source, item.sourceItem, thread)" />
</div>
</template>
<script setup lang="ts">
import type { Item as StateItem, ThreadRef } from "../state";
import Item from "./Item.vue";
const emits = defineEmits<{
selectItem: [source: number, sourceItem: number, thread: number]
}>();
defineProps<{
items: StateItem[],
selectedThread?: ThreadRef
}>();
</script>

134
src/components/Modal.vue Normal file
View file

@ -0,0 +1,134 @@
<template>
<teleport to="#modal-root">
<transition
name="modal"
@before-enter="isAnimating = true"
@after-leave="isAnimating = false"
appear
>
<div
class="modal-mask"
v-show="modelValue"
v-on:pointerdown.self="close"
v-bind="$attrs"
>
<div class="modal-wrapper">
<div class="modal-container" :width="width">
<div class="modal-header">
<slot name="header" :shown="isOpen">default header</slot>
</div>
<div class="modal-body">
<slot name="body" :shown="isOpen">default body</slot>
</div>
<div class="modal-footer">
<slot name="footer" :shown="isOpen">
<div class="modal-default-footer">
<div class="modal-default-flex-grow"></div>
<button class="button modal-default-button" @click="close">
Close
</button>
</div>
</slot>
</div>
</div>
</div>
</div>
</transition>
</teleport>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
const props = defineProps<{
modelValue: boolean;
preventClosing?: boolean;
width?: string;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: boolean): void;
}>();
const isOpen = computed(() => props.modelValue || isAnimating.value);
function close() {
if (props.preventClosing !== true) {
emit("update:modelValue", false);
}
}
const isAnimating = ref(false);
defineExpose({ isOpen });
</script>
<style>
.modal-mask {
position: fixed;
z-index: 9998;
top: 0;
left: 0;
bottom: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.5);
transition: opacity 0.3s ease;
}
.modal-wrapper {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.modal-container {
width: 640px;
max-width: 95vw;
max-height: 95vh;
background-color: white;
padding: 20px;
border-radius: 5px;
transition: all 0.3s ease;
text-align: left;
border: grey;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.modal-header {
width: 100%;
}
.modal-body {
margin: 20px 0;
width: 100%;
overflow-y: auto;
overflow-x: hidden;
}
.modal-footer {
width: 100%;
}
.modal-default-footer {
display: flex;
}
.modal-default-flex-grow {
flex-grow: 1;
}
.modal-enter-from {
opacity: 0;
}
.modal-leave-active {
opacity: 0;
}
.modal-enter-from .modal-container,
.modal-leave-active .modal-container {
-webkit-transform: scale(1.1);
transform: scale(1.1);
}
</style>

191
src/components/Nav.vue Normal file
View file

@ -0,0 +1,191 @@
<template>
<button data-drawer-target="default-sidebar" data-drawer-toggle="default-sidebar" aria-controls="default-sidebar" type="button" class="inline-flex items-center p-2 mt-2 ms-3 text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600">
<span class="sr-only">Open sidebar</span>
<FontAwesomeIcon :icon="faBars" class="w-6 h-6" />
</button>
<aside id="default-sidebar" class="fixed top-0 left-0 z-40 w-64 h-screen transition-transform -translate-x-full md:translate-x-0" aria-label="Sidebar">
<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">
<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">
<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" />
</RouterLink>
</li>
</ul>
<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">
<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" />
<span v-if="todoItems.length > 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">{{ Math.min(todoItems.length, 99) }}</span>
</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">
<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">
<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">
<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>
</li>
<li><hr/></li>
<li>
<div class="dark:text-white group p-2">
<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">
{{ source.name }}
</RouterLink>
</li>
</ul>
</div>
</li>
</ul>
<div class="grow"></div>
<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">
<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">
<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">
<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>
</li>
</ul>
</div>
</aside>
</template>
<script setup lang="ts">
import { faBars, faClock, faFaceSmile, faGear, faLeaf, faList, faListCheck, faShuffle, faThumbTack } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { initFlowbite } from 'flowbite'
import { computed, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import type { ContactStatus } from "../state"
import { categories, favorites, sources, todoItems, urgentItems } from '../state'
import Avatar from "./Avatar.vue"
onMounted(() => {
initFlowbite();
});
interface ContactNotif {
count: number;
id?: number;
name: string;
image?: string | undefined;
status?: ContactStatus;
source: number;
sourceItem: number;
thread: number;
}
const urgentContacts = computed(() =>
urgentItems.value.reduce((acc, item) =>
[
...acc,
...Object.keys(item.threads).map(t => {
const thread = parseInt(t);
const { contact, count } = item.threads[thread];
return {
...(contact != null ? sources.value[item.source].contacts[contact] : {
name: sources.value[item.source].items[item.sourceItem].title
}),
count,
source: item.source,
sourceItem: item.sourceItem,
thread
}
})
], [] as ContactNotif[]));
const pinned = computed(() => {
let pinned = favorites.value.slice();
const nonUrgent: ContactNotif[] = [];
categories.value.forEach(category => {
switch (category.priority) {
case "urgent":
category.activeItems.forEach(item => {
pinned = pinned.filter(pin => pin.source !== item.source || pin.sourceItem !== item.sourceItem);
});
break;
case "todo":case "notify":
category.activeItems.forEach(item => {
Object.keys(item.threads).forEach(t => {
const thread = parseInt(t);
pinned = pinned.filter(pin => {
if (pin.source !== item.source || pin.sourceItem !== item.sourceItem || pin.thread !== thread) {
return true;
}
const { contact, count } = item.threads[thread];
nonUrgent.push({
...(contact != null ? sources.value[item.source].contacts[contact] : {
name: sources.value[item.source].items[item.sourceItem].title
}),
count,
source: item.source,
sourceItem: item.sourceItem,
thread
})
return false;});
});
});
break;
default:
break;
}
});
return [...nonUrgent, ...pinned.map<ContactNotif>(threadRef => {
const item = sources.value[threadRef.source].items[threadRef.sourceItem];
return {
...threadRef,
name: item.threads[threadRef.thread].title,
count: 0
}
})];
});
</script>
<style scoped>
.star {
filter: drop-shadow(0 -1px 4px rgb(0 0 0));
}
</style>

65
src/components/Thread.vue Normal file
View file

@ -0,0 +1,65 @@
<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>
<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>
</template>
</div>
<input type="text" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { sources } from '../state';
import Avatar from './Avatar.vue';
import { RouterLink } from 'vue-router';
const props = defineProps<{
source: number;
sourceItem: number;
thread: number;
baseUrl: string;
}>();
const sourceObj = computed(() => sources.value[props.source]);
const item = computed(() => sourceObj.value.items[props.sourceItem]);
const threadItem = computed(() => item.value.threads[props.thread]);
</script>
<style scoped>
.other .message {
background-color: gray;
color: rgb(229 231 235);
}
.self .message {
background-color: #8db3ff;
}
.self .author {
text-align: right;
}
.ml-13 {
margin-left: 3.25rem;
}
.mr-13 {
margin-right: 3.25rem;
}
</style>

67
src/components/Todo.vue Normal file
View file

@ -0,0 +1,67 @@
<template>
<div class="flex gap-x-4">
<div class="grow basis-6/12">
<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" />
</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>
<Modal :model-value="true" class="block xl:hidden" v-if="selectedItem" @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>
</Modal>
</div>
</template>
<script setup lang="ts">
import { computed } 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 Thread from './Thread.vue';
const route = useRoute();
const router = useRouter();
const selectedItem = computed<ThreadRef | undefined>(() => {
const { source, sourceItem, thread } = route.params;
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 };
}
return undefined;
});
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) {
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;
}
}
router.push(`/todo/${source}/${sourceItem}/${thread}`);
}
function close() {
router.push("/todo");
}
</script>

18
src/main.ts Normal file
View file

@ -0,0 +1,18 @@
import { createApp } from "vue";
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";
const routes: RouteRecordRaw[] = [
{ path: '/', component: Todo },
{ path: '/todo/:source(\\d+)?/:sourceItem(\\d+)?/:thread(\\d+)?', component: Todo },
];
const router = createRouter({
history: createMemoryHistory(),
routes
});
createApp(App).use(router).mount("#app");

294
src/state.ts Normal file
View file

@ -0,0 +1,294 @@
import { computed, ref, watch } from "vue";
import { mergeSortedLists } from "./utils";
export type SourceType = "email" | "matrix" | "xmpp" | "rss" | "site";
export interface Item {
source: number;
sourceItem: number;
updatedAt: number;
threads: Record<number, ItemThread>;
}
export interface ItemThread {
category?: number;
count: number;
preview: string;
contact?: number;
snoozedUntil?: number;
updatedAt: number;
}
export interface SourceItem {
id: number;
title: string;
image?: string;
contacts?: number[];
threads: Record<number, SourceThread>;
}
export interface SourceThread {
id: number;
title: string;
timeline: ThreadEvent[];
}
export interface MessageEvent {
type: "message";
contact: number;
time: number;
message: string;
}
export interface CreateThreadEvent {
type: "create-thread";
contact: number;
time: number;
thread: number;
}
export type ThreadEvent = MessageEvent | CreateThreadEvent;
export type ContactStatus = "online" | "away" | "offline" | "unknown";
export interface Contact {
id: number;
name: string;
image?: string;
status: ContactStatus;
}
export interface Source {
id: number;
name: string;
type: SourceType;
items: Record<number, SourceItem>;
contacts: Record<number, Contact>;
selfContact: number;
}
export interface LogicalRule {
type: "any" | "all" | "none";
rules: Rule[];
}
export interface IsDmRule {
type: "isDM";
inverted: boolean;
}
export type Rule = LogicalRule | IsDmRule;
export type Priority = "urgent" | "notify" | "todo" | "garden";
export interface Category {
id: number;
name: string;
priority: Priority;
activeItems: Item[];
}
export interface ThreadRef {
source: number;
sourceItem: number;
thread: number;
}
export const sources = ref<Record<number, Source>>({
0: {
id: 0,
items: {
0: {
id: 0,
title: "Test Person",
threads: {
0: {
id: 0,
title: "Thread 1",
timeline: [
{ type: "message", contact: 0, time: Date.now(), message: "Howdy!" }
]
}
}
},
1: {
id: 1,
title: "Announcements",
threads: {
0: {
id: 0,
title: "Thread 2",
timeline: [
{ type: "message", contact: 0, time: Date.now(), message: "Howdy!" }
]
}
}
},
2: {
id: 2,
title: "C",
threads: {
0: {
id: 0,
title: "Thread 3",
timeline: [
{ type: "message", contact: 0, time: Date.now(), message: "Howdy!" }
]
}
}
},
3: {
id: 3,
title: "Another Person",
threads: {
0: {
id: 0,
title: "Thread 4",
timeline: [
{ type: "message", contact: 0, time: Date.now(), message: "Howdy!" },
{ type: "message", contact: 1, time: Date.now(), message: "Howdy back!" },
{ type: "message", contact: 0, time: Date.now(), message: "Howdy 2!" },
{ type: "message", contact: 0, time: Date.now(), message: "Howdy 3!" },
{ type: "create-thread", contact: 0, time: Date.now(), thread: 1 }
]
},
1: {
id: 1,
title: "Side Thread",
timeline: [
{ type: "message", contact: 0, time: Date.now(), message: "Howdy!" }
]
}
}
}
},
name: "Test Source",
type: "matrix",
contacts: {
0: {
id: 0,
name: "Test Person",
status: "online",
image: "https://picsum.photos/seed/avatar0/64"
},
1: {
id: 1,
name: "Me",
status: "online",
image: "https://picsum.photos/seed/avatar1/64"
}
},
selfContact: 1
}
});
export const items = ref<Item[]>([
{
source: 0,
sourceItem: 0,
updatedAt: Date.now(),
threads: {
0: {
category: 0,
count: 1,
preview: "Did you see the announcement?",
contact: 0,
updatedAt: Date.now()
}
}
},
{
source: 0,
sourceItem: 1,
updatedAt: Date.now() - 60 * 1000,
threads: {
0: {
category: 0,
count: 2,
preview: "Fire!",
updatedAt: Date.now() - 60 * 1000,
}
}
},
{
source: 0,
sourceItem: 3,
updatedAt: Date.now(),
threads: {
0: {
category: 1,
count: 6,
preview: "Did you get that?",
contact: 0,
updatedAt: Date.now()
},
1: {
category: 1,
count: 2,
preview: "Let's circle back",
updatedAt: Date.now()
}
}
}
]);
export const unsortedItems = ref<Item[]>([]);
export const rules = ref<{ category?: string; rules: Rule[] }[]>([]);
export const categories = ref<Category[]>([
{
id: 0,
name: "Urgent",
priority: "urgent",
activeItems: []
},
{
id: 1,
name: "DMs",
priority: "notify",
activeItems: []
}
]);
export const favorites = ref<ThreadRef[]>([
{ source: 0, sourceItem: 2, thread: 0 },
{ source: 0, sourceItem: 1, thread: 0 },
{ source: 0, sourceItem: 3, thread: 0 }
]);
export const urgentItems = computed(() =>
categories.value
.filter(cat => cat.priority === "urgent")
.reduce((acc, curr) => mergeSortedLists(acc, curr.activeItems), [] as Item[])
);
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)));
watch(
items,
items => {
const mappedItems: Record<number, Item[]> = {};
const unsorted: Item[] = [];
items.forEach(item => {
let includeInUnsorted = false;
const includeInCategories = new Set<number>();
Object.values(item.threads).forEach(t => {
if (t.category == null) {
includeInUnsorted = true;
} else {
includeInCategories.add(t.category);
}
});
if (includeInUnsorted) {
unsorted.push(item);
}
includeInCategories.forEach(cat => mappedItems[cat] = [...(mappedItems[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) ?? [];
});
unsortedItems.value = unsorted;
},
{ immediate: true }
);

13
src/style.css Normal file
View file

@ -0,0 +1,13 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.center {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
* {
transition: all .2s ease;
}

17
src/utils.ts Normal file
View file

@ -0,0 +1,17 @@
// 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> {
const merged: Array<T> = [];
let i = 0,
j = 0;
while (i < arr1.length && j < arr2.length) {
if (arr1[i] < arr2[j]) {
merged.push(arr1[i++]);
} else {
merged.push(arr2[j++]);
}
}
// Append any remaining elements from arr1 or arr2
return [...merged, ...arr1.slice(i), ...arr2.slice(j)];
}

1
src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

15
tailwind.config.js Normal file
View file

@ -0,0 +1,15 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
"./node_modules/flowbite/**/*.js"
],
theme: {
extend: {}
},
plugins: [
require('flowbite/plugin')
],
darkMode: 'selector',
}

27
tsconfig.json Normal file
View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "esnext",
"useDefineForClassFields": true,
"module": "esnext",
"lib": ["esnext", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"importHelpers": true,
"moduleResolution": "node",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"jsxImportSource": "vue",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
tsconfig.node.json Normal file
View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

8
vite.config.ts Normal file
View file

@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
base: "/"
})