feat: Implement rich text note composer

This commit is contained in:
Jesse Wierzbinski 2024-12-25 20:46:14 +01:00
parent e0e8db8d55
commit f0516cb58a
No known key found for this signature in database
22 changed files with 569 additions and 135 deletions

2
.gitignore vendored
View file

@ -25,3 +25,5 @@ logs
config
public/emojis
.npmrc

BIN
bun.lockb

Binary file not shown.

View file

@ -6,9 +6,9 @@
<Input v-model:model-value="state.contentWarning" v-if="state.sensitive"
placeholder="Put your content warning here" />
<Textarea id="text-input" :placeholder="chosenSplash" v-model:model-value="state.content"
class="!border-none !ring-0 !outline-none rounded-none p-0 max-h-full min-h-48 !ring-offset-0"
:disabled="sending" />
<EditorContent v-model:content="state.content" :placeholder="chosenSplash"
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" :mode="state.contentType === 'text/html' ? 'rich' : 'plain'" />
<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 />
@ -28,8 +28,11 @@
</Tooltip>
<Tooltip>
<TooltipTrigger as="div">
<Toggle variant="default" size="sm" :pressed="state.contentType === 'text/markdown'"
@update:pressed="i => state.contentType = i ? 'text/plain' : 'text/markdown'">
<Toggle variant="default" size="sm" :pressed="state.contentType === 'text/html'" @update:pressed="(i) =>
(state.contentType = i
? 'text/html'
: 'text/plain')
">
<LetterText class="!size-5" />
</Toggle>
</TooltipTrigger>
@ -87,7 +90,11 @@
</Tooltip>
<Button type="submit" size="lg" class="ml-auto" :disabled="sending" @click="submit">
<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>
</DialogFooter>
</template>
@ -112,9 +119,9 @@ import Note from "~/components/notes/note.vue";
import { Select, SelectContent, SelectItem } from "~/components/ui/select";
import * as m from "~/paraglide/messages.js";
import { SettingIds } from "~/settings";
import EditorContent from "../editor/content.vue";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Textarea } from "../ui/textarea";
import { Toggle } from "../ui/toggle";
import Files from "./files.vue";
@ -124,13 +131,6 @@ const defaultVisibility = useSetting(SettingIds.DefaultVisibility);
const { play } = useAudio();
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], () => {
if (sending.value || !ctrlEnterSend.value.value) {
return;
@ -176,7 +176,7 @@ const state = reactive({
content: relation?.source?.text || getMentions(),
sensitive: relation?.type === "edit" ? relation.note.sensitive : false,
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"
? relation.note.visibility
: (defaultVisibility.value.value ?? "public")) as Status["visibility"],

View 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>

View 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>

View 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">;

View file

@ -82,62 +82,5 @@ const formattedCharacterCount = characterCount
</script>
<style module>
.content pre:has(code) {
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);
}
@import url("~/styles/content.css");
</style>

View file

@ -13,60 +13,5 @@ const { content } = defineProps<{
</script>
<style module>
.content pre:has(code) {
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)
}
@import url("~/styles/content.css");
</style>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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";

View file

@ -157,7 +157,7 @@
"mean_mean_badger_inspire": "Value",
"antsy_whole_alligator_blink": "Confirm",
"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",
"top_patchy_earthworm_vent": "Attach a file",
"frail_broad_mallard_dart": "Mark as sensitive",

View file

@ -147,7 +147,7 @@
"mean_mean_badger_inspire": "Valeur",
"antsy_whole_alligator_blink": "Confirmer",
"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",
"top_patchy_earthworm_vent": "Joindre un fichier",
"frail_broad_mallard_dart": "Marquer comme sensible",

View file

@ -21,7 +21,7 @@
},
"scripts": {
"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",
"emojis:generate": "bun run utils/emojis.ts",
"postinstall": "paraglide-js compile --project ./project.inlang --outdir ./paraglide && nuxt prepare",
@ -33,6 +33,20 @@
"@nuxt/fonts": "^0.10.3",
"@nuxtjs/color-mode": "3.5.2",
"@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",
"@versia/client": "0.1.4",
"@vite-pwa/nuxt": "^0.10.6",
@ -41,7 +55,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"embla-carousel-vue": "^8.5.1",
"fastest-levenshtein": "^1.0.16",
"fuzzysort": "^3.1.0",
"html-to-text": "^9.0.5",
"lucide-vue-next": "^0.469.0",
"magic-regexp": "^0.8.0",

15
styles/content.css Normal file
View 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);
}