mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
feat: ✨ Implement rich text note composer
This commit is contained in:
parent
e0e8db8d55
commit
f0516cb58a
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -25,3 +25,5 @@ logs
|
||||||
config
|
config
|
||||||
|
|
||||||
public/emojis
|
public/emojis
|
||||||
|
|
||||||
|
.npmrc
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@
|
||||||
<Input v-model:model-value="state.contentWarning" v-if="state.sensitive"
|
<Input v-model:model-value="state.contentWarning" v-if="state.sensitive"
|
||||||
placeholder="Put your content warning here" />
|
placeholder="Put your content warning here" />
|
||||||
|
|
||||||
<Textarea id="text-input" :placeholder="chosenSplash" v-model:model-value="state.content"
|
<EditorContent v-model:content="state.content" :placeholder="chosenSplash"
|
||||||
class="!border-none !ring-0 !outline-none rounded-none p-0 max-h-full min-h-48 !ring-offset-0"
|
class="*:!border-none *:!ring-0 *:!outline-none *:rounded-none p-0 max-h-[50dvh] overflow-y-auto min-h-48 *:!ring-offset-0 *:h-full"
|
||||||
:disabled="sending" />
|
:disabled="sending" :mode="state.contentType === 'text/html' ? 'rich' : 'plain'" />
|
||||||
|
|
||||||
<div class="w-full flex flex-row gap-2 overflow-x-auto *:shrink-0 pb-2">
|
<div class="w-full flex flex-row gap-2 overflow-x-auto *:shrink-0 pb-2">
|
||||||
<input type="file" ref="fileInput" @change="uploadFileFromEvent" class="hidden" multiple />
|
<input type="file" ref="fileInput" @change="uploadFileFromEvent" class="hidden" multiple />
|
||||||
|
|
@ -28,8 +28,11 @@
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as="div">
|
<TooltipTrigger as="div">
|
||||||
<Toggle variant="default" size="sm" :pressed="state.contentType === 'text/markdown'"
|
<Toggle variant="default" size="sm" :pressed="state.contentType === 'text/html'" @update:pressed="(i) =>
|
||||||
@update:pressed="i => state.contentType = i ? 'text/plain' : 'text/markdown'">
|
(state.contentType = i
|
||||||
|
? 'text/html'
|
||||||
|
: 'text/plain')
|
||||||
|
">
|
||||||
<LetterText class="!size-5" />
|
<LetterText class="!size-5" />
|
||||||
</Toggle>
|
</Toggle>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
@ -87,7 +90,11 @@
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Button type="submit" size="lg" class="ml-auto" :disabled="sending" @click="submit">
|
<Button type="submit" size="lg" class="ml-auto" :disabled="sending" @click="submit">
|
||||||
<Loader v-if="sending" class="!size-5 animate-spin" />
|
<Loader v-if="sending" class="!size-5 animate-spin" />
|
||||||
{{ relation?.type === "edit" ? m.gaudy_strong_puma_slide() : m.free_teal_bulldog_learn() }}
|
{{
|
||||||
|
relation?.type === "edit"
|
||||||
|
? m.gaudy_strong_puma_slide()
|
||||||
|
: m.free_teal_bulldog_learn()
|
||||||
|
}}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -112,9 +119,9 @@ import Note from "~/components/notes/note.vue";
|
||||||
import { Select, SelectContent, SelectItem } from "~/components/ui/select";
|
import { Select, SelectContent, SelectItem } from "~/components/ui/select";
|
||||||
import * as m from "~/paraglide/messages.js";
|
import * as m from "~/paraglide/messages.js";
|
||||||
import { SettingIds } from "~/settings";
|
import { SettingIds } from "~/settings";
|
||||||
|
import EditorContent from "../editor/content.vue";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { Input } from "../ui/input";
|
import { Input } from "../ui/input";
|
||||||
import { Textarea } from "../ui/textarea";
|
|
||||||
import { Toggle } from "../ui/toggle";
|
import { Toggle } from "../ui/toggle";
|
||||||
import Files from "./files.vue";
|
import Files from "./files.vue";
|
||||||
|
|
||||||
|
|
@ -124,13 +131,6 @@ const defaultVisibility = useSetting(SettingIds.DefaultVisibility);
|
||||||
const { play } = useAudio();
|
const { play } = useAudio();
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
// Wait 0.3s for the dialog to open
|
|
||||||
setTimeout(() => {
|
|
||||||
document.getElementById("text-input")?.focus();
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
watch([Control_Enter, Command_Enter], () => {
|
watch([Control_Enter, Command_Enter], () => {
|
||||||
if (sending.value || !ctrlEnterSend.value.value) {
|
if (sending.value || !ctrlEnterSend.value.value) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -176,7 +176,7 @@ const state = reactive({
|
||||||
content: relation?.source?.text || getMentions(),
|
content: relation?.source?.text || getMentions(),
|
||||||
sensitive: relation?.type === "edit" ? relation.note.sensitive : false,
|
sensitive: relation?.type === "edit" ? relation.note.sensitive : false,
|
||||||
contentWarning: relation?.type === "edit" ? relation.note.spoiler_text : "",
|
contentWarning: relation?.type === "edit" ? relation.note.spoiler_text : "",
|
||||||
contentType: "text/markdown" as "text/markdown" | "text/plain",
|
contentType: "text/html" as "text/html" | "text/plain",
|
||||||
visibility: (relation?.type === "edit"
|
visibility: (relation?.type === "edit"
|
||||||
? relation.note.visibility
|
? relation.note.visibility
|
||||||
: (defaultVisibility.value.value ?? "public")) as Status["visibility"],
|
: (defaultVisibility.value.value ?? "public")) as Status["visibility"],
|
||||||
|
|
|
||||||
84
components/editor/content.vue
Normal file
84
components/editor/content.vue
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
<template>
|
||||||
|
<EditorContent :editor="editor"
|
||||||
|
:class="[$style.content, 'prose prose-sm dark:prose-invert break-words prose-a:no-underline prose-a:hover:underline prose-p:*:first-of-type:mt-0']" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import Highlight from "@tiptap/extension-highlight";
|
||||||
|
import Link from "@tiptap/extension-link";
|
||||||
|
import Mention from "@tiptap/extension-mention";
|
||||||
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import Subscript from "@tiptap/extension-subscript";
|
||||||
|
import Superscript from "@tiptap/extension-superscript";
|
||||||
|
import Underline from "@tiptap/extension-underline";
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import { Editor, EditorContent } from "@tiptap/vue-3";
|
||||||
|
import suggestion from "./suggestion.ts";
|
||||||
|
|
||||||
|
const content = defineModel<string>("content");
|
||||||
|
const {
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
mode = "rich",
|
||||||
|
} = defineProps<{
|
||||||
|
placeholder?: string;
|
||||||
|
mode?: "rich" | "plain";
|
||||||
|
disabled?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const editor = new Editor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder,
|
||||||
|
}),
|
||||||
|
Highlight,
|
||||||
|
Link,
|
||||||
|
Subscript,
|
||||||
|
Superscript,
|
||||||
|
Underline,
|
||||||
|
Mention.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "mention",
|
||||||
|
},
|
||||||
|
suggestion,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content: content.value,
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
content.value = mode === "rich" ? editor.getHTML() : editor.getText();
|
||||||
|
},
|
||||||
|
autofocus: true,
|
||||||
|
editable: !disabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (disabled) {
|
||||||
|
editor.setEditable(false);
|
||||||
|
} else {
|
||||||
|
editor.setEditable(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style module>
|
||||||
|
@import url("~/styles/content.css");
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tiptap p.is-editor-empty:first-child::before {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap .mention {
|
||||||
|
@apply font-bold rounded-sm text-primary-foreground bg-primary px-1 py-0.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
80
components/editor/mentions-list.vue
Normal file
80
components/editor/mentions-list.vue
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<template>
|
||||||
|
<Command class="rounded-lg border shadow-md min-w-[200px]" :selected-value="items[selectedIndex]?.key">
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
|
<CommandGroup class="mentions-group" heading="Users">
|
||||||
|
<CommandItem :value="user.key" v-for="user, index in items" :key="user.key" @click="selectItem(index)" class="scroll-m-10">
|
||||||
|
<Avatar class="mr-2 size-4" :src="user.value.avatar" :name="user.value.display_name" />
|
||||||
|
<span v-render-emojis="user.value.emojis">{{ user.value.display_name }}</span>
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { MentionNodeAttrs } from "@tiptap/extension-mention";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "~/components/ui/command";
|
||||||
|
import Avatar from "../profiles/avatar.vue";
|
||||||
|
import type { UserData } from "./suggestion";
|
||||||
|
|
||||||
|
const { items, command } = defineProps<{
|
||||||
|
items: UserData[];
|
||||||
|
command: (value: MentionNodeAttrs) => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const selectedIndex = ref(0);
|
||||||
|
|
||||||
|
const onKeyDown = ({ event }: { event: Event }) => {
|
||||||
|
if (event instanceof KeyboardEvent) {
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
selectedIndex.value = (selectedIndex.value + 1) % items.length;
|
||||||
|
scrollIntoView(selectedIndex.value);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
selectedIndex.value =
|
||||||
|
(selectedIndex.value - 1 + items.length) % items.length;
|
||||||
|
scrollIntoView(selectedIndex.value);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
selectItem(selectedIndex.value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectItem = (index: number) => {
|
||||||
|
const item = items[index];
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
command({
|
||||||
|
id: item.key,
|
||||||
|
label: item.value.acct,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollIntoView = (index: number) => {
|
||||||
|
const usersGroup = document.getElementsByClassName("mentions-group")[0];
|
||||||
|
const item = usersGroup?.children[index];
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
item.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "nearest",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ onKeyDown });
|
||||||
|
</script>
|
||||||
99
components/editor/suggestion.ts
Normal file
99
components/editor/suggestion.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { VueRenderer } from "@tiptap/vue-3";
|
||||||
|
import tippy, { type Instance } from "tippy.js";
|
||||||
|
|
||||||
|
import type { MentionNodeAttrs } from "@tiptap/extension-mention";
|
||||||
|
import type { SuggestionOptions } from "@tiptap/suggestion";
|
||||||
|
import type { Account } from "@versia/client/types";
|
||||||
|
import { go } from "fuzzysort";
|
||||||
|
import MentionList from "./mentions-list.vue";
|
||||||
|
|
||||||
|
export type UserData = {
|
||||||
|
key: string;
|
||||||
|
value: Account;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
items: async ({ query }) => {
|
||||||
|
if (query.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await client.value.searchAccount(query, { limit: 20 });
|
||||||
|
|
||||||
|
return go(
|
||||||
|
query,
|
||||||
|
users.data
|
||||||
|
// Deduplicate users
|
||||||
|
.filter(
|
||||||
|
(user, index, self) =>
|
||||||
|
self.findIndex((u) => u.acct === user.acct) === index,
|
||||||
|
)
|
||||||
|
.map((user) => ({
|
||||||
|
key: user.acct,
|
||||||
|
value: user,
|
||||||
|
})),
|
||||||
|
{ key: "key" },
|
||||||
|
)
|
||||||
|
.map((result) => ({
|
||||||
|
key: result.obj.key,
|
||||||
|
value: result.obj.value,
|
||||||
|
}))
|
||||||
|
.slice(0, 20);
|
||||||
|
},
|
||||||
|
|
||||||
|
render: () => {
|
||||||
|
let component: VueRenderer;
|
||||||
|
let popup: Instance[] & Instance;
|
||||||
|
|
||||||
|
return {
|
||||||
|
onStart: (props) => {
|
||||||
|
component = new VueRenderer(MentionList, {
|
||||||
|
props,
|
||||||
|
editor: props.editor,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!props.clientRect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-expect-error Tippy types are wrong
|
||||||
|
popup = tippy("body", {
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
appendTo: () => document.body,
|
||||||
|
content: component.element,
|
||||||
|
showOnCreate: true,
|
||||||
|
interactive: true,
|
||||||
|
trigger: "manual",
|
||||||
|
placement: "bottom-start",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onUpdate(props) {
|
||||||
|
component.updateProps(props);
|
||||||
|
|
||||||
|
if (!props.clientRect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
popup[0]?.setProps({
|
||||||
|
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onKeyDown(props) {
|
||||||
|
if (props.event.key === "Escape") {
|
||||||
|
popup[0]?.hide();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return component.ref?.onKeyDown(props);
|
||||||
|
},
|
||||||
|
|
||||||
|
onExit() {
|
||||||
|
popup[0]?.destroy();
|
||||||
|
component.destroy();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} as Omit<SuggestionOptions<UserData, MentionNodeAttrs>, "editor">;
|
||||||
|
|
@ -82,62 +82,5 @@ const formattedCharacterCount = characterCount
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style module>
|
<style module>
|
||||||
.content pre:has(code) {
|
@import url("~/styles/content.css");
|
||||||
word-wrap: normal;
|
|
||||||
background: transparent;
|
|
||||||
background-color: #ffffff0d;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
hyphens: none;
|
|
||||||
margin-top: 1rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
tab-size: 4;
|
|
||||||
white-space: pre;
|
|
||||||
word-break: normal;
|
|
||||||
word-spacing: normal;
|
|
||||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
|
||||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
|
||||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 0 0 #0000;
|
|
||||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow),
|
|
||||||
var(--tw-shadow, 0 0 #0000);
|
|
||||||
--tw-ring-color: hsla(0, 0%, 100%, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.content pre code {
|
|
||||||
display: block;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content code:not(pre code)::after,
|
|
||||||
.content code:not(pre code)::before {
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
.content ol li input[type="checkbox"],
|
|
||||||
.content ul li input[type="checkbox"] {
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
margin-bottom: 0.2rem;
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
margin-top: 0;
|
|
||||||
vertical-align: middle;
|
|
||||||
--tw-text-opacity: 1;
|
|
||||||
color: var(--theme-primary-400);
|
|
||||||
}
|
|
||||||
|
|
||||||
.content code:not(pre code) {
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
word-wrap: break-word;
|
|
||||||
background: transparent;
|
|
||||||
background-color: #ffffff0d;
|
|
||||||
hyphens: none;
|
|
||||||
margin-top: 1rem;
|
|
||||||
tab-size: 4;
|
|
||||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
|
||||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
|
||||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 0 0 #0000;
|
|
||||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow),
|
|
||||||
var(--tw-shadow, 0 0 #0000);
|
|
||||||
--tw-ring-color: hsla(0, 0%, 100%, 0.1);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -13,60 +13,5 @@ const { content } = defineProps<{
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style module>
|
<style module>
|
||||||
.content pre:has(code) {
|
@import url("~/styles/content.css");
|
||||||
word-wrap: normal;
|
|
||||||
background: transparent;
|
|
||||||
background-color: #ffffff0d;
|
|
||||||
border-radius: .25rem;
|
|
||||||
hyphens: none;
|
|
||||||
margin-top: 1rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
padding: .75rem 1rem;
|
|
||||||
tab-size: 4;
|
|
||||||
white-space: pre;
|
|
||||||
word-break: normal;
|
|
||||||
word-spacing: normal;
|
|
||||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
|
||||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
|
||||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 0 0 #0000;
|
|
||||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
|
||||||
--tw-ring-color: hsla(0, 0%, 100%, .1)
|
|
||||||
}
|
|
||||||
|
|
||||||
.content pre code {
|
|
||||||
display: block;
|
|
||||||
padding: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
.content code:not(pre code)::after,
|
|
||||||
.content code:not(pre code)::before {
|
|
||||||
content: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
.content ol li input[type=checkbox],
|
|
||||||
.content ul li input[type=checkbox] {
|
|
||||||
border-radius:.25rem;
|
|
||||||
margin-bottom:0.2rem;
|
|
||||||
margin-right:.5rem;
|
|
||||||
margin-top:0;
|
|
||||||
vertical-align: middle;
|
|
||||||
--tw-text-opacity:1;
|
|
||||||
color: var(--theme-primary-400);
|
|
||||||
}
|
|
||||||
|
|
||||||
.content code:not(pre code) {
|
|
||||||
border-radius: .25rem;
|
|
||||||
padding: .25rem .5rem;
|
|
||||||
word-wrap: break-word;
|
|
||||||
background: transparent;
|
|
||||||
background-color: #ffffff0d;
|
|
||||||
hyphens: none;
|
|
||||||
margin-top: 1rem;
|
|
||||||
tab-size: 4;
|
|
||||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
|
||||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
|
||||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 0 0 #0000;
|
|
||||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
|
||||||
--tw-ring-color: hsla(0, 0%, 100%, .1)
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
33
components/ui/command/Command.vue
Normal file
33
components/ui/command/Command.vue
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ComboboxRootEmits, ComboboxRootProps } from "radix-vue";
|
||||||
|
import { ComboboxRoot, useForwardPropsEmits } from "radix-vue";
|
||||||
|
import { type HTMLAttributes, computed } from "vue";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<ComboboxRootProps & { class?: HTMLAttributes["class"] }>(),
|
||||||
|
{
|
||||||
|
open: true,
|
||||||
|
modelValue: "",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emits = defineEmits<ComboboxRootEmits>();
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props;
|
||||||
|
|
||||||
|
return delegated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComboboxRoot
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn('flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ComboboxRoot>
|
||||||
|
</template>
|
||||||
21
components/ui/command/CommandDialog.vue
Normal file
21
components/ui/command/CommandDialog.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogRootEmits, DialogRootProps } from "radix-vue";
|
||||||
|
import { useForwardPropsEmits } from "radix-vue";
|
||||||
|
import { Dialog, DialogContent } from "~/components/ui/dialog";
|
||||||
|
import Command from "./Command.vue";
|
||||||
|
|
||||||
|
const props = defineProps<DialogRootProps>();
|
||||||
|
const emits = defineEmits<DialogRootEmits>();
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog v-bind="forwarded">
|
||||||
|
<DialogContent class="overflow-hidden p-0 shadow-lg">
|
||||||
|
<Command class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
<slot />
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
22
components/ui/command/CommandEmpty.vue
Normal file
22
components/ui/command/CommandEmpty.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ComboboxEmptyProps } from "radix-vue";
|
||||||
|
import { ComboboxEmpty } from "radix-vue";
|
||||||
|
import { type HTMLAttributes, computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<
|
||||||
|
ComboboxEmptyProps & { class?: HTMLAttributes["class"] }
|
||||||
|
>();
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props;
|
||||||
|
|
||||||
|
return delegated;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComboboxEmpty v-bind="delegatedProps" :class="cn('py-6 text-center text-sm', props.class)">
|
||||||
|
<slot />
|
||||||
|
</ComboboxEmpty>
|
||||||
|
</template>
|
||||||
31
components/ui/command/CommandGroup.vue
Normal file
31
components/ui/command/CommandGroup.vue
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ComboboxGroupProps } from "radix-vue";
|
||||||
|
import { ComboboxGroup, ComboboxLabel } from "radix-vue";
|
||||||
|
import { type HTMLAttributes, computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<
|
||||||
|
ComboboxGroupProps & {
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
heading?: string;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props;
|
||||||
|
|
||||||
|
return delegated;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComboboxGroup
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground', props.class)"
|
||||||
|
>
|
||||||
|
<ComboboxLabel v-if="heading" class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{{ heading }}
|
||||||
|
</ComboboxLabel>
|
||||||
|
<slot />
|
||||||
|
</ComboboxGroup>
|
||||||
|
</template>
|
||||||
39
components/ui/command/CommandInput.vue
Normal file
39
components/ui/command/CommandInput.vue
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Search } from "lucide-vue-next";
|
||||||
|
import {
|
||||||
|
ComboboxInput,
|
||||||
|
type ComboboxInputProps,
|
||||||
|
useForwardProps,
|
||||||
|
} from "radix-vue";
|
||||||
|
import { type HTMLAttributes, computed } from "vue";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<
|
||||||
|
ComboboxInputProps & {
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props;
|
||||||
|
|
||||||
|
return delegated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center border-b px-3" cmdk-input-wrapper>
|
||||||
|
<Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<ComboboxInput
|
||||||
|
v-bind="{ ...forwardedProps, ...$attrs }"
|
||||||
|
auto-focus
|
||||||
|
:class="cn('flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', props.class)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
28
components/ui/command/CommandItem.vue
Normal file
28
components/ui/command/CommandItem.vue
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ComboboxItemEmits, ComboboxItemProps } from "radix-vue";
|
||||||
|
import { ComboboxItem, useForwardPropsEmits } from "radix-vue";
|
||||||
|
import { type HTMLAttributes, computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<
|
||||||
|
ComboboxItemProps & { class?: HTMLAttributes["class"] }
|
||||||
|
>();
|
||||||
|
const emits = defineEmits<ComboboxItemEmits>();
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props;
|
||||||
|
|
||||||
|
return delegated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComboboxItem
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn('relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ComboboxItem>
|
||||||
|
</template>
|
||||||
30
components/ui/command/CommandList.vue
Normal file
30
components/ui/command/CommandList.vue
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ComboboxContentEmits, ComboboxContentProps } from "radix-vue";
|
||||||
|
import { ComboboxContent, useForwardPropsEmits } from "radix-vue";
|
||||||
|
import { type HTMLAttributes, computed } from "vue";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<ComboboxContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||||
|
{
|
||||||
|
dismissable: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const emits = defineEmits<ComboboxContentEmits>();
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props;
|
||||||
|
|
||||||
|
return delegated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComboboxContent v-bind="forwarded" :class="cn('max-h-[300px] overflow-y-auto overflow-x-hidden', props.class)">
|
||||||
|
<div role="presentation">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</ComboboxContent>
|
||||||
|
</template>
|
||||||
25
components/ui/command/CommandSeparator.vue
Normal file
25
components/ui/command/CommandSeparator.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ComboboxSeparatorProps } from "radix-vue";
|
||||||
|
import { ComboboxSeparator } from "radix-vue";
|
||||||
|
import { type HTMLAttributes, computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<
|
||||||
|
ComboboxSeparatorProps & { class?: HTMLAttributes["class"] }
|
||||||
|
>();
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props;
|
||||||
|
|
||||||
|
return delegated;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComboboxSeparator
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('-mx-1 h-px bg-border', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ComboboxSeparator>
|
||||||
|
</template>
|
||||||
14
components/ui/command/CommandShortcut.vue
Normal file
14
components/ui/command/CommandShortcut.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { HTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span :class="cn('ml-auto text-xs tracking-widest text-muted-foreground', props.class)">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
9
components/ui/command/index.ts
Normal file
9
components/ui/command/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export { default as Command } from "./Command.vue";
|
||||||
|
export { default as CommandDialog } from "./CommandDialog.vue";
|
||||||
|
export { default as CommandEmpty } from "./CommandEmpty.vue";
|
||||||
|
export { default as CommandGroup } from "./CommandGroup.vue";
|
||||||
|
export { default as CommandInput } from "./CommandInput.vue";
|
||||||
|
export { default as CommandItem } from "./CommandItem.vue";
|
||||||
|
export { default as CommandList } from "./CommandList.vue";
|
||||||
|
export { default as CommandSeparator } from "./CommandSeparator.vue";
|
||||||
|
export { default as CommandShortcut } from "./CommandShortcut.vue";
|
||||||
|
|
@ -157,7 +157,7 @@
|
||||||
"mean_mean_badger_inspire": "Value",
|
"mean_mean_badger_inspire": "Value",
|
||||||
"antsy_whole_alligator_blink": "Confirm",
|
"antsy_whole_alligator_blink": "Confirm",
|
||||||
"game_tough_seal_adore": "Mention someone",
|
"game_tough_seal_adore": "Mention someone",
|
||||||
"plane_born_koala_hope": "Enable Markdown",
|
"plane_born_koala_hope": "Enable rich text",
|
||||||
"blue_ornate_coyote_tickle": "Insert emoji",
|
"blue_ornate_coyote_tickle": "Insert emoji",
|
||||||
"top_patchy_earthworm_vent": "Attach a file",
|
"top_patchy_earthworm_vent": "Attach a file",
|
||||||
"frail_broad_mallard_dart": "Mark as sensitive",
|
"frail_broad_mallard_dart": "Mark as sensitive",
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@
|
||||||
"mean_mean_badger_inspire": "Valeur",
|
"mean_mean_badger_inspire": "Valeur",
|
||||||
"antsy_whole_alligator_blink": "Confirmer",
|
"antsy_whole_alligator_blink": "Confirmer",
|
||||||
"game_tough_seal_adore": "Mentionner quelqu'un",
|
"game_tough_seal_adore": "Mentionner quelqu'un",
|
||||||
"plane_born_koala_hope": "Activer le Markdown",
|
"plane_born_koala_hope": "Activer le formatage du texte",
|
||||||
"blue_ornate_coyote_tickle": "Insérer un emoji",
|
"blue_ornate_coyote_tickle": "Insérer un emoji",
|
||||||
"top_patchy_earthworm_vent": "Joindre un fichier",
|
"top_patchy_earthworm_vent": "Joindre un fichier",
|
||||||
"frail_broad_mallard_dart": "Marquer comme sensible",
|
"frail_broad_mallard_dart": "Marquer comme sensible",
|
||||||
|
|
|
||||||
18
package.json
18
package.json
|
|
@ -21,7 +21,7 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "paraglide-js compile --project ./project.inlang --outdir ./paraglide && nuxt build",
|
"build": "paraglide-js compile --project ./project.inlang --outdir ./paraglide && nuxt build",
|
||||||
"dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 bun --bun nuxt dev --https --https.cert config/versia-fe.localhost.pem --https.key config/versia-fe.localhost-key.pem --host versia-fe.localhost",
|
"dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 bun nuxt dev --https --https.cert config/versia-fe.localhost.pem --https.key config/versia-fe.localhost-key.pem --host versia-fe.localhost",
|
||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"emojis:generate": "bun run utils/emojis.ts",
|
"emojis:generate": "bun run utils/emojis.ts",
|
||||||
"postinstall": "paraglide-js compile --project ./project.inlang --outdir ./paraglide && nuxt prepare",
|
"postinstall": "paraglide-js compile --project ./project.inlang --outdir ./paraglide && nuxt prepare",
|
||||||
|
|
@ -33,6 +33,20 @@
|
||||||
"@nuxt/fonts": "^0.10.3",
|
"@nuxt/fonts": "^0.10.3",
|
||||||
"@nuxtjs/color-mode": "3.5.2",
|
"@nuxtjs/color-mode": "3.5.2",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
|
"@tiptap/extension-highlight": "^2.10.4",
|
||||||
|
"@tiptap/extension-image": "^2.10.4",
|
||||||
|
"@tiptap/extension-link": "^2.10.4",
|
||||||
|
"@tiptap/extension-mention": "^2.10.4",
|
||||||
|
"@tiptap/extension-placeholder": "^2.10.4",
|
||||||
|
"@tiptap/extension-subscript": "^2.10.4",
|
||||||
|
"@tiptap/extension-superscript": "^2.10.4",
|
||||||
|
"@tiptap/extension-task-item": "^2.10.4",
|
||||||
|
"@tiptap/extension-task-list": "^2.10.4",
|
||||||
|
"@tiptap/extension-underline": "^2.10.4",
|
||||||
|
"@tiptap/pm": "^2.10.4",
|
||||||
|
"@tiptap/starter-kit": "^2.10.4",
|
||||||
|
"@tiptap/suggestion": "^2.10.4",
|
||||||
|
"@tiptap/vue-3": "^2.10.4",
|
||||||
"@vee-validate/zod": "^4.15.0",
|
"@vee-validate/zod": "^4.15.0",
|
||||||
"@versia/client": "0.1.4",
|
"@versia/client": "0.1.4",
|
||||||
"@vite-pwa/nuxt": "^0.10.6",
|
"@vite-pwa/nuxt": "^0.10.6",
|
||||||
|
|
@ -41,7 +55,7 @@
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"embla-carousel-vue": "^8.5.1",
|
"embla-carousel-vue": "^8.5.1",
|
||||||
"fastest-levenshtein": "^1.0.16",
|
"fuzzysort": "^3.1.0",
|
||||||
"html-to-text": "^9.0.5",
|
"html-to-text": "^9.0.5",
|
||||||
"lucide-vue-next": "^0.469.0",
|
"lucide-vue-next": "^0.469.0",
|
||||||
"magic-regexp": "^0.8.0",
|
"magic-regexp": "^0.8.0",
|
||||||
|
|
|
||||||
15
styles/content.css
Normal file
15
styles/content.css
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
.content code:not(pre code)::after,
|
||||||
|
.content code:not(pre code)::before {
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.content ol li input[type="checkbox"],
|
||||||
|
.content ul li input[type="checkbox"] {
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
margin-top: 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: var(--theme-primary-400);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue