WIP
This commit is contained in:
commit
a8e3eaea46
29 changed files with 1160 additions and 0 deletions
1
.eslintignore
Normal file
1
.eslintignore
Normal file
|
@ -0,0 +1 @@
|
|||
.eslintrc.cjs
|
56
.eslintrc.cjs
Normal file
56
.eslintrc.cjs
Normal 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
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
node_modules
|
7
.prettierrc.json
Normal file
7
.prettierrc.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "auto",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 4,
|
||||
"trailingComma": "none"
|
||||
}
|
9
README.md
Normal file
9
README.md
Normal 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
BIN
bun.lockb
Normal file
Binary file not shown.
16
index.html
Normal file
16
index.html
Normal 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
34
package.json
Normal 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
6
postcss.config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
1
public/vite.svg
Normal file
1
public/vite.svg
Normal 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
16
src/App.vue
Normal 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
1
src/assets/vue.svg
Normal 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
32
src/components/Avatar.vue
Normal 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>
|
17
src/components/CategoryHeader.vue
Normal file
17
src/components/CategoryHeader.vue
Normal 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
80
src/components/Item.vue
Normal 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>
|
22
src/components/ItemGroup.vue
Normal file
22
src/components/ItemGroup.vue
Normal 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
134
src/components/Modal.vue
Normal 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
191
src/components/Nav.vue
Normal 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
65
src/components/Thread.vue
Normal 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
67
src/components/Todo.vue
Normal 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
18
src/main.ts
Normal 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
294
src/state.ts
Normal 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
13
src/style.css
Normal 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
17
src/utils.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
15
tailwind.config.js
Normal file
15
tailwind.config.js
Normal 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
27
tsconfig.json
Normal 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
11
tsconfig.node.json
Normal 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
8
vite.config.ts
Normal 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: "/"
|
||||
})
|
Loading…
Reference in a new issue