store for lists; output correct lists; start contextmenui

This commit is contained in:
2025-03-07 16:53:05 +01:00
parent ad8fa067f4
commit 393812b872
32 changed files with 1289 additions and 53 deletions
-1
View File
@@ -4,7 +4,6 @@
</PropertyGroup>
<ItemGroup>
<Folder Include="app\" />
<Folder Include="src\utils\" />
<Folder Include="src\views\" />
</ItemGroup>
</Project>
+81 -29
View File
@@ -1,47 +1,99 @@
<template>
<aside class="menu">
<img class="logo" src="/just.svg" :alt="$t('app.name')" />
<nav class="navigation">
<navigation-item name="Shopping" symbol="ListTodo" :active="true" :count="7" />
<navigation-item name="Work" symbol="ListTodo" :count="702" />
<navigation-item name="Apartment" symbol="ListTodo" />
<navigation-item name="Add a list" symbol="ListPlus" :minor="true" />
</nav>
<div class="menu-bottom">
<navigation-item name="Settings" symbol="Bolt" :minor="true" />
<div class="app">
<app-menu :open="menuOpen" v-click-outside="closeMenu" />
<main class="main">
<header class="top">
<img class="top-logo" src="/just.svg" :alt="$t('app.name')" />
<button type="button" class="top-menu-toggle" @click.stop="menuOpen=!menuOpen">
<ui-icon symbol="Menu" :size="18" :stroke-width="2" />
</button>
<button type="button" class="top-list">
Shopping
<ui-icon symbol="ChevronDown" :size="16" />
</button>
</header>
<!-- <button type="button" @click="preload">Preload</button>-->
</main>
</div>
</aside>
</template>
<script setup lang="ts">
import NavigationItem from '@/components/navigation-item.vue';
import { posts } from '@/db';
import { ref } from 'vue'
import AppMenu from '@/modules/app-menu.vue'
import { useResize } from '@/utils/composables'
let items = posts.find().fetch();
console.info(items);
let menuOpen = ref(false);
function closeMenu()
{
menuOpen.value = false;
}
let resizeTimeout: number | undefined = undefined;
useResize(() =>
{
document.body.classList.add('is-resizing');
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() =>
{
document.body.classList.remove('is-resizing');
}, 300);
})
</script>
<style lang="scss" scoped>
.menu
.main
{
width: 260px;
margin: 10px;
border-radius: 8px;
background: var(--color-menu-bg);
height: calc(100vh - 20px);
overflow-y: auto;
padding: 16px;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
padding: var(--space-m) 0;
@media (max-width: 700px)
{
padding: var(--space-m);
}
}
.logo
.top
{
width: 30px;
color: var(--color-text-dim);
display: flex;
align-items: stretch;
gap: var(--space-s);
}
.navigation
.top-logo
{
margin-top: 12px;
height: 30px;
}
.top-menu-toggle
{
color: var(--color-text-dim);
width: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 0.2s ease;
&:hover
{
color: var(--color-text);
}
}
.top-logo, .top-menu-toggle
{
@media (min-width: 701px)
{
display: none;
}
}
.top-list
{
color: var(--color-text-dim);
display: inline-flex;
align-items: center;
gap: var(--space-s);
font-weight: 500;
}
</style>
+289
View File
@@ -0,0 +1,289 @@
<template>
<button v-if="!confirming" :disabled="disabled" type="button" @click.stop="onClick" class="ui-dropdown-button" :class="{ 'has-icon': icon, 'is-active': selected, 'is-multiline': multiline }">
<ui-icon v-if="icon" :symbol="icon" class="ui-dropdown-button-icon" />
<span class="-name"><ui-localize :value="label" /><span v-if="confirm && !confirming"> &hellip;</span></span>
<ui-icon v-if="selected" symbol="fth-check" class="ui-dropdown-button-selected" />
<i v-if="loading" class="ui-dropdown-button-progress"></i>
</button>
<div v-if="confirming" class="ui-dropdown-button ui-dropdown-button-confirmation">
<ui-icon v-if="icon" :symbol="icon" class="ui-dropdown-button-icon" />
<span class="-name"><ui-localize :value="label" /><span>?</span></span>
<ui-button type="small light" icon="fth-x" title="@ui.cancel" @click.stop="confirming=false" />
<ui-button :type="negative ? 'small danger' : 'small primary'" icon="fth-check" title="@ui.ok" @click.stop="onClick($event, true)" />
</div>
</template>
<script>
export default {
name: 'uiDropdownButton',
props: {
value: {
default: null
},
multiline: {
type: Boolean,
default: false
},
label: {
type: String,
required: true
},
icon: {
type: String
},
selected: {
type: Boolean,
default: false
},
confirm: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
negative: {
type: Boolean,
default: false
},
prevent: {
type: Boolean,
default: false
}
},
data: () => ({
dropdown: null,
loading: false,
confirming: false
}),
mounted ()
{
// find parent dropdown so we can pass it on item click
let current = this;
do
{
if (current.$options.name === 'uiDropdown')
{
this.dropdown = current;
break;
}
}
while (current = current.$parent);
if (!this.dropdown)
{
console.warn('ui-dropdown-button: Could not find parent <ui-dropdown />');
}
},
methods: {
onClick(e, confirmed)
{
var instance = this;
if (!this.loading && !this.disabled)
{
if (this.confirm && !confirmed)
{
this.confirming = true;
//var confirmed = window.confirm('confirm?');
return;
}
else
{
this.confirming = false;
}
if (!this.prevent && this.dropdown)
{
this.dropdown.hide();
}
let self = this;
this.$emit('click', this.value, {
dropdown: this.dropdown,
hide()
{
if (self.dropdown)
{
self.dropdown.hide();
}
instance.$emit('hide');
},
loading(isLoading)
{
instance.loading = isLoading;
}
});
}
}
}
}
</script>
<style lang="scss">
button.ui-dropdown-button, a.ui-dropdown-button
{
display: grid;
width: 100%;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px;
align-items: center;
font-size: var(--font-size);
font-weight: 500;
padding: 0 16px;
height: 48px;
color: var(--color-text);
border-radius: var(--radius);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 100%;
.-minor
{
display: block;
font-size: var(--font-size-xs);
color: var(--color-text-dim-one);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&.has-icon
{
grid-template-columns: 30px minmax(0, 1fr) auto;
&:not([disabled]):hover .ui-dropdown-button-icon
{
color: var(--color-text);
}
}
&.has-icon.is-negative:not([disabled]):hover .ui-dropdown-button-icon
{
color: var(--color-accent-error);
}
&.is-multiline
{
white-space: normal;
overflow: visible;
}
&:not([disabled]):hover, &.is-active, &:focus
{
background: var(--color-dropdown-selected);
color: var(--color-text);
//font-weight: 700;
.ui-dropdown-list-item-icon
{
color: var(--color-text);
}
}
&.is-active
{
font-weight: 700;
}
&.is-active .ui-dropdown-button-icon
{
color: var(--color-text);
}
&[disabled]
{
color: var(--color-text-dim);
cursor: default;
pointer-events: none;
.ui-dropdown-list-item-icon,
.ui-dropdown-button-icon
{
color: var(--color-text-dim);
}
}
& + .ui-dropdown-button
{
margin-top: 4px;
}
}
.ui-dropdown-button-icon
{
font-size: 18px;
line-height: 1;
font-weight: 400;
position: relative;
top: -1px;
color: var(--color-text);
}
.ui-dropdown-button-progress
{
width: 16px;
height: 16px;
z-index: 2;
border-radius: 40px;
border: 2px solid transparent;
border-left-color: var(--color-text);
opacity: 1;
will-change: transform;
animation: rotating .5s linear infinite;
transition: opacity .25s ease;
}
.ui-dropdown-button-confirmation
{
display: grid;
flex-grow: 0;
width: 100%;
grid-template-columns: 30px minmax(0, 1fr) auto auto;
gap: 6px;
align-items: center;
font-size: var(--font-size);
padding: 0 6px 0 16px;
height: 48px;
color: var(--color-text);
border-radius: var(--radius);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 300px;
.ui-button + .ui-button
{
margin-left: 2px;
}
.ui-button
{
width: 40px;
}
}
@keyframes rotating
{
from
{
-webkit-transform: rotate(0);
transform: rotate(0)
}
to
{
-webkit-transform: rotate(1turn);
transform: rotate(1turn)
}
}
</style>
+238
View File
@@ -0,0 +1,238 @@
<template>
<div class="ui-dropdown-container" :class="{ 'is-open': open }" v-click-outside="hide">
<slot name="before"></slot>
<div v-if="hasButton" ref="trigger" class="ui-dropdown-toggle" @click.prevent.stop="toggle">
<slot name="button"></slot>
</div>
<div class="ui-dropdown" ref="overlay" role="dialog" v-if="open" :class="dropdownClasses" :style="dropdownStyle">
<slot></slot>
</div>
</div>
</template>
<script>
//import Overlay from 'zero/helpers/overlay.js';
import { defineComponent } from 'vue';
const varKey = 'zero.ui-dropdown.current';
export default defineComponent({
name: 'uiDropdown',
props: {
align: {
type: String,
default: 'left',
handler(newValue, oldValue)
{
this.updateAlignment();
}
},
theme: {
type: String,
default: 'dark'
},
locked: {
type: Boolean,
default: false
},
disablePositioning: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
}
},
computed: {
hasButton()
{
return this.$slots.hasOwnProperty('button');
},
dropdownClasses()
{
let classes = ['align-' + this.valign, 'align-' + this.halign];
if (!!this.theme)
{
classes.push('theme-' + this.theme);
}
return classes;
},
dropdownStyle()
{
if (this.topOffset != 0)
{
return `--v-offset: ${this.topOffset}px`;
}
return null;
}
},
data: () => ({
open: false,
valign: 'top',
halign: 'left',
topOffset: 0
}),
mounted()
{
this.updateAlignment();
},
methods: {
updateAlignment()
{
this.valign = this.align.indexOf('bottom') > -1 ? 'bottom' : 'top';
this.halign = this.align.indexOf('right') > -1 ? 'right' : 'left';
},
toggle()
{
if (this.open)
{
this.hide();
}
else if (!this.disabled)
{
this.show();
}
},
show()
{
if (this.disabled)
{
return;
}
if (this.zero.runtimeVariables[varKey])
{
this.zero.runtimeVariables[varKey].hide();
}
this.zero.runtimeVariables[varKey] = this;
//Overlay.setDropdown(this);
this.open = true;
this.position();
this.$emit('opened');
},
hide()
{
if (this.locked)
{
return;
}
this.open = false;
this.$emit('closed');
this.zero.runtimeVariables[varKey] = null;
},
position()
{
if (this.disablePositioning)
{
return;
}
this.$nextTick(() =>
{
// the trigger which is the relative to the overlay
const reference = this.$el;
// get bounding boxes both for the trigger and the overlay
const triggerBoundingBox = reference ? reference.getBoundingClientRect() : { width: 0, height: 0, left: 0, top: 0 };
const overlayBoundingBox = this.$refs.overlay.getBoundingClientRect();
const windowBox = { width: window.innerWidth, height: window.innerHeight };
const windowOffset = 32;
// calculate available space for the dropdown in all 4 directions areound the trigger element
let availableSpace = {
left: triggerBoundingBox.left + triggerBoundingBox.width - windowOffset,
right: windowBox.width - triggerBoundingBox.left - windowOffset,
top: triggerBoundingBox.top - windowOffset,
bottom: windowBox.height - triggerBoundingBox.top - triggerBoundingBox.height - windowOffset
};
if (this.valign == 'top' && availableSpace.bottom < overlayBoundingBox.height && availableSpace.top > availableSpace.bottom)
{
this.valign = 'bottom';
}
if (this.valign == 'bottom' && availableSpace.top < overlayBoundingBox.height && availableSpace.bottom > availableSpace.top)
{
this.valign = 'top';
}
if (this.halign == 'left' && availableSpace.right < overlayBoundingBox.width && availableSpace.left > availableSpace.right)
{
this.halign = 'right';
}
if (this.halign == 'right' && availableSpace.left < overlayBoundingBox.width && availableSpace.right > availableSpace.left)
{
this.halign = 'left';
}
const headerHeight = 90;
const topOffset = ~~((availableSpace.top - headerHeight) - overlayBoundingBox.height) + 1;
this.topOffset = this.valign == 'bottom' && topOffset < 0 ? topOffset : 0;
});
}
}
});
</script>
<style lang="scss">
.ui-dropdown-container
{
position: relative;
}
.ui-dropdown
{
--v-offset: 0px;
position: absolute;
min-width: 300px;
min-height: 20px;
background: var(--color-dropdown);
border-radius: var(--radius);
border: 1px solid var(--color-dropdown-border);
box-shadow: var(--shadow-dropdown);
z-index: 8;
top: calc(100% + 5px);
padding: 5px;
color: var(--color-text);
&.align-right
{
right: 0;
}
&.align-top
{
top: calc(100% + 5px);
bottom: auto;
}
&.align-bottom
{
bottom: calc(100% + 5px + var(--v-offset));
top: auto;
}
}
</style>
+4
View File
@@ -1,7 +1,11 @@
import { type App } from 'vue';
import uiIcon from './ui-icon.vue';
import uiContextmenu from './ui-contextmenu.vue';
import uiContextmenuButton from './ui-contextmenu-button.vue';
export function createComponents (app: App)
{
app.component('ui-icon', uiIcon);
app.component('ui-contextmenu', uiContextmenu);
app.component('ui-contextmenu-button', uiContextmenuButton);
}
@@ -0,0 +1,74 @@
<template>
<button :disabled="disabled" type="button" @click.stop="onClick" class="ui-contextmenu-button">
<ui-icon v-if="symbol" :symbol="symbol" class="ui-contextmenu-button-icon" />
<span v-else class="ui-contextmenu-button-icon is-placeholder"></span>
<span class="ui-contextmenu-button-label" v-text="label" :class="{ 'is-minor': minor }"></span>
</button>
</template>
<script setup lang="ts">
interface Props {
label: string,
symbol?: string,
disabled?: boolean,
autohide?: boolean,
minor?: boolean
}
const { label, symbol, disabled, minor } = defineProps<Props>();
function onClick(ev: Event)
{
console.info('clicked');
}
</script>
<style lang="scss">
.ui-contextmenu-button
{
appearance: none;
height: 34px;
border-radius: var(--radius-inner);
display: grid;
grid-template-columns: auto minmax(0, auto) 1fr;
text-decoration: none;
align-items: center;
gap: var(--space-s);
background: var(--color-contextmenu-item-bg);
padding: 0 var(--space-m);
width: 100%;
//transition: background-color 0.2s ease;
&[disabled]
{
pointer-events: none;
}
&:hover
{
cursor: pointer;
background: var(--color-contextmenu-item-hover-bg);
}
}
.ui-contextmenu-button-icon
{
height: 18px;
stroke-width: 1.5px;
color: var(--color-text-dim);
margin-right: var(--space-s);
}
.ui-contextmenu-button-label
{
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--color-text);
&.is-minor, .ui-contextmenu-button[disabled] &
{
color: var(--color-text-dim);
}
}
</style>
+141
View File
@@ -0,0 +1,141 @@
<template>
<div class="ui-contextmenu-container" :class="{ 'is-open': open }">
<div v-if="$slots.button" ref="trigger" class="ui-contextmenu-toggle" @click.prevent.stop="toggle">
<slot name="button"></slot>
</div>
<div class="ui-contextmenu" ref="container" role="dialog" :hidden="!open" v-click-outside="hide">
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, useHost, useTemplateRef, defineExpose, nextTick } from 'vue';
import cache from '@/utils/cache';
const key = 'just.ui-contextmenu.current';
const emit = defineEmits(['opened', 'closed']);
interface Props {
disabled?: boolean
}
const { disabled } = defineProps<Props>();
const $container = useTemplateRef('container');
let open = ref(false);
// toggles the context menu
function toggle()
{
return open ? hide() : show();
}
// try to show the context menu
function show(ev?: PointerEvent | undefined)
{
if (disabled)
{
return;
}
// cache open context-menu so it can be auto-closed
if (cache.has(key))
{
cache.get(key).hide();
}
cache.set(key, { hide });
open.value = true;
if (ev != null)
{
nextTick(() =>
{
reposition(ev);
});
}
emit('opened');
}
// hide this context menu
function hide()
{
console.info('hide');
open.value = false;
emit('closed');
cache.delete(key);
}
function reposition({ pageY, pageX }: PointerEvent): void
{
const menuWidth = 100;
const menuHeight = 300;
let position = {
left: pageX + 2 - (menuWidth + pageX >= window.innerWidth ? menuWidth : 0),
top: pageY + 2 - (menuHeight + pageY >= window.innerHeight ? menuHeight : 0)
};
$container.value!.style.left = position.left + 'px';
$container.value!.style.top = position.top + 'px';
}
defineExpose({
show,
hide,
reposition
})
</script>
<style lang="scss">
.ui-contextmenu
{
--v-offset: 0px;
position: absolute;
min-width: 220px;
min-height: 20px;
background: var(--color-contextmenu-bg);
border-radius: var(--radius);
border: 1px solid var(--color-contextmenu-border);
box-shadow: var(--color-contextmenu-shadow);
z-index: 8;
top: calc(100% + 5px);
padding: 4px;
color: var(--color-text);
display: flex;
flex-direction: column;
&.align-right
{
right: 0;
}
&.align-top
{
top: calc(100% + 5px);
bottom: auto;
}
&.align-bottom
{
bottom: calc(100% + 5px + var(--v-offset));
top: auto;
}
hr
{
border: none;
border-bottom: 1px solid var(--color-contextmenu-line); //var(--color-dropdown-line);
margin: 4px 0;
& + hr
{
display: none;
}
}
}
</style>
-4
View File
@@ -1,4 +0,0 @@
import { type Post } from '@/models';
import createCollection from '@/db/createCollection.ts'
export const posts = await createCollection<Post>('posts');
+3 -1
View File
@@ -1,3 +1,5 @@
{
"app.name": "Just"
"app.name": "Just",
"menu.lists.add": "Add a list",
"menu.settings": "Settings"
}
+3
View File
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createI18n } from 'vue-i18n'
import { createComponents } from '@/components'
import { createDirectives } from '@/utils/directives'
import './styles/styles.scss'
import App from './App.vue'
@@ -10,8 +11,10 @@ import en_translations from './locales/en.json'
const app = createApp(App)
createComponents(app)
createDirectives(app)
app.use(createPinia())
app.use(createI18n<[typeof en_translations], 'en'>({
legacy: false,
locale: 'en',
messages: {
en: en_translations
+7
View File
@@ -0,0 +1,7 @@
export type Entity = {
id: string,
createdAt: number,
lastModifiedAt: number,
sort: number,
};
+3 -1
View File
@@ -1 +1,3 @@
export { type Post } from './post'
export { type Entity } from './entity'
export { type Task } from './task'
export { type TaskList } from './taskList'
+9
View File
@@ -0,0 +1,9 @@
import { type Entity } from './entity';
export type Task = {
name: string,
done: boolean,
listId: string,
dueAt?: Date,
remindAt?: Date
} & Entity;
+5
View File
@@ -0,0 +1,5 @@
import { type Entity } from './entity';
export type TaskList = {
name: string
} & Entity;
+95
View File
@@ -0,0 +1,95 @@
<template>
<aside class="menu" :class="{ 'is-open': open }">
<img class="logo" src="/just.svg" :alt="$t('app.name')" />
<nav class="navigation">
<navigation-item v-for="list in lists" :key="list.id" :name="list.name" :active="currentList == list" symbol="ListTodo" @contextmenu.prevent="onListRightClicked($event, list)" />
<navigation-item :name="$t('menu.lists.add')" symbol="ListPlus" :minor="true" />
<ui-contextmenu ref="contextmenu">
<ui-contextmenu-button label="Rename" symbol="TextCursorInput" @click="rename(currentList!)" />
<ui-contextmenu-button label="Duplicate" symbol="CopyPlus" />
<hr />
<ui-contextmenu-button label="Delete" symbol="Trash" />
</ui-contextmenu>
</nav>
<div class="menu-bottom">
<navigation-item :name="$t('menu.settings')" symbol="Bolt" :minor="true" />
</div>
</aside>
</template>
<script setup lang="ts">
import NavigationItem from './navigation-item.vue'
import { onMounted, reactive, ref, useTemplateRef } from 'vue'
import { taskLists } from '@/stores'
import { type TaskList } from '@/models'
import UiContextmenuButton from '@/components/ui-contextmenu-button.vue'
interface Props {
open?: boolean
}
const { open } = defineProps<Props>();
let lists: TaskList[] = reactive<TaskList[]>([]);
let currentList: TaskList | undefined;
let currentEvent: PointerEvent | undefined;
let $contextmenu = useTemplateRef('contextmenu');
onMounted(async () =>
{
(await taskLists.getAll()).forEach(list =>
{
lists.push(list);
});
currentList = lists[0];
});
function onListRightClicked(ev: PointerEvent, list: TaskList)
{
currentList = list;
$contextmenu.value!.show(ev);
}
function rename(list: TaskList)
{
console.info('rename: ' + list.name);
}
</script>
<style scoped lang="scss">
.menu
{
width: 260px;
border-radius: var(--radius);
background: var(--color-menu-bg);
align-self: stretch;
overflow-y: auto;
padding: var(--space);
display: grid;
grid-template-rows: auto 1fr auto;
@media (max-width: 700px)
{
height: calc(100vh - 2 * var(--space-m));
position: absolute;
transform: translateX(-110%);
transition: transform 0.6s ease;
pointer-events: none;
&.is-open
{
transform: translateX(0);
pointer-events: auto;
}
}
}
.logo
{
height: 30px;
}
.navigation
{
margin: var(--space) 0;
}
</style>
@@ -1,28 +1,43 @@
<template>
<button type="button" class="navigation-item" :class="classes">
<ui-icon :symbol="symbol" class="navigation-item-icon" />
<span class="navigation-item-text" v-text="name"></span>
<span v-if="!editing" class="navigation-item-text" v-text="name"></span>
<span v-else>
<input type="text" v-model="nameModel" class="navigation-item-input" />
</span>
<span class="navigation-item-count" v-if="count > 0" v-text="count"></span>
</button>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import { reactive, ref, defineExpose } from 'vue'
interface Props {
symbol: string,
name: string,
count?: number,
active?: boolean,
minor?: boolean
minor?: boolean,
canSetName?: boolean
}
const { symbol, name, count = 0, active, minor } = defineProps<Props>();
const { symbol, name, count = 0, active, minor, canSetName } = defineProps<Props>();
const editing = ref(false);
const nameModel = ref(name);
const classes = reactive({
'is-active': active,
'is-list': !minor
})
function edit()
{
this.editing = true;
}
defineExpose({
edit
});
</script>
<style scoped lang="scss">
@@ -30,14 +45,14 @@
{
appearance: none;
height: 38px;
border-radius: 6px;
border-radius: var(--radius-inner);
display: grid;
grid-template-columns: auto minmax(0, auto) 1fr;
text-decoration: none;
align-items: center;
gap: 8px;
gap: var(--space-s);
background: transparent;
padding: 0 12px;
padding: 0 var(--space-m);
width: 100%;
border: 1px solid transparent;
cursor: pointer;
@@ -71,7 +86,7 @@
height: 20px;
stroke-width: 1.5px;
color: var(--color-text-dim);
margin-right: 8px;
margin-right: var(--space-s);
}
.navigation-item-text
@@ -103,4 +118,9 @@
color: var(--color-text-dim);
//transition: background-color 0.2s ease;
}
.navigation-item-input
{
}
</style>
+8
View File
@@ -0,0 +1,8 @@
import { type Task, type TaskList } from '@/models';
import createCollection from './createCollection.ts'
export const taskListsCollection = await createCollection<TaskList>('taskLists');
export const tasksCollection = await createCollection<Task>('tasks');
export * as tasks from './tasks.ts';
export * as taskLists from './lists.ts';
+91
View File
@@ -0,0 +1,91 @@
import { taskListsCollection, tasksCollection } from '.'
import { type TaskList } from '@/models'
/**
* Stores a new task list
* @param name The name of the list
* @returns The newly created task list
*/
export async function add(name: string): Promise<TaskList>
{
const modelBefore: TaskList | undefined = await taskListsCollection.find(undefined, {
limit: 1,
sort: {
sort: -1
},
}).fetch().pop();
const sort: number = (modelBefore?.sort ?? 0) + 1;
const date: number = Date.now();
return await taskListsCollection.insert({
name,
sort,
createdAt: date,
lastModifiedAt: date
});
}
/**
* Stores updated properties of a list
* (only name + sort get updated)
* @param model The existing list
* @returns The updated task list
*/
export async function update(model: TaskList): Promise<TaskList>
{
const lastModifiedAt = Date.now();
let updated: boolean = (await taskListsCollection.updateOne({ id: model.id }, {
$set: {
lastModifiedAt,
name: model.name,
sort: model.sort
}
})) === 1;
if (updated)
{
model.lastModifiedAt = lastModifiedAt;
}
return model;
}
/**
* Try to get a single task list from the store
* @param id Id of the list
* @returns The task list, if found
*/
export async function get(id: string): Promise<TaskList | undefined>
{
return await taskListsCollection.findOne({ id });
}
/**
* Get all task lists from the store
* @returns Alle stored task lists
*/
export async function getAll(): Promise<TaskList[]>
{
return await taskListsCollection.find(undefined, {
sort: {
sort: 1
}
}).fetch();
}
/**
* Tries to remove a task list by id.
* This also removes all associated tasks.
* @param id Id of the list
*/
export async function remove(id: string): Promise<void>
{
await taskListsCollection.removeOne({ id });
await tasksCollection.removeMany({ listId: id });
}
View File
+10
View File
@@ -0,0 +1,10 @@
:root
{
--space-s: 8px;
--space-m: 12px;
--space: 16px;
--radius: 8px;
--radius-inner: 6px;
}
+35 -1
View File
@@ -7,13 +7,38 @@
src: url('./inter.woff2') format("woff2");
}
html, body
{
font-size: 14px;
}
body
{
color: var(--color-text);
font-family: 'inter', sans-serif;
font-size: 14px;
line-height: 1.5;
background: var(--color-layout-bg);
&.is-resizing *
{
transition: none !important;
animation-play-state: paused !important;
}
}
.app
{
width: min(800px, 100%);
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 30px;
padding: 10px;
height: 100vh;
@media (max-width: 700px)
{
display: block;
}
}
.app-icon
@@ -23,3 +48,12 @@ body
stroke-linejoin: round;
fill: transparent;
}
button
{
appearance: none;
background: none;
border: none;
color: var(--color-text);
cursor: pointer;
}
+1
View File
@@ -1,4 +1,5 @@
@use './config';
@use './theme';
@use './reset';
@use './main';
+13 -5
View File
@@ -6,7 +6,7 @@
--color-bg-shade-2: #1D1D1D;
--color-bg-shade-3: #232323;
--color-bg-shade-4: #2D2D2D;
--color-bg-shade-5: #343434;
--color-bg-shade-5: #3A3A3A;
// define all foreground color shades
--color-fg-shade-1: #FFFFFF;
@@ -18,14 +18,22 @@
--color-layout-icons-fg: var(--color-fg-shade-3);
--color-layout-icons-hover-fg: var(--color-fg-shade-2);
// general text shades
--color-text: var(--color-fg-shade-1);
--color-text-dim: var(--color-fg-shade-2);
--color-text-dim-one: var(--color-fg-shade-3);
// menu shades
--color-menu-bg: var(--color-bg-shade-3);
--color-menu-item-hover-bg: var(--color-bg-shade-4);
--color-menu-item-count-bg: var(--color-bg-shade-4);
--color-menu-item-hover-count-bg: var(--color-bg-shade-5);
// general text shades
--color-text: var(--color-fg-shade-1);
--color-text-dim: var(--color-fg-shade-2);
--color-text-dim-one: var(--color-fg-shade-3);
// contextmenu shades
--color-contextmenu-bg: var(--color-bg-shade-4);
--color-contextmenu-border: var(--color-bg-shade-5);
--color-contextmenu-shadow: 3px 6px 12px rgba(0, 0, 0, 0.07);
--color-contextmenu-line: var(--color-bg-shade-5);
--color-contextmenu-item-bg: transparent;
--color-contextmenu-item-hover-bg: var(--color-bg-shade-5);
}
+3
View File
@@ -0,0 +1,3 @@
var cache: Map<string, any> = new Map();
export default cache;
+2
View File
@@ -0,0 +1,2 @@
export { useResize } from './useResize';
+6
View File
@@ -0,0 +1,6 @@
import { onMounted, onUnmounted } from 'vue'
export function useResize(callback: () => void) {
onMounted(() => window.addEventListener('resize', callback))
onUnmounted(() => window.removeEventListener('resize', callback))
}
+7
View File
@@ -0,0 +1,7 @@
import { type App } from 'vue';
import clickOutside from './v-click-outside';
export function createDirectives (app: App)
{
app.directive('click-outside', clickOutside);
}
@@ -0,0 +1,130 @@
const HANDLERS_PROPERTY = '__v-click-outside'
const HAS_WINDOWS = typeof window !== 'undefined'
const HAS_NAVIGATOR = typeof navigator !== 'undefined'
const IS_TOUCH =
HAS_WINDOWS &&
('ontouchstart' in window ||
(HAS_NAVIGATOR && navigator.msMaxTouchPoints > 0))
const EVENTS = IS_TOUCH ? ['touchstart'] : ['click']
function processDirectiveArguments(bindingValue) {
const isFunction = typeof bindingValue === 'function'
if (!isFunction && typeof bindingValue !== 'object') {
throw new Error(
'v-click-outside: Binding value must be a function or an object',
)
}
return {
handler: isFunction ? bindingValue : bindingValue.handler,
middleware: bindingValue.middleware || ((item) => item),
events: bindingValue.events || EVENTS,
isActive: !(bindingValue.isActive === false),
detectIframe: !(bindingValue.detectIframe === false),
capture: !!bindingValue.capture,
}
}
function execHandler({ event, handler, middleware }) {
if (middleware(event)) {
handler(event)
}
}
function onFauxIframeClick({ el, event, handler, middleware }) {
// Note: on firefox clicking on iframe triggers blur, but only on
// next event loop it becomes document.activeElement
// https://stackoverflow.com/q/2381336#comment61192398_23231136
setTimeout(() => {
const { activeElement } = document
if (
activeElement &&
activeElement.tagName === 'IFRAME' &&
!el.contains(activeElement)
) {
execHandler({ event, handler, middleware })
}
}, 0)
}
function onEvent({ el, event, handler, middleware }) {
// Note: composedPath is not supported on IE and Edge, more information here:
// https://developer.mozilla.org/en-US/docs/Web/API/Event/composedPath
// In the meanwhile, we are using el.contains for those browsers, not
// the ideal solution, but using IE or EDGE is not ideal either.
const path = (event.composedPath && event.composedPath()) || event.path
const isClickOutside = path
? path.indexOf(el) < 0
: !el.contains(event.target)
if (!isClickOutside) {
return
}
execHandler({ event, handler, middleware })
}
function bind(el, { value }) {
const {
events,
handler,
middleware,
isActive,
detectIframe,
capture,
} = processDirectiveArguments(value)
if (!isActive) {
return
}
el[HANDLERS_PROPERTY] = events.map((eventName) => ({
event: eventName,
srcTarget: document.documentElement,
handler: (event) => onEvent({ el, event, handler, middleware }),
capture,
}))
if (detectIframe) {
const detectIframeEvent = {
event: 'blur',
srcTarget: window,
handler: (event) => onFauxIframeClick({ el, event, handler, middleware }),
capture,
}
el[HANDLERS_PROPERTY] = [...el[HANDLERS_PROPERTY], detectIframeEvent]
}
el[HANDLERS_PROPERTY].forEach(({ event, srcTarget, handler }) =>
setTimeout(() => {
// Note: More info about this implementation can be found here:
// https://github.com/ndelvalle/v-click-outside/issues/137
if (!el[HANDLERS_PROPERTY]) {
return
}
srcTarget.addEventListener(event, handler, capture)
}, 0),
)
}
function unbind(el) {
const handlers = el[HANDLERS_PROPERTY] || []
handlers.forEach(({ event, srcTarget, handler, capture }) =>
srcTarget.removeEventListener(event, handler, capture),
)
delete el[HANDLERS_PROPERTY]
}
function update(el, { value, oldValue }) {
if (JSON.stringify(value) === JSON.stringify(oldValue)) {
return
}
unbind(el)
bind(el, { value })
}
export default HAS_WINDOWS ? {
beforeMount: bind,
updated: update,
unmounted: unbind
} : {}