store for lists; output correct lists; start contextmenui
This commit is contained in:
@@ -4,7 +4,6 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="app\" />
|
<Folder Include="app\" />
|
||||||
<Folder Include="src\utils\" />
|
|
||||||
<Folder Include="src\views\" />
|
<Folder Include="src\views\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
+1
-1
@@ -10,4 +10,4 @@
|
|||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+82
-30
@@ -1,47 +1,99 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside class="menu">
|
<div class="app">
|
||||||
<img class="logo" src="/just.svg" :alt="$t('app.name')" />
|
<app-menu :open="menuOpen" v-click-outside="closeMenu" />
|
||||||
<nav class="navigation">
|
<main class="main">
|
||||||
<navigation-item name="Shopping" symbol="ListTodo" :active="true" :count="7" />
|
<header class="top">
|
||||||
<navigation-item name="Work" symbol="ListTodo" :count="702" />
|
<img class="top-logo" src="/just.svg" :alt="$t('app.name')" />
|
||||||
<navigation-item name="Apartment" symbol="ListTodo" />
|
<button type="button" class="top-menu-toggle" @click.stop="menuOpen=!menuOpen">
|
||||||
<navigation-item name="Add a list" symbol="ListPlus" :minor="true" />
|
<ui-icon symbol="Menu" :size="18" :stroke-width="2" />
|
||||||
</nav>
|
</button>
|
||||||
<div class="menu-bottom">
|
<button type="button" class="top-list">
|
||||||
<navigation-item name="Settings" symbol="Bolt" :minor="true" />
|
Shopping
|
||||||
</div>
|
<ui-icon symbol="ChevronDown" :size="16" />
|
||||||
</aside>
|
</button>
|
||||||
|
</header>
|
||||||
|
<!-- <button type="button" @click="preload">Preload</button>-->
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import NavigationItem from '@/components/navigation-item.vue';
|
import { ref } from 'vue'
|
||||||
import { posts } from '@/db';
|
import AppMenu from '@/modules/app-menu.vue'
|
||||||
|
import { useResize } from '@/utils/composables'
|
||||||
|
|
||||||
let items = posts.find().fetch();
|
let menuOpen = ref(false);
|
||||||
console.info(items);
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.menu
|
.main
|
||||||
{
|
{
|
||||||
width: 260px;
|
padding: var(--space-m) 0;
|
||||||
margin: 10px;
|
|
||||||
border-radius: 8px;
|
@media (max-width: 700px)
|
||||||
background: var(--color-menu-bg);
|
{
|
||||||
height: calc(100vh - 20px);
|
padding: var(--space-m);
|
||||||
overflow-y: auto;
|
}
|
||||||
padding: 16px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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>
|
</style>
|
||||||
@@ -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"> …</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>
|
||||||
@@ -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>
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { type App } from 'vue';
|
import { type App } from 'vue';
|
||||||
import uiIcon from './ui-icon.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)
|
export function createComponents (app: App)
|
||||||
{
|
{
|
||||||
app.component('ui-icon', uiIcon);
|
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>
|
||||||
@@ -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>
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { type Post } from '@/models';
|
|
||||||
import createCollection from '@/db/createCollection.ts'
|
|
||||||
|
|
||||||
export const posts = await createCollection<Post>('posts');
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
{
|
{
|
||||||
"app.name": "Just"
|
"app.name": "Just",
|
||||||
|
"menu.lists.add": "Add a list",
|
||||||
|
"menu.settings": "Settings"
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
|
|||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
import { createComponents } from '@/components'
|
import { createComponents } from '@/components'
|
||||||
|
import { createDirectives } from '@/utils/directives'
|
||||||
|
|
||||||
import './styles/styles.scss'
|
import './styles/styles.scss'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
@@ -10,8 +11,10 @@ import en_translations from './locales/en.json'
|
|||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
createComponents(app)
|
createComponents(app)
|
||||||
|
createDirectives(app)
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(createI18n<[typeof en_translations], 'en'>({
|
app.use(createI18n<[typeof en_translations], 'en'>({
|
||||||
|
legacy: false,
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
messages: {
|
messages: {
|
||||||
en: en_translations
|
en: en_translations
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
export type Entity = {
|
||||||
|
id: string,
|
||||||
|
createdAt: number,
|
||||||
|
lastModifiedAt: number,
|
||||||
|
sort: number,
|
||||||
|
};
|
||||||
@@ -1 +1,3 @@
|
|||||||
export { type Post } from './post'
|
export { type Entity } from './entity'
|
||||||
|
export { type Task } from './task'
|
||||||
|
export { type TaskList } from './taskList'
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { type Entity } from './entity';
|
||||||
|
|
||||||
|
export type Task = {
|
||||||
|
name: string,
|
||||||
|
done: boolean,
|
||||||
|
listId: string,
|
||||||
|
dueAt?: Date,
|
||||||
|
remindAt?: Date
|
||||||
|
} & Entity;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { type Entity } from './entity';
|
||||||
|
|
||||||
|
export type TaskList = {
|
||||||
|
name: string
|
||||||
|
} & Entity;
|
||||||
@@ -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>
|
<template>
|
||||||
<button type="button" class="navigation-item" :class="classes">
|
<button type="button" class="navigation-item" :class="classes">
|
||||||
<ui-icon :symbol="symbol" class="navigation-item-icon" />
|
<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>
|
<span class="navigation-item-count" v-if="count > 0" v-text="count"></span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive } from 'vue'
|
import { reactive, ref, defineExpose } from 'vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
symbol: string,
|
symbol: string,
|
||||||
name: string,
|
name: string,
|
||||||
count?: number,
|
count?: number,
|
||||||
active?: boolean,
|
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({
|
const classes = reactive({
|
||||||
'is-active': active,
|
'is-active': active,
|
||||||
'is-list': !minor
|
'is-list': !minor
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
function edit()
|
||||||
|
{
|
||||||
|
this.editing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
edit
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -30,14 +45,14 @@
|
|||||||
{
|
{
|
||||||
appearance: none;
|
appearance: none;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-inner);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, auto) 1fr;
|
grid-template-columns: auto minmax(0, auto) 1fr;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--space-s);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 0 12px;
|
padding: 0 var(--space-m);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -71,7 +86,7 @@
|
|||||||
height: 20px;
|
height: 20px;
|
||||||
stroke-width: 1.5px;
|
stroke-width: 1.5px;
|
||||||
color: var(--color-text-dim);
|
color: var(--color-text-dim);
|
||||||
margin-right: 8px;
|
margin-right: var(--space-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
.navigation-item-text
|
.navigation-item-text
|
||||||
@@ -103,4 +118,9 @@
|
|||||||
color: var(--color-text-dim);
|
color: var(--color-text-dim);
|
||||||
//transition: background-color 0.2s ease;
|
//transition: background-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navigation-item-input
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -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';
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
:root
|
||||||
|
{
|
||||||
|
--space-s: 8px;
|
||||||
|
--space-m: 12px;
|
||||||
|
--space: 16px;
|
||||||
|
|
||||||
|
--radius: 8px;
|
||||||
|
--radius-inner: 6px;
|
||||||
|
}
|
||||||
@@ -7,13 +7,38 @@
|
|||||||
src: url('./inter.woff2') format("woff2");
|
src: url('./inter.woff2') format("woff2");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html, body
|
||||||
|
{
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
body
|
body
|
||||||
{
|
{
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-family: 'inter', sans-serif;
|
font-family: 'inter', sans-serif;
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
background: var(--color-layout-bg);
|
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
|
.app-icon
|
||||||
@@ -22,4 +47,13 @@ body
|
|||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
fill: transparent;
|
fill: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
button
|
||||||
|
{
|
||||||
|
appearance: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
|
|
||||||
|
@use './config';
|
||||||
@use './theme';
|
@use './theme';
|
||||||
@use './reset';
|
@use './reset';
|
||||||
@use './main';
|
@use './main';
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
--color-bg-shade-2: #1D1D1D;
|
--color-bg-shade-2: #1D1D1D;
|
||||||
--color-bg-shade-3: #232323;
|
--color-bg-shade-3: #232323;
|
||||||
--color-bg-shade-4: #2D2D2D;
|
--color-bg-shade-4: #2D2D2D;
|
||||||
--color-bg-shade-5: #343434;
|
--color-bg-shade-5: #3A3A3A;
|
||||||
|
|
||||||
// define all foreground color shades
|
// define all foreground color shades
|
||||||
--color-fg-shade-1: #FFFFFF;
|
--color-fg-shade-1: #FFFFFF;
|
||||||
@@ -18,14 +18,22 @@
|
|||||||
--color-layout-icons-fg: var(--color-fg-shade-3);
|
--color-layout-icons-fg: var(--color-fg-shade-3);
|
||||||
--color-layout-icons-hover-fg: var(--color-fg-shade-2);
|
--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
|
// menu shades
|
||||||
--color-menu-bg: var(--color-bg-shade-3);
|
--color-menu-bg: var(--color-bg-shade-3);
|
||||||
--color-menu-item-hover-bg: var(--color-bg-shade-4);
|
--color-menu-item-hover-bg: var(--color-bg-shade-4);
|
||||||
--color-menu-item-count-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);
|
--color-menu-item-hover-count-bg: var(--color-bg-shade-5);
|
||||||
|
|
||||||
// general text shades
|
// contextmenu shades
|
||||||
--color-text: var(--color-fg-shade-1);
|
--color-contextmenu-bg: var(--color-bg-shade-4);
|
||||||
--color-text-dim: var(--color-fg-shade-2);
|
--color-contextmenu-border: var(--color-bg-shade-5);
|
||||||
--color-text-dim-one: var(--color-fg-shade-3);
|
--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);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
var cache: Map<string, any> = new Map();
|
||||||
|
export default cache;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
export { useResize } from './useResize';
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
export function useResize(callback: () => void) {
|
||||||
|
onMounted(() => window.addEventListener('resize', callback))
|
||||||
|
onUnmounted(() => window.removeEventListener('resize', callback))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
} : {}
|
||||||
Reference in New Issue
Block a user