initial
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<router-view />
|
||||
<Toast position="bottom-right" />
|
||||
<ConfirmDialog />
|
||||
</template>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -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 |
Vendored
+95
@@ -0,0 +1,95 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
/* oxlint-disable */
|
||||
/* oxfmt-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue').EffectScope
|
||||
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
|
||||
const computed: typeof import('vue').computed
|
||||
const createApp: typeof import('vue').createApp
|
||||
const createPinia: typeof import('pinia').createPinia
|
||||
const customRef: typeof import('vue').customRef
|
||||
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
|
||||
const defineComponent: typeof import('vue').defineComponent
|
||||
const defineStore: typeof import('pinia').defineStore
|
||||
const effectScope: typeof import('vue').effectScope
|
||||
const getActivePinia: typeof import('pinia').getActivePinia
|
||||
const getCurrentInstance: typeof import('vue').getCurrentInstance
|
||||
const getCurrentScope: typeof import('vue').getCurrentScope
|
||||
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
|
||||
const h: typeof import('vue').h
|
||||
const inject: typeof import('vue').inject
|
||||
const isProxy: typeof import('vue').isProxy
|
||||
const isReactive: typeof import('vue').isReactive
|
||||
const isReadonly: typeof import('vue').isReadonly
|
||||
const isRef: typeof import('vue').isRef
|
||||
const isShallow: typeof import('vue').isShallow
|
||||
const mapActions: typeof import('pinia').mapActions
|
||||
const mapGetters: typeof import('pinia').mapGetters
|
||||
const mapState: typeof import('pinia').mapState
|
||||
const mapStores: typeof import('pinia').mapStores
|
||||
const mapWritableState: typeof import('pinia').mapWritableState
|
||||
const markRaw: typeof import('vue').markRaw
|
||||
const nextTick: typeof import('vue').nextTick
|
||||
const onActivated: typeof import('vue').onActivated
|
||||
const onBeforeMount: typeof import('vue').onBeforeMount
|
||||
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
|
||||
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
|
||||
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
|
||||
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
|
||||
const onDeactivated: typeof import('vue').onDeactivated
|
||||
const onErrorCaptured: typeof import('vue').onErrorCaptured
|
||||
const onMounted: typeof import('vue').onMounted
|
||||
const onRenderTracked: typeof import('vue').onRenderTracked
|
||||
const onRenderTriggered: typeof import('vue').onRenderTriggered
|
||||
const onScopeDispose: typeof import('vue').onScopeDispose
|
||||
const onServerPrefetch: typeof import('vue').onServerPrefetch
|
||||
const onUnmounted: typeof import('vue').onUnmounted
|
||||
const onUpdated: typeof import('vue').onUpdated
|
||||
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
|
||||
const provide: typeof import('vue').provide
|
||||
const reactive: typeof import('vue').reactive
|
||||
const readonly: typeof import('vue').readonly
|
||||
const ref: typeof import('vue').ref
|
||||
const resolveComponent: typeof import('vue').resolveComponent
|
||||
const setActivePinia: typeof import('pinia').setActivePinia
|
||||
const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix
|
||||
const shallowReactive: typeof import('vue').shallowReactive
|
||||
const shallowReadonly: typeof import('vue').shallowReadonly
|
||||
const shallowRef: typeof import('vue').shallowRef
|
||||
const storeToRefs: typeof import('pinia').storeToRefs
|
||||
const toRaw: typeof import('vue').toRaw
|
||||
const toRef: typeof import('vue').toRef
|
||||
const toRefs: typeof import('vue').toRefs
|
||||
const toValue: typeof import('vue').toValue
|
||||
const triggerRef: typeof import('vue').triggerRef
|
||||
const unref: typeof import('vue').unref
|
||||
const useAttrs: typeof import('vue').useAttrs
|
||||
const useConfirm: typeof import('primevue/useconfirm').useConfirm
|
||||
const useCssModule: typeof import('vue').useCssModule
|
||||
const useCssVars: typeof import('vue').useCssVars
|
||||
const useDialog: typeof import('primevue/usedialog').useDialog
|
||||
const useId: typeof import('vue').useId
|
||||
const useLink: typeof import('vue-router').useLink
|
||||
const useModel: typeof import('vue').useModel
|
||||
const useRoute: typeof import('vue-router').useRoute
|
||||
const useRouter: typeof import('vue-router').useRouter
|
||||
const useSlots: typeof import('vue').useSlots
|
||||
const useTemplateRef: typeof import('vue').useTemplateRef
|
||||
const useToast: typeof import('primevue/usetoast').useToast
|
||||
const watch: typeof import('vue').watch
|
||||
const watchEffect: typeof import('vue').watchEffect
|
||||
const watchPostEffect: typeof import('vue').watchPostEffect
|
||||
const watchSyncEffect: typeof import('vue').watchSyncEffect
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
Vendored
+38
@@ -0,0 +1,38 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
Avatar: typeof import('primevue/avatar')['default']
|
||||
Button: typeof import('primevue/button')['default']
|
||||
Card: typeof import('primevue/card')['default']
|
||||
Checkbox: typeof import('primevue/checkbox')['default']
|
||||
Column: typeof import('primevue/column')['default']
|
||||
ConfirmDialog: typeof import('primevue/confirmdialog')['default']
|
||||
DataTable: typeof import('primevue/datatable')['default']
|
||||
DatePicker: typeof import('primevue/datepicker')['default']
|
||||
Dialog: typeof import('primevue/dialog')['default']
|
||||
InputText: typeof import('primevue/inputtext')['default']
|
||||
Menu: typeof import('primevue/menu')['default']
|
||||
Message: typeof import('primevue/message')['default']
|
||||
Password: typeof import('primevue/password')['default']
|
||||
ProgressSpinner: typeof import('primevue/progressspinner')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
Select: typeof import('primevue/select')['default']
|
||||
Splitter: typeof import('primevue/splitter')['default']
|
||||
SplitterPanel: typeof import('primevue/splitterpanel')['default']
|
||||
Tag: typeof import('primevue/tag')['default']
|
||||
Textarea: typeof import('primevue/textarea')['default']
|
||||
Toast: typeof import('primevue/toast')['default']
|
||||
ToggleSwitch: typeof import('primevue/toggleswitch')['default']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div v-if="doc" class="flex h-full flex-col">
|
||||
<div class="flex items-start gap-2 border-b border-slate-200 px-4 py-3 dark:border-slate-700 lg:gap-3 lg:px-5">
|
||||
<Button
|
||||
icon="pi pi-arrow-left"
|
||||
rounded
|
||||
text
|
||||
severity="secondary"
|
||||
class="lg:hidden"
|
||||
aria-label="Back to documents"
|
||||
@click="$emit('back')"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-lg font-semibold">{{ doc.title }}</div>
|
||||
<div class="flex items-center gap-1 text-[0.8rem] text-slate-500 dark:text-slate-400">
|
||||
<i class="pi pi-history"></i> Updated {{ formatDate(doc.updatedAt) }}
|
||||
<span v-if="saveState === 'saved'" class="text-[0.75rem] text-emerald-500">Saved</span>
|
||||
<span v-else-if="saveState === 'saving'" class="text-[0.75rem] text-amber-500">Saving…</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-actions flex gap-2">
|
||||
<Button v-if="canEdit" icon="pi pi-pencil" severity="secondary" outlined size="small" label="Rename" @click="$emit('rename')" />
|
||||
<Button v-if="canEdit" icon="pi pi-folder-open" severity="secondary" outlined size="small" label="Move" @click="$emit('move')" />
|
||||
<Button v-if="canDelete" icon="pi pi-trash" severity="danger" outlined size="small" label="Delete" @click="$emit('delete')" />
|
||||
</div>
|
||||
</div>
|
||||
<RichTextEditor
|
||||
:model-value="modelValue"
|
||||
class="flex-1 overflow-auto px-5 py-4"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex h-full flex-col items-center justify-center gap-2">
|
||||
<i class="pi pi-file dark:text-slate-600" style="font-size: 2.5rem; color: #cbd5e1"></i>
|
||||
<p class="text-slate-500 dark:text-slate-400">Select a document from the tree to start editing</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import RichTextEditor from './RichTextEditor.vue'
|
||||
import type { DocumentItem } from '../types'
|
||||
|
||||
defineProps<{
|
||||
doc: DocumentItem | null
|
||||
modelValue: string
|
||||
saveState: 'idle' | 'saving' | 'saved'
|
||||
canEdit: boolean
|
||||
canDelete: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
rename: []
|
||||
move: []
|
||||
delete: []
|
||||
back: []
|
||||
}>()
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@media (max-width: 1023px) {
|
||||
.editor-actions :deep(.p-button-label) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-md px-1.5 py-1 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||
:class="{ 'bg-blue-100 dark:bg-blue-900/50': isSelected }"
|
||||
@click="onRowClick"
|
||||
>
|
||||
<span class="tree-chevron" @click.stop="onToggle">
|
||||
<i :class="isFolder && expanded ? 'pi pi-chevron-down' : 'pi pi-chevron-right'" class="text-[0.7rem] text-slate-500 dark:text-slate-400"></i>
|
||||
</span>
|
||||
<i
|
||||
:class="isFolder ? (expanded ? 'pi pi-folder-open' : 'pi pi-folder') : 'pi pi-file'"
|
||||
:style="{ color: isFolder ? '#3b82f6' : '#94a3b8' }"
|
||||
></i>
|
||||
<span>{{ node.title }}</span>
|
||||
</div>
|
||||
<div v-if="expanded && node.children.length" class="ml-[18px] border-l border-slate-200 pl-2 dark:border-slate-700">
|
||||
<TreeNode
|
||||
v-for="child in node.children"
|
||||
:key="child.id"
|
||||
:node="child"
|
||||
:selected-id="selectedId"
|
||||
@select="$emit('select', $event)"
|
||||
@toggle="$emit('toggle', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DocumentNode } from '../types'
|
||||
|
||||
defineOptions({ name: 'TreeNode' })
|
||||
|
||||
const props = defineProps<{
|
||||
node: DocumentNode
|
||||
selectedId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [id: string]
|
||||
toggle: [id: string]
|
||||
}>()
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
const isFolder = computed(() => props.node.type === 'Folder')
|
||||
const isSelected = computed(() => props.node.id === props.selectedId)
|
||||
|
||||
function onRowClick() {
|
||||
if (isFolder.value) {
|
||||
expanded.value = !expanded.value
|
||||
emit('toggle', props.node.id)
|
||||
} else {
|
||||
emit('select', props.node.id)
|
||||
}
|
||||
}
|
||||
|
||||
function onToggle() {
|
||||
if (isFolder.value) {
|
||||
expanded.value = !expanded.value
|
||||
emit('toggle', props.node.id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-hidden p-3">
|
||||
<div>
|
||||
<InputText
|
||||
:model-value="searchTerm"
|
||||
placeholder="Search documents..."
|
||||
class="w-full"
|
||||
@update:model-value="onSearch"
|
||||
/>
|
||||
<div v-if="canCreate" class="mt-2 flex gap-2">
|
||||
<Button label="Doc" icon="pi pi-plus" severity="secondary" size="small" @click="$emit('create', 'Document')" />
|
||||
<Button label="Folder" icon="pi pi-plus" severity="secondary" size="small" @click="$emit('create', 'Folder')" />
|
||||
</div>
|
||||
<div class="mt-2 text-[0.8rem] text-slate-500 dark:text-slate-400">
|
||||
{{ creatingLabel }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex-1 overflow-auto">
|
||||
<TreeNode
|
||||
v-for="root in tree"
|
||||
:key="root.id"
|
||||
:node="root"
|
||||
:selected-id="selectedId"
|
||||
@select="$emit('select', $event)"
|
||||
/>
|
||||
<div v-if="!tree.length && !loading" class="p-4 text-center text-slate-500 dark:text-slate-400">No documents yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TreeNode from './DocumentTreeNode.vue'
|
||||
import type { DocumentNode, DocumentType } from '../types'
|
||||
|
||||
defineProps<{
|
||||
tree: DocumentNode[]
|
||||
loading: boolean
|
||||
selectedId: string | null
|
||||
searchTerm: string
|
||||
creatingLabel: string
|
||||
canCreate: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:searchTerm': [value: string]
|
||||
select: [id: string]
|
||||
create: [type: DocumentType]
|
||||
}>()
|
||||
|
||||
function onSearch(value: unknown) {
|
||||
emit('update:searchTerm', typeof value === 'string' ? value.trim() : '')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import viteLogo from '../assets/vite.svg'
|
||||
import heroImg from '../assets/hero.png'
|
||||
import vueLogo from '../assets/vue.svg'
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="center">
|
||||
<div class="hero">
|
||||
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||
</div>
|
||||
<button type="button" class="counter" @click="count++">
|
||||
Count is {{ count }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img class="logo" :src="viteLogo" alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vuejs.org/" target="_blank">
|
||||
<img class="button-icon" :src="vueLogo" alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<Ckeditor
|
||||
:editor="Editor"
|
||||
:model-value="modelValue ?? ''"
|
||||
:config="editorConfig"
|
||||
@update:model-value="onUpdate"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Ckeditor } from '@ckeditor/ckeditor5-vue'
|
||||
import {
|
||||
ClassicEditor,
|
||||
Essentials,
|
||||
Paragraph,
|
||||
Heading,
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Strikethrough,
|
||||
List,
|
||||
BlockQuote,
|
||||
Link,
|
||||
Code,
|
||||
CodeBlock,
|
||||
Table,
|
||||
TableToolbar,
|
||||
Image,
|
||||
ImageUpload,
|
||||
ImageStyle,
|
||||
ImageToolbar,
|
||||
ImageCaption,
|
||||
Base64UploadAdapter,
|
||||
} from 'ckeditor5'
|
||||
import 'ckeditor5/ckeditor5.css'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | null | undefined
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
function onUpdate(value: unknown) {
|
||||
emit('update:modelValue', typeof value === 'string' ? value : '')
|
||||
}
|
||||
|
||||
const Editor = ClassicEditor
|
||||
|
||||
const editorConfig = {
|
||||
licenseKey: 'GPL',
|
||||
plugins: [
|
||||
Essentials,
|
||||
Paragraph,
|
||||
Heading,
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Strikethrough,
|
||||
List,
|
||||
BlockQuote,
|
||||
Link,
|
||||
Code,
|
||||
CodeBlock,
|
||||
Table,
|
||||
TableToolbar,
|
||||
Image,
|
||||
ImageUpload,
|
||||
ImageStyle,
|
||||
ImageToolbar,
|
||||
ImageCaption,
|
||||
Base64UploadAdapter,
|
||||
],
|
||||
toolbar: [
|
||||
'undo',
|
||||
'redo',
|
||||
'|',
|
||||
'heading',
|
||||
'|',
|
||||
'bold',
|
||||
'italic',
|
||||
'underline',
|
||||
'strikethrough',
|
||||
'|',
|
||||
'bulletedList',
|
||||
'numberedList',
|
||||
'blockQuote',
|
||||
'link',
|
||||
'|',
|
||||
'code',
|
||||
'codeBlock',
|
||||
'insertTable',
|
||||
'|',
|
||||
'imageUpload',
|
||||
],
|
||||
image: {
|
||||
toolbar: ['imageStyle:alignLeft', 'imageStyle:full', 'imageStyle:alignRight', '|', 'toggleImageCaption'],
|
||||
},
|
||||
table: {
|
||||
contentToolbar: ['tableColumn', 'tableRow', 'mergeTableCells'],
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'mws_theme'
|
||||
const DARK_CLASS = 'app-dark'
|
||||
|
||||
const isDark = ref(loadInitial())
|
||||
|
||||
function loadInitial(): boolean {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored === 'dark') return true
|
||||
if (stored === 'light') return false
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
|
||||
function apply(value: boolean) {
|
||||
document.documentElement.classList.toggle(DARK_CLASS, value)
|
||||
}
|
||||
|
||||
apply(isDark.value)
|
||||
|
||||
watch(isDark, (value) => {
|
||||
apply(value)
|
||||
localStorage.setItem(STORAGE_KEY, value ? 'dark' : 'light')
|
||||
})
|
||||
|
||||
export function useTheme() {
|
||||
function toggle() {
|
||||
isDark.value = !isDark.value
|
||||
}
|
||||
return { isDark, toggle }
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex h-dvh flex-col lg:grid"
|
||||
:class="sidebarOpen ? 'lg:grid-cols-[250px_1fr]' : 'lg:grid-cols-1'"
|
||||
>
|
||||
<div
|
||||
v-if="sidebarOpen"
|
||||
class="fixed inset-0 z-30 bg-black/40 lg:hidden"
|
||||
@click="sidebarOpen = false"
|
||||
></div>
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-40 flex w-[250px] transform flex-col gap-2 bg-slate-800 p-4 text-slate-200 transition-transform duration-200 lg:static"
|
||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:hidden'"
|
||||
>
|
||||
<div class="mb-4 flex items-center justify-between text-xl font-bold text-white">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-briefcase"></i>
|
||||
MWS
|
||||
</div>
|
||||
<button class="lg:hidden" aria-label="Close menu" @click="sidebarOpen = false">
|
||||
<i class="pi pi-times text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="flex flex-col gap-1">
|
||||
<router-link
|
||||
v-for="item in visibleNavItems"
|
||||
:key="item.key"
|
||||
class="nav-link"
|
||||
:to="item.to"
|
||||
@click="handleNavClick"
|
||||
>
|
||||
<i :class="item.icon"></i> {{ item.label }}
|
||||
</router-link>
|
||||
<router-link class="nav-link" to="/settings" @click="handleNavClick">
|
||||
<i class="pi pi-cog"></i> Settings
|
||||
</router-link>
|
||||
</nav>
|
||||
<div class="flex-1"></div>
|
||||
<div class="flex items-center gap-2 text-slate-300">
|
||||
<Avatar :label="initials" style="background: #3b82f6; color: #fff" size="normal" />
|
||||
<span>{{ auth.user?.displayName ?? auth.user?.username }}</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="flex items-center justify-between border-b border-slate-200 bg-white px-4 py-2.5 dark:border-slate-800 dark:bg-slate-900 lg:px-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
icon="pi pi-bars"
|
||||
rounded
|
||||
text
|
||||
:aria-label="sidebarOpen ? 'Close menu' : 'Open menu'"
|
||||
@click="sidebarOpen = !sidebarOpen"
|
||||
/>
|
||||
<span class="truncate text-slate-500 dark:text-slate-400">{{ currentProject?.name ?? 'My Workspace' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
|
||||
rounded
|
||||
text
|
||||
:aria-label="theme.isDark.value ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||
@click="theme.toggle"
|
||||
/>
|
||||
<Menu ref="menu" :model="menuItems" popup />
|
||||
<Button
|
||||
icon="pi pi-ellipsis-v"
|
||||
rounded
|
||||
text
|
||||
aria-label="Options"
|
||||
@click="toggleMenu"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<main class="flex-1 overflow-auto p-4 lg:p-6">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import { getProject } from '../services/backend'
|
||||
import type { Project } from '../types'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const menu = ref()
|
||||
const sidebarOpen = ref(false)
|
||||
const theme = useTheme()
|
||||
|
||||
const desktopMq = window.matchMedia('(min-width: 1024px)')
|
||||
const isDesktop = ref(desktopMq.matches)
|
||||
desktopMq.addEventListener('change', (e) => (isDesktop.value = e.matches))
|
||||
|
||||
function handleNavClick() {
|
||||
if (!isDesktop.value) sidebarOpen.value = false
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ key: 'dashboard', label: 'Dashboard', icon: 'pi pi-home', to: '/dashboard' },
|
||||
{ key: 'projects', label: 'Projects', icon: 'pi pi-folder-open', to: '/projects' },
|
||||
{ key: 'accounts', label: 'Accounts', icon: 'pi pi-users', to: '/accounts' },
|
||||
{ key: 'roles', label: 'Roles', icon: 'pi pi-shield', to: '/roles' },
|
||||
]
|
||||
const visibleNavItems = computed(() => navItems.filter((item) => auth.canView(item.key)))
|
||||
|
||||
const currentProject = ref<Project | null>(null)
|
||||
|
||||
async function loadCurrentProject(id: string | string[]) {
|
||||
try {
|
||||
currentProject.value = await getProject(String(id))
|
||||
} catch {
|
||||
currentProject.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
(id) => {
|
||||
if (id) void loadCurrentProject(id)
|
||||
else currentProject.value = null
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const initials = computed(() => {
|
||||
const name = auth.user?.displayName ?? auth.user?.username ?? '?'
|
||||
return name.slice(0, 2).toUpperCase()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
if (!isDesktop.value) sidebarOpen.value = false
|
||||
},
|
||||
)
|
||||
|
||||
const menuItems = computed(() => [
|
||||
{
|
||||
label: auth.user?.displayName ?? auth.user?.username,
|
||||
items: [
|
||||
{
|
||||
label: 'Logout',
|
||||
icon: 'pi pi-sign-out',
|
||||
command: () => {
|
||||
auth.logout()
|
||||
window.location.href = '/login'
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
function toggleMenu(event: Event) {
|
||||
menu.value?.toggle(event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-link {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.nav-link:hover {
|
||||
background-color: #334155;
|
||||
color: #fff;
|
||||
}
|
||||
.nav-link.router-link-active {
|
||||
background-color: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div class="project-layout">
|
||||
<div class="mb-5 flex gap-1 overflow-x-auto border-b border-slate-200 dark:border-slate-700">
|
||||
<router-link
|
||||
class="-mb-px whitespace-nowrap border-b-2 border-transparent px-4 py-2.5 font-medium text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200 [&.router-link-exact-active]:border-blue-500 [&.router-link-exact-active]:text-blue-500"
|
||||
:to="{ name: 'project-overview' }"
|
||||
>Overview</router-link>
|
||||
<router-link
|
||||
class="-mb-px whitespace-nowrap border-b-2 border-transparent px-4 py-2.5 font-medium text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200 [&.router-link-active]:border-blue-500 [&.router-link-active]:text-blue-500"
|
||||
:to="{ name: 'documents' }"
|
||||
>Documents</router-link>
|
||||
<router-link
|
||||
class="-mb-px whitespace-nowrap border-b-2 border-transparent px-4 py-2.5 font-medium text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200 [&.router-link-active]:border-blue-500 [&.router-link-active]:text-blue-500"
|
||||
:to="{ name: 'tasks' }"
|
||||
>Tasks</router-link>
|
||||
<router-link
|
||||
class="-mb-px whitespace-nowrap border-b-2 border-transparent px-4 py-2.5 font-medium text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200 [&.router-link-active]:border-blue-500 [&.router-link-active]:text-blue-500"
|
||||
:to="{ name: 'members' }"
|
||||
>Members</router-link>
|
||||
</div>
|
||||
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import ConfirmationService from 'primevue/confirmationservice'
|
||||
import Aura from '@primevue/themes/aura'
|
||||
|
||||
import 'primeicons/primeicons.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(PrimeVue, {
|
||||
theme: {
|
||||
preset: Aura,
|
||||
options: { darkModeSelector: '.app-dark' },
|
||||
},
|
||||
})
|
||||
app.use(ToastService)
|
||||
app.use(ConfirmationService)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
public?: boolean
|
||||
screenKey?: string
|
||||
}
|
||||
}
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('../views/auth/LoginView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../layouts/MainLayout.vue'),
|
||||
children: [
|
||||
{ path: '', redirect: '/projects' },
|
||||
{ path: 'dashboard', name: 'dashboard', component: () => import('../views/DashboardView.vue'), meta: { screenKey: 'dashboard' } },
|
||||
{ path: 'projects', name: 'projects', component: () => import('../views/projects/ProjectsListView.vue'), meta: { screenKey: 'projects' } },
|
||||
{ path: 'accounts', name: 'accounts', component: () => import('../views/AccountsView.vue'), meta: { screenKey: 'accounts' } },
|
||||
{ path: 'roles', name: 'roles', component: () => import('../views/RolesView.vue'), meta: { screenKey: 'roles' } },
|
||||
{
|
||||
path: 'projects/:id',
|
||||
component: () => import('../layouts/ProjectLayout.vue'),
|
||||
meta: { screenKey: 'projects' },
|
||||
children: [
|
||||
{ path: '', name: 'project-overview', component: () => import('../views/projects/ProjectOverviewView.vue') },
|
||||
{ path: 'documents', name: 'documents', component: () => import('../views/documents/DocumentsView.vue'), meta: { screenKey: 'documents' } },
|
||||
{ path: 'tasks', name: 'tasks', component: () => import('../views/tasks/TasksListView.vue'), meta: { screenKey: 'tasks' } },
|
||||
{ path: 'tasks/board', name: 'tasks-board', component: () => import('../views/tasks/TasksBoardView.vue'), meta: { screenKey: 'tasks' } },
|
||||
{ path: 'members', name: 'members', component: () => import('../views/projects/MembersView.vue') },
|
||||
],
|
||||
},
|
||||
{ path: 'settings', name: 'settings', component: () => import('../views/SettingsView.vue') },
|
||||
],
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/projects' },
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
if (!to.meta.public && !auth.isAuthenticated) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
if (to.name === 'login' && auth.isAuthenticated) {
|
||||
return { path: '/projects' }
|
||||
}
|
||||
if (auth.isAuthenticated) {
|
||||
await auth.ensureMenu()
|
||||
}
|
||||
if (to.meta.screenKey && !auth.canView(to.meta.screenKey)) {
|
||||
return { path: '/projects' }
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,38 @@
|
||||
import axios, { type AxiosInstance } from 'axios'
|
||||
|
||||
export const api: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:2000',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('mws_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('mws_token')
|
||||
localStorage.removeItem('mws_user')
|
||||
if (!window.location.pathname.startsWith('/login')) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const data = error.response?.data as { message?: string } | undefined
|
||||
return data?.message ?? error.message ?? 'Request failed'
|
||||
}
|
||||
return 'Request failed'
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { api } from './api'
|
||||
import type { LoginResponse, Project, ProjectMember, MemberRole, Account, MenuItem, Role, SaveRoleRequest } from '../types'
|
||||
|
||||
export async function login(username: string, password: string): Promise<LoginResponse> {
|
||||
const { data } = await api.post<LoginResponse>('/api/auth/login', { username, password })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getMenu(): Promise<MenuItem[]> {
|
||||
const { data } = await api.get<MenuItem[]>('/api/menu')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getRoles(): Promise<Role[]> {
|
||||
const { data } = await api.get<Role[]>('/api/roles')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getRole(id: string): Promise<Role> {
|
||||
const { data } = await api.get<Role>(`/api/roles/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createRole(payload: SaveRoleRequest): Promise<Role> {
|
||||
const { data } = await api.post<Role>('/api/roles', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateRole(id: string, payload: SaveRoleRequest): Promise<Role> {
|
||||
const { data } = await api.put<Role>(`/api/roles/${id}`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteRole(id: string): Promise<void> {
|
||||
await api.delete(`/api/roles/${id}`)
|
||||
}
|
||||
|
||||
export async function getProjects(): Promise<Project[]> {
|
||||
const { data } = await api.get<Project[]>('/api/projects')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function searchProjects(q: string): Promise<Project[]> {
|
||||
const { data } = await api.get<Project[]>('/api/projects/search', { params: { q } })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getProject(id: string): Promise<Project> {
|
||||
const { data } = await api.get<Project>(`/api/projects/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getProjectOverview(id: string) {
|
||||
const { data } = await api.get(`/api/projects/${id}/overview`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createProject(name: string, description?: string): Promise<Project> {
|
||||
const { data } = await api.post<Project>('/api/projects', { name, description })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateProject(
|
||||
id: string,
|
||||
payload: { name: string; description: string | null; status: string },
|
||||
): Promise<Project> {
|
||||
const { data } = await api.put<Project>(`/api/projects/${id}`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteProject(id: string): Promise<void> {
|
||||
await api.delete(`/api/projects/${id}`)
|
||||
}
|
||||
|
||||
export async function getMembers(projectId: string): Promise<ProjectMember[]> {
|
||||
const { data } = await api.get<ProjectMember[]>(`/api/projects/${projectId}/members`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function addMember(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
role: MemberRole,
|
||||
): Promise<ProjectMember> {
|
||||
const { data } = await api.post<ProjectMember>(`/api/projects/${projectId}/members`, { userId, role })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function removeMember(projectId: string, userId: string): Promise<void> {
|
||||
await api.delete(`/api/projects/${projectId}/members/${userId}`)
|
||||
}
|
||||
|
||||
export async function getUsers(q?: string) {
|
||||
const { data } = await api.get('/api/users', { params: { q } })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getAccounts(q?: string): Promise<Account[]> {
|
||||
const { data } = await api.get<Account[]>('/api/accounts', { params: { q } })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createAccount(payload: {
|
||||
username: string
|
||||
displayName: string
|
||||
password: string
|
||||
roleId: string
|
||||
}): Promise<Account> {
|
||||
const { data } = await api.post<Account>('/api/accounts', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateAccount(
|
||||
id: string,
|
||||
payload: { displayName: string; roleId: string; isActive: boolean },
|
||||
): Promise<Account> {
|
||||
const { data } = await api.put<Account>(`/api/accounts/${id}`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteAccount(id: string): Promise<void> {
|
||||
await api.delete(`/api/accounts/${id}`)
|
||||
}
|
||||
|
||||
export async function resetAccountPassword(id: string, newPassword: string): Promise<void> {
|
||||
await api.post(`/api/accounts/${id}/reset-password`, { newPassword })
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { api } from './api'
|
||||
import type { DocumentItem, DocumentNode, DocumentType, Task, TaskPriority, TaskStatus } from '../types'
|
||||
|
||||
export async function getDocumentTree(projectId: string): Promise<DocumentNode[]> {
|
||||
const { data } = await api.get<DocumentNode[]>(`/api/projects/${projectId}/documents`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getDocument(id: string): Promise<DocumentItem> {
|
||||
const { data } = await api.get<DocumentItem>(`/api/documents/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createDocument(
|
||||
projectId: string,
|
||||
payload: { title: string; type: DocumentType; parentId: string | null; content?: string | null },
|
||||
): Promise<DocumentItem> {
|
||||
const { data } = await api.post<DocumentItem>(`/api/projects/${projectId}/documents`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateDocument(
|
||||
id: string,
|
||||
payload: { title: string; content?: string | null },
|
||||
): Promise<DocumentItem> {
|
||||
const { data } = await api.put<DocumentItem>(`/api/documents/${id}`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function moveDocument(id: string, newParentId: string | null): Promise<DocumentItem> {
|
||||
const { data } = await api.put<DocumentItem>(`/api/documents/${id}/move`, { newParentId })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteDocument(id: string): Promise<void> {
|
||||
await api.delete(`/api/documents/${id}`)
|
||||
}
|
||||
|
||||
export async function searchDocuments(q: string): Promise<DocumentNode[]> {
|
||||
const { data } = await api.get<DocumentNode[]>('/api/documents/search', { params: { q } })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getTasks(
|
||||
projectId: string,
|
||||
filters?: { status?: string; priority?: string; assigneeId?: string },
|
||||
): Promise<Task[]> {
|
||||
const { data } = await api.get<Task[]>(`/api/projects/${projectId}/tasks`, { params: filters })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getTask(id: string): Promise<Task> {
|
||||
const { data } = await api.get<Task>(`/api/tasks/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createTask(
|
||||
projectId: string,
|
||||
payload: {
|
||||
title: string
|
||||
description?: string | null
|
||||
status?: TaskStatus
|
||||
priority?: TaskPriority
|
||||
assigneeId?: string | null
|
||||
dueDate?: string | null
|
||||
},
|
||||
): Promise<Task> {
|
||||
const { data } = await api.post<Task>(`/api/projects/${projectId}/tasks`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateTask(
|
||||
id: string,
|
||||
payload: {
|
||||
title: string
|
||||
description: string | null
|
||||
status: TaskStatus
|
||||
priority: TaskPriority
|
||||
assigneeId: string | null
|
||||
dueDate: string | null
|
||||
},
|
||||
): Promise<Task> {
|
||||
const { data } = await api.put<Task>(`/api/tasks/${id}`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteTask(id: string): Promise<void> {
|
||||
await api.delete(`/api/tasks/${id}`)
|
||||
}
|
||||
|
||||
export async function searchTasks(q: string): Promise<Task[]> {
|
||||
const { data } = await api.get<Task[]>('/api/tasks/search', { params: { q } })
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { MenuItem, User } from '../types'
|
||||
import { login as apiLogin, getMenu } from '../services/backend'
|
||||
|
||||
function loadUser(): User | null {
|
||||
try {
|
||||
const raw = localStorage.getItem('mws_user')
|
||||
return raw ? (JSON.parse(raw) as User) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
token: localStorage.getItem('mws_token'),
|
||||
user: loadUser(),
|
||||
menu: [] as MenuItem[],
|
||||
menuLoaded: false,
|
||||
}),
|
||||
getters: {
|
||||
isAuthenticated: (state) => !!state.token,
|
||||
canView: (state) => (key: string) => state.menu.find((m) => m.key === key)?.canView ?? false,
|
||||
can: (state) => (key: string, action: 'create' | 'edit' | 'delete') => {
|
||||
const item = state.menu.find((m) => m.key === key)
|
||||
if (!item) return false
|
||||
if (action === 'create') return item.canCreate
|
||||
if (action === 'edit') return item.canEdit
|
||||
return item.canDelete
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async login(username: string, password: string) {
|
||||
const response = await apiLogin(username, password)
|
||||
this.token = response.token
|
||||
this.user = response.user
|
||||
localStorage.setItem('mws_token', response.token)
|
||||
localStorage.setItem('mws_user', JSON.stringify(response.user))
|
||||
await this.loadMenu()
|
||||
},
|
||||
async loadMenu() {
|
||||
this.menu = await getMenu()
|
||||
this.menuLoaded = true
|
||||
},
|
||||
async ensureMenu() {
|
||||
if (!this.menuLoaded) await this.loadMenu()
|
||||
},
|
||||
logout() {
|
||||
this.token = null
|
||||
this.user = null
|
||||
this.menu = []
|
||||
this.menuLoaded = false
|
||||
localStorage.removeItem('mws_token')
|
||||
localStorage.removeItem('mws_user')
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@custom-variant dark (&:where(.app-dark, .app-dark *));
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
font-family: var(--font-family, Inter, 'Segoe UI', Roboto, Arial, sans-serif);
|
||||
background: #f5f7fa;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.app-dark body {
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ck-editor__editable {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.ck-content ol,
|
||||
.ck-content ul {
|
||||
padding-inline-start: 40px;
|
||||
}
|
||||
|
||||
.ck-content li {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.app-dark .ck.ck-editor {
|
||||
--ck-color-base-background: #0f172a;
|
||||
--ck-color-base-border: #334155;
|
||||
--ck-color-base-text: #e2e8f0;
|
||||
--ck-color-text: #e2e8f0;
|
||||
--ck-color-focus-border: #3b82f6;
|
||||
--ck-color-toolbar-background: #1e293b;
|
||||
--ck-color-toolbar-border: #334155;
|
||||
--ck-color-dropdown-panel-background: #1e293b;
|
||||
--ck-color-panel-background: #1e293b;
|
||||
--ck-color-panel-border: #334155;
|
||||
--ck-color-button-default-hover-background: #334155;
|
||||
--ck-color-button-default-active-background: #334155;
|
||||
--ck-color-button-on-background: #334155;
|
||||
--ck-color-input-background: #0f172a;
|
||||
--ck-color-input-border: #334155;
|
||||
--ck-color-input-text: #e2e8f0;
|
||||
--ck-color-tooltip-background: #334155;
|
||||
--ck-color-tooltip-text: #e2e8f0;
|
||||
--ck-color-table-border: #475569;
|
||||
--ck-color-link-default: #60a5fa;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.field {
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
.field > label {
|
||||
@apply mb-1.5 block text-sm font-medium;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
displayName: string
|
||||
roleId: string
|
||||
roleName: string
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: string
|
||||
username: string
|
||||
displayName: string
|
||||
roleId: string
|
||||
roleName: string
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
user: User
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
key: string
|
||||
label: string
|
||||
path: string
|
||||
canView: boolean
|
||||
canCreate: boolean
|
||||
canEdit: boolean
|
||||
canDelete: boolean
|
||||
}
|
||||
|
||||
export interface PermissionEntry {
|
||||
screen: string
|
||||
canView: boolean
|
||||
canCreate: boolean
|
||||
canEdit: boolean
|
||||
canDelete: boolean
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: string
|
||||
name: string
|
||||
isSystem: boolean
|
||||
permissions: PermissionEntry[]
|
||||
}
|
||||
|
||||
export interface SaveRoleRequest {
|
||||
name: string
|
||||
permissions: PermissionEntry[]
|
||||
}
|
||||
|
||||
export type ProjectStatus = 'Active' | 'Archived'
|
||||
export type MemberRole = 'Owner' | 'Member'
|
||||
export type DocumentType = 'Folder' | 'Document'
|
||||
export type TaskStatus = 'Todo' | 'InProgress' | 'Done' | 'Cancelled'
|
||||
export type TaskPriority = 'Low' | 'Medium' | 'High'
|
||||
|
||||
export interface Project {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
status: ProjectStatus
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ProjectMember {
|
||||
userId: string
|
||||
username: string
|
||||
displayName: string
|
||||
role: MemberRole
|
||||
}
|
||||
|
||||
export interface ProjectOverview {
|
||||
project: Project
|
||||
memberCount: number
|
||||
documentCount: number
|
||||
taskCountsByStatus: Record<string, number>
|
||||
recentTasks: RecentTask[]
|
||||
recentDocuments: RecentDocument[]
|
||||
}
|
||||
|
||||
export interface RecentTask {
|
||||
id: string
|
||||
title: string
|
||||
status: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface RecentDocument {
|
||||
id: string
|
||||
title: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface DocumentNode {
|
||||
id: string
|
||||
parentId: string | null
|
||||
title: string
|
||||
type: DocumentType
|
||||
updatedAt: string
|
||||
children: DocumentNode[]
|
||||
}
|
||||
|
||||
export interface DocumentItem {
|
||||
id: string
|
||||
projectId: string
|
||||
parentId: string | null
|
||||
title: string
|
||||
content: string | null
|
||||
type: DocumentType
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
updatedBy: string | null
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string
|
||||
projectId: string
|
||||
title: string
|
||||
description: string | null
|
||||
status: TaskStatus
|
||||
priority: TaskPriority
|
||||
assigneeId: string | null
|
||||
assigneeName: string | null
|
||||
dueDate: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface TaskCounts {
|
||||
[status: string]: number
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 class="m-0 text-2xl font-semibold">Accounts</h1>
|
||||
<Button v-if="auth.can('accounts', 'create')" label="New Account" icon="pi pi-plus" @click="openCreate" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<InputText v-model.trim="searchTerm" placeholder="Search accounts..." class="w-full sm:w-[320px]" @input="debouncedSearch" />
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<DataTable :value="accounts" :loading="loading" emptyMessage="No accounts" class="min-w-[640px]">
|
||||
<Column header="User" style="width: 35%">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar :label="(data.displayName || data.username).slice(0, 2).toUpperCase()"
|
||||
style="background: #3b82f6; color: #fff" />
|
||||
<span>{{ data.displayName }}</span>
|
||||
<span class="text-slate-500 dark:text-slate-400">@{{ data.username }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="roleName" header="Role" style="width: 15%">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.roleName" severity="secondary" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="isActive" header="Status" style="width: 15%">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.isActive ? 'Active' : 'Disabled'" :severity="data.isActive ? 'success' : 'danger'" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Created" style="width: 15%">
|
||||
<template #body="{ data }">
|
||||
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.createdAt) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="" style="width: 20%">
|
||||
<template #body="{ data }">
|
||||
<div class="flex justify-end gap-1">
|
||||
<Button v-if="auth.can('accounts', 'edit')" icon="pi pi-key" text severity="secondary" aria-label="Reset password" @click="openReset(data)" />
|
||||
<Button v-if="auth.can('accounts', 'edit')" icon="pi pi-pencil" text @click="openEdit(data)" />
|
||||
<Button
|
||||
v-if="auth.can('accounts', 'delete') && data.id !== auth.user?.id"
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
severity="danger"
|
||||
@click="confirmDelete(data)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="createDialog" header="New Account" :modal="true" style="width: min(460px, 92vw)">
|
||||
<div class="field">
|
||||
<label for="acc-username">Username</label>
|
||||
<InputText id="acc-username" v-model.trim="createForm.username" class="w-full" autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="acc-name">Display name</label>
|
||||
<InputText id="acc-name" v-model.trim="createForm.displayName" class="w-full" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="acc-pass">Password</label>
|
||||
<Password id="acc-pass" v-model="createForm.password" class="w-full" inputClass="w-full" toggleMask :feedback="false" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="acc-role">Role</label>
|
||||
<Select id="acc-role" v-model="createForm.roleId" :options="roleOptions" optionLabel="label" optionValue="value" class="w-full" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancel" severity="secondary" text @click="createDialog = false" />
|
||||
<Button label="Create" :loading="saving" @click="onCreate" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="editDialog" header="Edit Account" :modal="true" style="width: min(460px, 92vw)">
|
||||
<div class="field">
|
||||
<label for="edit-name">Display name</label>
|
||||
<InputText id="edit-name" v-model.trim="editForm.displayName" class="w-full" autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="edit-role">Role</label>
|
||||
<Select id="edit-role" v-model="editForm.roleId" :options="roleOptions" optionLabel="label" optionValue="value" class="w-full" />
|
||||
</div>
|
||||
<div class="field flex items-center gap-2">
|
||||
<ToggleSwitch v-model="editForm.isActive" inputId="edit-active" />
|
||||
<label for="edit-active">Active</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancel" severity="secondary" text @click="editDialog = false" />
|
||||
<Button label="Save" :loading="saving" @click="onEdit" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="resetDialog" header="Reset Password" :modal="true" style="width: min(420px, 92vw)">
|
||||
<div class="field">
|
||||
<label for="reset-pass">New password</label>
|
||||
<Password id="reset-pass" v-model="resetPassword" class="w-full" inputClass="w-full" toggleMask :feedback="false" autofocus />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancel" severity="secondary" text @click="resetDialog = false" />
|
||||
<Button label="Reset" :loading="saving" @click="onReset" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccounts, createAccount, updateAccount, deleteAccount, resetAccountPassword, getRoles } from '../services/backend'
|
||||
import { errorMessage } from '../services/api'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { Account } from '../types'
|
||||
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const accounts = ref<Account[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const searchTerm = ref('')
|
||||
|
||||
const roleOptions = ref<{ label: string; value: string }[]>([])
|
||||
|
||||
const createDialog = ref(false)
|
||||
const createForm = ref({ username: '', displayName: '', password: '', roleId: '' })
|
||||
|
||||
const editDialog = ref(false)
|
||||
const editTarget = ref<Account | null>(null)
|
||||
const editForm = ref({ displayName: '', roleId: '', isActive: true })
|
||||
|
||||
const resetDialog = ref(false)
|
||||
const resetTarget = ref<Account | null>(null)
|
||||
const resetPassword = ref('')
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
async function loadAccounts() {
|
||||
loading.value = true
|
||||
try {
|
||||
accounts.value = await getAccounts(searchTerm.value || undefined)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRoles() {
|
||||
try {
|
||||
roleOptions.value = (await getRoles()).map((r) => ({ label: r.name, value: r.id }))
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(loadAccounts, 300)
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
createForm.value = { username: '', displayName: '', password: '', roleId: roleOptions.value[0]?.value ?? '' }
|
||||
createDialog.value = true
|
||||
}
|
||||
|
||||
async function onCreate() {
|
||||
if (!createForm.value.username || !createForm.value.displayName || !createForm.value.password) {
|
||||
toast.add({ severity: 'warn', summary: 'Fill in all fields', life: 3000 })
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await createAccount(createForm.value)
|
||||
createDialog.value = false
|
||||
toast.add({ severity: 'success', summary: 'Account created', life: 3000 })
|
||||
await loadAccounts()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(account: Account) {
|
||||
editTarget.value = account
|
||||
editForm.value = { displayName: account.displayName, roleId: account.roleId, isActive: account.isActive }
|
||||
editDialog.value = true
|
||||
}
|
||||
|
||||
async function onEdit() {
|
||||
if (!editTarget.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
await updateAccount(editTarget.value.id, editForm.value)
|
||||
editDialog.value = false
|
||||
toast.add({ severity: 'success', summary: 'Account updated', life: 3000 })
|
||||
await loadAccounts()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openReset(account: Account) {
|
||||
resetTarget.value = account
|
||||
resetPassword.value = ''
|
||||
resetDialog.value = true
|
||||
}
|
||||
|
||||
async function onReset() {
|
||||
if (!resetTarget.value || !resetPassword.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Password is required', life: 3000 })
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await resetAccountPassword(resetTarget.value.id, resetPassword.value)
|
||||
resetDialog.value = false
|
||||
toast.add({ severity: 'success', summary: 'Password reset', life: 3000 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(account: Account) {
|
||||
confirm.require({
|
||||
message: `Delete account "${account.displayName}"?`,
|
||||
header: 'Delete',
|
||||
accept: async () => {
|
||||
try {
|
||||
await deleteAccount(account.id)
|
||||
toast.add({ severity: 'success', summary: 'Account deleted', life: 2000 })
|
||||
await loadAccounts()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleDateString()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadAccounts(), loadRoles()])
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<h1 class="m-0 text-2xl font-semibold">Dashboard</h1>
|
||||
<p class="mt-1 text-slate-500 dark:text-slate-400">
|
||||
Welcome back, {{ auth.user?.displayName ?? auth.user?.username }}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-3">
|
||||
<Card class="[&_.p-card-body]:pt-3">
|
||||
<template #content>
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400">Projects</div>
|
||||
<div class="text-[1.8rem] font-bold">{{ projects.length }}</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card class="[&_.p-card-body]:pt-3">
|
||||
<template #content>
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400">Active</div>
|
||||
<div class="text-[1.8rem] font-bold">{{ activeCount }}</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card class="[&_.p-card-body]:pt-3">
|
||||
<template #content>
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400">Archived</div>
|
||||
<div class="text-[1.8rem] font-bold">{{ archivedCount }}</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card class="mb-6">
|
||||
<template #title>Recent Projects</template>
|
||||
<template #content>
|
||||
<div class="overflow-x-auto">
|
||||
<DataTable :value="recentProjects" :loading="loading" emptyMessage="No projects yet">
|
||||
<Column field="name" header="Name">
|
||||
<template #body="{ data }">
|
||||
<router-link
|
||||
:to="{ name: 'project-overview', params: { id: data.id } }"
|
||||
class="font-medium text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
{{ data.name }}
|
||||
</router-link>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="Status" style="width: 140px">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status" :severity="data.status === 'Archived' ? 'warning' : 'success'" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Updated" style="width: 160px">
|
||||
<template #body="{ data }">
|
||||
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.updatedAt) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button icon="pi pi-plus" label="New Project" :to="{ name: 'projects' }" />
|
||||
<Button icon="pi pi-cog" label="Settings" outlined :to="{ name: 'settings' }" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getProjects } from '../services/backend'
|
||||
import { errorMessage } from '../services/api'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { Project } from '../types'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const activeCount = computed(() => projects.value.filter((p) => p.status === 'Active').length)
|
||||
const archivedCount = computed(() => projects.value.filter((p) => p.status === 'Archived').length)
|
||||
const recentProjects = computed(() => projects.value.slice(0, 8))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
projects.value = await getProjects()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleDateString()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 class="m-0 text-2xl font-semibold">Roles</h1>
|
||||
<Button v-if="auth.can('roles', 'create')" label="New Role" icon="pi pi-plus" @click="openCreate" />
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<DataTable :value="roles" :loading="loading" emptyMessage="No roles" class="min-w-[480px]">
|
||||
<Column field="name" header="Name" style="width: 40%" />
|
||||
<Column header="Type" style="width: 20%">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.isSystem ? 'System' : 'Custom'" :severity="data.isSystem ? 'warn' : 'secondary'" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="" style="width: 40%">
|
||||
<template #body="{ data }">
|
||||
<div class="flex justify-end gap-1">
|
||||
<Button v-if="auth.can('roles', 'edit')" icon="pi pi-pencil" text @click="openEdit(data)" />
|
||||
<Button
|
||||
v-if="auth.can('roles', 'delete')"
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
severity="danger"
|
||||
:disabled="data.isSystem"
|
||||
@click="confirmDelete(data)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="formDialog" :header="editTarget ? 'Edit Role' : 'New Role'" :modal="true" style="width: min(640px, 92vw)">
|
||||
<div class="field">
|
||||
<label for="role-name">Name</label>
|
||||
<InputText id="role-name" v-model.trim="form.name" class="w-full" autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Permissions</label>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-[420px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-200 dark:border-slate-700">
|
||||
<th class="py-2 text-left font-medium">Screen</th>
|
||||
<th class="w-16 text-center font-medium">View</th>
|
||||
<th class="w-16 text-center font-medium">Create</th>
|
||||
<th class="w-16 text-center font-medium">Edit</th>
|
||||
<th class="w-16 text-center font-medium">Delete</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in form.permissions" :key="row.screen" class="border-b border-slate-100 dark:border-slate-800">
|
||||
<td class="py-2">{{ screenLabel(row.screen) }}</td>
|
||||
<td class="text-center"><Checkbox v-model="row.canView" binary /></td>
|
||||
<td class="text-center"><Checkbox v-model="row.canCreate" binary /></td>
|
||||
<td class="text-center"><Checkbox v-model="row.canEdit" binary /></td>
|
||||
<td class="text-center"><Checkbox v-model="row.canDelete" binary /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancel" severity="secondary" text @click="formDialog = false" />
|
||||
<Button label="Save" :loading="saving" @click="onSave" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getRoles, createRole, updateRole, deleteRole } from '../services/backend'
|
||||
import { errorMessage } from '../services/api'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { PermissionEntry, Role } from '../types'
|
||||
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const roles = ref<Role[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const formDialog = ref(false)
|
||||
const editTarget = ref<Role | null>(null)
|
||||
const form = ref<{ name: string; permissions: PermissionEntry[] }>({ name: '', permissions: [] })
|
||||
|
||||
function emptyPermissions(): PermissionEntry[] {
|
||||
return auth.menu.map((m) => ({ screen: m.key, canView: false, canCreate: false, canEdit: false, canDelete: false }))
|
||||
}
|
||||
|
||||
function screenLabel(key: string) {
|
||||
return auth.menu.find((m) => m.key === key)?.label ?? key
|
||||
}
|
||||
|
||||
async function loadRoles() {
|
||||
loading.value = true
|
||||
try {
|
||||
roles.value = await getRoles()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editTarget.value = null
|
||||
form.value = { name: '', permissions: emptyPermissions() }
|
||||
formDialog.value = true
|
||||
}
|
||||
|
||||
function openEdit(role: Role) {
|
||||
editTarget.value = role
|
||||
const permissions = emptyPermissions().map((row) => {
|
||||
const existing = role.permissions.find((p) => p.screen === row.screen)
|
||||
return existing ? { ...existing } : row
|
||||
})
|
||||
form.value = { name: role.name, permissions }
|
||||
formDialog.value = true
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!form.value.name) {
|
||||
toast.add({ severity: 'warn', summary: 'Name is required', life: 3000 })
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editTarget.value) {
|
||||
await updateRole(editTarget.value.id, form.value)
|
||||
} else {
|
||||
await createRole(form.value)
|
||||
}
|
||||
formDialog.value = false
|
||||
toast.add({ severity: 'success', summary: 'Role saved', life: 3000 })
|
||||
await loadRoles()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(role: Role) {
|
||||
confirm.require({
|
||||
message: `Delete role "${role.name}"?`,
|
||||
header: 'Delete',
|
||||
accept: async () => {
|
||||
try {
|
||||
await deleteRole(role.id)
|
||||
toast.add({ severity: 'success', summary: 'Role deleted', life: 2000 })
|
||||
await loadRoles()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(loadRoles)
|
||||
</script>
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="max-w-xl">
|
||||
<div class="mb-6">
|
||||
<h1 class="m-0 text-2xl font-semibold">Settings</h1>
|
||||
</div>
|
||||
|
||||
<Card class="mb-4">
|
||||
<template #title>Profile</template>
|
||||
<template #content>
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar :label="initials" style="background: #3b82f6; color: #fff" size="large" />
|
||||
<div>
|
||||
<div class="font-semibold">{{ auth.user?.displayName }}</div>
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400">@{{ auth.user?.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="mb-4">
|
||||
<template #title>Appearance</template>
|
||||
<template #content>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-medium">Dark mode</div>
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400">Switch between light and dark theme</div>
|
||||
</div>
|
||||
<Button
|
||||
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
|
||||
:label="theme.isDark.value ? 'Light' : 'Dark'"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="theme.toggle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<template #title>Account</template>
|
||||
<template #content>
|
||||
<Button icon="pi pi-sign-out" label="Logout" severity="danger" outlined @click="onLogout" />
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const theme = useTheme()
|
||||
|
||||
const initials = computed(() => {
|
||||
const name = auth.user?.displayName ?? auth.user?.username ?? '?'
|
||||
return name.slice(0, 2).toUpperCase()
|
||||
})
|
||||
|
||||
function onLogout() {
|
||||
auth.logout()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center bg-slate-100 p-4 dark:bg-slate-900">
|
||||
<Button
|
||||
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
|
||||
rounded
|
||||
text
|
||||
style="position: fixed; right: 1rem; top: 1rem; z-index: 10"
|
||||
:aria-label="theme.isDark.value ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||
@click="theme.toggle"
|
||||
/>
|
||||
<Card class="w-full max-w-[380px]">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-briefcase" style="color: #3b82f6"></i>
|
||||
MWS — My Workspace
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<form @submit.prevent="submit">
|
||||
<div class="field">
|
||||
<label for="username">Username</label>
|
||||
<InputText id="username" v-model.trim="username" class="w-full" autocomplete="username" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<InputText
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
class="w-full"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<Message v-if="error" severity="error" variant="simple" class="mb-2 w-full">{{ error }}</Message>
|
||||
<Button type="submit" label="Sign in" class="w-full" :loading="loading" />
|
||||
</form>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { errorMessage } from '../../services/api'
|
||||
import { useTheme } from '../../composables/useTheme'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const theme = useTheme()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
if (!username.value || !password.value) {
|
||||
error.value = 'Username and password are required'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(username.value, password.value)
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/projects'
|
||||
router.push(redirect)
|
||||
} catch (e) {
|
||||
error.value = errorMessage(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<Splitter v-if="isDesktop" class="h-[calc(100vh-170px)]">
|
||||
<SplitterPanel :size="30" :minSize="20">
|
||||
<DocumentTreePanel
|
||||
:tree="tree"
|
||||
:loading="loading"
|
||||
:selected-id="selectedId"
|
||||
v-model:search-term="searchTerm"
|
||||
:creating-label="creatingLabel"
|
||||
:can-create="auth.can('documents', 'create')"
|
||||
@select="selectDocument"
|
||||
@create="openCreate"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
|
||||
<SplitterPanel>
|
||||
<DocumentEditorPanel
|
||||
:doc="doc"
|
||||
:model-value="contentModel"
|
||||
:save-state="saveState"
|
||||
:can-edit="auth.can('documents', 'edit')"
|
||||
:can-delete="auth.can('documents', 'delete')"
|
||||
@update:model-value="onContentChange"
|
||||
@rename="openRename"
|
||||
@move="openMove"
|
||||
@delete="confirmDelete"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
|
||||
<div v-else>
|
||||
<DocumentTreePanel
|
||||
v-if="!doc"
|
||||
:tree="tree"
|
||||
:loading="loading"
|
||||
:selected-id="selectedId"
|
||||
v-model:search-term="searchTerm"
|
||||
:creating-label="creatingLabel"
|
||||
:can-create="auth.can('documents', 'create')"
|
||||
@select="selectDocument"
|
||||
@create="openCreate"
|
||||
/>
|
||||
<DocumentEditorPanel
|
||||
v-else
|
||||
:doc="doc"
|
||||
:model-value="contentModel"
|
||||
:save-state="saveState"
|
||||
:can-edit="auth.can('documents', 'edit')"
|
||||
:can-delete="auth.can('documents', 'delete')"
|
||||
@update:model-value="onContentChange"
|
||||
@rename="openRename"
|
||||
@move="openMove"
|
||||
@delete="confirmDelete"
|
||||
@back="closeDocument"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="createDialog" :header="`New ${createType}`" :modal="true" style="width: min(420px, 92vw)">
|
||||
<div class="field">
|
||||
<label>Title</label>
|
||||
<InputText v-model.trim="createTitle" class="w-full" autofocus @keyup.enter="onCreate" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancel" severity="secondary" text @click="createDialog = false" />
|
||||
<Button label="Create" @click="onCreate" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="renameDialog" header="Rename" :modal="true" style="width: min(420px, 92vw)">
|
||||
<div class="field">
|
||||
<label>Title</label>
|
||||
<InputText v-model.trim="renameTitle" class="w-full" @keyup.enter="onRename" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancel" severity="secondary" text @click="renameDialog = false" />
|
||||
<Button label="Save" @click="onRename" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="moveDialog" header="Move to Folder" :modal="true" style="width: min(440px, 92vw)">
|
||||
<Select
|
||||
v-model="moveTarget"
|
||||
:options="folderOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Root"
|
||||
class="w-full"
|
||||
/>
|
||||
<template #footer>
|
||||
<Button label="Cancel" severity="secondary" text @click="moveDialog = false" />
|
||||
<Button label="Move" @click="onMove" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DocumentTreePanel from '../../components/DocumentTreePanel.vue'
|
||||
import DocumentEditorPanel from '../../components/DocumentEditorPanel.vue'
|
||||
import {
|
||||
getDocumentTree,
|
||||
getDocument,
|
||||
createDocument,
|
||||
updateDocument,
|
||||
moveDocument,
|
||||
deleteDocument,
|
||||
searchDocuments,
|
||||
} from '../../services/modules'
|
||||
import { errorMessage } from '../../services/api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import type { DocumentItem, DocumentNode, DocumentType } from '../../types'
|
||||
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const projectId = String(route.params.id)
|
||||
const tree = ref<DocumentNode[]>([])
|
||||
const loading = ref(false)
|
||||
const selectedId = ref<string | null>(null)
|
||||
const doc = ref<DocumentItem | null>(null)
|
||||
const contentModel = ref('')
|
||||
const saveState = ref<'idle' | 'saving' | 'saved'>('idle')
|
||||
|
||||
const searchTerm = ref('')
|
||||
const searchMode = ref(false)
|
||||
|
||||
const createDialog = ref(false)
|
||||
const createType = ref<DocumentType>('Document')
|
||||
const createTitle = ref('')
|
||||
const renameDialog = ref(false)
|
||||
const renameTitle = ref('')
|
||||
const moveDialog = ref(false)
|
||||
const moveTarget = ref<string | null>(null)
|
||||
|
||||
const isDesktop = ref(false)
|
||||
let mediaQuery: MediaQueryList | null = null
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const creatingLabel = computed(() =>
|
||||
doc.value?.type === 'Folder' ? `New items go inside: ${doc.value.title}` : 'New items are created at root',
|
||||
)
|
||||
|
||||
const folderOptions = computed(() => {
|
||||
const folders: { label: string; value: string }[] = []
|
||||
function walk(nodes: DocumentNode[], prefix: string) {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'Folder') {
|
||||
const label = prefix + node.title
|
||||
folders.push({ label, value: node.id })
|
||||
walk(node.children, label + '/')
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(tree.value, '')
|
||||
return folders
|
||||
})
|
||||
|
||||
async function loadTree() {
|
||||
try {
|
||||
tree.value = searchMode.value ? await searchDocuments(searchTerm.value) : await getDocumentTree(projectId)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(searchTerm, () => {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(async () => {
|
||||
searchMode.value = !!searchTerm.value
|
||||
await loadTree()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
async function selectDocument(id: string) {
|
||||
selectedId.value = id
|
||||
clearTimeout(saveTimer)
|
||||
try {
|
||||
doc.value = await getDocument(id)
|
||||
contentModel.value = doc.value.content ?? ''
|
||||
saveState.value = 'idle'
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
function closeDocument() {
|
||||
doc.value = null
|
||||
selectedId.value = null
|
||||
}
|
||||
|
||||
function openCreate(type: DocumentType) {
|
||||
createType.value = type
|
||||
createTitle.value = ''
|
||||
createDialog.value = true
|
||||
}
|
||||
|
||||
async function onCreate() {
|
||||
if (!createTitle.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Title is required', life: 3000 })
|
||||
return
|
||||
}
|
||||
const parentId = doc.value?.type === 'Folder' ? doc.value.id : null
|
||||
try {
|
||||
const created = await createDocument(projectId, {
|
||||
title: createTitle.value,
|
||||
type: createType.value,
|
||||
parentId,
|
||||
content: createType.value === 'Document' ? '' : null,
|
||||
})
|
||||
createDialog.value = false
|
||||
await loadTree()
|
||||
if (created.type === 'Document') {
|
||||
await selectDocument(created.id)
|
||||
}
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
function openRename() {
|
||||
if (doc.value) {
|
||||
renameTitle.value = doc.value.title
|
||||
renameDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function onRename() {
|
||||
if (!doc.value || !renameTitle.value) return
|
||||
try {
|
||||
const updated = await updateDocument(doc.value.id, { title: renameTitle.value, content: doc.value.content })
|
||||
doc.value = updated
|
||||
renameDialog.value = false
|
||||
toast.add({ severity: 'success', summary: 'Renamed', life: 2000 })
|
||||
await loadTree()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
function openMove() {
|
||||
moveTarget.value = doc.value?.parentId ?? null
|
||||
moveDialog.value = true
|
||||
}
|
||||
|
||||
async function onMove() {
|
||||
if (!doc.value) return
|
||||
try {
|
||||
await moveDocument(doc.value.id, moveTarget.value)
|
||||
moveDialog.value = false
|
||||
toast.add({ severity: 'success', summary: 'Moved', life: 2000 })
|
||||
await loadTree()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (!doc.value) return
|
||||
confirm.require({
|
||||
message: `Delete "${doc.value.title}"?`,
|
||||
header: 'Delete',
|
||||
accept: async () => {
|
||||
try {
|
||||
await deleteDocument(doc.value!.id)
|
||||
doc.value = null
|
||||
selectedId.value = null
|
||||
toast.add({ severity: 'success', summary: 'Deleted', life: 2000 })
|
||||
await loadTree()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function onContentChange(value: string) {
|
||||
if (!doc.value) return
|
||||
contentModel.value = value
|
||||
saveState.value = 'saving'
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(async () => {
|
||||
try {
|
||||
const updated = await updateDocument(doc.value!.id, { title: doc.value!.title, content: contentModel.value })
|
||||
doc.value = updated
|
||||
saveState.value = 'saved'
|
||||
} catch (e) {
|
||||
saveState.value = 'idle'
|
||||
toast.add({ severity: 'error', summary: 'Save failed', detail: errorMessage(e), life: 5000 })
|
||||
}
|
||||
}, 800)
|
||||
}
|
||||
|
||||
function syncDesktop() {
|
||||
isDesktop.value = mediaQuery?.matches ?? false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mediaQuery = window.matchMedia('(min-width: 1024px)')
|
||||
syncDesktop()
|
||||
mediaQuery.addEventListener('change', syncDesktop)
|
||||
loading.value = true
|
||||
void loadTree()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
mediaQuery?.removeEventListener('change', syncDesktop)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<InputText v-model.trim="userSearch" placeholder="Search users..." class="w-full sm:w-[320px]" @input="debouncedUsers" />
|
||||
<Button v-if="isOwner" label="Add Member" icon="pi pi-plus" @click="addDialog = true" />
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<DataTable :value="members" :loading="loading" emptyMessage="No members" class="min-w-[480px]">
|
||||
<Column header="User" style="width: 50%">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar :label="(data.displayName || data.username).slice(0, 2).toUpperCase()"
|
||||
style="background: #3b82f6; color: #fff" />
|
||||
<span>{{ data.displayName }}</span>
|
||||
<span class="text-slate-500 dark:text-slate-400">@{{ data.username }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="role" header="Role" style="width: 20%">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.role" :severity="data.role === 'Owner' ? 'warn' : 'secondary'" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="" style="width: 10%">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
v-if="isOwner && data.role !== 'Owner'"
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
severity="danger"
|
||||
@click="onRemove(data.userId)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="addDialog" header="Add Member" :modal="true" style="width: min(460px, 92vw)">
|
||||
<Select
|
||||
v-model="selectedUserId"
|
||||
:options="userOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Select user"
|
||||
filter
|
||||
class="w-full"
|
||||
/>
|
||||
<Select v-model="newRole" :options="roleOptions" optionLabel="label" optionValue="value"
|
||||
placeholder="Role" class="mt-2 w-full" />
|
||||
<template #footer>
|
||||
<Button label="Cancel" severity="secondary" text @click="addDialog = false" />
|
||||
<Button label="Add" :loading="adding" @click="onAdd" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getMembers, addMember, removeMember, getUsers } from '../../services/backend'
|
||||
import { errorMessage } from '../../services/api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import type { ProjectMember } from '../../types'
|
||||
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const projectId = String(route.params.id)
|
||||
const members = ref<ProjectMember[]>([])
|
||||
const loading = ref(false)
|
||||
const isOwner = ref(false)
|
||||
|
||||
const addDialog = ref(false)
|
||||
const userSearch = ref('')
|
||||
const users = ref<{ label: string; value: string }[]>([])
|
||||
const selectedUserId = ref<string | null>(null)
|
||||
const newRole = ref<'Owner' | 'Member'>('Member')
|
||||
const roleOptions = [
|
||||
{ label: 'Member', value: 'Member' },
|
||||
{ label: 'Owner', value: 'Owner' },
|
||||
]
|
||||
const adding = ref(false)
|
||||
|
||||
const userOptions = computed(() => users.value.filter((u) => !members.value.some((m) => m.userId === u.value)))
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
async function loadMembers() {
|
||||
loading.value = true
|
||||
try {
|
||||
members.value = await getMembers(projectId)
|
||||
isOwner.value = members.value.some((m) => m.userId === auth.user?.id && m.role === 'Owner')
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
users.value = (await getUsers(userSearch.value || undefined)).map((u: { id: string; username: string; displayName: string }) => ({
|
||||
label: `${u.displayName} (@${u.username})`,
|
||||
value: u.id,
|
||||
}))
|
||||
}
|
||||
|
||||
function debouncedUsers() {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(loadUsers, 300)
|
||||
}
|
||||
|
||||
async function onAdd() {
|
||||
if (!selectedUserId.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Select a user', life: 3000 })
|
||||
return
|
||||
}
|
||||
adding.value = true
|
||||
try {
|
||||
await addMember(projectId, selectedUserId.value, newRole.value)
|
||||
addDialog.value = false
|
||||
selectedUserId.value = null
|
||||
toast.add({ severity: 'success', summary: 'Member added', life: 3000 })
|
||||
await loadMembers()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
adding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemove(userId: string) {
|
||||
try {
|
||||
await removeMember(projectId, userId)
|
||||
toast.add({ severity: 'success', summary: 'Member removed', life: 3000 })
|
||||
await loadMembers()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMembers()
|
||||
await loadUsers()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div v-if="overview">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<h1 class="m-0 text-2xl font-bold">{{ overview.project.name }}</h1>
|
||||
<Tag v-if="overview.project.status === 'Archived'" value="Archived" severity="warning" />
|
||||
</div>
|
||||
<p v-if="overview.project.description" class="-mt-3 mb-4 text-slate-500 dark:text-slate-400">{{ overview.project.description }}</p>
|
||||
|
||||
<div class="mb-3 grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-3">
|
||||
<Card class="[&_.p-card-body]:pt-3">
|
||||
<template #content>
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400">Members</div>
|
||||
<div class="text-[1.8rem] font-bold">{{ overview.memberCount }}</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card class="[&_.p-card-body]:pt-3">
|
||||
<template #content>
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400">Documents</div>
|
||||
<div class="text-[1.8rem] font-bold">{{ overview.documentCount }}</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card v-for="(count, status) in taskCounts" :key="status" class="[&_.p-card-body]:pt-3">
|
||||
<template #content>
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400">{{ statusLabel(status) }}</div>
|
||||
<div class="text-[1.8rem] font-bold">{{ count }}</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex flex-col gap-3 lg:flex-row">
|
||||
<Card class="min-w-0 flex-1">
|
||||
<template #title>Recent Tasks</template>
|
||||
<template #content>
|
||||
<div class="overflow-x-auto">
|
||||
<DataTable :value="overview.recentTasks" emptyMessage="No tasks">
|
||||
<Column field="title" header="Title" />
|
||||
<Column field="status" header="Status" style="width: 120px">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status" :severity="statusSeverity(data.status)" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card class="min-w-0 flex-1">
|
||||
<template #title>Recent Documents</template>
|
||||
<template #content>
|
||||
<div class="overflow-x-auto">
|
||||
<DataTable :value="overview.recentDocuments" emptyMessage="No documents">
|
||||
<Column field="title" header="Title" />
|
||||
<Column header="Updated" style="width: 150px">
|
||||
<template #body="{ data }">
|
||||
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.updatedAt) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button v-if="auth.can('projects', 'edit')" icon="pi pi-pencil" label="Edit project" outlined @click="openEdit" />
|
||||
<Button
|
||||
v-if="auth.can('projects', 'delete') && overview.project.status !== 'Archived'"
|
||||
icon="pi pi-archive"
|
||||
label="Archive project"
|
||||
severity="warning"
|
||||
outlined
|
||||
@click="confirmArchive"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="editDialog" header="Edit Project" :modal="true" style="width: min(480px, 92vw)">
|
||||
<div class="field">
|
||||
<label for="ename">Name</label>
|
||||
<InputText id="ename" v-model.trim="editName" class="w-full" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="edesc">Description</label>
|
||||
<Textarea id="edesc" v-model="editDescription" rows="3" class="w-full" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancel" severity="secondary" text @click="editDialog = false" />
|
||||
<Button label="Save" :loading="saving" @click="onSave" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center p-[60px]">
|
||||
<ProgressSpinner />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getProjectOverview, updateProject, deleteProject } from '../../services/backend'
|
||||
import { errorMessage } from '../../services/api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import type { ProjectOverview } from '../../types'
|
||||
|
||||
const route = useRoute()
|
||||
const confirm = useConfirm()
|
||||
const toast = useToast()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const overview = ref<ProjectOverview | null>(null)
|
||||
const editDialog = ref(false)
|
||||
const editName = ref('')
|
||||
const editDescription = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
const taskCounts = computed(() => {
|
||||
const counts = overview.value?.taskCountsByStatus ?? {}
|
||||
const order = ['Todo', 'InProgress', 'Done', 'Cancelled']
|
||||
const result: Record<string, number> = {}
|
||||
for (const key of order) {
|
||||
if (counts[key] !== undefined) {
|
||||
result[key] = counts[key]
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
overview.value = await getProjectOverview(String(route.params.id))
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit() {
|
||||
if (overview.value) {
|
||||
editName.value = overview.value.project.name
|
||||
editDescription.value = overview.value.project.description ?? ''
|
||||
editDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!overview.value || !editName.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Name is required', life: 3000 })
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await updateProject(overview.value.project.id, {
|
||||
name: editName.value,
|
||||
description: editDescription.value || null,
|
||||
status: overview.value.project.status,
|
||||
})
|
||||
editDialog.value = false
|
||||
toast.add({ severity: 'success', summary: 'Saved', life: 3000 })
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmArchive() {
|
||||
if (!overview.value) return
|
||||
confirm.require({
|
||||
message: `Archive project "${overview.value.project.name}"?`,
|
||||
header: 'Archive Project',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Archive',
|
||||
rejectLabel: 'Cancel',
|
||||
accept: async () => {
|
||||
try {
|
||||
await deleteProject(overview.value!.project.id)
|
||||
toast.add({ severity: 'success', summary: 'Project archived', life: 3000 })
|
||||
window.location.href = '/projects'
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
return status.replace(/([A-Z])/g, ' $1').trim()
|
||||
}
|
||||
|
||||
function statusSeverity(status: string) {
|
||||
return status === 'Done' ? 'success' : status === 'InProgress' ? 'info' : status === 'Cancelled' ? 'danger' : 'secondary'
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleDateString()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 class="m-0 text-2xl font-semibold">Projects</h1>
|
||||
<Button v-if="auth.can('projects', 'create')" label="New Project" icon="pi pi-plus" @click="createDialog = true" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<InputText
|
||||
v-model.trim="searchTerm"
|
||||
placeholder="Search projects..."
|
||||
icon="pi pi-search"
|
||||
class="w-full sm:w-[320px]"
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<DataTable :value="projects" :loading="loading" v-model:selection="selectedProject" selectionMode="single"
|
||||
dataKey="id" @row-select="onRowSelect" emptyMessage="No projects found" class="min-w-[600px]">
|
||||
<Column field="name" header="Name" style="width: 30%">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-folder" style="color: #3b82f6"></i>
|
||||
<span style="font-weight: 600">{{ data.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="description" header="Description" style="width: 40%">
|
||||
<template #body="{ data }">
|
||||
<span class="text-slate-500 dark:text-slate-400">{{ data.description }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="Status" style="width: 15%">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status" :severity="data.status === 'Archived' ? 'warning' : 'success'" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Updated" style="width: 15%">
|
||||
<template #body="{ data }">
|
||||
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.updatedAt) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="createDialog" header="New Project" :modal="true" style="width: min(480px, 92vw)">
|
||||
<div class="field">
|
||||
<label for="pname">Name</label>
|
||||
<InputText id="pname" v-model.trim="newName" class="w-full" autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="pdesc">Description</label>
|
||||
<Textarea id="pdesc" v-model="newDescription" rows="3" class="w-full" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancel" severity="secondary" text @click="createDialog = false" />
|
||||
<Button label="Create" :loading="creating" @click="onCreate" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { createProject, getProjects, searchProjects } from '../../services/backend'
|
||||
import { errorMessage } from '../../services/api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import type { Project } from '../../types'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const loading = ref(false)
|
||||
const searchTerm = ref('')
|
||||
const selectedProject = ref<Project | null>(null)
|
||||
|
||||
const createDialog = ref(false)
|
||||
const newName = ref('')
|
||||
const newDescription = ref('')
|
||||
const creating = ref(false)
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
async function loadProjects() {
|
||||
loading.value = true
|
||||
try {
|
||||
projects.value = searchTerm.value
|
||||
? await searchProjects(searchTerm.value)
|
||||
: await getProjects()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(loadProjects, 300)
|
||||
}
|
||||
|
||||
function onRowSelect() {
|
||||
if (selectedProject.value) {
|
||||
router.push({ name: 'project-overview', params: { id: selectedProject.value.id } })
|
||||
}
|
||||
}
|
||||
|
||||
async function onCreate() {
|
||||
if (!newName.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Name is required', life: 3000 })
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const project = await createProject(newName.value, newDescription.value || undefined)
|
||||
createDialog.value = false
|
||||
newName.value = ''
|
||||
newDescription.value = ''
|
||||
toast.add({ severity: 'success', summary: 'Project created', detail: project.name, life: 3000 })
|
||||
router.push({ name: 'project-overview', params: { id: project.id } })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleDateString()
|
||||
}
|
||||
|
||||
onMounted(loadProjects)
|
||||
</script>
|
||||
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
:header="task ? 'Edit Task' : 'New Task'"
|
||||
:modal="true"
|
||||
style="width: min(520px, 92vw)"
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
>
|
||||
<div class="field">
|
||||
<label>Title</label>
|
||||
<InputText v-model.trim="form.title" class="w-full" autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Description</label>
|
||||
<Textarea v-model="form.description" rows="3" class="w-full" />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="field">
|
||||
<label>Status</label>
|
||||
<Select v-model="form.status" :options="statusOptions" optionLabel="label" optionValue="value" class="w-full" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Priority</label>
|
||||
<Select v-model="form.priority" :options="priorityOptions" optionLabel="label" optionValue="value" class="w-full" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Assignee</label>
|
||||
<Select
|
||||
v-model="form.assigneeId"
|
||||
:options="assigneeOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
showClear
|
||||
placeholder="Unassigned"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Due date</label>
|
||||
<DatePicker v-model="form.dueDate" class="w-full" dateFormat="yy-mm-dd" showClear />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="savingError" class="mb-2">
|
||||
<Message severity="error" variant="simple">{{ savingError }}</Message>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-between">
|
||||
<Button
|
||||
v-if="task && canDelete"
|
||||
label="Delete"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
text
|
||||
@click="onDelete"
|
||||
/>
|
||||
<div>
|
||||
<Button label="Cancel" severity="secondary" text @click="emit('update:visible', false)" />
|
||||
<Button v-if="canEdit" label="Save" :loading="saving" @click="onSave" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { createTask, updateTask, deleteTask } from '../../services/modules'
|
||||
import { errorMessage } from '../../services/api'
|
||||
import type { Task, TaskPriority, TaskStatus } from '../../types'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
task: Task | null
|
||||
projectId: string
|
||||
members: { userId: string; displayName: string }[]
|
||||
canEdit: boolean
|
||||
canDelete: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
saved: []
|
||||
deleted: []
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
const saving = ref(false)
|
||||
const savingError = ref('')
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'To Do', value: 'Todo' as TaskStatus },
|
||||
{ label: 'In Progress', value: 'InProgress' as TaskStatus },
|
||||
{ label: 'Done', value: 'Done' as TaskStatus },
|
||||
{ label: 'Cancelled', value: 'Cancelled' as TaskStatus },
|
||||
]
|
||||
|
||||
const priorityOptions = [
|
||||
{ label: 'Low', value: 'Low' as TaskPriority },
|
||||
{ label: 'Medium', value: 'Medium' as TaskPriority },
|
||||
{ label: 'High', value: 'High' as TaskPriority },
|
||||
]
|
||||
|
||||
const assigneeOptions = computed(() => props.members.map((m) => ({ label: m.displayName, value: m.userId })))
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
status: 'Todo' as TaskStatus,
|
||||
priority: 'Medium' as TaskPriority,
|
||||
assigneeId: null as string | null,
|
||||
dueDate: null as Date | null,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
savingError.value = ''
|
||||
if (props.task) {
|
||||
form.title = props.task.title
|
||||
form.description = props.task.description ?? ''
|
||||
form.status = props.task.status
|
||||
form.priority = props.task.priority
|
||||
form.assigneeId = props.task.assigneeId
|
||||
form.dueDate = props.task.dueDate ? new Date(props.task.dueDate) : null
|
||||
} else {
|
||||
form.title = ''
|
||||
form.description = ''
|
||||
form.status = 'Todo'
|
||||
form.priority = 'Medium'
|
||||
form.assigneeId = null
|
||||
form.dueDate = null
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function onSave() {
|
||||
if (!form.title.trim()) {
|
||||
savingError.value = 'Title is required'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
savingError.value = ''
|
||||
try {
|
||||
const dueDate = form.dueDate ? form.dueDate.toISOString() : null
|
||||
if (props.task) {
|
||||
await updateTask(props.task.id, {
|
||||
title: form.title.trim(),
|
||||
description: form.description || null,
|
||||
status: form.status,
|
||||
priority: form.priority,
|
||||
assigneeId: form.assigneeId,
|
||||
dueDate,
|
||||
})
|
||||
} else {
|
||||
await createTask(props.projectId, {
|
||||
title: form.title.trim(),
|
||||
description: form.description || null,
|
||||
status: form.status,
|
||||
priority: form.priority,
|
||||
assigneeId: form.assigneeId,
|
||||
dueDate,
|
||||
})
|
||||
}
|
||||
toast.add({ severity: 'success', summary: 'Saved', life: 2000 })
|
||||
emit('saved')
|
||||
} catch (e) {
|
||||
savingError.value = errorMessage(e)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
if (!props.task) return
|
||||
saving.value = true
|
||||
try {
|
||||
await deleteTask(props.task.id)
|
||||
toast.add({ severity: 'success', summary: 'Task deleted', life: 2000 })
|
||||
emit('deleted')
|
||||
} catch (e) {
|
||||
savingError.value = errorMessage(e)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
label="List"
|
||||
icon="pi pi-list"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:to="{ name: 'tasks' }"
|
||||
/>
|
||||
<Button label="Board" icon="pi pi-th-large" severity="secondary" outlined :to="{ name: 'tasks-board' }" />
|
||||
</div>
|
||||
<Button v-if="auth.can('tasks', 'create')" label="New Task" icon="pi pi-plus" @click="openCreate" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4 overflow-x-auto pb-4">
|
||||
<div
|
||||
v-for="col in columns"
|
||||
:key="col.status"
|
||||
class="min-w-[260px] flex-1 rounded-lg bg-slate-100 p-2 dark:bg-slate-800"
|
||||
@dragover.prevent="dragOverStatus = col.status"
|
||||
@dragleave="dragOverStatus = null"
|
||||
@drop.prevent="onDrop(col.status)"
|
||||
>
|
||||
<div class="flex items-center justify-between px-3 py-2 font-semibold">
|
||||
<span>{{ col.label }}</span>
|
||||
<Tag :value="tasksIn(col.status).length" severity="secondary" />
|
||||
</div>
|
||||
<div
|
||||
v-for="task in tasksIn(col.status)"
|
||||
:key="task.id"
|
||||
class="mb-2 cursor-pointer rounded-lg border border-slate-200 bg-white p-3 hover:border-blue-500 dark:border-slate-700 dark:bg-slate-900"
|
||||
:class="{ 'opacity-40': draggingId === task.id }"
|
||||
:draggable="auth.can('tasks', 'edit')"
|
||||
@dragstart="onDragStart(task)"
|
||||
@dragend="onDragEnd"
|
||||
@click="openEdit(task)"
|
||||
>
|
||||
<div style="font-weight: 500">{{ task.title }}</div>
|
||||
<div v-if="task.description" class="mt-1 truncate text-[0.8rem] text-slate-500 dark:text-slate-400">{{ task.description }}</div>
|
||||
<div class="mt-2 flex items-center gap-2 text-[0.8rem]">
|
||||
<Tag :value="task.priority" :severity="prioritySeverity(task.priority)" />
|
||||
<span class="text-slate-500 dark:text-slate-400">{{ task.assigneeName ?? 'Unassigned' }}</span>
|
||||
<span v-if="task.dueDate" class="text-slate-500 dark:text-slate-400">{{ formatDate(task.dueDate) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskDetailDialog
|
||||
v-model:visible="dialogVisible"
|
||||
:task="editingTask"
|
||||
:project-id="projectId"
|
||||
:members="members"
|
||||
:can-edit="auth.can('tasks', 'edit')"
|
||||
:can-delete="auth.can('tasks', 'delete')"
|
||||
@saved="onSaved"
|
||||
@deleted="onDeleted"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TaskDetailDialog from './TaskDetailDialog.vue'
|
||||
import { getTasks, updateTask } from '../../services/modules'
|
||||
import { getMembers } from '../../services/backend'
|
||||
import { errorMessage } from '../../services/api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import type { Task, TaskPriority, TaskStatus } from '../../types'
|
||||
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const projectId = String(route.params.id)
|
||||
const tasks = ref<Task[]>([])
|
||||
const members = ref<{ userId: string; displayName: string }[]>([])
|
||||
|
||||
const draggingId = ref<string | null>(null)
|
||||
const dragOverStatus = ref<TaskStatus | null>(null)
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editingTask = ref<Task | null>(null)
|
||||
|
||||
const columns = [
|
||||
{ status: 'Todo' as TaskStatus, label: 'To Do' },
|
||||
{ status: 'InProgress' as TaskStatus, label: 'In Progress' },
|
||||
{ status: 'Done' as TaskStatus, label: 'Done' },
|
||||
{ status: 'Cancelled' as TaskStatus, label: 'Cancelled' },
|
||||
]
|
||||
|
||||
function tasksIn(status: TaskStatus) {
|
||||
return tasks.value.filter((t) => t.status === status)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
tasks.value = await getTasks(projectId)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMembers() {
|
||||
try {
|
||||
members.value = (await getMembers(projectId)).map((m) => ({
|
||||
userId: m.userId,
|
||||
displayName: m.displayName,
|
||||
}))
|
||||
} catch {
|
||||
members.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function onDragStart(task: Task) {
|
||||
draggingId.value = task.id
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggingId.value = null
|
||||
dragOverStatus.value = null
|
||||
}
|
||||
|
||||
async function onDrop(status: TaskStatus) {
|
||||
const id = draggingId.value
|
||||
dragOverStatus.value = null
|
||||
draggingId.value = null
|
||||
if (!id) return
|
||||
const task = tasks.value.find((t) => t.id === id)
|
||||
if (!task || task.status === status) return
|
||||
try {
|
||||
await updateTask(id, {
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
status,
|
||||
priority: task.priority,
|
||||
assigneeId: task.assigneeId,
|
||||
dueDate: task.dueDate,
|
||||
})
|
||||
toast.add({ severity: 'success', summary: `Moved to ${statusLabel(status)}`, life: 2000 })
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingTask.value = null
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(task: Task) {
|
||||
editingTask.value = task
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function onSaved() {
|
||||
dialogVisible.value = false
|
||||
void load()
|
||||
}
|
||||
|
||||
function onDeleted() {
|
||||
dialogVisible.value = false
|
||||
void load()
|
||||
}
|
||||
|
||||
function prioritySeverity(p: TaskPriority) {
|
||||
return p === 'High' ? 'danger' : p === 'Medium' ? 'warn' : 'secondary'
|
||||
}
|
||||
|
||||
function statusLabel(s: TaskStatus) {
|
||||
return s.replace(/([A-Z])/g, ' $1').trim()
|
||||
}
|
||||
|
||||
function formatDate(v: string) {
|
||||
return new Date(v).toLocaleDateString()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMembers()
|
||||
await load()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Select
|
||||
v-model="filterStatus"
|
||||
:options="statusOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="All statuses"
|
||||
class="w-full sm:w-[180px]"
|
||||
/>
|
||||
<Select
|
||||
v-model="filterPriority"
|
||||
:options="priorityOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="All priorities"
|
||||
class="w-full sm:w-[180px]"
|
||||
/>
|
||||
<Select
|
||||
v-model="filterAssignee"
|
||||
:options="assigneeOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="All assignees"
|
||||
class="w-full sm:w-[180px]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
:label="boardMode ? 'List' : 'Board'"
|
||||
:icon="boardMode ? 'pi pi-list' : 'pi pi-th-large'"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:to="boardMode ? { name: 'tasks' } : { name: 'tasks-board' }"
|
||||
/>
|
||||
<Button v-if="auth.can('tasks', 'create')" label="New Task" icon="pi pi-plus" @click="openCreate" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<DataTable :value="filteredTasks" :loading="loading" dataKey="id" emptyMessage="No tasks"
|
||||
@row-click="openEdit" class="min-w-[700px]">
|
||||
<Column field="title" header="Title" style="width: 35%">
|
||||
<template #body="{ data }">
|
||||
<div style="font-weight: 500">{{ data.title }}</div>
|
||||
<div v-if="data.description" class="max-w-[400px] truncate text-[0.8rem] text-slate-500 dark:text-slate-400">{{ data.description }}</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="Status" style="width: 12%">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="statusLabel(data.status)" :severity="statusSeverity(data.status)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="priority" header="Priority" style="width: 10%">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="priorityLabel(data.priority)" :severity="prioritySeverity(data.priority)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="assigneeName" header="Assignee" style="width: 14%">
|
||||
<template #body="{ data }">
|
||||
<span>{{ data.assigneeName ?? '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Due date" style="width: 12%">
|
||||
<template #body="{ data }">
|
||||
<span>{{ data.dueDate ? formatDate(data.dueDate) : '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Updated" style="width: 12%">
|
||||
<template #body="{ data }">
|
||||
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.updatedAt) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<TaskDetailDialog
|
||||
v-model:visible="dialogVisible"
|
||||
:task="editingTask"
|
||||
:project-id="projectId"
|
||||
:members="members"
|
||||
:can-edit="auth.can('tasks', 'edit')"
|
||||
:can-delete="auth.can('tasks', 'delete')"
|
||||
@saved="onSaved"
|
||||
@deleted="onDeleted"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TaskDetailDialog from './TaskDetailDialog.vue'
|
||||
import { getTasks } from '../../services/modules'
|
||||
import { getMembers } from '../../services/backend'
|
||||
import { errorMessage } from '../../services/api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import type { Task, TaskPriority, TaskStatus } from '../../types'
|
||||
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const projectId = String(route.params.id)
|
||||
const tasks = ref<Task[]>([])
|
||||
const loading = ref(false)
|
||||
const members = ref<{ userId: string; displayName: string }[]>([])
|
||||
|
||||
const filterStatus = ref<string | null>(null)
|
||||
const filterPriority = ref<string | null>(null)
|
||||
const filterAssignee = ref<string | null>(null)
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editingTask = ref<Task | null>(null)
|
||||
|
||||
const boardMode = computed(() => route.name === 'tasks-board')
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'Todo', value: 'Todo' },
|
||||
{ label: 'In Progress', value: 'InProgress' },
|
||||
{ label: 'Done', value: 'Done' },
|
||||
{ label: 'Cancelled', value: 'Cancelled' },
|
||||
]
|
||||
|
||||
const priorityOptions = [
|
||||
{ label: 'Low', value: 'Low' },
|
||||
{ label: 'Medium', value: 'Medium' },
|
||||
{ label: 'High', value: 'High' },
|
||||
]
|
||||
|
||||
const assigneeOptions = computed(() =>
|
||||
members.value.map((m) => ({ label: m.displayName, value: m.userId })),
|
||||
)
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
return tasks.value.filter((t) => {
|
||||
if (filterStatus.value && t.status !== filterStatus.value) return false
|
||||
if (filterPriority.value && t.priority !== filterPriority.value) return false
|
||||
if (filterAssignee.value && t.assigneeId !== filterAssignee.value) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
tasks.value = await getTasks(projectId)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMembers() {
|
||||
try {
|
||||
members.value = (await getMembers(projectId)).map((m) => ({
|
||||
userId: m.userId,
|
||||
displayName: m.displayName,
|
||||
}))
|
||||
} catch {
|
||||
members.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(filterStatus, load)
|
||||
watch(filterPriority, load)
|
||||
watch(filterAssignee, load)
|
||||
|
||||
function openCreate() {
|
||||
editingTask.value = null
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(event: { data: Task }) {
|
||||
editingTask.value = event.data
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function onSaved() {
|
||||
dialogVisible.value = false
|
||||
void load()
|
||||
}
|
||||
|
||||
function onDeleted() {
|
||||
dialogVisible.value = false
|
||||
void load()
|
||||
}
|
||||
|
||||
function statusLabel(s: TaskStatus) {
|
||||
return s.replace(/([A-Z])/g, ' $1').trim()
|
||||
}
|
||||
|
||||
function statusSeverity(s: TaskStatus) {
|
||||
return s === 'Done' ? 'success' : s === 'InProgress' ? 'info' : s === 'Cancelled' ? 'danger' : 'secondary'
|
||||
}
|
||||
|
||||
function priorityLabel(p: TaskPriority) {
|
||||
return p
|
||||
}
|
||||
|
||||
function prioritySeverity(p: TaskPriority) {
|
||||
return p === 'High' ? 'danger' : p === 'Medium' ? 'warn' : 'secondary'
|
||||
}
|
||||
|
||||
function formatDate(v: string) {
|
||||
return new Date(v).toLocaleDateString()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMembers()
|
||||
await load()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user