chore: ⬆️ Upgrade to Nuxt 4
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 1s
Deploy to GitHub Pages / build (push) Failing after 1s
Deploy to GitHub Pages / deploy (push) Has been skipped
Docker / build (push) Failing after 1s
Mirror to Codeberg / Mirror (push) Failing after 1s

This commit is contained in:
Jesse Wierzbinski 2025-07-16 07:48:39 +02:00
parent 8debe97f63
commit 7f7cf20311
386 changed files with 2376 additions and 2332 deletions

View file

@ -0,0 +1,98 @@
<script setup lang="ts">
import type { Editor } from "@tiptap/vue-3";
import { BubbleMenu } from "@tiptap/vue-3/menus";
import {
BoldIcon,
CurlyBracesIcon,
ItalicIcon,
StrikethroughIcon,
SubscriptIcon,
SuperscriptIcon,
UnderlineIcon,
} from "lucide-vue-next";
import { ToggleGroup, ToggleGroupItem } from "~/components/ui/toggle-group";
const { editor } = defineProps<{
editor: Editor;
}>();
const active = ref<string[]>(
[
editor.isActive("bold") ? "bold" : null,
editor.isActive("italic") ? "italic" : null,
editor.isActive("underline") ? "underline" : null,
editor.isActive("code") ? "code" : null,
editor.isActive("strike") ? "strike" : null,
editor.isActive("subscript") ? "subscript" : null,
editor.isActive("superscript") ? "superscript" : null,
].filter((s) => s !== null),
);
watch(active, (value) => {
if (value.includes("bold")) {
editor.chain().focus().toggleBold().run();
} else {
editor.chain().unsetBold().run();
}
if (value.includes("italic")) {
editor.chain().focus().toggleItalic().run();
} else {
editor.chain().unsetItalic().run();
}
if (value.includes("underline")) {
editor.chain().focus().toggleUnderline().run();
} else {
editor.chain().unsetUnderline().run();
}
if (value.includes("code")) {
editor.chain().focus().toggleCode().run();
} else {
editor.chain().unsetCode().run();
}
if (value.includes("strike")) {
editor.chain().focus().toggleStrike().run();
} else {
editor.chain().unsetStrike().run();
}
if (value.includes("subscript")) {
editor.chain().focus().toggleSubscript().run();
} else {
editor.chain().unsetSubscript().run();
}
if (value.includes("superscript")) {
editor.chain().focus().toggleSuperscript().run();
} else {
editor.chain().unsetSuperscript().run();
}
});
</script>
<template>
<BubbleMenu :editor="editor" class="bg-popover rounded-md">
<ToggleGroup type="multiple"
v-model="active"
>
<ToggleGroupItem value="bold">
<BoldIcon />
</ToggleGroupItem>
<ToggleGroupItem value="italic">
<ItalicIcon />
</ToggleGroupItem>
<ToggleGroupItem value="underline">
<UnderlineIcon />
</ToggleGroupItem>
<ToggleGroupItem value="code">
<CurlyBracesIcon />
</ToggleGroupItem>
<ToggleGroupItem value="strike">
<StrikethroughIcon />
</ToggleGroupItem>
<ToggleGroupItem value="subscript">
<SubscriptIcon />
</ToggleGroupItem>
<ToggleGroupItem value="superscript">
<SuperscriptIcon />
</ToggleGroupItem>
</ToggleGroup>
</BubbleMenu>
</template>

View file

@ -0,0 +1,122 @@
<template>
<BubbleMenu :editor="editor" />
<EditorContent :editor="editor"
v-bind="$attrs"
:class="[$style.content, 'relative 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 Emoji, { emojis } from "@tiptap/extension-emoji";
import Highlight from "@tiptap/extension-highlight";
import { TaskItem, TaskList } from "@tiptap/extension-list";
import Mention from "@tiptap/extension-mention";
import Subscript from "@tiptap/extension-subscript";
import Superscript from "@tiptap/extension-superscript";
import { Placeholder } from "@tiptap/extensions";
import StarterKit from "@tiptap/starter-kit";
import { Editor, EditorContent } from "@tiptap/vue-3";
import BubbleMenu from "./bubble-menu.vue";
import { emojiSuggestion, mentionSuggestion } from "./suggestion.ts";
const content = defineModel<string>("content");
const rawContent = defineModel<string>("rawContent");
const {
placeholder,
disabled,
mode = "rich",
} = defineProps<{
placeholder?: string;
mode?: "rich" | "plain";
disabled?: boolean;
}>();
const emit = defineEmits<{
pasteFiles: [files: File[]];
}>();
const editor = new Editor({
extensions: [
StarterKit,
Placeholder.configure({
placeholder,
}),
Highlight,
Subscript,
Superscript,
TaskList,
TaskItem,
Mention.configure({
HTMLAttributes: {
class: "mention",
},
suggestion: mentionSuggestion,
}),
Emoji.configure({
emojis: emojis.concat(
identity.value?.emojis.map((emoji) => ({
name: emoji.shortcode,
shortcodes: [emoji.shortcode],
group: emoji.category ?? undefined,
tags: [],
fallbackImage: emoji.url,
})) || [],
),
HTMLAttributes: {
class: "emoji not-prose",
},
suggestion: emojiSuggestion,
}),
],
content: content.value,
onUpdate: ({ editor }) => {
content.value = mode === "rich" ? editor.getHTML() : editor.getText();
rawContent.value = editor.getText();
},
onPaste: (event) => {
// If pasting files, prevent the default behavior
if (event.clipboardData && event.clipboardData.files.length > 0) {
event.preventDefault();
const files = Array.from(event.clipboardData.files);
emit("pasteFiles", files);
}
},
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>
@reference "../../styles/index.css";
.tiptap p.is-editor-empty:first-child::before {
color: 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;
}
.tiptap .emoji>img {
@apply h-[1lh] align-middle inline hover:scale-110 transition-transform duration-75 ease-in-out;
}
</style>

View file

@ -0,0 +1,88 @@
<template>
<Command class="rounded border shadow-md min-w-[200px] h-fit not-prose" :selected-value="emojis[selectedIndex]?.id">
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup class="emojis-group" heading="Emojis">
<CommandItem :value="emoji.id" v-for="emoji, index in emojis" :key="emoji.id" @click="selectItem(index)" class="scroll-m-10">
<img class="h-[1lh] align-middle inline hover:scale-110 transition-transform duration-75 ease-in-out" :src="emoji.url" :title="emoji.shortcode" />
<span>{{ emoji.shortcode }}</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</template>
<script setup lang="ts">
import type {} from "@tiptap/extension-emoji";
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from "~/components/ui/command";
const { items, command } = defineProps<{
items: string[];
command: (value: { name: string }) => void;
}>();
const selectedIndex = ref(0);
const emojis = computed(() => {
return items
.map((item) => {
return identity.value?.emojis.find(
(emoji) => emoji.shortcode === item,
);
})
.filter((emoji) => emoji !== undefined);
});
const onKeyDown = ({ event }: { event: Event }) => {
if (event instanceof KeyboardEvent) {
if (event.key === "ArrowDown") {
selectedIndex.value =
(selectedIndex.value + 1) % emojis.value.length;
scrollIntoView(selectedIndex.value);
return true;
}
if (event.key === "ArrowUp") {
selectedIndex.value =
(selectedIndex.value - 1 + emojis.value.length) %
emojis.value.length;
scrollIntoView(selectedIndex.value);
return true;
}
if (event.key === "Enter") {
selectItem(selectedIndex.value);
return true;
}
}
};
const selectItem = (index: number) => {
const item = emojis.value[index];
if (item) {
command({
name: item.shortcode,
});
}
};
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>

View file

@ -0,0 +1,80 @@
<template>
<Command class="rounded border shadow-md min-w-[200px] h-fit not-prose" :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="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>

View file

@ -0,0 +1,189 @@
import { computePosition, flip, shift } from "@floating-ui/dom";
import type { Editor } from "@tiptap/core";
import type { MentionNodeAttrs } from "@tiptap/extension-mention";
import type { SuggestionOptions } from "@tiptap/suggestion";
import { posToDOMRect, VueRenderer } from "@tiptap/vue-3";
import type { Account, CustomEmoji } from "@versia/client/schemas";
import { go } from "fuzzysort";
import type { z } from "zod";
import EmojiList from "./emojis-list.vue";
import MentionList from "./mentions-list.vue";
export type UserData = {
key: string;
value: z.infer<typeof Account>;
};
const updatePosition = (editor: Editor, element: HTMLElement): void => {
const virtualElement = {
getBoundingClientRect: () =>
posToDOMRect(
editor.view,
editor.state.selection.from,
editor.state.selection.to,
),
};
computePosition(virtualElement, element, {
placement: "bottom-start",
strategy: "absolute",
middleware: [shift(), flip()],
}).then(({ x, y, strategy }) => {
element.style.width = "max-content";
element.style.position = strategy;
element.style.left = `${x}px`;
element.style.top = `${y}px`;
});
};
export const mentionSuggestion = {
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;
return {
onStart: (props) => {
component = new VueRenderer(MentionList, {
props,
editor: props.editor,
});
if (!props.clientRect || !component.element) {
return;
}
(component.element as HTMLElement).style.position = "absolute";
props.editor.view.dom.parentElement?.appendChild(
component.element,
);
updatePosition(props.editor, component.element as HTMLElement);
},
onUpdate(props) {
component.updateProps(props);
if (!props.clientRect) {
return;
}
updatePosition(props.editor, component.element as HTMLElement);
},
onKeyDown(props) {
if (props.event.key === "Escape") {
component.destroy();
return true;
}
return component.ref?.onKeyDown(props);
},
onExit() {
component.element?.remove();
component.destroy();
},
};
},
} as Omit<SuggestionOptions<UserData, MentionNodeAttrs>, "editor">;
export const emojiSuggestion = {
items: ({ query }) => {
if (query.length === 0) {
return [];
}
const emojis = (identity.value as Identity).emojis;
return go(
query,
emojis
.filter((emoji) => emoji.shortcode.includes(query))
.map((emoji) => ({
key: emoji.shortcode,
value: emoji,
})),
{ key: "key" },
)
.map((result) => result.obj.key)
.slice(0, 20);
},
render: () => {
let component: VueRenderer;
return {
onStart: (props) => {
component = new VueRenderer(EmojiList, {
props,
editor: props.editor,
});
if (!props.clientRect || !component.element) {
return;
}
(component.element as HTMLElement).style.position = "absolute";
props.editor.view.dom.parentElement?.appendChild(
component.element,
);
updatePosition(props.editor, component.element as HTMLElement);
},
onUpdate(props) {
component.updateProps(props);
if (!props.clientRect) {
return;
}
updatePosition(props.editor, component.element as HTMLElement);
},
onKeyDown(props) {
if (props.event.key === "Escape") {
component.destroy();
return true;
}
return component.ref?.onKeyDown(props);
},
onExit() {
component.element?.remove();
component.destroy();
},
};
},
} as Omit<SuggestionOptions<string>, "editor">;