1 parent
f0706432d4
commit
b9c5f64989
6 files changed
+1121
-219
No files matched your search
Generated
+812
-190
File diff suppressed because it is too large.
Load diff
+8
-2
@@ -16,8 +16,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/roboto": "^5.3.0",
|
||||
"@tiptap/extension-list": "^3.32.0",
|
||||
"@tiptap/extensions": "^3.32.0",
|
||||
"@tiptap/markdown": "^3.32.0",
|
||||
"@tiptap/pm": "^3.32.0",
|
||||
"@tiptap/starter-kit": "^3.32.0",
|
||||
"@tiptap/vue-3": "^3.32.0",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"better-auth": "^1.7.1",
|
||||
"better-auth": "^1.7.13",
|
||||
"chart.js": "^4.5.1",
|
||||
"firebase": "^13.0.0",
|
||||
"pinia": "^4.0.3",
|
||||
@@ -44,7 +50,7 @@
|
||||
"sass-embedded": "^1.103.1",
|
||||
"typescript": "^6.0.3",
|
||||
"unplugin-fonts": "^2.0.0",
|
||||
"vite": "^8.2.2",
|
||||
"vite": "^8.3.5",
|
||||
"vite-plugin-vue-devtools": "^8.2.1",
|
||||
"vue-eslint-parser": "^10.4.1",
|
||||
"vue-tsc": "^3.3.11"
|
||||
|
||||
@@ -30,19 +30,13 @@ const cancel = () => {
|
||||
<template>
|
||||
<div class="issue-content">
|
||||
<template v-if="!editing">
|
||||
<v-hover #default="{ isHovering, props }">
|
||||
<v-sheet
|
||||
v-bind="props"
|
||||
:class="isHovering ? 'px-0' : 'px-0'"
|
||||
class="cursor-pointer d-flex ga-1"
|
||||
@click="startEdit"
|
||||
>
|
||||
|
||||
<v-sheet v-bind="props" border class="cursor-pointer d-flex ga-1 pa-4" @click="startEdit">
|
||||
<div class="content-sheet">
|
||||
{{ text ?? 'click to add content' }}
|
||||
</div>
|
||||
<v-icon v-if="isHovering" icon="mdi-pencil" size="small" />
|
||||
</v-sheet>
|
||||
</v-hover>
|
||||
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
@@ -58,12 +52,8 @@ const cancel = () => {
|
||||
/>
|
||||
|
||||
<div class="d-flex justify-end ga-1">
|
||||
<v-btn density="compact" icon variant="text" @click="cancel">
|
||||
<v-icon icon="mdi-cancel" />
|
||||
</v-btn>
|
||||
<v-btn density="compact" icon color="primary" variant="flat" @click="save">
|
||||
<v-icon icon="mdi-check" />
|
||||
</v-btn>
|
||||
<v-btn density="compact" prepend-icon="mdi-cancel" text="Cancel" variant="text" @click="cancel" />
|
||||
<v-btn density="compact" prepend-icon="mdi-check" color="primary" variant="flat" @click="save" text="Save" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -75,10 +65,6 @@ const cancel = () => {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
//.content-sheet:hover {
|
||||
//background-color: rgba(var(--v-theme-on-surface), 0.06);
|
||||
//}
|
||||
|
||||
.v-sheet {
|
||||
transition: padding 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
@@ -135,8 +135,15 @@ defineExpose({
|
||||
|
||||
<editable-text :text="issue.title" @changed="onTitleChanged" />
|
||||
|
||||
<expansion-panel title="Info">
|
||||
<v-card elevation="0" border>
|
||||
<v-card-text class="u-grid">
|
||||
<div class="text-medium-emphasis">Reporter:</div>
|
||||
<reporter-info :user="issue.reporter" />
|
||||
|
||||
<div class="text-medium-emphasis">Assignee:</div>
|
||||
<assignee-menu :value="issue.assignee" :compact="false" @changed="onAssigneeChanged" />
|
||||
|
||||
<div class="text-medium-emphasis">Status:</div>
|
||||
<status-menu :value="issue.status" @changed="onStatusChanged" />
|
||||
|
||||
@@ -146,12 +153,6 @@ defineExpose({
|
||||
<div class="text-medium-emphasis">Priority:</div>
|
||||
<priority-menu :value="issue.priority" :compact="false" @changed="onPriorityChanged" />
|
||||
|
||||
<div class="text-medium-emphasis">Reporter:</div>
|
||||
<reporter-info :user="issue.reporter" />
|
||||
|
||||
<div class="text-medium-emphasis">Assignee:</div>
|
||||
<assignee-menu :value="issue.assignee" :compact="false" @changed="onAssigneeChanged" />
|
||||
|
||||
<div class="text-medium-emphasis">Created At:</div>
|
||||
<div>{{ createdAtFormatted }}</div>
|
||||
|
||||
@@ -159,6 +160,7 @@ defineExpose({
|
||||
<issue-date :date="issue.dueDate" @changed="onDueDateChanged" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</expansion-panel>
|
||||
|
||||
<expansion-panel title="Details" class="details__content">
|
||||
<issue-content :text="issue.content" @changed="onContentChanged" />
|
||||
@@ -242,6 +244,6 @@ defineExpose({
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
column-gap: 1rem;
|
||||
row-gap: 1.5rem;
|
||||
row-gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,282 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { EditorContent, useEditor, type Editor } from '@tiptap/vue-3'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { TaskItem, TaskList } from '@tiptap/extension-list'
|
||||
import { Placeholder } from '@tiptap/extensions'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
|
||||
interface Props {
|
||||
placeholder?: string
|
||||
/** false renders the markdown read-only, without the toolbar */
|
||||
editable?: boolean
|
||||
autofocus?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { placeholder: 'Write something…', editable: true, autofocus: false })
|
||||
|
||||
/** Markdown text */
|
||||
const model = defineModel<string>({ default: '' })
|
||||
|
||||
const editor = useEditor({
|
||||
content: model.value,
|
||||
contentType: 'markdown',
|
||||
editable: props.editable,
|
||||
autofocus: props.autofocus ? 'end' : false,
|
||||
extensions: [
|
||||
// Markdown has no underline, so it would be lost on save
|
||||
StarterKit.configure({ underline: false, link: { openOnClick: !props.editable } }),
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
Placeholder.configure({ placeholder: () => props.placeholder }),
|
||||
Markdown,
|
||||
],
|
||||
onUpdate: ({ editor }) => {
|
||||
model.value = editor.getMarkdown()
|
||||
},
|
||||
})
|
||||
|
||||
// External changes only: skip when the value came from the editor itself
|
||||
watch(model, (value) => {
|
||||
if (!editor.value || value === editor.value.getMarkdown()) return
|
||||
editor.value.commands.setContent(value, { contentType: 'markdown', emitUpdate: false })
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.editable,
|
||||
(editable) => editor.value?.setEditable(editable),
|
||||
)
|
||||
|
||||
interface ToolbarButton {
|
||||
icon: string
|
||||
title: string
|
||||
run: (editor: Editor) => void
|
||||
active?: (editor: Editor) => boolean
|
||||
disabled?: (editor: Editor) => boolean
|
||||
}
|
||||
|
||||
const toolbar: ToolbarButton[][] = [
|
||||
[
|
||||
{
|
||||
icon: 'mdi-format-bold',
|
||||
title: 'Bold',
|
||||
run: (e) => e.chain().focus().toggleBold().run(),
|
||||
active: (e) => e.isActive('bold'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-format-italic',
|
||||
title: 'Italic',
|
||||
run: (e) => e.chain().focus().toggleItalic().run(),
|
||||
active: (e) => e.isActive('italic'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-format-strikethrough',
|
||||
title: 'Strikethrough',
|
||||
run: (e) => e.chain().focus().toggleStrike().run(),
|
||||
active: (e) => e.isActive('strike'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-code-tags',
|
||||
title: 'Inline code',
|
||||
run: (e) => e.chain().focus().toggleCode().run(),
|
||||
active: (e) => e.isActive('code'),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
icon: 'mdi-format-header-2',
|
||||
title: 'Heading',
|
||||
run: (e) => e.chain().focus().toggleHeading({ level: 2 }).run(),
|
||||
active: (e) => e.isActive('heading', { level: 2 }),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-format-header-3',
|
||||
title: 'Subheading',
|
||||
run: (e) => e.chain().focus().toggleHeading({ level: 3 }).run(),
|
||||
active: (e) => e.isActive('heading', { level: 3 }),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
icon: 'mdi-format-list-bulleted',
|
||||
title: 'Bullet list',
|
||||
run: (e) => e.chain().focus().toggleBulletList().run(),
|
||||
active: (e) => e.isActive('bulletList'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-format-list-numbered',
|
||||
title: 'Numbered list',
|
||||
run: (e) => e.chain().focus().toggleOrderedList().run(),
|
||||
active: (e) => e.isActive('orderedList'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-format-list-checks',
|
||||
title: 'Task list',
|
||||
run: (e) => e.chain().focus().toggleTaskList().run(),
|
||||
active: (e) => e.isActive('taskList'),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
icon: 'mdi-format-quote-close',
|
||||
title: 'Quote',
|
||||
run: (e) => e.chain().focus().toggleBlockquote().run(),
|
||||
active: (e) => e.isActive('blockquote'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-code-block-braces',
|
||||
title: 'Code block',
|
||||
run: (e) => e.chain().focus().toggleCodeBlock().run(),
|
||||
active: (e) => e.isActive('codeBlock'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-minus',
|
||||
title: 'Horizontal rule',
|
||||
run: (e) => e.chain().focus().setHorizontalRule().run(),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
icon: 'mdi-undo',
|
||||
title: 'Undo',
|
||||
run: (e) => e.chain().focus().undo().run(),
|
||||
disabled: (e) => !e.can().undo(),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-redo',
|
||||
title: 'Redo',
|
||||
run: (e) => e.chain().focus().redo().run(),
|
||||
disabled: (e) => !e.can().redo(),
|
||||
},
|
||||
],
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-sheet class="markdown-editor" :class="{ 'markdown-editor--editable': editable }">
|
||||
<div v-if="editable && editor" class="toolbar d-flex flex-wrap align-center ga-1 pa-1">
|
||||
<template v-for="(group, i) in toolbar" :key="i">
|
||||
<v-divider v-if="i > 0" vertical class="mx-1" />
|
||||
<v-btn
|
||||
v-for="button in group"
|
||||
:key="button.icon"
|
||||
:title="button.title"
|
||||
:icon="button.icon"
|
||||
:active="button.active?.(editor) ?? false"
|
||||
:disabled="button.disabled?.(editor) ?? false"
|
||||
density="comfortable"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="button.run(editor)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<EditorContent :editor="editor" class="content" />
|
||||
</v-sheet>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.markdown-editor--editable {
|
||||
&:focus-within {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.content :deep(.tiptap) {
|
||||
min-height: 6rem;
|
||||
max-height: 30rem;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.content :deep(.tiptap) {
|
||||
outline: none;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
> * + * {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
ul[data-type='taskList'] {
|
||||
list-style: none;
|
||||
padding-left: 0.25rem;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
|
||||
> label {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
> div {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 3px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
padding-left: 0.75rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 4px;
|
||||
padding: 0.1em 0.3em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
|
||||
code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -34,6 +34,10 @@ const avatar = computed(() => {
|
||||
return user?.image ?? undefined
|
||||
})
|
||||
|
||||
const createdAt = computed(() => {
|
||||
return user?.createdAt ? new Date(user.createdAt).toLocaleString('ru-RU') : ''
|
||||
})
|
||||
|
||||
function showError(text: string, e: unknown) {
|
||||
snackbarStore.add({ title: text, text: e instanceof Error ? e.message : '', color: 'error' })
|
||||
}
|
||||
@@ -130,7 +134,7 @@ async function removeAvatar() {
|
||||
<div class="flex-grow-1">
|
||||
<editable-text class="mb-2" :text="user.name" placeholder="Enter your name" required @changed="saveName" />
|
||||
<div>E-mail: {{ user.email }}</div>
|
||||
<div>Created At: {{ user.createdAt }}</div>
|
||||
<div>Created At: {{ createdAt }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<v-skeleton-loader v-else type="avatar, list-item-three-line" />
|
||||
|
||||
Reference in new issue
Block a user