mirror of
https://github.com/versia-pub/frontend.git
synced 2026-03-13 03:29:16 +01:00
chore: ⬆️ Upgrade to Nuxt 4
Some checks failed
Some checks failed
This commit is contained in:
parent
8debe97f63
commit
7f7cf20311
386 changed files with 2376 additions and 2332 deletions
132
app/app.vue
Normal file
132
app/app.vue
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<template>
|
||||
<TooltipProvider>
|
||||
<Component is="style">
|
||||
{{ preferences.custom_css }}
|
||||
</Component>
|
||||
<NuxtPwaAssets />
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
<ConfirmationModal />
|
||||
<!-- pointer-events-auto fixes https://github.com/unovue/shadcn-vue/issues/462 -->
|
||||
<Toaster class="pointer-events-auto" />
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { convert } from "html-to-text";
|
||||
import { overwriteGetLocale } from "../paraglide/runtime";
|
||||
import ConfirmationModal from "./components/modals/confirm.vue";
|
||||
import { Toaster } from "./components/ui/sonner";
|
||||
import { TooltipProvider } from "./components/ui/tooltip";
|
||||
|
||||
// Sin
|
||||
//import "~/styles/mcdonalds.css";
|
||||
|
||||
const lang = useLanguage();
|
||||
overwriteGetLocale(() => lang.value);
|
||||
|
||||
const code = useRequestURL().searchParams.get("code");
|
||||
const origin = useRequestURL().searchParams.get("origin");
|
||||
const appData = useAppData();
|
||||
const instance = useInstance();
|
||||
const description = useExtendedDescription(client);
|
||||
const route = useRoute();
|
||||
|
||||
// Theme switcher
|
||||
const colorMode = useColorMode();
|
||||
const radius = useCssVar("--radius");
|
||||
|
||||
watch(preferences.color_theme, (newVal) => {
|
||||
// Add theme-changing class to html to trigger transition
|
||||
document.documentElement.classList.add("theme-changing");
|
||||
colorMode.preference = newVal;
|
||||
|
||||
setTimeout(() => {
|
||||
// Remove theme-changing class after transition
|
||||
document.documentElement.classList.remove("theme-changing");
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
watch(
|
||||
preferences.border_radius,
|
||||
(newVal) => {
|
||||
radius.value = `${newVal}rem`;
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
useSeoMeta({
|
||||
titleTemplate: (titleChunk) => {
|
||||
return titleChunk ? `${titleChunk} · Versia` : "Versia";
|
||||
},
|
||||
title: computed(() => instance.value?.title ?? ""),
|
||||
ogImage: computed(() => instance.value?.banner?.url),
|
||||
twitterTitle: computed(() => instance.value?.title ?? ""),
|
||||
twitterDescription: computed(() =>
|
||||
convert(description.value?.content ?? ""),
|
||||
),
|
||||
twitterImage: computed(() => instance.value?.banner?.url),
|
||||
description: computed(() => convert(description.value?.content ?? "")),
|
||||
ogDescription: computed(() => convert(description.value?.content ?? "")),
|
||||
ogSiteName: "Versia",
|
||||
colorScheme: "dark",
|
||||
themeColor: "#f9a8d4",
|
||||
});
|
||||
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
lang: "en",
|
||||
},
|
||||
});
|
||||
|
||||
if (code && origin && appData.value && route.path !== "/oauth/code") {
|
||||
const newOrigin = new URL(
|
||||
URL.canParse(origin) ? origin : `https://${origin}`,
|
||||
);
|
||||
|
||||
signInWithCode(code, appData.value, newOrigin);
|
||||
}
|
||||
|
||||
if (origin && !code) {
|
||||
const newOrigin = new URL(
|
||||
URL.canParse(origin) ? origin : `https://${origin}`,
|
||||
);
|
||||
|
||||
signIn(appData, newOrigin);
|
||||
}
|
||||
|
||||
useListen("identity:change", (newIdentity) => {
|
||||
identity.value = newIdentity;
|
||||
window.location.pathname = "/";
|
||||
});
|
||||
|
||||
useCacheRefresh(client);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import "tailwindcss";
|
||||
@import "./styles/index.css";
|
||||
|
||||
body {
|
||||
font-family: Inter, sans-serif;
|
||||
}
|
||||
|
||||
html.theme-changing * {
|
||||
/* Stroke and fill aren't animatable */
|
||||
transition: background-color 1s ease, border 1s ease, color 1s ease,
|
||||
box-shadow 1s ease !important;
|
||||
}
|
||||
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
</style>
|
||||
BIN
app/assets/ipad-dark.webp
Normal file
BIN
app/assets/ipad-dark.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
BIN
app/assets/ipad-light.webp
Normal file
BIN
app/assets/ipad-light.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
24
app/assets/robots.txt
Normal file
24
app/assets/robots.txt
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
User-agent: AdsBot-Google
|
||||
User-agent: Amazonbot
|
||||
User-agent: anthropic-ai
|
||||
User-agent: Applebot-Extended
|
||||
User-agent: Bytespider
|
||||
User-agent: CCBot
|
||||
User-agent: ChatGPT-User
|
||||
User-agent: ClaudeBot
|
||||
User-agent: Claude-Web
|
||||
User-agent: cohere-ai
|
||||
User-agent: Diffbot
|
||||
User-agent: FacebookBot
|
||||
User-agent: FriendlyCrawler
|
||||
User-agent: Google-Extended
|
||||
User-agent: GoogleOther
|
||||
User-agent: GPTBot
|
||||
User-agent: img2dataset
|
||||
User-agent: omgili
|
||||
User-agent: omgilibot
|
||||
User-agent: peer39_crawler
|
||||
User-agent: peer39_crawler/1.0
|
||||
User-agent: PerplexityBot
|
||||
User-agent: YouBot
|
||||
Disallow: /
|
||||
18
app/components/composer/button.vue
Normal file
18
app/components/composer/button.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<template>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as="div">
|
||||
<slot />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ tooltip }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
|
||||
const { tooltip } = defineProps<{
|
||||
tooltip: string;
|
||||
}>();
|
||||
</script>
|
||||
84
app/components/composer/buttons.vue
Normal file
84
app/components/composer/buttons.vue
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<template>
|
||||
<ComposerButton :tooltip="m.game_tough_seal_adore()">
|
||||
<Button variant="ghost" size="icon">
|
||||
<AtSign class="!size-5" />
|
||||
</Button>
|
||||
</ComposerButton>
|
||||
<ComposerButton :tooltip="m.plane_born_koala_hope()">
|
||||
<Toggle variant="default" size="sm" :model-value="contentType === 'text/html'" @update:model-value="
|
||||
(i) =>
|
||||
(contentType = i ? 'text/html' : 'text/plain')
|
||||
">
|
||||
<LetterText class="!size-5" />
|
||||
</Toggle>
|
||||
</ComposerButton>
|
||||
<VisibilityPicker v-model:visibility="visibility">
|
||||
<Button variant="ghost" size="icon" :disabled="relation?.type === 'edit'">
|
||||
<component :is="visibilities[visibility].icon" />
|
||||
</Button>
|
||||
</VisibilityPicker>
|
||||
<ComposerButton :tooltip="m.blue_ornate_coyote_tickle()">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Smile class="!size-5" />
|
||||
</Button>
|
||||
</ComposerButton>
|
||||
<ComposerButton :tooltip="m.top_patchy_earthworm_vent()">
|
||||
<Button variant="ghost" size="icon" @click="emit('pickFile')">
|
||||
<FilePlus2 class="!size-5" />
|
||||
</Button>
|
||||
</ComposerButton>
|
||||
<ComposerButton :tooltip="m.frail_broad_mallard_dart()">
|
||||
<Toggle variant="default" size="sm" v-model="sensitive">
|
||||
<TriangleAlert class="!size-5" />
|
||||
</Toggle>
|
||||
</ComposerButton>
|
||||
<CharacterCounter class="ml-auto" :max="(identity as Identity).instance.configuration.statuses.max_characters" :current="rawContent.length" />
|
||||
<Button type="submit" size="lg" :disabled="sending || !canSend" @click="emit('submit')">
|
||||
<Loader v-if="sending" class="!size-5 animate-spin" />
|
||||
{{
|
||||
relation?.type === "edit"
|
||||
? m.gaudy_strong_puma_slide()
|
||||
: m.free_teal_bulldog_learn()
|
||||
}}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
AtSign,
|
||||
FilePlus2,
|
||||
LetterText,
|
||||
Loader,
|
||||
Smile,
|
||||
TriangleAlert,
|
||||
} from "lucide-vue-next";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
import { Button } from "../ui/button";
|
||||
import { Toggle } from "../ui/toggle";
|
||||
import ComposerButton from "./button.vue";
|
||||
import CharacterCounter from "./character-counter.vue";
|
||||
import { type ComposerState, visibilities } from "./composer";
|
||||
import VisibilityPicker from "./visibility-picker.vue";
|
||||
|
||||
const { relation, sending, canSend, rawContent } = defineProps<{
|
||||
relation?: ComposerState["relation"];
|
||||
sending: boolean;
|
||||
canSend: boolean;
|
||||
rawContent: string;
|
||||
}>();
|
||||
|
||||
const contentType = defineModel<ComposerState["contentType"]>("contentType", {
|
||||
required: true,
|
||||
});
|
||||
const visibility = defineModel<ComposerState["visibility"]>("visibility", {
|
||||
required: true,
|
||||
});
|
||||
const sensitive = defineModel<ComposerState["sensitive"]>("sensitive", {
|
||||
required: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [];
|
||||
pickFile: [];
|
||||
}>();
|
||||
</script>
|
||||
39
app/components/composer/character-counter.vue
Normal file
39
app/components/composer/character-counter.vue
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<template>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<div v-bind="$attrs" class="m-1">
|
||||
<TriangleAlert v-if="isOverflowing" class="text-destructive-foreground size-6" />
|
||||
<svg v-else viewBox="0 0 100 100" class="transform rotate-[-90deg] size-6">
|
||||
<!-- Background Circle -->
|
||||
<circle cx="50" cy="50" r="46" stroke="currentColor" class="text-muted" stroke-width="8"
|
||||
fill="none" />
|
||||
<!-- Progress Circle -->
|
||||
<circle cx="50" cy="50" r="46" stroke="currentColor" stroke-width="8" fill="none"
|
||||
stroke-dasharray="100" :stroke-dashoffset="100 - percentage" pathLength="100"
|
||||
stroke-linecap="round" class="text-accent-foreground transition-all duration-500" />
|
||||
</svg>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent class="text-center">
|
||||
<p>{{ current }} / {{ max }}</p>
|
||||
<p v-if="isOverflowing">Too long!</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { TriangleAlert } from "lucide-vue-next";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
|
||||
const { max, current } = defineProps<{
|
||||
max: number;
|
||||
current: number;
|
||||
}>();
|
||||
|
||||
const percentage = computed(() => {
|
||||
return Math.min((current / max) * 100, 100);
|
||||
});
|
||||
const isOverflowing = computed(() => {
|
||||
return current > max;
|
||||
});
|
||||
</script>
|
||||
240
app/components/composer/composer.ts
Normal file
240
app/components/composer/composer.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import type { ResponseError } from "@versia/client";
|
||||
import type { Attachment, Status, StatusSource } from "@versia/client/schemas";
|
||||
import { AtSign, Globe, Lock, LockOpen } from "lucide-vue-next";
|
||||
import type { FunctionalComponent } from "vue";
|
||||
import { toast } from "vue-sonner";
|
||||
import type { z } from "zod";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
|
||||
export interface ComposerState {
|
||||
relation?: {
|
||||
type: "reply" | "quote" | "edit";
|
||||
note: z.infer<typeof Status>;
|
||||
source?: z.infer<typeof StatusSource>;
|
||||
};
|
||||
content: string;
|
||||
rawContent: string;
|
||||
sensitive: boolean;
|
||||
contentWarning: string;
|
||||
contentType: "text/html" | "text/plain";
|
||||
visibility: z.infer<typeof Status.shape.visibility>;
|
||||
files: {
|
||||
apiId?: string;
|
||||
file: File;
|
||||
alt?: string;
|
||||
uploading: boolean;
|
||||
updating: boolean;
|
||||
}[];
|
||||
sending: boolean;
|
||||
canSend: boolean;
|
||||
}
|
||||
|
||||
const { play } = useAudio();
|
||||
export const state = reactive<ComposerState>({
|
||||
relation: undefined,
|
||||
content: "",
|
||||
rawContent: "",
|
||||
sensitive: false,
|
||||
contentWarning: "",
|
||||
contentType: "text/html",
|
||||
visibility: preferences.default_visibility.value,
|
||||
files: [],
|
||||
sending: false,
|
||||
canSend: false,
|
||||
});
|
||||
|
||||
watch(
|
||||
state,
|
||||
(newState) => {
|
||||
const characterLimit =
|
||||
identity.value?.instance.configuration.statuses.max_characters ?? 0;
|
||||
const characterCount = newState.rawContent.length;
|
||||
|
||||
state.canSend =
|
||||
characterCount > 0
|
||||
? characterCount <= characterLimit
|
||||
: newState.files.length > 0;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
export const visibilities: Record<
|
||||
z.infer<typeof Status.shape.visibility>,
|
||||
{
|
||||
icon: FunctionalComponent;
|
||||
name: string;
|
||||
text: string;
|
||||
}
|
||||
> = {
|
||||
public: {
|
||||
icon: Globe,
|
||||
name: m.lost_trick_dog_grace(),
|
||||
text: m.last_mean_peacock_zip(),
|
||||
},
|
||||
unlisted: {
|
||||
icon: LockOpen,
|
||||
name: m.funny_slow_jannes_walk(),
|
||||
text: m.grand_strong_gibbon_race(),
|
||||
},
|
||||
private: {
|
||||
icon: Lock,
|
||||
name: m.grassy_empty_raven_startle(),
|
||||
text: m.white_teal_ostrich_yell(),
|
||||
},
|
||||
direct: {
|
||||
icon: AtSign,
|
||||
name: m.pretty_bold_baboon_wave(),
|
||||
text: m.lucky_mean_robin_link(),
|
||||
},
|
||||
};
|
||||
|
||||
export const getRandomSplash = (): string => {
|
||||
const splashes = useConfig().COMPOSER_SPLASHES;
|
||||
|
||||
return splashes[Math.floor(Math.random() * splashes.length)] as string;
|
||||
};
|
||||
|
||||
export const calculateMentionsFromReply = (
|
||||
note: z.infer<typeof Status>,
|
||||
): string => {
|
||||
const peopleToMention = note.mentions
|
||||
.concat(note.account)
|
||||
// Deduplicate mentions
|
||||
.filter((men, i, a) => a.indexOf(men) === i)
|
||||
// Remove self
|
||||
.filter((men) => men.id !== identity.value?.account.id);
|
||||
|
||||
if (peopleToMention.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const mentions = peopleToMention.map((me) => `@${me.acct}`).join(" ");
|
||||
|
||||
return `${mentions} `;
|
||||
};
|
||||
|
||||
const fileFromUrl = (url: URL | string): Promise<File> => {
|
||||
return fetch(url).then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch file");
|
||||
}
|
||||
|
||||
return response.blob().then((blob) => {
|
||||
const file = new File([blob], "file", { type: blob.type });
|
||||
return file;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const stateFromRelation = async (
|
||||
relationType: "reply" | "quote" | "edit",
|
||||
note: z.infer<typeof Status>,
|
||||
source?: z.infer<typeof StatusSource>,
|
||||
): Promise<void> => {
|
||||
state.relation = {
|
||||
type: relationType,
|
||||
note,
|
||||
source,
|
||||
};
|
||||
state.content = note.content || calculateMentionsFromReply(note);
|
||||
state.rawContent = source?.text || "";
|
||||
|
||||
if (relationType === "edit") {
|
||||
state.sensitive = note.sensitive;
|
||||
state.contentWarning = source?.spoiler_text || note.spoiler_text;
|
||||
state.visibility = note.visibility;
|
||||
state.files = await Promise.all(
|
||||
note.media_attachments.map(async (file) => ({
|
||||
apiId: file.id,
|
||||
alt: file.description ?? undefined,
|
||||
file: await fileFromUrl(file.url),
|
||||
uploading: false,
|
||||
updating: false,
|
||||
})),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const uploadFile = (file: File): Promise<void> => {
|
||||
const index =
|
||||
state.files.push({
|
||||
file,
|
||||
uploading: true,
|
||||
updating: false,
|
||||
}) - 1;
|
||||
|
||||
return client.value
|
||||
.uploadMedia(file)
|
||||
.then((media) => {
|
||||
if (!state.files[index]) {
|
||||
throw new Error("File not found");
|
||||
}
|
||||
|
||||
state.files[index].uploading = false;
|
||||
state.files[index].apiId = (
|
||||
media.data as z.infer<typeof Attachment>
|
||||
).id;
|
||||
})
|
||||
.catch(() => {
|
||||
state.files.splice(index, 1);
|
||||
});
|
||||
};
|
||||
|
||||
export const send = async (): Promise<void> => {
|
||||
if (state.sending) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.sending = true;
|
||||
|
||||
try {
|
||||
if (state.relation?.type === "edit") {
|
||||
const { data } = await client.value.editStatus(
|
||||
state.relation.note.id,
|
||||
{
|
||||
status: state.content,
|
||||
content_type: state.contentType,
|
||||
sensitive: state.sensitive,
|
||||
spoiler_text: state.sensitive
|
||||
? state.contentWarning
|
||||
: undefined,
|
||||
media_ids: state.files
|
||||
.map((f) => f.apiId)
|
||||
.filter((f) => f !== undefined),
|
||||
},
|
||||
);
|
||||
|
||||
useEvent("composer:send-edit", data);
|
||||
play("publish");
|
||||
useEvent("composer:close");
|
||||
} else {
|
||||
const { data } = await client.value.postStatus(state.content, {
|
||||
content_type: state.contentType,
|
||||
sensitive: state.sensitive,
|
||||
spoiler_text: state.sensitive
|
||||
? state.contentWarning
|
||||
: undefined,
|
||||
media_ids: state.files
|
||||
.map((f) => f.apiId)
|
||||
.filter((f) => f !== undefined),
|
||||
quote_id:
|
||||
state.relation?.type === "quote"
|
||||
? state.relation.note.id
|
||||
: undefined,
|
||||
in_reply_to_id:
|
||||
state.relation?.type === "reply"
|
||||
? state.relation.note.id
|
||||
: undefined,
|
||||
visibility: state.visibility,
|
||||
});
|
||||
|
||||
useEvent("composer:send", data as z.infer<typeof Status>);
|
||||
play("publish");
|
||||
useEvent("composer:close");
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error((e as ResponseError).message);
|
||||
} finally {
|
||||
state.sending = false;
|
||||
}
|
||||
};
|
||||
83
app/components/composer/composer.vue
Normal file
83
app/components/composer/composer.vue
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<template>
|
||||
<div v-if="relation" class="overflow-auto max-h-72">
|
||||
<Note :note="relation.note" :hide-actions="true" :small-layout="true" />
|
||||
</div>
|
||||
|
||||
<ContentWarning v-if="state.sensitive" v-model="state.contentWarning" />
|
||||
|
||||
<EditorContent @paste-files="uploadFiles" v-model:content="state.content" v-model:raw-content="state.rawContent" :placeholder="getRandomSplash()"
|
||||
class="[&>.tiptap]:!border-none [&>.tiptap]:!ring-0 [&>.tiptap]:!outline-none [&>.tiptap]:rounded-none p-0 [&>.tiptap]:max-h-[50dvh] [&>.tiptap]:overflow-y-auto [&>.tiptap]:min-h-48 [&>.tiptap]:!ring-offset-0 [&>.tiptap]:h-full"
|
||||
:disabled="state.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 />
|
||||
<Files v-model:files="state.files" />
|
||||
</div>
|
||||
|
||||
<DialogFooter class="items-center flex-row overflow-x-auto">
|
||||
<ComposerButtons @submit="send" @pick-file="fileInput?.click()" v-model:content-type="state.contentType" v-model:sensitive="state.sensitive" v-model:visibility="state.visibility" :relation="state.relation" :sending="state.sending" :can-send="state.canSend" :raw-content="state.rawContent" />
|
||||
</DialogFooter>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Note from "~/components/notes/note.vue";
|
||||
import EditorContent from "../editor/content.vue";
|
||||
import { DialogFooter } from "../ui/dialog";
|
||||
import ComposerButtons from "./buttons.vue";
|
||||
import {
|
||||
type ComposerState,
|
||||
getRandomSplash,
|
||||
send,
|
||||
state,
|
||||
stateFromRelation,
|
||||
uploadFile,
|
||||
} from "./composer";
|
||||
import ContentWarning from "./content-warning.vue";
|
||||
import Files from "./files.vue";
|
||||
|
||||
const { Control_Enter, Command_Enter } = useMagicKeys();
|
||||
const fileInput = useTemplateRef<HTMLInputElement>("fileInput");
|
||||
|
||||
watch([Control_Enter, Command_Enter], () => {
|
||||
if (state.sending || !preferences.ctrl_enter_send.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
send();
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
relation?: ComposerState["relation"];
|
||||
}>();
|
||||
|
||||
watch(
|
||||
props,
|
||||
async (props) => {
|
||||
if (props.relation) {
|
||||
await stateFromRelation(
|
||||
props.relation.type,
|
||||
props.relation.note,
|
||||
props.relation.source,
|
||||
);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const uploadFileFromEvent = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const files = Array.from(target.files ?? []);
|
||||
|
||||
for (const file of files) {
|
||||
uploadFile(file);
|
||||
}
|
||||
|
||||
target.value = "";
|
||||
};
|
||||
|
||||
const uploadFiles = (files: File[]) => {
|
||||
for (const file of files) {
|
||||
uploadFile(file);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
9
app/components/composer/content-warning.vue
Normal file
9
app/components/composer/content-warning.vue
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<template>
|
||||
<Input v-model:model-value="contentWarning" placeholder="Put your content warning here" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Input } from "../ui/input";
|
||||
|
||||
const contentWarning = defineModel<string>();
|
||||
</script>
|
||||
105
app/components/composer/dialog.vue
Normal file
105
app/components/composer/dialog.vue
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<script setup lang="ts">
|
||||
import type { Status, StatusSource } from "@versia/client/schemas";
|
||||
import { toast } from "vue-sonner";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
import Composer from "./composer.vue";
|
||||
|
||||
useListen("composer:open", () => {
|
||||
if (identity.value) {
|
||||
open.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
useListen("composer:edit", async (note) => {
|
||||
const id = toast.loading(m.wise_late_fireant_walk(), {
|
||||
duration: 0,
|
||||
});
|
||||
const { data: source } = await client.value.getStatusSource(note.id);
|
||||
relation.value = {
|
||||
type: "edit",
|
||||
note,
|
||||
source,
|
||||
};
|
||||
open.value = true;
|
||||
toast.dismiss(id);
|
||||
});
|
||||
|
||||
useListen("composer:reply", (note) => {
|
||||
relation.value = {
|
||||
type: "reply",
|
||||
note,
|
||||
};
|
||||
open.value = true;
|
||||
});
|
||||
|
||||
useListen("composer:quote", (note) => {
|
||||
relation.value = {
|
||||
type: "quote",
|
||||
note,
|
||||
};
|
||||
open.value = true;
|
||||
});
|
||||
|
||||
useListen("composer:close", () => {
|
||||
open.value = false;
|
||||
relation.value = null;
|
||||
// Unfocus the active element
|
||||
activeElement.value?.blur();
|
||||
});
|
||||
|
||||
const activeElement = useActiveElement();
|
||||
const open = ref(false);
|
||||
const relation = ref(
|
||||
null as {
|
||||
type: "reply" | "quote" | "edit";
|
||||
note: z.infer<typeof Status>;
|
||||
source?: z.infer<typeof StatusSource>;
|
||||
} | null,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:open="open"
|
||||
@update:open="
|
||||
(o) => {
|
||||
if (!o) {
|
||||
relation = null; // Unfocus the active element
|
||||
activeElement?.blur();
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<DialogContent
|
||||
:hide-close="true"
|
||||
class="sm:max-w-xl max-w-full w-[calc(100%-2*0.5rem)] grid-cols-1 max-h-[90dvh] p-5 pt-6 top-2 sm:top-1/2 translate-y-0 sm:-translate-y-1/2"
|
||||
>
|
||||
<DialogTitle class="sr-only">
|
||||
{{
|
||||
relation?.type === "reply"
|
||||
? m.loved_busy_mantis_slide()
|
||||
: relation?.type === "quote"
|
||||
? "Quote"
|
||||
: m.chunky_dull_marlin_trip()
|
||||
}}
|
||||
</DialogTitle>
|
||||
<DialogDescription class="sr-only">
|
||||
{{
|
||||
relation?.type === "reply"
|
||||
? m.tired_grassy_vulture_forgive()
|
||||
: relation?.type === "quote"
|
||||
? m.livid_livid_nils_snip()
|
||||
: m.brief_cool_capybara_fear()
|
||||
}}
|
||||
</DialogDescription>
|
||||
<Composer :relation="relation ?? undefined" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
127
app/components/composer/file-preview.vue
Normal file
127
app/components/composer/file-preview.vue
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
as="button"
|
||||
:disabled="file.uploading || file.updating"
|
||||
class="block bg-card text-card-foreground shadow-sm h-28 overflow-hidden rounded relative min-w-28 *:disabled:opacity-50"
|
||||
>
|
||||
<img :src="createObjectURL(file.file)" class="object-contain h-28 w-full" :alt="file.alt" />
|
||||
<Badge
|
||||
v-if="!(file.uploading || file.updating)"
|
||||
class="absolute bottom-1 right-1"
|
||||
variant="default"
|
||||
>{{ formatBytes(file.file.size) }}</Badge
|
||||
>
|
||||
<Spinner v-else class="absolute bottom-1 right-1 size-8 p-1.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="min-w-48">
|
||||
<DropdownMenuLabel>{{ file.file.name }}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="editName">
|
||||
<TextCursorInput />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="editCaption">
|
||||
<Captions />
|
||||
Add caption
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="emit('remove')">
|
||||
<Delete />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Captions, Delete, TextCursorInput } from "lucide-vue-next";
|
||||
import Spinner from "~/components/graphics/spinner.vue";
|
||||
import { confirmModalService } from "~/components/modals/composable.ts";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import type { ComposerState } from "./composer";
|
||||
|
||||
const file = defineModel<ComposerState["files"][number]>("file", {
|
||||
required: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: [];
|
||||
}>();
|
||||
|
||||
const editName = async () => {
|
||||
const result = await confirmModalService.confirm({
|
||||
title: "Enter a new name",
|
||||
defaultValue: file.value.file.name,
|
||||
confirmText: "Edit",
|
||||
inputType: "text",
|
||||
});
|
||||
|
||||
if (result.confirmed) {
|
||||
file.value.updating = true;
|
||||
file.value.file = new File(
|
||||
[file.value.file],
|
||||
result.value ?? file.value.file.name,
|
||||
{
|
||||
type: file.value.file.type,
|
||||
lastModified: file.value.file.lastModified,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await client.value.updateMedia(file.value.apiId ?? "", {
|
||||
file: file.value.file,
|
||||
});
|
||||
} finally {
|
||||
file.value.updating = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const editCaption = async () => {
|
||||
const result = await confirmModalService.confirm({
|
||||
title: "Enter a caption",
|
||||
message:
|
||||
"Captions are useful for people with visual impairments, or when the image can't be displayed.",
|
||||
defaultValue: file.value.alt,
|
||||
confirmText: "Add",
|
||||
inputType: "textarea",
|
||||
});
|
||||
|
||||
if (result.confirmed) {
|
||||
file.value.updating = true;
|
||||
file.value.alt = result.value;
|
||||
|
||||
try {
|
||||
await client.value.updateMedia(file.value.apiId ?? "", {
|
||||
description: file.value.alt,
|
||||
});
|
||||
} finally {
|
||||
file.value.updating = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const createObjectURL = URL.createObjectURL;
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) {
|
||||
return "0 Bytes";
|
||||
}
|
||||
const k = 1000;
|
||||
const digitsAfterPoint = 2;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(digitsAfterPoint))} ${
|
||||
sizes[i]
|
||||
}`;
|
||||
};
|
||||
</script>
|
||||
13
app/components/composer/files.vue
Normal file
13
app/components/composer/files.vue
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<template>
|
||||
<FilePreview v-for="(file, index) in files" :key="file.apiId" :file="file" @update:file="files[index] = $event"
|
||||
@remove="files.splice(index, 1)" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { ComposerState } from "./composer";
|
||||
import FilePreview from "./file-preview.vue";
|
||||
|
||||
const files = defineModel<ComposerState["files"]>("files", {
|
||||
required: true,
|
||||
});
|
||||
</script>
|
||||
33
app/components/composer/visibility-picker.vue
Normal file
33
app/components/composer/visibility-picker.vue
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<template>
|
||||
<Select v-model:model-value="visibility">
|
||||
<SelectTrigger as-child disable-default-classes disable-select-icon>
|
||||
<slot />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="(v, k) in visibilities" :key="k" @click="visibility = k" :value="k">
|
||||
<div class="flex flex-row gap-3 items-center w-full justify-between">
|
||||
<component :is="v.icon" class="size-4" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-semibold">{{ v.name }}</span>
|
||||
<span>{{ v.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "~/components/ui/select";
|
||||
import { type ComposerState, visibilities } from "./composer";
|
||||
|
||||
const visibility = defineModel<ComposerState["visibility"]>("visibility", {
|
||||
required: true,
|
||||
});
|
||||
</script>
|
||||
98
app/components/editor/bubble-menu.vue
Normal file
98
app/components/editor/bubble-menu.vue
Normal 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>
|
||||
122
app/components/editor/content.vue
Normal file
122
app/components/editor/content.vue
Normal 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>
|
||||
88
app/components/editor/emojis-list.vue
Normal file
88
app/components/editor/emojis-list.vue
Normal 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>
|
||||
80
app/components/editor/mentions-list.vue
Normal file
80
app/components/editor/mentions-list.vue
Normal 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>
|
||||
189
app/components/editor/suggestion.ts
Normal file
189
app/components/editor/suggestion.ts
Normal 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">;
|
||||
28
app/components/errors/AuthRequired.vue
Normal file
28
app/components/errors/AuthRequired.vue
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<template>
|
||||
<Alert layout="button">
|
||||
<LogIn />
|
||||
<AlertTitle>{{ m.sunny_quick_lionfish_flip() }}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{{ m.brave_known_pelican_drip() }}
|
||||
</AlertDescription>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="w-full"
|
||||
@click="signInAction"
|
||||
>
|
||||
{{ m.fuzzy_sea_moth_absorb() }}
|
||||
</Button>
|
||||
</Alert>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { LogIn } from "lucide-vue-next";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
|
||||
const appData = useAppData();
|
||||
const signInAction = async () => signIn(appData, await askForInstance());
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
15
app/components/errors/NoPosts.vue
Normal file
15
app/components/errors/NoPosts.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<Alert>
|
||||
<AlertTitle>{{ m.fine_arable_lemming_fold() }}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{{ m.petty_honest_fish_stir() }}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
15
app/components/errors/NotFound.vue
Normal file
15
app/components/errors/NotFound.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<Alert>
|
||||
<AlertTitle>{{ m.empty_awful_lark_dart() }}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{{ m.clean_even_mayfly_tap() }}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
15
app/components/errors/ReachedEnd.vue
Normal file
15
app/components/errors/ReachedEnd.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<Alert>
|
||||
<AlertTitle>{{ m.steep_suave_fish_snap() }}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{{ m.muddy_bland_shark_accept() }}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
26
app/components/errors/noscript.vue
Normal file
26
app/components/errors/noscript.vue
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<script setup lang="ts">
|
||||
import SquarePattern from "../graphics/square-pattern.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid min-h-screen place-items-center px-6 py-24 sm:py-32 lg:px-8 fixed inset-0 z-[1000000] bg-dark-900">
|
||||
<SquarePattern />
|
||||
<div class="prose prose-invert max-w-lg">
|
||||
<h1 class="mt-4 text-3xl font-bold tracking-tight text-gray-100 sm:text-5xl">JavaScript is disabled
|
||||
</h1>
|
||||
<p class="mt-6 text-base leading-7 text-gray-400">
|
||||
This website requires JavaScript to function properly. Please enable JavaScript in your browser
|
||||
settings.
|
||||
</p>
|
||||
<p class="mt-6 text-base leading-7 text-gray-400">
|
||||
If you are using a browser that does not support JavaScript, please consider using a modern browser
|
||||
like <a href="https://www.mozilla.org/firefox/new/" class="underline">Firefox</a> or <a
|
||||
href="https://www.google.com/chrome/" class="underline">Chrome</a>.
|
||||
</p>
|
||||
<p class="mt-6 text-base leading-7 text-gray-400">
|
||||
This application does not track you, collect user data, use cookies of any kind or send requests to
|
||||
servers outside of your account's instance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
34
app/components/form/switch.vue
Normal file
34
app/components/form/switch.vue
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<Card>
|
||||
<FormItem class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
|
||||
<CardHeader class="flex flex-col gap-1.5 p-0">
|
||||
<FormLabel class="font-semibold tracking-tight" :as="CardTitle">
|
||||
{{ title }}
|
||||
</FormLabel>
|
||||
<FormDescription class="text-xs leading-none" v-if="description">
|
||||
{{ description }}
|
||||
</FormDescription>
|
||||
</CardHeader>
|
||||
<FormControl>
|
||||
<slot />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "../ui/form";
|
||||
|
||||
const { title, description } = defineProps<{
|
||||
title: string;
|
||||
description?: string;
|
||||
}>();
|
||||
</script>
|
||||
29
app/components/form/text.vue
Normal file
29
app/components/form/text.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<template>
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{{ title }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<slot />
|
||||
</FormControl>
|
||||
<FormDescription v-if="description">
|
||||
{{ description }}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "../ui/form";
|
||||
|
||||
const { title, description } = defineProps<{
|
||||
title: string;
|
||||
description?: string;
|
||||
}>();
|
||||
</script>
|
||||
12
app/components/graphics/spinner.vue
Normal file
12
app/components/graphics/spinner.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<template>
|
||||
<Card class="flex items-center justify-center">
|
||||
<Loader class="size-6 animate-spin" />
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Loader } from "lucide-vue-next";
|
||||
import { Card } from "~/components/ui/card";
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
19
app/components/graphics/square-pattern.vue
Normal file
19
app/components/graphics/square-pattern.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<template>
|
||||
<svg class="absolute inset-x-0 top-0 h-full w-full stroke-primary/[0.07] [mask-image:radial-gradient(100%_100%_at_top_right,var(--primary-foreground),transparent)] pointer-events-none"
|
||||
aria-hidden="true">
|
||||
<defs>
|
||||
<pattern id="983e3e4c-de6d-4c3f-8d64-b9761d1534cc" width="200" height="200" x="50%" y="-1"
|
||||
patternUnits="userSpaceOnUse">
|
||||
<path d="M.5 200V.5H200" fill="none"></path>
|
||||
</pattern>
|
||||
</defs><svg x="50%" y="-1" class="overflow-visible fill-primary/[0.03]">
|
||||
<path d="M-200 0h201v201h-201Z M600 0h201v201h-201Z M-400 600h201v201h-201Z M200 800h201v201h-201Z"
|
||||
stroke-width="0"></path>
|
||||
</svg>
|
||||
<rect width="100%" height="100%" stroke-width="0" fill="url(#983e3e4c-de6d-4c3f-8d64-b9761d1534cc)"></rect>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
</script>
|
||||
41
app/components/instance/small-card.vue
Normal file
41
app/components/instance/small-card.vue
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<template>
|
||||
<Card class="grid grid-cols-[auto_1fr] gap-2">
|
||||
<Avatar :src="instance.thumbnail?.url ??
|
||||
'https://cdn.versia.pub/branding/icon.svg'
|
||||
" :name="instance.title" />
|
||||
<div class="grid text-sm leading-tight *:line-clamp-1">
|
||||
<span class="truncate font-semibold">
|
||||
{{
|
||||
instance.domain
|
||||
}}
|
||||
</span>
|
||||
<span class="line-clamp-3 text-xs">
|
||||
{{
|
||||
instance.versia_version || instance.version
|
||||
}}
|
||||
</span>
|
||||
|
||||
</div>
|
||||
<h1 class="line-clamp-1 text-sm font-semibold col-span-2">
|
||||
{{
|
||||
instance.title
|
||||
}}
|
||||
</h1>
|
||||
<p class="line-clamp-5 text-xs col-span-2">
|
||||
{{
|
||||
instance.description
|
||||
}}
|
||||
</p>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Instance } from "@versia/client/schemas";
|
||||
import type z from "zod";
|
||||
import Avatar from "../profiles/avatar.vue";
|
||||
import { Card } from "../ui/card";
|
||||
|
||||
const { instance } = defineProps<{
|
||||
instance: z.infer<typeof Instance>;
|
||||
}>();
|
||||
</script>
|
||||
34
app/components/modals/composable.ts
Normal file
34
app/components/modals/composable.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export type ConfirmModalOptions = {
|
||||
title?: string;
|
||||
message?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
inputType?: "none" | "text" | "textarea" | "url";
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
export type ConfirmModalResult = {
|
||||
confirmed: boolean;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
class ConfirmModalService {
|
||||
private modalRef = ref<{
|
||||
open: (options: ConfirmModalOptions) => Promise<ConfirmModalResult>;
|
||||
} | null>(null);
|
||||
|
||||
register(modal: {
|
||||
open: (options: ConfirmModalOptions) => Promise<ConfirmModalResult>;
|
||||
}) {
|
||||
this.modalRef.value = modal;
|
||||
}
|
||||
|
||||
confirm(options: ConfirmModalOptions): Promise<ConfirmModalResult> {
|
||||
if (!this.modalRef.value) {
|
||||
throw new Error("Confirmation modal not initialized");
|
||||
}
|
||||
return this.modalRef.value.open(options);
|
||||
}
|
||||
}
|
||||
|
||||
export const confirmModalService = new ConfirmModalService();
|
||||
70
app/components/modals/confirm-inline.vue
Normal file
70
app/components/modals/confirm-inline.vue
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<script setup lang="ts">
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
import type { ConfirmModalOptions, ConfirmModalResult } from "./composable.ts";
|
||||
|
||||
defineProps<{
|
||||
modalOptions: ConfirmModalOptions;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [result: ConfirmModalResult];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
const inputValue = ref<string>("");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog>
|
||||
<DialogTrigger :as-child="true">
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
<DialogContent class="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ modalOptions.title }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ modalOptions.message }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="modalOptions.inputType === 'text'" class="grid gap-4 py-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="confirmInput" class="text-right">{{ m.mean_mean_badger_inspire() }}</Label>
|
||||
<Input id="confirmInput" v-model="inputValue" class="col-span-3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="modalOptions.inputType === 'textarea'" class="grid gap-4 py-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="confirmTextarea" class="text-right">{{ m.mean_mean_badger_inspire() }}</Label>
|
||||
<Textarea id="confirmTextarea" v-model="inputValue" class="col-span-3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="() => emit('cancel')">
|
||||
{{ modalOptions.cancelText }}
|
||||
</Button>
|
||||
<Button @click="() => emit('confirm', {
|
||||
confirmed: true,
|
||||
value: inputValue,
|
||||
})">
|
||||
{{ modalOptions.confirmText }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
107
app/components/modals/confirm.vue
Normal file
107
app/components/modals/confirm.vue
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input, UrlInput } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
import {
|
||||
type ConfirmModalOptions,
|
||||
type ConfirmModalResult,
|
||||
confirmModalService,
|
||||
} from "./composable.ts";
|
||||
|
||||
const isOpen = ref(false);
|
||||
const modalOptions = ref<ConfirmModalOptions>({
|
||||
title: m.antsy_whole_alligator_blink(),
|
||||
message: "",
|
||||
inputType: "none",
|
||||
confirmText: m.antsy_whole_alligator_blink(),
|
||||
cancelText: m.soft_bold_ant_attend(),
|
||||
});
|
||||
const inputValue = ref("");
|
||||
const resolvePromise = ref<((result: ConfirmModalResult) => void) | null>(null);
|
||||
|
||||
function open(options: ConfirmModalOptions): Promise<ConfirmModalResult> {
|
||||
isOpen.value = true;
|
||||
isValid.value = false;
|
||||
|
||||
modalOptions.value = {
|
||||
title: options.title || m.antsy_whole_alligator_blink(),
|
||||
message: options.message,
|
||||
inputType: options.inputType || "none",
|
||||
confirmText: options.confirmText || m.antsy_whole_alligator_blink(),
|
||||
cancelText: options.cancelText || m.soft_bold_ant_attend(),
|
||||
};
|
||||
inputValue.value = options.defaultValue || "";
|
||||
|
||||
return new Promise((resolve) => {
|
||||
resolvePromise.value = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (resolvePromise.value) {
|
||||
resolvePromise.value({
|
||||
confirmed: true,
|
||||
value: inputValue.value,
|
||||
});
|
||||
}
|
||||
isOpen.value = false;
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (resolvePromise.value) {
|
||||
resolvePromise.value({
|
||||
confirmed: false,
|
||||
});
|
||||
}
|
||||
isOpen.value = false;
|
||||
}
|
||||
|
||||
confirmModalService.register({
|
||||
open,
|
||||
});
|
||||
|
||||
const isValid = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialog :key="String(isOpen)" :open="isOpen" @update:open="isOpen = false">
|
||||
<AlertDialogContent class="sm:max-w-[425px] flex flex-col">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{{ modalOptions.title }}</AlertDialogTitle>
|
||||
<AlertDialogDescription v-if="modalOptions.message">
|
||||
{{ modalOptions.message }}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<Input v-if="modalOptions.inputType === 'text'" v-model="inputValue" />
|
||||
|
||||
<UrlInput v-if="modalOptions.inputType === 'url'" v-model="inputValue" placeholder="google.com" v-model:is-valid="isValid" />
|
||||
|
||||
<Textarea v-else-if="modalOptions.inputType === 'textarea'" v-model="inputValue" rows="6" />
|
||||
|
||||
<AlertDialogFooter class="w-full">
|
||||
<AlertDialogCancel :as-child="true">
|
||||
<Button variant="outline" @click="handleCancel">
|
||||
{{ modalOptions.cancelText }}
|
||||
</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction :as-child="true">
|
||||
<Button @click="handleConfirm" :disabled="!isValid && modalOptions.inputType === 'url'">
|
||||
{{ modalOptions.confirmText }}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</template>
|
||||
9
app/components/modals/drawer-content.vue
Normal file
9
app/components/modals/drawer-content.vue
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<template>
|
||||
<DrawerContent class="flex flex-col gap-2 px-4 mb-4 [&>:nth-child(2)]:mt-4">
|
||||
<slot />
|
||||
</DrawerContent>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { DrawerContent } from "../ui/drawer";
|
||||
</script>
|
||||
35
app/components/navigation/mobile-navbar.vue
Normal file
35
app/components/navigation/mobile-navbar.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<div
|
||||
class="fixed md:hidden bottom-0 inset-x-0 border-t h-16 bg-background z-10 flex items-center justify-around *:h-full *:w-full gap-6 px-4 py-2 [&>a>svg]:size-5 [&>button>svg]:size-5"
|
||||
>
|
||||
<Button :as="NuxtLink" href="/" variant="ghost" size="icon">
|
||||
<Home />
|
||||
</Button>
|
||||
<Button
|
||||
:as="NuxtLink"
|
||||
href="/notifications"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<Bell />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon">
|
||||
<User />
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
:title="m.salty_aloof_turkey_nudge()"
|
||||
@click="useEvent('composer:open')"
|
||||
>
|
||||
<Pen />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Bell, Home, Pen, User } from "lucide-vue-next";
|
||||
import { NuxtLink } from "#components";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
import { Button } from "../ui/button";
|
||||
</script>
|
||||
61
app/components/navigation/timelines.vue
Normal file
61
app/components/navigation/timelines.vue
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<template>
|
||||
<Tabs v-model:model-value="current">
|
||||
<TabsList>
|
||||
<TabsTrigger v-for="timeline in timelines.filter(
|
||||
i => i.requiresLogin ? !!identity : true,
|
||||
)" :key="timeline.value" :value="timeline.value" :as="NuxtLink" :href="timeline.url">
|
||||
{{ timeline.name }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { BedSingle, Globe, House, MapIcon } from "lucide-vue-next";
|
||||
import { NuxtLink } from "#components";
|
||||
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
|
||||
const timelines = [
|
||||
{
|
||||
name: m.bland_chunky_sparrow_propel(),
|
||||
value: "home",
|
||||
url: "/home",
|
||||
icon: House,
|
||||
requiresLogin: true,
|
||||
},
|
||||
{
|
||||
name: m.lost_trick_dog_grace(),
|
||||
value: "public",
|
||||
url: "/public",
|
||||
icon: MapIcon,
|
||||
requiresLogin: false,
|
||||
},
|
||||
{
|
||||
name: m.crazy_game_parrot_pave(),
|
||||
value: "local",
|
||||
url: "/local",
|
||||
icon: BedSingle,
|
||||
requiresLogin: false,
|
||||
},
|
||||
{
|
||||
name: m.real_tame_moose_greet(),
|
||||
value: "global",
|
||||
url: "/global",
|
||||
icon: Globe,
|
||||
requiresLogin: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { beforeEach } = useRouter();
|
||||
const { path } = useRoute();
|
||||
|
||||
const current = computed(() => {
|
||||
if (path === "/") {
|
||||
return identity.value ? "home" : "public";
|
||||
}
|
||||
|
||||
const timeline = timelines.find((i) => i.url === path);
|
||||
return timeline ? timeline.value : "public";
|
||||
});
|
||||
</script>
|
||||
15
app/components/notes/action-button.vue
Normal file
15
app/components/notes/action-button.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<Button variant="ghost" class="max-w-14 w-full" size="sm">
|
||||
<component :is="icon" class="size-4" />
|
||||
<slot />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { FunctionalComponent } from "vue";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const { icon } = defineProps<{
|
||||
icon: FunctionalComponent;
|
||||
}>();
|
||||
</script>
|
||||
167
app/components/notes/actions.vue
Normal file
167
app/components/notes/actions.vue
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
<template>
|
||||
<div class="flex flex-row w-full max-w-sm items-stretch justify-between">
|
||||
<ActionButton :icon="Reply" @click="emit('reply')" :title="m.drab_tense_turtle_comfort()" :disabled="!identity">
|
||||
{{ numberFormat(replyCount) }}
|
||||
</ActionButton>
|
||||
<ActionButton :icon="Heart" @click="liked ? unlike() : like()" :title="liked ? m.vexed_fluffy_clownfish_dance() : m.royal_close_samuel_scold()" :disabled="!identity" :class="liked && '*:fill-red-600 *:text-red-600'">
|
||||
{{ numberFormat(likeCount) }}
|
||||
</ActionButton>
|
||||
<ActionButton :icon="Repeat" @click="reblogged ? unreblog() : reblog()" :title="reblogged ? m.lime_neat_ox_stab() : m.aware_helpful_marlin_drop()" :disabled="!identity" :class="reblogged && '*:text-green-600'">
|
||||
{{ numberFormat(reblogCount) }}
|
||||
</ActionButton>
|
||||
<ActionButton :icon="Quote" @click="emit('quote')" :title="m.true_shy_jackal_drip()" :disabled="!identity" />
|
||||
<Picker @pick="react">
|
||||
<ActionButton :icon="Smile" :title="m.bald_cool_kangaroo_jump()" :disabled="!identity" />
|
||||
</Picker>
|
||||
<Menu :api-note-string="apiNoteString" :url="url" :remote-url="remoteUrl" :is-remote="isRemote" :author-id="authorId" @edit="emit('edit')" :note-id="noteId" @delete="emit('delete')">
|
||||
<ActionButton :icon="Ellipsis" :title="m.busy_merry_cowfish_absorb()" />
|
||||
</Menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { CustomEmoji, Status } from "@versia/client/schemas";
|
||||
import { Ellipsis, Heart, Quote, Repeat, Reply, Smile } from "lucide-vue-next";
|
||||
import { toast } from "vue-sonner";
|
||||
import type { z } from "zod";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
import { getLocale } from "~~/paraglide/runtime";
|
||||
import { confirmModalService } from "../modals/composable";
|
||||
import ActionButton from "./action-button.vue";
|
||||
import Menu from "./menu.vue";
|
||||
import type { UnicodeEmoji } from "./reactions/picker/emoji";
|
||||
import Picker from "./reactions/picker/index.vue";
|
||||
|
||||
const { noteId } = defineProps<{
|
||||
replyCount: number;
|
||||
likeCount: number;
|
||||
reblogCount: number;
|
||||
apiNoteString: string;
|
||||
noteId: string;
|
||||
isRemote: boolean;
|
||||
url: string;
|
||||
remoteUrl?: string;
|
||||
authorId: string;
|
||||
liked: boolean;
|
||||
reblogged: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [];
|
||||
reply: [];
|
||||
quote: [];
|
||||
delete: [];
|
||||
react: [];
|
||||
}>();
|
||||
const { play } = useAudio();
|
||||
|
||||
const like = async () => {
|
||||
if (preferences.confirm_actions.value.includes("like")) {
|
||||
const confirmation = await confirmModalService.confirm({
|
||||
title: m.slimy_least_ray_aid(),
|
||||
message: m.stale_new_ray_jolt(),
|
||||
confirmText: m.royal_close_samuel_scold(),
|
||||
inputType: "none",
|
||||
});
|
||||
|
||||
if (!confirmation.confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
play("like");
|
||||
const id = toast.loading(m.slimy_candid_tiger_read());
|
||||
const { data } = await client.value.favouriteStatus(noteId);
|
||||
toast.dismiss(id);
|
||||
toast.success(m.mealy_slow_buzzard_commend());
|
||||
useEvent("note:edit", data);
|
||||
};
|
||||
|
||||
const unlike = async () => {
|
||||
if (preferences.confirm_actions.value.includes("like")) {
|
||||
const confirmation = await confirmModalService.confirm({
|
||||
title: m.odd_strong_halibut_prosper(),
|
||||
message: m.slow_blue_parrot_savor(),
|
||||
confirmText: m.vexed_fluffy_clownfish_dance(),
|
||||
inputType: "none",
|
||||
});
|
||||
|
||||
if (!confirmation.confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const id = toast.loading(m.busy_active_leopard_strive());
|
||||
const { data } = await client.value.unfavouriteStatus(noteId);
|
||||
toast.dismiss(id);
|
||||
toast.success(m.fresh_direct_bear_affirm());
|
||||
useEvent("note:edit", data);
|
||||
};
|
||||
|
||||
const reblog = async () => {
|
||||
if (preferences.confirm_actions.value.includes("reblog")) {
|
||||
const confirmation = await confirmModalService.confirm({
|
||||
title: m.best_mellow_llama_surge(),
|
||||
message: m.salty_plain_mallard_gaze(),
|
||||
confirmText: m.aware_helpful_marlin_drop(),
|
||||
inputType: "none",
|
||||
});
|
||||
|
||||
if (!confirmation.confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const id = toast.loading(m.late_sunny_cobra_scold());
|
||||
const { data } = await client.value.reblogStatus(noteId);
|
||||
toast.dismiss(id);
|
||||
toast.success(m.weird_moving_hawk_lift());
|
||||
useEvent(
|
||||
"note:edit",
|
||||
(data.reblog as z.infer<typeof Status> | null) || data,
|
||||
);
|
||||
};
|
||||
|
||||
const unreblog = async () => {
|
||||
if (preferences.confirm_actions.value.includes("reblog")) {
|
||||
const confirmation = await confirmModalService.confirm({
|
||||
title: m.main_fancy_octopus_loop(),
|
||||
message: m.odd_alive_swan_express(),
|
||||
confirmText: m.lime_neat_ox_stab(),
|
||||
inputType: "none",
|
||||
});
|
||||
|
||||
if (!confirmation.confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const id = toast.loading(m.white_sharp_gorilla_embrace());
|
||||
const { data } = await client.value.unreblogStatus(noteId);
|
||||
toast.dismiss(id);
|
||||
toast.success(m.royal_polite_moose_catch());
|
||||
useEvent("note:edit", data);
|
||||
};
|
||||
|
||||
const react = async (emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji) => {
|
||||
const id = toast.loading(m.gray_stale_antelope_roam());
|
||||
const text = (emoji as UnicodeEmoji).hexcode
|
||||
? (emoji as UnicodeEmoji).unicode
|
||||
: `:${(emoji as z.infer<typeof CustomEmoji>).shortcode}:`;
|
||||
|
||||
const { data } = await client.value.createEmojiReaction(noteId, text);
|
||||
|
||||
toast.dismiss(id);
|
||||
toast.success(m.main_least_turtle_fall());
|
||||
useEvent("note:edit", data);
|
||||
};
|
||||
|
||||
const numberFormat = (number = 0) =>
|
||||
number !== 0
|
||||
? new Intl.NumberFormat(getLocale(), {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(number)
|
||||
: undefined;
|
||||
</script>
|
||||
19
app/components/notes/attachment.vue
Normal file
19
app/components/notes/attachment.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<template>
|
||||
<ImageAttachment v-if="attachment.type === 'image'" :attachment="attachment" />
|
||||
<VideoAttachment v-else-if="attachment.type === 'video' || attachment.type === 'gifv'" :attachment="attachment" />
|
||||
<AudioAttachment v-else-if="attachment.type === 'audio'" :attachment="attachment" />
|
||||
<FileAttachment v-else :attachment="attachment" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Attachment } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import AudioAttachment from "./attachments/audio.vue";
|
||||
import FileAttachment from "./attachments/file.vue";
|
||||
import ImageAttachment from "./attachments/image.vue";
|
||||
import VideoAttachment from "./attachments/video.vue";
|
||||
|
||||
defineProps<{
|
||||
attachment: z.infer<typeof Attachment>;
|
||||
}>();
|
||||
</script>
|
||||
16
app/components/notes/attachments.vue
Normal file
16
app/components/notes/attachments.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<template>
|
||||
<!-- [&:has(>:last-child:nth-child(1))] means "when this element has 1 child" -->
|
||||
<div class="grid gap-4 grid-cols-2 *:max-h-56 [&:has(>:last-child:nth-child(1))]:grid-cols-1 sm:[&:has(>:last-child:nth-child(1))>*]:max-h-72">
|
||||
<Attachment v-for="attachment in attachments" :key="attachment.id" :attachment="attachment" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Attachment as AttachmentType } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import Attachment from "./attachment.vue";
|
||||
|
||||
defineProps<{
|
||||
attachments: z.infer<typeof AttachmentType>[];
|
||||
}>();
|
||||
</script>
|
||||
15
app/components/notes/attachments/audio.vue
Normal file
15
app/components/notes/attachments/audio.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<Base :attachment="attachment">
|
||||
<audio :src="attachment.url" :alt="attachment.description ?? undefined" controls />
|
||||
</Base>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Attachment } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import Base from "./base.vue";
|
||||
|
||||
const { attachment } = defineProps<{
|
||||
attachment: z.infer<typeof Attachment>;
|
||||
}>();
|
||||
</script>
|
||||
74
app/components/notes/attachments/base.vue
Normal file
74
app/components/notes/attachments/base.vue
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<template>
|
||||
<Dialog>
|
||||
<Card class="w-full h-full overflow-hidden relative p-0 *:first:w-full *:first:h-full *:first:object-contain *:first:bg-muted/20">
|
||||
<DialogTrigger v-if="lightbox" :as-child="true">
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
<slot v-else />
|
||||
<!-- Alt text viewer -->
|
||||
<Popover v-if="attachment.description">
|
||||
<div class="absolute top-0 right-0 p-2">
|
||||
<PopoverTrigger :as-child="true">
|
||||
<Button variant="outline" size="icon" title="View alt text">
|
||||
<Captions />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
<PopoverContent>
|
||||
<p class="text-sm">{{ attachment.description }}</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Card>
|
||||
<DialogContent :hide-close="true"
|
||||
class="duration-200 bg-transparent border-none overflow-hidden !animate-none gap-6 w-screen h-screen !max-w-none">
|
||||
<div class="grid grid-rows-[auto_1fr_auto]">
|
||||
<div class="flex flex-row gap-2 w-full">
|
||||
<DialogTitle class="sr-only">{{ attachment.type }}</DialogTitle>
|
||||
<Button as="a" :href="attachment?.url" target="_blank" :download="true" variant="outline" size="icon"
|
||||
class="ml-auto">
|
||||
<Download />
|
||||
</Button>
|
||||
<DialogClose :as-child="true">
|
||||
<Button variant="outline" size="icon">
|
||||
<X />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
<div class="flex items-center justify-center overflow-hidden *:max-h-[80vh] *:max-w-[80vw] *:w-full *:h-full *:object-contain">
|
||||
<slot />
|
||||
</div>
|
||||
<DialogDescription class="flex items-center justify-center">
|
||||
<Card v-if="attachment.description" class="max-w-md max-h-48 overflow-auto text-sm">
|
||||
<p>{{ attachment.description }}</p>
|
||||
</Card>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Attachment } from "@versia/client/schemas";
|
||||
import { Captions, Download, File, X } from "lucide-vue-next";
|
||||
import type { z } from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card } from "~/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover";
|
||||
|
||||
const { attachment, lightbox = false } = defineProps<{
|
||||
attachment: z.infer<typeof Attachment>;
|
||||
lightbox?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
19
app/components/notes/attachments/file.vue
Normal file
19
app/components/notes/attachments/file.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<template>
|
||||
<Base :attachment="attachment" lightbox>
|
||||
<div class="flex flex-col items-center justify-center min-h-48 text-sm gap-2">
|
||||
<File class="size-12" />
|
||||
<span>File attachment</span>
|
||||
</div>
|
||||
</Base>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Attachment } from "@versia/client/schemas";
|
||||
import { File } from "lucide-vue-next";
|
||||
import type { z } from "zod";
|
||||
import Base from "./base.vue";
|
||||
|
||||
const { attachment } = defineProps<{
|
||||
attachment: z.infer<typeof Attachment>;
|
||||
}>();
|
||||
</script>
|
||||
15
app/components/notes/attachments/image.vue
Normal file
15
app/components/notes/attachments/image.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<Base :attachment="attachment" lightbox>
|
||||
<img :src="attachment.url" :alt="attachment.description ?? undefined" />
|
||||
</Base>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Attachment } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import Base from "./base.vue";
|
||||
|
||||
const { attachment } = defineProps<{
|
||||
attachment: z.infer<typeof Attachment>;
|
||||
}>();
|
||||
</script>
|
||||
15
app/components/notes/attachments/video.vue
Normal file
15
app/components/notes/attachments/video.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<Base :attachment="attachment">
|
||||
<video :src="attachment.url" :alt="attachment.description ?? undefined" controls />
|
||||
</Base>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Attachment } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import Base from "./base.vue";
|
||||
|
||||
const { attachment } = defineProps<{
|
||||
attachment: z.infer<typeof Attachment>;
|
||||
}>();
|
||||
</script>
|
||||
23
app/components/notes/content-warning.vue
Normal file
23
app/components/notes/content-warning.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<template>
|
||||
<Alert layout="button">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>{{ contentWarning || m.sour_seemly_bird_hike() }}</AlertTitle>
|
||||
<Button @click="blurred = !blurred" variant="outline" size="sm">{{ blurred ? m.bald_direct_turtle_win() :
|
||||
m.known_flaky_cockroach_dash() }}</Button>
|
||||
</Alert>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { TriangleAlert } from "lucide-vue-next";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const { contentWarning } = defineProps<{
|
||||
contentWarning?: string;
|
||||
}>();
|
||||
|
||||
const blurred = defineModel<boolean>({
|
||||
default: true,
|
||||
});
|
||||
</script>
|
||||
37
app/components/notes/content.vue
Normal file
37
app/components/notes/content.vue
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<template>
|
||||
<ContentWarning v-if="(sensitive || contentWarning) && preferences.show_content_warning" :content-warning="contentWarning" v-model="blurred" />
|
||||
|
||||
<OverflowGuard v-if="content" :character-count="characterCount" :class="(blurred && preferences.show_content_warning) && 'blur-md'">
|
||||
<Prose v-html="content" v-render-emojis="emojis"></Prose>
|
||||
</OverflowGuard>
|
||||
|
||||
<Attachments v-if="attachments.length > 0" :attachments="attachments" :class="(blurred && preferences.show_content_warning) && 'blur-xl'" />
|
||||
|
||||
<div v-if="quote" class="mt-4 rounded border overflow-hidden">
|
||||
<Note :note="quote" :hide-actions="true" :small-layout="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Attachment, CustomEmoji, Status } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import Attachments from "./attachments.vue";
|
||||
import ContentWarning from "./content-warning.vue";
|
||||
import Note from "./note.vue";
|
||||
import OverflowGuard from "./overflow-guard.vue";
|
||||
import Prose from "./prose.vue";
|
||||
|
||||
const { content, plainContent, sensitive, contentWarning } = defineProps<{
|
||||
plainContent?: string;
|
||||
content: string;
|
||||
quote?: NonNullable<z.infer<typeof Status.shape.quote>>;
|
||||
emojis: z.infer<typeof CustomEmoji>[];
|
||||
attachments: z.infer<typeof Attachment>[];
|
||||
sensitive: boolean;
|
||||
contentWarning?: string;
|
||||
}>();
|
||||
|
||||
const blurred = ref(sensitive || !!contentWarning);
|
||||
|
||||
const characterCount = plainContent?.length;
|
||||
</script>
|
||||
113
app/components/notes/header.vue
Normal file
113
app/components/notes/header.vue
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<template>
|
||||
<div class="rounded grid grid-cols-[auto_1fr_auto] items-center gap-3">
|
||||
<HoverCard v-model:open="popupOpen" @update:open="() => {
|
||||
if (!preferences.popup_avatar_hover) {
|
||||
popupOpen = false;
|
||||
}
|
||||
}" :open-delay="2000">
|
||||
<HoverCardTrigger :as-child="true">
|
||||
<NuxtLink :href="urlAsPath" :class="cn('relative size-12', smallLayout && 'size-8')">
|
||||
<Avatar :class="cn('size-12 border border-card', smallLayout && 'size-8')" :src="author.avatar"
|
||||
:name="author.display_name" />
|
||||
<Avatar v-if="cornerAvatar" class="size-6 border absolute -bottom-1 -right-1" :src="cornerAvatar" />
|
||||
</NuxtLink>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent class="w-96">
|
||||
<SmallCard :account="author" />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<Col
|
||||
:class="smallLayout && 'text-sm'">
|
||||
<Text class="font-semibold" v-render-emojis="author.emojis">{{
|
||||
author.display_name
|
||||
}}</Text>
|
||||
<div class="-mt-1">
|
||||
<Address as="span" :username="username" :domain="instance" />
|
||||
·
|
||||
<Text as="span" muted class="ml-auto tracking-normal" :title="fullTime">{{ timeAgo }}</Text>
|
||||
</div>
|
||||
</Col>
|
||||
<div v-if="!smallLayout">
|
||||
<NuxtLink :href="noteUrlAsPath" class="text-xs text-muted-foreground"
|
||||
:title="visibilities[visibility].text">
|
||||
<component :is="visibilities[visibility].icon" class="size-4" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Account, Status } from "@versia/client/schemas";
|
||||
import type {
|
||||
UseTimeAgoMessages,
|
||||
UseTimeAgoUnitNamesDefault,
|
||||
} from "@vueuse/core";
|
||||
import { AtSign, Globe, Lock, LockOpen } from "lucide-vue-next";
|
||||
import type { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getLocale } from "~~/paraglide/runtime";
|
||||
import Address from "../profiles/address.vue";
|
||||
import Avatar from "../profiles/avatar.vue";
|
||||
import SmallCard from "../profiles/small-card.vue";
|
||||
import Col from "../typography/layout/col.vue";
|
||||
import Text from "../typography/text.vue";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "../ui/hover-card";
|
||||
|
||||
const { createdAt, noteUrl, author, authorUrl } = defineProps<{
|
||||
cornerAvatar?: string;
|
||||
visibility: z.infer<typeof Status.shape.visibility>;
|
||||
noteUrl: string;
|
||||
createdAt: Date;
|
||||
smallLayout?: boolean;
|
||||
author: z.infer<typeof Account>;
|
||||
authorUrl: string;
|
||||
}>();
|
||||
|
||||
const [username, instance] = author.acct.split("@");
|
||||
const digitRegex = /\d/;
|
||||
const urlAsPath = new URL(authorUrl).pathname;
|
||||
const noteUrlAsPath = new URL(noteUrl).pathname;
|
||||
const timeAgo = useTimeAgo(createdAt, {
|
||||
messages: {
|
||||
justNow: "now",
|
||||
past: (n) => (n.match(digitRegex) ? `${n}` : n),
|
||||
future: (n) => (n.match(digitRegex) ? `in ${n}` : n),
|
||||
month: (n) => `${n}mo`,
|
||||
year: (n) => `${n}y`,
|
||||
day: (n) => `${n}d`,
|
||||
week: (n) => `${n}w`,
|
||||
hour: (n) => `${n}h`,
|
||||
minute: (n) => `${n}m`,
|
||||
second: (n) => `${n}s`,
|
||||
invalid: "",
|
||||
} as UseTimeAgoMessages<UseTimeAgoUnitNamesDefault>,
|
||||
});
|
||||
const fullTime = new Intl.DateTimeFormat(getLocale(), {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(createdAt);
|
||||
const popupOpen = ref(false);
|
||||
|
||||
const visibilities = {
|
||||
public: {
|
||||
icon: Globe,
|
||||
text: "This note is public: it can be seen by anyone.",
|
||||
},
|
||||
unlisted: {
|
||||
icon: LockOpen,
|
||||
text: "This note is unlisted: it can be seen by anyone with the link.",
|
||||
},
|
||||
private: {
|
||||
icon: Lock,
|
||||
text: "This note is private: it can only be seen by followers.",
|
||||
},
|
||||
direct: {
|
||||
icon: AtSign,
|
||||
text: "This note is direct: it can only be seen by mentioned users.",
|
||||
},
|
||||
};
|
||||
</script>
|
||||
138
app/components/notes/menu.vue
Normal file
138
app/components/notes/menu.vue
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<script setup lang="tsx">
|
||||
import {
|
||||
Ban,
|
||||
Code,
|
||||
Delete,
|
||||
ExternalLink,
|
||||
Flag,
|
||||
Hash,
|
||||
Link,
|
||||
Pencil,
|
||||
Trash,
|
||||
} from "lucide-vue-next";
|
||||
import { toast } from "vue-sonner";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { confirmModalService } from "~/components/modals/composable.ts";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
|
||||
const { authorId, noteId } = defineProps<{
|
||||
apiNoteString: string;
|
||||
isRemote: boolean;
|
||||
url: string;
|
||||
remoteUrl?: string;
|
||||
authorId: string;
|
||||
noteId: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [];
|
||||
delete: [];
|
||||
}>();
|
||||
|
||||
const { copy } = useClipboard();
|
||||
const loggedIn = !!identity.value;
|
||||
const authorIsMe = loggedIn && authorId === identity.value?.account.id;
|
||||
|
||||
const copyText = (text: string) => {
|
||||
copy(text);
|
||||
toast.success(m.flat_nice_worm_dream());
|
||||
};
|
||||
|
||||
const blockUser = async (userId: string) => {
|
||||
const id = toast.loading(m.top_cute_bison_nudge());
|
||||
await client.value.blockAccount(userId);
|
||||
toast.dismiss(id);
|
||||
|
||||
toast.success(m.main_weary_racoon_peek());
|
||||
};
|
||||
|
||||
const _delete = async () => {
|
||||
if (preferences.confirm_actions.value.includes("delete")) {
|
||||
const confirmation = await confirmModalService.confirm({
|
||||
title: m.calm_icy_weasel_twirl(),
|
||||
message: m.gray_fun_toucan_slide(),
|
||||
confirmText: m.royal_best_tern_transform(),
|
||||
inputType: "none",
|
||||
});
|
||||
|
||||
if (!confirmation.confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const id = toast.loading(m.new_funny_fox_boil());
|
||||
await client.value.deleteStatus(noteId);
|
||||
toast.dismiss(id);
|
||||
|
||||
toast.success(m.green_tasty_bumblebee_beam());
|
||||
emit("delete");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="min-w-56">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem v-if="authorIsMe" as="button" @click="emit('edit')">
|
||||
<Pencil />
|
||||
{{ m.front_lime_grizzly_persist() }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem as="button" @click="copyText(apiNoteString)">
|
||||
<Code />
|
||||
{{ m.yummy_moving_scallop_sail() }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem as="button" @click="copyText(noteId)">
|
||||
<Hash />
|
||||
{{ m.sunny_zany_jellyfish_pop() }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem as="button" @click="copyText(url)">
|
||||
<Link />
|
||||
{{ m.ago_new_pelican_drip() }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem as="button" v-if="isRemote && remoteUrl" @click="copyText(remoteUrl)">
|
||||
<Link />
|
||||
{{ m.solid_witty_zebra_walk() }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem as="a" v-if="isRemote" target="_blank" rel="noopener noreferrer" :href="remoteUrl">
|
||||
<ExternalLink />
|
||||
{{ m.active_trite_lark_inspire() }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator v-if="authorIsMe" />
|
||||
<DropdownMenuGroup v-if="authorIsMe">
|
||||
<DropdownMenuItem as="button" :disabled="true">
|
||||
<Delete />
|
||||
{{ m.real_green_clownfish_pet() }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem as="button" @click="_delete">
|
||||
<Trash />
|
||||
{{ m.tense_quick_cod_favor() }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator v-if="loggedIn && !authorIsMe" />
|
||||
<DropdownMenuGroup v-if="loggedIn && !authorIsMe">
|
||||
<DropdownMenuItem as="button" :disabled="true">
|
||||
<Flag />
|
||||
{{ m.great_few_jaguar_rise() }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem as="button" @click="blockUser(authorId)">
|
||||
<Ban />
|
||||
{{ m.misty_soft_sparrow_vent() }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
110
app/components/notes/note.vue
Normal file
110
app/components/notes/note.vue
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<template>
|
||||
<Card as="article" class="relative gap-3 items-stretch">
|
||||
<CardHeader as="header" class="space-y-2">
|
||||
<ReblogHeader
|
||||
v-if="note.reblog"
|
||||
:avatar="note.account.avatar"
|
||||
:display-name="note.account.display_name"
|
||||
:url="reblogAccountUrl"
|
||||
:emojis="note.account.emojis"
|
||||
/>
|
||||
<Header
|
||||
:author="noteToUse.account"
|
||||
:author-url="accountUrl"
|
||||
:corner-avatar="note.reblog ? note.account.avatar : undefined"
|
||||
:note-url="url"
|
||||
:visibility="noteToUse.visibility"
|
||||
:created-at="new Date(noteToUse.created_at)"
|
||||
:small-layout="smallLayout"
|
||||
class="z-[1]"
|
||||
/>
|
||||
<div
|
||||
v-if="topAvatarBar"
|
||||
:class="
|
||||
cn(
|
||||
'shrink-0 bg-border w-0.5 absolute top-0 h-7 left-[3rem]'
|
||||
)
|
||||
"
|
||||
></div>
|
||||
<div
|
||||
v-if="bottomAvatarBar"
|
||||
:class="
|
||||
cn(
|
||||
'shrink-0 bg-border w-0.5 absolute bottom-0 h-[calc(100%-1.5rem)] left-[3rem]'
|
||||
)
|
||||
"
|
||||
></div>
|
||||
</CardHeader>
|
||||
<!-- Simply offset by the size of avatar + 0.75rem (the gap) -->
|
||||
<CardContent
|
||||
:class="
|
||||
['space-y-4', contentUnderUsername && (smallLayout ? 'ml-11' : 'ml-[4.25rem]')]
|
||||
"
|
||||
>
|
||||
<Content
|
||||
:content="noteToUse.content"
|
||||
:quote="note.quote ?? undefined"
|
||||
:attachments="noteToUse.media_attachments"
|
||||
:plain-content="noteToUse.text ?? undefined"
|
||||
:emojis="noteToUse.emojis"
|
||||
:sensitive="noteToUse.sensitive"
|
||||
:content-warning="noteToUse.spoiler_text"
|
||||
/>
|
||||
<Reactions v-if="noteToUse.reactions && noteToUse.reactions.length > 0" :reactions="noteToUse.reactions" :emojis="noteToUse.emojis" :status-id="noteToUse.id" />
|
||||
</CardContent>
|
||||
<CardFooter v-if="!hideActions">
|
||||
<Actions
|
||||
:reply-count="noteToUse.replies_count"
|
||||
:like-count="noteToUse.favourites_count"
|
||||
:url="url"
|
||||
:api-note-string="JSON.stringify(noteToUse, null, 4)"
|
||||
:reblog-count="noteToUse.reblogs_count"
|
||||
:remote-url="noteToUse.url ?? undefined"
|
||||
:is-remote="isRemote"
|
||||
:author-id="noteToUse.account.id"
|
||||
@edit="useEvent('composer:edit', noteToUse)"
|
||||
@reply="useEvent('composer:reply', noteToUse)"
|
||||
@quote="useEvent('composer:quote', noteToUse)"
|
||||
@delete="useEvent('note:delete', noteToUse)"
|
||||
:note-id="noteToUse.id"
|
||||
:liked="noteToUse.favourited ?? false"
|
||||
:reblogged="noteToUse.reblogged ?? false"
|
||||
/>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Status } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "../ui/card";
|
||||
import Actions from "./actions.vue";
|
||||
import Content from "./content.vue";
|
||||
import Header from "./header.vue";
|
||||
import Reactions from "./reactions/index.vue";
|
||||
import ReblogHeader from "./reblog-header.vue";
|
||||
|
||||
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||
|
||||
const { note } = defineProps<{
|
||||
note: PartialBy<z.infer<typeof Status>, "reblog" | "quote">;
|
||||
hideActions?: boolean;
|
||||
smallLayout?: boolean;
|
||||
contentUnderUsername?: boolean;
|
||||
topAvatarBar?: boolean;
|
||||
bottomAvatarBar?: boolean;
|
||||
}>();
|
||||
|
||||
// Notes can be reblogs, in which case the actual thing to render is inside the reblog property
|
||||
const noteToUse = computed(() =>
|
||||
note.reblog
|
||||
? (note.reblog as z.infer<typeof Status>)
|
||||
: (note as z.infer<typeof Status>),
|
||||
);
|
||||
|
||||
const url = wrapUrl(`/@${noteToUse.value.account.acct}/${noteToUse.value.id}`);
|
||||
const accountUrl = wrapUrl(`/@${noteToUse.value.account.acct}`);
|
||||
const reblogAccountUrl = wrapUrl(`/@${note.account.acct}`);
|
||||
const isRemote = noteToUse.value.account.acct.includes("@");
|
||||
</script>
|
||||
49
app/components/notes/overflow-guard.vue
Normal file
49
app/components/notes/overflow-guard.vue
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<template>
|
||||
<div ref="container" class="overflow-y-hidden relative duration-200" :style="{
|
||||
maxHeight: collapsed ? '18rem' : `${container?.scrollHeight}px`,
|
||||
}">
|
||||
<slot />
|
||||
<div v-if="isOverflowing && collapsed"
|
||||
class="absolute inset-x-0 bottom-0 h-36 bg-gradient-to-t from-black/5 to-transparent rounded-b"></div>
|
||||
<Button v-if="isOverflowing" @click="collapsed = !collapsed"
|
||||
class="absolute bottom-2 right-1/2 translate-x-1/2">{{
|
||||
collapsed
|
||||
? `${m.lazy_honest_mammoth_bump()}${formattedCharacterCount ? ` • ${m.dark_spare_goldfish_charm({
|
||||
count: formattedCharacterCount,
|
||||
})}` : ""}`
|
||||
: m.that_misty_mule_arrive()
|
||||
}}</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
import { getLocale } from "~~/paraglide/runtime";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const { characterCount = 0 } = defineProps<{
|
||||
characterCount?: number;
|
||||
}>();
|
||||
|
||||
const container = useTemplateRef<HTMLDivElement>("container");
|
||||
const collapsed = ref(true);
|
||||
|
||||
// max-h-72 is 18rem
|
||||
const remToPx = (rem: number) =>
|
||||
rem *
|
||||
Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).fontSize || "16px",
|
||||
);
|
||||
|
||||
const isOverflowing = computed(() => {
|
||||
if (!container.value) {
|
||||
return false;
|
||||
}
|
||||
return container.value.scrollHeight > remToPx(18);
|
||||
});
|
||||
|
||||
const formattedCharacterCount =
|
||||
characterCount > 0
|
||||
? new Intl.NumberFormat(getLocale()).format(characterCount)
|
||||
: undefined;
|
||||
</script>
|
||||
12
app/components/notes/prose.vue
Normal file
12
app/components/notes/prose.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<template>
|
||||
<div :class="[
|
||||
'prose prose-sm block relative dark:prose-invert duration-200 !max-w-full break-words prose-a:no-underline hover:prose-a:underline',
|
||||
$style.content,
|
||||
]">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
@import "~/styles/content.css";
|
||||
</style>
|
||||
17
app/components/notes/reactions/index.vue
Normal file
17
app/components/notes/reactions/index.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<template>
|
||||
<div class="flex flex-row gap-2 flex-wrap">
|
||||
<Reaction v-for="reaction in reactions" :key="reaction.name" :reaction="reaction" :emoji="emojis.find(e => `:${e.shortcode}:` === reaction.name)" :status-id="statusId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { CustomEmoji, NoteReaction } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import Reaction from "./reaction.vue";
|
||||
|
||||
const { statusId, reactions, emojis } = defineProps<{
|
||||
statusId: string;
|
||||
reactions: z.infer<typeof NoteReaction>[];
|
||||
emojis: z.infer<typeof CustomEmoji>[];
|
||||
}>();
|
||||
</script>
|
||||
15
app/components/notes/reactions/picker/category-header.vue
Normal file
15
app/components/notes/reactions/picker/category-header.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<div class="sticky top-2 z-10 flex items-center justify-center p-2">
|
||||
<Badge variant="secondary">
|
||||
{{ categoryName }}
|
||||
</Badge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
|
||||
const { categoryName } = defineProps<{
|
||||
categoryName: string;
|
||||
}>();
|
||||
</script>
|
||||
21
app/components/notes/reactions/picker/display.vue
Normal file
21
app/components/notes/reactions/picker/display.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<template>
|
||||
<div class="p-2 text-sm font-semibold border-0 rounded-none text-center flex flex-row items-center gap-2 truncate">
|
||||
<img v-if="(emoji as InferredEmoji)?.url" :src="(emoji as InferredEmoji)?.url"
|
||||
:alt="(emoji as InferredEmoji)?.shortcode" class="h-8 align-middle inline not-prose" />
|
||||
<span v-else-if="(emoji as UnicodeEmoji)?.unicode" class="text-2xl align-middle inline not-prose">
|
||||
{{ (emoji as UnicodeEmoji)?.unicode }}
|
||||
</span>
|
||||
{{ (emoji as InferredEmoji)?.shortcode || (emoji as UnicodeEmoji)?.shortcode }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { CustomEmoji } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import type { UnicodeEmoji } from "./emoji.ts";
|
||||
|
||||
type InferredEmoji = z.infer<typeof CustomEmoji>;
|
||||
const { emoji } = defineProps<{
|
||||
emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji | null;
|
||||
}>();
|
||||
</script>
|
||||
140
app/components/notes/reactions/picker/emoji.ts
Normal file
140
app/components/notes/reactions/picker/emoji.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* Adapted from Cinny's code
|
||||
* @see https://github.com/cinnyapp/cinny/blob/e6f4eeca8edc85ab64179e545b4e2e8c15763633/src/app/plugins/emoji.ts
|
||||
*/
|
||||
|
||||
import type { CompactEmoji } from "emojibase";
|
||||
import emojisData from "emojibase-data/en/compact.json";
|
||||
import joypixels from "emojibase-data/en/shortcodes/joypixels.json";
|
||||
|
||||
export type UnicodeEmoji = CompactEmoji & {
|
||||
shortcode: string;
|
||||
};
|
||||
|
||||
export enum EmojiGroupId {
|
||||
People = "People",
|
||||
Nature = "Nature",
|
||||
Food = "Food",
|
||||
Activity = "Activity",
|
||||
Travel = "Travel",
|
||||
Object = "Object",
|
||||
Symbol = "Symbol",
|
||||
Flag = "Flag",
|
||||
}
|
||||
|
||||
export type UnicodeEmojiGroup = {
|
||||
id: EmojiGroupId;
|
||||
order: number;
|
||||
emojis: UnicodeEmoji[];
|
||||
};
|
||||
|
||||
export const getShortcodesFor = (
|
||||
hexcode: string,
|
||||
): string[] | string | undefined => joypixels[hexcode];
|
||||
|
||||
export const getShortcodeFor = (hexcode: string): string | undefined => {
|
||||
const shortcode = joypixels[hexcode];
|
||||
return Array.isArray(shortcode) ? shortcode[0] : shortcode;
|
||||
};
|
||||
|
||||
export const emojiGroups: UnicodeEmojiGroup[] = [
|
||||
{
|
||||
id: EmojiGroupId.People,
|
||||
order: 0,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Nature,
|
||||
order: 1,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Food,
|
||||
order: 2,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Activity,
|
||||
order: 3,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Travel,
|
||||
order: 4,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Object,
|
||||
order: 5,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Symbol,
|
||||
order: 6,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Flag,
|
||||
order: 7,
|
||||
emojis: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const emojis: UnicodeEmoji[] = [];
|
||||
|
||||
function addEmojiToGroup(groupIndex: number, emoji: UnicodeEmoji) {
|
||||
emojiGroups[groupIndex]?.emojis.push(emoji);
|
||||
}
|
||||
|
||||
function getGroupIndex(emoji: UnicodeEmoji): number | undefined {
|
||||
switch (emoji.group) {
|
||||
case 0:
|
||||
case 1:
|
||||
return 0; // People
|
||||
case 3:
|
||||
return 1; // Nature
|
||||
case 4:
|
||||
return 2; // Food
|
||||
case 6:
|
||||
return 3; // Activity
|
||||
case 5:
|
||||
return 4; // Travel
|
||||
case 7:
|
||||
return 5; // Object
|
||||
case 8:
|
||||
case undefined:
|
||||
return 6; // Symbol
|
||||
case 9:
|
||||
return 7; // Flag
|
||||
default:
|
||||
return undefined; // Unknown group
|
||||
}
|
||||
}
|
||||
|
||||
for (const emoji of emojisData) {
|
||||
const myShortCodes = getShortcodesFor(emoji.hexcode);
|
||||
|
||||
if (!myShortCodes) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(myShortCodes) && myShortCodes.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const em: UnicodeEmoji = {
|
||||
...emoji,
|
||||
shortcode: Array.isArray(myShortCodes)
|
||||
? (myShortCodes[0] as string)
|
||||
: myShortCodes,
|
||||
shortcodes: Array.isArray(myShortCodes)
|
||||
? myShortCodes
|
||||
: emoji.shortcodes,
|
||||
};
|
||||
|
||||
const groupIndex = getGroupIndex(em);
|
||||
|
||||
if (groupIndex !== undefined) {
|
||||
addEmojiToGroup(groupIndex, em);
|
||||
emojis.push(em);
|
||||
}
|
||||
}
|
||||
27
app/components/notes/reactions/picker/emoji.vue
Normal file
27
app/components/notes/reactions/picker/emoji.vue
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<template>
|
||||
<Button @focus="() => emit('select', emoji)" @mouseenter="() => emit('select', emoji)" @click="() => emit('pick', emoji)" size="icon" variant="ghost"
|
||||
class="size-12">
|
||||
<img v-if="(emoji as InferredEmoji).url" :src="(emoji as InferredEmoji).url"
|
||||
:alt="(emoji as InferredEmoji).shortcode" class="h-8 align-middle inline not-prose" />
|
||||
<span v-else-if="(emoji as UnicodeEmoji).unicode" class="text-2xl align-middle inline not-prose">
|
||||
{{ (emoji as UnicodeEmoji).unicode }}
|
||||
</span>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { CustomEmoji } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import type { UnicodeEmoji } from "./emoji";
|
||||
|
||||
const { emoji } = defineProps<{
|
||||
emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji;
|
||||
}>();
|
||||
type InferredEmoji = z.infer<typeof CustomEmoji>;
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji];
|
||||
pick: [emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji];
|
||||
}>();
|
||||
</script>
|
||||
138
app/components/notes/reactions/picker/index.vue
Normal file
138
app/components/notes/reactions/picker/index.vue
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<slot />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0 w-fit">
|
||||
<div class="grid-cols-[minmax(0,1fr)_auto] gap-0 grid divide-x *:h-112 *:overflow-y-auto"
|
||||
orientation="vertical">
|
||||
<div class="grid grid-rows-[auto_minmax(0,1fr)_auto] gap-0" ref="emojiContainer">
|
||||
<div class="p-2">
|
||||
<Input placeholder="Search" v-model="filter" />
|
||||
</div>
|
||||
<VList :data="virtualizedItems" #default="{ item }" class="relative" :style="{
|
||||
width: `calc(var(--spacing) * ((12 * ${EMOJI_PER_ROW}) + (${EMOJI_PER_ROW} - 1)) + var(--spacing) * 4)`,
|
||||
}">
|
||||
<CategoryHeader :key="item.headerId" v-if="item.type === 'header'" :category-name="item.name" />
|
||||
<div v-else-if="item.type === 'emoji-row'" :key="item.rowId" class="flex gap-1 p-2">
|
||||
<Emoji v-for="emoji in item.emojis" :key="getEmojiKey(emoji)" :emoji="emoji"
|
||||
@select="(e) => selectedEmoji = e" @pick="e => {
|
||||
emit('pick', e); open = false;
|
||||
}" />
|
||||
</div>
|
||||
</VList>
|
||||
<EmojiDisplay :emoji="selectedEmoji" :style="{
|
||||
width: `calc(var(--spacing) * ((12 * ${EMOJI_PER_ROW}) + (${EMOJI_PER_ROW} - 1)) + var(--spacing) * 4)`,
|
||||
}" />
|
||||
</div>
|
||||
<Sidebar :categories="categories" @select="scrollToCategory" />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { CustomEmoji } from "@versia/client/schemas";
|
||||
import { VList } from "virtua/vue";
|
||||
import type { z } from "zod";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover";
|
||||
import CategoryHeader from "./category-header.vue";
|
||||
import EmojiDisplay from "./display.vue";
|
||||
import { type EmojiGroupId, emojiGroups, type UnicodeEmoji } from "./emoji.ts";
|
||||
import Emoji from "./emoji.vue";
|
||||
import Sidebar from "./sidebar.vue";
|
||||
import { EMOJI_PER_ROW, getVirtualizedItems } from "./virtual.ts";
|
||||
|
||||
const emit = defineEmits<{
|
||||
pick: [emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji];
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
const selectedEmoji = ref<z.infer<typeof CustomEmoji> | UnicodeEmoji | null>(
|
||||
null,
|
||||
);
|
||||
const emojiContainer = useTemplateRef<HTMLDivElement>("emojiContainer");
|
||||
const filter = ref("");
|
||||
|
||||
const customEmojis = computed(() => identity.value?.emojis ?? []);
|
||||
|
||||
const customEmojiCategories = computed(() => {
|
||||
const categories: Record<string, z.infer<typeof CustomEmoji>[]> = {};
|
||||
|
||||
for (const emoji of customEmojis.value) {
|
||||
const categoryName = emoji.category || "Uncategorized";
|
||||
|
||||
if (!categories[categoryName]) {
|
||||
categories[categoryName] = [];
|
||||
}
|
||||
|
||||
categories[categoryName]?.push(emoji);
|
||||
}
|
||||
|
||||
return categories;
|
||||
});
|
||||
|
||||
const categories = computed(() => {
|
||||
const customCategories = Object.entries(customEmojiCategories.value).map(
|
||||
([name, emojis]) => ({
|
||||
name,
|
||||
src: (emojis[0]?.url as string) || "",
|
||||
}),
|
||||
);
|
||||
|
||||
const groupCategories = emojiGroups.map((group) => ({
|
||||
name: group.id,
|
||||
groupId: group.id,
|
||||
}));
|
||||
|
||||
return [...customCategories, ...groupCategories];
|
||||
});
|
||||
|
||||
const virtualizedItems = computed(() =>
|
||||
getVirtualizedItems(customEmojiCategories.value, filter.value),
|
||||
);
|
||||
|
||||
const getEmojiKey = (emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji) => {
|
||||
if ("url" in emoji) {
|
||||
return `custom-${emoji.shortcode}`;
|
||||
}
|
||||
return `unicode-${emoji.shortcode}`;
|
||||
};
|
||||
|
||||
const scrollToCategory = (category: {
|
||||
name: string;
|
||||
groupId?: EmojiGroupId;
|
||||
src?: string;
|
||||
}) => {
|
||||
const categoryId = category.groupId || `custom-${category.name}`;
|
||||
const headerIndex = virtualizedItems.value.findIndex(
|
||||
(item) => item.type === "header" && item.categoryId === categoryId,
|
||||
);
|
||||
|
||||
const child = emojiContainer.value?.children[1];
|
||||
|
||||
if (headerIndex !== -1 && child) {
|
||||
// Estimate scroll position based on item heights
|
||||
// Headers are approximately 38px, emoji rows are approximately 64px
|
||||
let scrollTop = 0;
|
||||
for (let i = 0; i < headerIndex; i++) {
|
||||
const item = virtualizedItems.value[i];
|
||||
if (item?.type === "header") {
|
||||
scrollTop += 38;
|
||||
} else if (item?.type === "emoji-row") {
|
||||
scrollTop += 64;
|
||||
}
|
||||
}
|
||||
|
||||
child.scrollTo({
|
||||
top: scrollTop,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
47
app/components/notes/reactions/picker/sidebar.vue
Normal file
47
app/components/notes/reactions/picker/sidebar.vue
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<template>
|
||||
<div class="grid gap-1 bg-transparent p-2">
|
||||
<Button v-for="category in categories" :key="category.name" size="icon" variant="ghost" @click="() => emit('select', category)">
|
||||
<component v-if="category.groupId" :is="emojiGroupIconMap[category.groupId]" class="size-6 text-primary" />
|
||||
<img v-else-if="category.src" :src="category.src" class="size-6 align-middle inline not-prose" role="presentation" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
Box,
|
||||
CarFront,
|
||||
Flag,
|
||||
Leaf,
|
||||
Percent,
|
||||
Pizza,
|
||||
Smile,
|
||||
Volleyball,
|
||||
} from "lucide-vue-next";
|
||||
import type { FunctionalComponent } from "vue";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { EmojiGroupId } from "./emoji";
|
||||
|
||||
const { categories } = defineProps<{
|
||||
categories: {
|
||||
name: string;
|
||||
groupId?: EmojiGroupId;
|
||||
src?: string;
|
||||
}[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [category: { name: string; groupId?: EmojiGroupId; src?: string }];
|
||||
}>();
|
||||
|
||||
const emojiGroupIconMap: Record<EmojiGroupId, FunctionalComponent> = {
|
||||
[EmojiGroupId.People]: Smile,
|
||||
[EmojiGroupId.Nature]: Leaf,
|
||||
[EmojiGroupId.Food]: Pizza,
|
||||
[EmojiGroupId.Activity]: Volleyball,
|
||||
[EmojiGroupId.Travel]: CarFront,
|
||||
[EmojiGroupId.Object]: Box,
|
||||
[EmojiGroupId.Symbol]: Percent,
|
||||
[EmojiGroupId.Flag]: Flag,
|
||||
};
|
||||
</script>
|
||||
117
app/components/notes/reactions/picker/virtual.ts
Normal file
117
app/components/notes/reactions/picker/virtual.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import type { CustomEmoji } from "@versia/client/schemas";
|
||||
import { go } from "fuzzysort";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { z } from "zod";
|
||||
import { emojiGroups, type UnicodeEmoji } from "./emoji";
|
||||
|
||||
export const EMOJI_PER_ROW = 7;
|
||||
export type VirtualizedItem =
|
||||
| { headerId: string; type: "header"; name: string; categoryId: string }
|
||||
| {
|
||||
rowId: string;
|
||||
type: "emoji-row";
|
||||
emojis: (z.infer<typeof CustomEmoji> | UnicodeEmoji)[];
|
||||
};
|
||||
|
||||
export const getVirtualizedItems = (
|
||||
customCategories: Record<string, z.infer<typeof CustomEmoji>[]>,
|
||||
searchQuery?: string,
|
||||
): VirtualizedItem[] => {
|
||||
const items: VirtualizedItem[] = [];
|
||||
|
||||
// Add custom emoji categories first
|
||||
for (const [categoryName, categoryEmojis] of Object.entries(
|
||||
customCategories,
|
||||
)) {
|
||||
// Add category header
|
||||
items.push({
|
||||
headerId: nanoid(),
|
||||
type: "header",
|
||||
name: categoryName,
|
||||
categoryId: `custom-${categoryName}`,
|
||||
});
|
||||
|
||||
// Add emoji rows for this category
|
||||
for (let i = 0; i < categoryEmojis.length; i += EMOJI_PER_ROW) {
|
||||
items.push({
|
||||
rowId: nanoid(),
|
||||
type: "emoji-row",
|
||||
emojis: categoryEmojis.slice(i, i + EMOJI_PER_ROW),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add unicode emoji groups
|
||||
for (const group of emojiGroups) {
|
||||
if (group.emojis.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add group header
|
||||
items.push({
|
||||
headerId: nanoid(),
|
||||
type: "header",
|
||||
name: group.id,
|
||||
categoryId: group.id,
|
||||
});
|
||||
|
||||
// Add emoji rows for this group
|
||||
for (let i = 0; i < group.emojis.length; i += EMOJI_PER_ROW) {
|
||||
items.push({
|
||||
rowId: nanoid(),
|
||||
type: "emoji-row",
|
||||
emojis: group.emojis.slice(i, i + EMOJI_PER_ROW),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If search query is provided, add extra category for search results
|
||||
// with emojis that contain the search query in their shortcode
|
||||
// ordered with fuzzysort
|
||||
if (searchQuery) {
|
||||
const customEmojiMatches = Object.values(customCategories)
|
||||
.flat()
|
||||
.filter((emoji) =>
|
||||
emoji.shortcode
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
const unicodeEmojiMatches = emojiGroups
|
||||
.flatMap((group) => group.emojis)
|
||||
.filter((emoji) =>
|
||||
emoji.shortcode
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
const results = go(
|
||||
searchQuery,
|
||||
[...customEmojiMatches, ...unicodeEmojiMatches],
|
||||
{
|
||||
key: "shortcode",
|
||||
limit: 20,
|
||||
},
|
||||
);
|
||||
|
||||
items.splice(0, 0, {
|
||||
headerId: nanoid(),
|
||||
type: "header",
|
||||
name: "Search Results",
|
||||
categoryId: "search-results",
|
||||
});
|
||||
|
||||
for (let i = 0; i < results.length; i += EMOJI_PER_ROW) {
|
||||
const emojis = results
|
||||
.slice(i, i + EMOJI_PER_ROW)
|
||||
.map((result) => result.obj);
|
||||
|
||||
items.splice(1 + i / EMOJI_PER_ROW, 0, {
|
||||
rowId: nanoid(),
|
||||
type: "emoji-row",
|
||||
emojis,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
101
app/components/notes/reactions/reaction.vue
Normal file
101
app/components/notes/reactions/reaction.vue
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<template>
|
||||
<HoverCard @update:open="(open) => open && accounts === null && refreshReactions()">
|
||||
<HoverCardTrigger as-child>
|
||||
<Button @click="reaction.me ? !reaction.remote && unreact() : !reaction.remote && react()" :variant="reaction.me ? 'secondary' : reaction.remote ? 'ghost' : 'outline'" size="sm" class="gap-2">
|
||||
<img v-if="emoji" :src="emoji.url" :alt="emoji.shortcode"
|
||||
class="h-[1lh] align-middle inline not-prose" />
|
||||
<span v-else>
|
||||
{{ reaction.name }}
|
||||
</span>
|
||||
{{ formatNumber(reaction.count) }}
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent class="p-3">
|
||||
<Spinner v-if="accounts === null" class="border-0" />
|
||||
<ul v-else class="flex flex-col gap-4">
|
||||
<li
|
||||
v-for="account in accounts">
|
||||
<NuxtLink :to="`/@${account.acct}`" class="flex items-center gap-2">
|
||||
<Avatar class="size-6" :key="account.id" :src="account.avatar"
|
||||
:name="account.display_name || account.username" />
|
||||
<span class="text-sm font-semibold line-clamp-1">
|
||||
{{ account.display_name || account.username }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
Account,
|
||||
CustomEmoji,
|
||||
NoteReaction,
|
||||
} from "@versia/client/schemas";
|
||||
import { toast } from "vue-sonner";
|
||||
import type { z } from "zod";
|
||||
import Spinner from "~/components/graphics/spinner.vue";
|
||||
import Avatar from "~/components/profiles/avatar.vue";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "~/components/ui/hover-card";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
import { getLocale } from "~~/paraglide/runtime.js";
|
||||
|
||||
const { reaction, emoji, statusId } = defineProps<{
|
||||
statusId: string;
|
||||
reaction: z.infer<typeof NoteReaction>;
|
||||
emoji?: z.infer<typeof CustomEmoji>;
|
||||
}>();
|
||||
|
||||
const formatNumber = (number: number) =>
|
||||
new Intl.NumberFormat(getLocale(), {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(number);
|
||||
|
||||
const accounts = ref<z.infer<typeof Account>[] | null>(null);
|
||||
|
||||
const refreshReactions = async () => {
|
||||
const { data } = await client.value.getStatusReactions(statusId);
|
||||
const accountIds =
|
||||
data.find((r) => r.name === reaction.name)?.account_ids.slice(0, 10) ??
|
||||
[];
|
||||
|
||||
const { data: accountsData } = await client.value.getAccounts(accountIds);
|
||||
|
||||
accounts.value = accountsData;
|
||||
};
|
||||
|
||||
const react = async () => {
|
||||
const id = toast.loading(m.gray_stale_antelope_roam());
|
||||
|
||||
const { data } = await client.value.createEmojiReaction(
|
||||
statusId,
|
||||
reaction.name,
|
||||
);
|
||||
|
||||
toast.dismiss(id);
|
||||
toast.success(m.main_least_turtle_fall());
|
||||
useEvent("note:edit", data);
|
||||
};
|
||||
|
||||
const unreact = async () => {
|
||||
const id = toast.loading(m.many_weary_bat_intend());
|
||||
|
||||
const { data } = await client.value.deleteEmojiReaction(
|
||||
statusId,
|
||||
reaction.name,
|
||||
);
|
||||
|
||||
toast.dismiss(id);
|
||||
toast.success(m.aware_even_oryx_race());
|
||||
useEvent("note:edit", data);
|
||||
};
|
||||
</script>
|
||||
28
app/components/notes/reblog-header.vue
Normal file
28
app/components/notes/reblog-header.vue
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<template>
|
||||
<NuxtLink :href="urlAsPath">
|
||||
<Card class="flex-row px-2 py-1 items-center gap-2 hover:bg-muted duration-100 text-sm">
|
||||
<Repeat class="size-4 text-primary" />
|
||||
<Avatar class="size-6 border" :src="avatar" :name="displayName" />
|
||||
<span class="font-semibold" v-render-emojis="emojis">{{ displayName }}</span>
|
||||
{{ m.large_vivid_horse_catch() }}
|
||||
</Card>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { CustomEmoji } from "@versia/client/schemas";
|
||||
import { Repeat } from "lucide-vue-next";
|
||||
import type { z } from "zod";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
import Avatar from "../profiles/avatar.vue";
|
||||
import { Card } from "../ui/card";
|
||||
|
||||
const { url } = defineProps<{
|
||||
avatar: string;
|
||||
displayName: string;
|
||||
emojis: z.infer<typeof CustomEmoji>[];
|
||||
url: string;
|
||||
}>();
|
||||
|
||||
const urlAsPath = new URL(url).pathname;
|
||||
</script>
|
||||
29
app/components/notes/thread.vue
Normal file
29
app/components/notes/thread.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<template>
|
||||
<div>
|
||||
<Note
|
||||
v-if="parent"
|
||||
:note="parent"
|
||||
:hide-actions="true"
|
||||
:content-under-username="true"
|
||||
:bottom-avatar-bar="true"
|
||||
class="border-b-0 rounded-b-none"
|
||||
/>
|
||||
<Note
|
||||
:note="note"
|
||||
:class="parent && 'border-t-0 rounded-t-none'"
|
||||
:top-avatar-bar="!!parent"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Status } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import Note from "./note.vue";
|
||||
|
||||
const { note } = defineProps<{
|
||||
note: z.infer<typeof Status>;
|
||||
}>();
|
||||
|
||||
const parent = useNote(client, note.in_reply_to_id);
|
||||
</script>
|
||||
80
app/components/notifications/follow-request.vue
Normal file
80
app/components/notifications/follow-request.vue
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<template>
|
||||
<div v-if="relationship?.requested_by !== false" class="flex flex-row items-center gap-3 p-4">
|
||||
<NuxtLink :href="followerUrl" class="relative size-10">
|
||||
<Avatar class="size-10 border border-border" :src="follower.avatar" :name="follower.display_name" />
|
||||
</NuxtLink>
|
||||
<div class="flex flex-col gap-0.5 justify-center flex-1 text-left leading-tight text-sm">
|
||||
<span class="truncate font-semibold" v-render-emojis="follower.emojis">{{
|
||||
follower.display_name
|
||||
}}</span>
|
||||
<span class="truncate tracking-tight">
|
||||
<Address :username="username" :domain="domain" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading" class="flex p-2 items-center justify-center h-12">
|
||||
<Loader class="size-4 animate-spin" />
|
||||
</div>
|
||||
<div v-else-if="relationship?.requested_by === false" class="flex p-2 items-center justify-center h-12">
|
||||
<Check class="size-4" />
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-2 p-2 gap-2">
|
||||
<Button variant="secondary" size="sm" @click="accept" :title="m.slow_these_kestrel_sail()">
|
||||
<Check />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" @click="reject" :title="m.weary_steep_yak_embrace()">
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Account } from "@versia/client/schemas";
|
||||
import { Check, Loader, X } from "lucide-vue-next";
|
||||
import { toast } from "vue-sonner";
|
||||
import type { z } from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
import Address from "../profiles/address.vue";
|
||||
import Avatar from "../profiles/avatar.vue";
|
||||
|
||||
const { follower } = defineProps<{
|
||||
follower: z.infer<typeof Account>;
|
||||
}>();
|
||||
|
||||
const loading = ref(true);
|
||||
const followerUrl = `/@${follower.acct}`;
|
||||
const [username, domain] = follower.acct.split("@");
|
||||
const { relationship } = useRelationship(client, follower.id);
|
||||
|
||||
// TODO: Add "followed" notification
|
||||
watch(relationship, () => {
|
||||
if (relationship.value) {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const accept = async () => {
|
||||
const id = toast.loading(m.cool_slimy_coyote_affirm());
|
||||
loading.value = true;
|
||||
|
||||
const { data } = await client.value.acceptFollowRequest(follower.id);
|
||||
|
||||
toast.dismiss(id);
|
||||
toast.success(m.busy_awful_mouse_jump());
|
||||
relationship.value = data;
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const reject = async () => {
|
||||
const id = toast.loading(m.front_sunny_penguin_flip());
|
||||
loading.value = true;
|
||||
|
||||
const { data } = await client.value.rejectFollowRequest(follower.id);
|
||||
|
||||
toast.dismiss(id);
|
||||
toast.success(m.green_flat_mayfly_trust());
|
||||
relationship.value = data;
|
||||
loading.value = false;
|
||||
};
|
||||
</script>
|
||||
127
app/components/notifications/notification.vue
Normal file
127
app/components/notifications/notification.vue
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<template>
|
||||
<Card class="*:w-full p-2">
|
||||
<Collapsible :default-open="true" v-slot="{ open }" class="space-y-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger :as-child="true">
|
||||
<CardHeader
|
||||
v-if="notification.account"
|
||||
class="flex flex-row items-center gap-2 px-2"
|
||||
>
|
||||
<component :is="icon" class="size-5 shrink-0" />
|
||||
<Avatar
|
||||
class="size-5 border border-card"
|
||||
:src="notification.account.avatar"
|
||||
:name="notification.account.display_name"
|
||||
/>
|
||||
<span
|
||||
class="font-semibold text-sm"
|
||||
v-render-emojis="notification.account.emojis"
|
||||
>{{ notification.account.display_name }}</span
|
||||
>
|
||||
<CollapsibleTrigger :as-child="true">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="ml-auto [&_svg]:data-[state=open]:-rotate-180"
|
||||
:title="open ? 'Collapse' : 'Expand'"
|
||||
>
|
||||
<ChevronDown class="duration-200" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</CardHeader>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ text }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<CollapsibleContent :as-child="true">
|
||||
<CardContent class="p-0">
|
||||
<Note
|
||||
v-if="notification.status"
|
||||
:note="notification.status"
|
||||
:small-layout="true"
|
||||
:hide-actions="true"
|
||||
/>
|
||||
<FollowRequest
|
||||
v-else-if="
|
||||
notification.type === 'follow_request' &&
|
||||
notification.account
|
||||
"
|
||||
:follower="notification.account"
|
||||
/>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Notification } from "@versia/client/schemas";
|
||||
import {
|
||||
AtSign,
|
||||
ChevronDown,
|
||||
Heart,
|
||||
Repeat,
|
||||
User,
|
||||
UserPlus,
|
||||
} from "lucide-vue-next";
|
||||
import type { z } from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "~/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "~/components/ui/collapsible";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "~/components/ui/tooltip";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
import Note from "../notes/note.vue";
|
||||
import Avatar from "../profiles/avatar.vue";
|
||||
import FollowRequest from "./follow-request.vue";
|
||||
|
||||
const { notification } = defineProps<{
|
||||
notification: z.infer<typeof Notification>;
|
||||
}>();
|
||||
|
||||
const icon = computed(() => {
|
||||
switch (notification.type) {
|
||||
case "mention":
|
||||
return AtSign;
|
||||
case "reblog":
|
||||
return Repeat;
|
||||
case "follow":
|
||||
return UserPlus;
|
||||
case "favourite":
|
||||
return Heart;
|
||||
case "follow_request":
|
||||
return User;
|
||||
// case "follow_accept":
|
||||
// return UserCheck;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const text = computed(() => {
|
||||
switch (notification.type) {
|
||||
case "mention":
|
||||
return m.fuzzy_orange_tuna_succeed();
|
||||
case "reblog":
|
||||
return m.grand_proof_quail_read();
|
||||
case "follow":
|
||||
return m.top_steep_scallop_care();
|
||||
case "favourite":
|
||||
return m.swift_just_beetle_devour();
|
||||
case "follow_request":
|
||||
return m.seemly_short_thrush_bloom();
|
||||
//case "follow_accept":
|
||||
// return m.weird_seemly_termite_scold();
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
171
app/components/oauth/login.vue
Normal file
171
app/components/oauth/login.vue
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
<script setup lang="ts">
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import type { Instance } from "@versia/client/schemas";
|
||||
import { Loader } from "lucide-vue-next";
|
||||
import { useForm } from "vee-validate";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
|
||||
const { instance } = defineProps<{
|
||||
instance: z.infer<typeof Instance>;
|
||||
}>();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const ssoConfig = computed(() => instance.sso);
|
||||
|
||||
const formSchema = toTypedSchema(
|
||||
z.object({
|
||||
identifier: z
|
||||
.string()
|
||||
.min(3, {
|
||||
message: m.aware_house_dolphin_win(),
|
||||
})
|
||||
.or(
|
||||
z.string().email({
|
||||
message: m.weary_fresh_dragonfly_bless(),
|
||||
}),
|
||||
),
|
||||
password: z.string().min(3, {
|
||||
message: m.aware_house_dolphin_win(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: formSchema,
|
||||
});
|
||||
|
||||
const redirectUrl = new URL("/api/auth/login", `https://${instance.domain}`);
|
||||
|
||||
const params = useUrlSearchParams();
|
||||
|
||||
for (const name of [
|
||||
"redirect_uri",
|
||||
"response_type",
|
||||
"client_id",
|
||||
"scope",
|
||||
"state",
|
||||
]) {
|
||||
if (params[name]) {
|
||||
redirectUrl.searchParams.set(name, params[name] as string);
|
||||
}
|
||||
}
|
||||
|
||||
const issuerRedirectUrl = (issuerId: string) => {
|
||||
const url = new URL("/oauth/sso", client.value.url);
|
||||
|
||||
for (const name of [
|
||||
"redirect_uri",
|
||||
"response_type",
|
||||
"client_id",
|
||||
"scope",
|
||||
"state",
|
||||
]) {
|
||||
if (params[name]) {
|
||||
url.searchParams.set(name, params[name] as string);
|
||||
}
|
||||
}
|
||||
|
||||
url.searchParams.set("issuer", issuerId);
|
||||
return url.toString();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-6">
|
||||
<form
|
||||
@submit="form.submitForm"
|
||||
method="post"
|
||||
:action="redirectUrl.toString()"
|
||||
>
|
||||
<div class="grid gap-6">
|
||||
<FormField v-slot="{ componentField }" name="identifier">
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{{ m.fluffy_soft_wolf_cook() }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="petergriffin"
|
||||
type="text"
|
||||
auto-capitalize="none"
|
||||
auto-complete="idenfifier"
|
||||
auto-correct="off"
|
||||
:disabled="isLoading"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="password">
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{{ m.livid_bright_wallaby_quiz() }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="hunter2"
|
||||
type="password"
|
||||
auto-capitalize="none"
|
||||
auto-complete="password"
|
||||
auto-correct="off"
|
||||
:disabled="isLoading"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<Button :disabled="isLoading" type="submit">
|
||||
<Loader v-if="isLoading" class="mr-2 size-4 animate-spin" />
|
||||
{{ m.fuzzy_sea_moth_absorb() }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div
|
||||
v-if="ssoConfig && ssoConfig.providers.length > 0"
|
||||
class="relative"
|
||||
>
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<span class="w-full border-t" />
|
||||
</div>
|
||||
<div class="relative flex justify-center text-xs uppercase">
|
||||
<span class="bg-background px-2 text-muted-foreground">
|
||||
{{ m.tidy_tidy_cow_cut() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="ssoConfig && ssoConfig.providers.length > 0"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<Button
|
||||
as="a"
|
||||
:href="issuerRedirectUrl(provider.id)"
|
||||
variant="outline"
|
||||
type="button"
|
||||
:disabled="isLoading"
|
||||
v-for="provider of ssoConfig.providers"
|
||||
>
|
||||
<Loader v-if="isLoading" class="mr-2 animate-spin" />
|
||||
<img
|
||||
crossorigin="anonymous"
|
||||
:src="provider.icon"
|
||||
:alt="`${provider.name}'s logo`"
|
||||
class="size-4 mr-2"
|
||||
/>
|
||||
{{ provider.name }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
44
app/components/preferences/category.vue
Normal file
44
app/components/preferences/category.vue
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<template>
|
||||
<section class="space-y-2">
|
||||
<CardTitle class="text-xs">
|
||||
{{ name }}
|
||||
</CardTitle>
|
||||
<Card class="p-0 gap-0">
|
||||
<div v-for="preference of preferences" :key="preference">
|
||||
<TextPreferenceVue v-if="(prefs[preference] instanceof TextPreference)" :pref="(prefs[preference] as TextPreference)" :name="preference" />
|
||||
<BooleanPreferenceVue v-else-if="(prefs[preference] instanceof BooleanPreference)" :pref="(prefs[preference] as BooleanPreference)" :name="preference" />
|
||||
<SelectPreferenceVue v-else-if="(prefs[preference] instanceof SelectPreference)" :pref="(prefs[preference] as SelectPreference<string>)" :name="preference" />
|
||||
<NumberPreferenceVue v-else-if="(prefs[preference] instanceof NumberPreference)" :pref="(prefs[preference] as NumberPreference)" :name="preference" />
|
||||
<MultiSelectPreferenceVue v-else-if="(prefs[preference] instanceof MultiSelectPreference)" :pref="(prefs[preference] as MultiSelectPreference<string>)" :name="preference" />
|
||||
<CodePreferenceVue v-else-if="(prefs[preference] instanceof CodePreference)" :pref="(prefs[preference] as CodePreference)" :name="preference" />
|
||||
<UrlPreferenceVue v-else-if="(prefs[preference] instanceof UrlPreference)" :pref="(prefs[preference] as UrlPreference)" :name="preference" />
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Card, CardTitle } from "../ui/card/index.ts";
|
||||
import { preferences as prefs } from "./preferences.ts";
|
||||
import BooleanPreferenceVue from "./types/boolean.vue";
|
||||
import CodePreferenceVue from "./types/code.vue";
|
||||
import MultiSelectPreferenceVue from "./types/multiselect.vue";
|
||||
import NumberPreferenceVue from "./types/number.vue";
|
||||
import SelectPreferenceVue from "./types/select.vue";
|
||||
import TextPreferenceVue from "./types/text.vue";
|
||||
import UrlPreferenceVue from "./types/url.vue";
|
||||
import {
|
||||
BooleanPreference,
|
||||
CodePreference,
|
||||
MultiSelectPreference,
|
||||
NumberPreference,
|
||||
SelectPreference,
|
||||
TextPreference,
|
||||
UrlPreference,
|
||||
} from "./types.ts";
|
||||
|
||||
const { preferences = [], name } = defineProps<{
|
||||
preferences: (keyof typeof prefs)[];
|
||||
name: string;
|
||||
}>();
|
||||
</script>
|
||||
60
app/components/preferences/developer.vue
Normal file
60
app/components/preferences/developer.vue
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<template>
|
||||
<Card class="grid gap-3 text-sm">
|
||||
<dl class="grid gap-3">
|
||||
<div v-for="[key, value] of data" :key="key" class="flex flex-row items-baseline justify-between gap-4 truncate">
|
||||
<dt class="text-muted-foreground">
|
||||
{{ key }}
|
||||
</dt>
|
||||
<dd class="font-mono" v-if="typeof value === 'string'">{{ value }}</dd>
|
||||
<dd class="font-mono" v-else>
|
||||
<component :is="value" />
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script lang="tsx" setup>
|
||||
import type { VNode } from "vue";
|
||||
import { toast } from "vue-sonner";
|
||||
import { Button } from "../ui/button";
|
||||
import { Card } from "../ui/card";
|
||||
|
||||
const copy = (data: string) => {
|
||||
navigator.clipboard.writeText(data);
|
||||
toast.success("Copied to clipboard");
|
||||
};
|
||||
|
||||
const appData = useAppData();
|
||||
|
||||
const data: [string, string | VNode][] = [
|
||||
["User ID", identity.value?.account.id ?? ""],
|
||||
["Instance domain", identity.value?.instance.domain ?? ""],
|
||||
["Instance version", identity.value?.instance.versia_version ?? ""],
|
||||
["Client ID", appData.value?.client_id ?? ""],
|
||||
[
|
||||
"Client secret",
|
||||
<Button
|
||||
variant="outline"
|
||||
class="font-sans"
|
||||
size="sm"
|
||||
// @ts-expect-error missing onClick types
|
||||
onClick={() => copy(appData.value?.client_secret ?? "")}
|
||||
>
|
||||
Click to copy
|
||||
</Button>,
|
||||
],
|
||||
[
|
||||
"Access token",
|
||||
<Button
|
||||
variant="outline"
|
||||
class="font-sans"
|
||||
size="sm"
|
||||
// @ts-expect-error missing onClick types
|
||||
onClick={() => copy(identity.value?.tokens.access_token ?? "")}
|
||||
>
|
||||
Click to copy
|
||||
</Button>,
|
||||
],
|
||||
];
|
||||
</script>
|
||||
169
app/components/preferences/dialog.vue
Normal file
169
app/components/preferences/dialog.vue
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
InfoIcon,
|
||||
PaletteIcon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
SmileIcon,
|
||||
TerminalSquareIcon,
|
||||
UserIcon,
|
||||
} from "lucide-vue-next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import pkg from "~~/package.json";
|
||||
import Avatar from "../profiles/avatar.vue";
|
||||
import TinyCard from "../profiles/tiny-card.vue";
|
||||
import { Separator } from "../ui/separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||
import Category from "./category.vue";
|
||||
import Developer from "./developer.vue";
|
||||
import Emojis from "./emojis/index.vue";
|
||||
import Page from "./page.vue";
|
||||
import { preferences } from "./preferences";
|
||||
import Profile from "./profile.vue";
|
||||
import Stats from "./stats.vue";
|
||||
|
||||
const pages = Object.values(preferences)
|
||||
.map((p) => p.options.category)
|
||||
.filter((c) => c !== undefined)
|
||||
.map((c) => c.split("/")[0] as string)
|
||||
.concat(["Account", "Emojis", "Developer", "About"])
|
||||
// Remove duplicates
|
||||
.filter((c, i, a) => a.indexOf(c) === i);
|
||||
const extraPages = ["Account", "Emojis", "Developer", "About"];
|
||||
|
||||
const icons: Record<string, Component> = {
|
||||
Account: UserIcon,
|
||||
Appearance: PaletteIcon,
|
||||
Emojis: SmileIcon,
|
||||
Behaviour: SettingsIcon,
|
||||
Roles: ShieldCheckIcon,
|
||||
Developer: TerminalSquareIcon,
|
||||
About: InfoIcon,
|
||||
};
|
||||
|
||||
// For each page, map the associated categories
|
||||
const categories = Object.fromEntries(
|
||||
pages.map((page) => {
|
||||
const categories = Object.values(preferences)
|
||||
.map((p) => p.options.category)
|
||||
.filter((c) => c !== undefined)
|
||||
.filter((c) => c.split("/")[0] === page)
|
||||
.map((c) => c.split("/")[1] as string)
|
||||
// Remove duplicates
|
||||
.filter((c, i, a) => a.indexOf(c) === i);
|
||||
|
||||
return [page, categories];
|
||||
}),
|
||||
);
|
||||
|
||||
const { account: author1 } = useAccountFromAcct(
|
||||
client,
|
||||
"jessew@vs.cpluspatch.com",
|
||||
);
|
||||
|
||||
const { account: author2 } = useAccountFromAcct(
|
||||
client,
|
||||
"aprl@social.lysand.org",
|
||||
);
|
||||
|
||||
const { account: author3 } = useAccountFromAcct(
|
||||
client,
|
||||
"lina@social.lysand.org",
|
||||
);
|
||||
|
||||
const { account: author4 } = useAccountFromAcct(client, "nyx@v.everypizza.im");
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
useListen("preferences:open", () => {
|
||||
open.value = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open" v-if="identity">
|
||||
<DialogContent class="md:max-w-5xl w-full h-full p-0 md:max-h-[70dvh] overflow-hidden">
|
||||
<Tabs class="md:grid-cols-[auto_minmax(0,1fr)] !grid gap-2 *:p-4 overflow-hidden *:overflow-y-auto *:h-full" orientation="vertical"
|
||||
:default-value="pages[0]">
|
||||
<DialogHeader class="gap-6 grid grid-rows-[auto_minmax(0,1fr)] border-b md:border-b-0 md:border-r min-w-60 text-left">
|
||||
<div class="grid gap-3 items-center grid-cols-[auto_minmax(0,1fr)]">
|
||||
<Avatar :name="identity.account.display_name || identity.account.username"
|
||||
:src="identity.account.avatar" />
|
||||
<DialogTitle>Preferences</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription class="sr-only">
|
||||
Make changes to your preferences here.
|
||||
</DialogDescription>
|
||||
<TabsList class="md:grid md:grid-cols-1 w-full h-fit *:justify-start !justify-start">
|
||||
<TabsTrigger v-for="page in pages" :key="page" :value="page">
|
||||
<component :is="icons[page]" class="size-4 mr-2" />
|
||||
{{ page }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</DialogHeader>
|
||||
<TabsContent v-for="page in pages.filter(p => !extraPages.includes(p))" :key="page" :value="page"
|
||||
as-child>
|
||||
<Page :title="page">
|
||||
<Category v-for="category in categories[page]" :key="category"
|
||||
:preferences="Object.entries(preferences).filter(([, p]) => p.options.category === `${page}/${category}`).map(([k,]) => k as keyof typeof preferences)"
|
||||
:name="category" />
|
||||
</Page>
|
||||
</TabsContent>
|
||||
<TabsContent value="Emojis" as-child>
|
||||
<Page title="Emojis">
|
||||
<Emojis />
|
||||
</Page>
|
||||
</TabsContent>
|
||||
<TabsContent value="Account" as-child>
|
||||
<Page title="Account">
|
||||
<Profile />
|
||||
</Page>
|
||||
</TabsContent>
|
||||
<TabsContent value="Developer" as-child>
|
||||
<Page title="Developer">
|
||||
<Developer />
|
||||
</Page>
|
||||
</TabsContent>
|
||||
<TabsContent value="About" as-child>
|
||||
<Page title="About">
|
||||
<section class="space-y-4">
|
||||
<p class="leading-7 text-sm max-w-xl">
|
||||
{{ pkg.description }}
|
||||
</p>
|
||||
|
||||
<Stats />
|
||||
</section>
|
||||
<Separator />
|
||||
<section class="space-y-2">
|
||||
<h3 class="text-lg font-semibold tracking-tight">Developers</h3>
|
||||
<div class="grid lg:grid-cols-3 md:grid-cols-2 grid-cols-1 gap-4">
|
||||
<TinyCard v-if="author1" :account="author1" domain="vs.cpluspatch.com" />
|
||||
<TinyCard v-if="author2" :account="author2" domain="social.lysand.org" />
|
||||
<TinyCard v-if="author3" :account="author3" domain="social.lysand.org" />
|
||||
<TinyCard v-if="author4" :account="author4" domain="v.everypizza.im" />
|
||||
</div>
|
||||
</section>
|
||||
<Separator />
|
||||
<section class="space-y-2">
|
||||
<h3 class="text-lg font-semibold tracking-tight">Dependencies</h3>
|
||||
<ul class="grid lg:grid-cols-2 gap-2 grid-cols-1 items-center justify-center list-disc ml-6">
|
||||
<li v-for="[dep, version] in Object.entries(pkg.dependencies)" :key="dep">
|
||||
<code
|
||||
class="rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-xs font-semibold">
|
||||
{{ dep }}@{{ version }}
|
||||
</code>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</Page>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
73
app/components/preferences/emojis/batch-dropdown.vue
Normal file
73
app/components/preferences/emojis/batch-dropdown.vue
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="min-w-48">
|
||||
<DropdownMenuItem @click="deleteAll" :disabled="!canEdit">
|
||||
<Delete />
|
||||
{{ m.tense_quick_cod_favor() }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { type CustomEmoji, RolePermission } from "@versia/client/schemas";
|
||||
import { Delete } from "lucide-vue-next";
|
||||
import { toast } from "vue-sonner";
|
||||
import type { z } from "zod";
|
||||
import { confirmModalService } from "~/components/modals/composable";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
|
||||
const { emojis } = defineProps<{
|
||||
emojis: z.infer<typeof CustomEmoji>[];
|
||||
}>();
|
||||
|
||||
const permissions = usePermissions();
|
||||
const canEdit =
|
||||
(!emojis.some((e) => e.global) &&
|
||||
permissions.value.includes(RolePermission.ManageOwnEmojis)) ||
|
||||
permissions.value.includes(RolePermission.ManageEmojis);
|
||||
|
||||
const deleteAll = async () => {
|
||||
if (!identity.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { confirmed } = await confirmModalService.confirm({
|
||||
title: m.tense_quick_cod_favor(),
|
||||
message: m.next_hour_jurgen_sprout({
|
||||
amount: emojis.length,
|
||||
}),
|
||||
confirmText: m.tense_quick_cod_favor(),
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
const id = toast.loading(
|
||||
m.equal_only_crow_file({
|
||||
amount: emojis.length,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await Promise.all(
|
||||
emojis.map((emoji) => client.value.deleteEmoji(emoji.id)),
|
||||
);
|
||||
toast.dismiss(id);
|
||||
toast.success("Emojis deleted");
|
||||
|
||||
identity.value.emojis = identity.value.emojis.filter(
|
||||
(e) => !emojis.some((emoji) => e.id === emoji.id),
|
||||
);
|
||||
} catch {
|
||||
toast.dismiss(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
108
app/components/preferences/emojis/dropdown.vue
Normal file
108
app/components/preferences/emojis/dropdown.vue
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" title="Open menu" class="size-8 p-0">
|
||||
<MoreHorizontal class="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="min-w-48">
|
||||
<DropdownMenuItem @click="editName">
|
||||
<TextCursorInput />
|
||||
{{ m.cuddly_such_swallow_hush() }}
|
||||
</DropdownMenuItem>
|
||||
<!-- <DropdownMenuItem @click="editCaption">
|
||||
<Captions />
|
||||
<span>Add caption</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator /> -->
|
||||
<DropdownMenuItem @click="_delete">
|
||||
<Delete />
|
||||
{{ m.tense_quick_cod_favor() }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { type CustomEmoji, RolePermission } from "@versia/client/schemas";
|
||||
import { Delete, MoreHorizontal, TextCursorInput } from "lucide-vue-next";
|
||||
import { toast } from "vue-sonner";
|
||||
import type { z } from "zod";
|
||||
import { confirmModalService } from "~/components/modals/composable";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
|
||||
const { emoji } = defineProps<{
|
||||
emoji: z.infer<typeof CustomEmoji>;
|
||||
}>();
|
||||
|
||||
const permissions = usePermissions();
|
||||
const canEdit =
|
||||
(!emoji.global &&
|
||||
permissions.value.includes(RolePermission.ManageOwnEmojis)) ||
|
||||
permissions.value.includes(RolePermission.ManageEmojis);
|
||||
|
||||
const editName = async () => {
|
||||
if (!identity.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await confirmModalService.confirm({
|
||||
title: m.slimy_awful_florian_sail(),
|
||||
defaultValue: emoji.shortcode,
|
||||
confirmText: m.teary_antsy_panda_aid(),
|
||||
inputType: "text",
|
||||
});
|
||||
|
||||
if (result.confirmed) {
|
||||
const id = toast.loading(m.teary_tame_gull_bless());
|
||||
try {
|
||||
const { data } = await client.value.updateEmoji(emoji.id, {
|
||||
shortcode: result.value,
|
||||
});
|
||||
|
||||
toast.dismiss(id);
|
||||
toast.success(m.gaudy_lime_bison_adore());
|
||||
|
||||
identity.value.emojis = identity.value.emojis.map((e) =>
|
||||
e.id === emoji.id ? data : e,
|
||||
);
|
||||
} catch {
|
||||
toast.dismiss(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const _delete = async () => {
|
||||
if (!identity.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { confirmed } = await confirmModalService.confirm({
|
||||
title: m.tense_quick_cod_favor(),
|
||||
message: m.honest_factual_carp_aspire(),
|
||||
confirmText: m.tense_quick_cod_favor(),
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
const id = toast.loading(m.weary_away_liger_zip());
|
||||
try {
|
||||
await client.value.deleteEmoji(emoji.id);
|
||||
toast.dismiss(id);
|
||||
toast.success(m.crisp_whole_canary_tear());
|
||||
|
||||
identity.value.emojis = identity.value.emojis.filter(
|
||||
(e) => e.id !== emoji.id,
|
||||
);
|
||||
} catch {
|
||||
toast.dismiss(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
19
app/components/preferences/emojis/index.vue
Normal file
19
app/components/preferences/emojis/index.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<template>
|
||||
<div v-if="emojis.length > 0" class="grow">
|
||||
<Table :emojis="emojis" :can-upload="canUpload" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import Table from "./table.vue";
|
||||
|
||||
const permissions = usePermissions();
|
||||
const canUpload = computed(
|
||||
() =>
|
||||
permissions.value.includes(RolePermission.ManageOwnEmojis) ||
|
||||
permissions.value.includes(RolePermission.ManageEmojis),
|
||||
);
|
||||
|
||||
const emojis = computed(() => identity.value?.emojis ?? []);
|
||||
</script>
|
||||
362
app/components/preferences/emojis/table.vue
Normal file
362
app/components/preferences/emojis/table.vue
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
<script setup lang="tsx">
|
||||
import type {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
ExpandedState,
|
||||
SortingState,
|
||||
Updater,
|
||||
VisibilityState,
|
||||
} from "@tanstack/vue-table";
|
||||
import {
|
||||
FlexRender,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useVueTable,
|
||||
} from "@tanstack/vue-table";
|
||||
import type { CustomEmoji } from "@versia/client/schemas";
|
||||
import {
|
||||
ArrowDownAZ,
|
||||
ArrowUpAz,
|
||||
ArrowUpDown,
|
||||
ChevronDown,
|
||||
Ellipsis,
|
||||
Globe,
|
||||
Home,
|
||||
Plus,
|
||||
} from "lucide-vue-next";
|
||||
import { ref } from "vue";
|
||||
import type { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import BatchDropdown from "./batch-dropdown.vue";
|
||||
import Dropdown from "./dropdown.vue";
|
||||
import Uploader from "./uploader.vue";
|
||||
|
||||
// No destructuring props to avoid reactivity issues
|
||||
const props = defineProps<{
|
||||
emojis: z.infer<typeof CustomEmoji>[];
|
||||
canUpload: boolean;
|
||||
}>();
|
||||
|
||||
const emojisRef = computed(() => props.emojis);
|
||||
|
||||
const valueUpdater = <T extends Updater<any>>(updaterOrValue: T, ref: Ref) => {
|
||||
ref.value =
|
||||
typeof updaterOrValue === "function"
|
||||
? updaterOrValue(ref.value)
|
||||
: updaterOrValue;
|
||||
};
|
||||
|
||||
const columns: ColumnDef<z.infer<typeof CustomEmoji>>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
modelValue={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onUpdate:modelValue={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
modelValue={row.getIsSelected()}
|
||||
onUpdate:modelValue={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "url",
|
||||
header: "Image",
|
||||
cell: ({ row }) => (
|
||||
<img
|
||||
src={row.getValue("url")}
|
||||
alt={`:${row.getValue("shortcode")}:`}
|
||||
title={row.getValue("shortcode")}
|
||||
class="h-[1lh] align-middle inline not-prose hover:scale-110 transition-transform duration-75 ease-in-out"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "shortcode",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="link"
|
||||
class="!p-0 !h-auto"
|
||||
// @ts-expect-error types don't include onClick
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Shortcode
|
||||
{column.getIsSorted() === false ? (
|
||||
<ArrowUpDown class="ml-2 size-4" />
|
||||
) : column.getIsSorted() === "asc" ? (
|
||||
<ArrowDownAZ class="ml-2 size-4" />
|
||||
) : (
|
||||
<ArrowUpAz class="ml-2 size-4" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div class="font-mono">{row.getValue("shortcode")}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "category",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="link"
|
||||
class="!p-0 !h-auto"
|
||||
// @ts-expect-error types don't include onClick
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Category
|
||||
{column.getIsSorted() === false ? (
|
||||
<ArrowUpDown class="ml-2 size-4" />
|
||||
) : column.getIsSorted() === "asc" ? (
|
||||
<ArrowDownAZ class="ml-2 size-4" />
|
||||
) : (
|
||||
<ArrowUpAz class="ml-2 size-4" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div class="font-mono">
|
||||
{row.getValue("category") ?? "Uncategorized"}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "global",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="link"
|
||||
class="!p-0 !h-auto"
|
||||
// @ts-expect-error types don't include onClick
|
||||
onClick={() => {
|
||||
const filter = column.getFilterValue();
|
||||
|
||||
if (filter === undefined) {
|
||||
column.setFilterValue(false);
|
||||
} else if (filter === false) {
|
||||
column.setFilterValue(true);
|
||||
} else {
|
||||
column.setFilterValue(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Uploader
|
||||
{column.getFilterValue() === undefined ? (
|
||||
<Ellipsis class="ml-2 size-4" />
|
||||
) : column.getFilterValue() ? (
|
||||
<Globe class="ml-2 size-4" />
|
||||
) : (
|
||||
<Home class="ml-2 size-4" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div class="font-mono">
|
||||
{row.getValue("global") ? "Admin" : "You"}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: ({ table }) => {
|
||||
const selected = table
|
||||
.getFilteredSelectedRowModel()
|
||||
.rows.map((r) => r.original);
|
||||
|
||||
return (
|
||||
<div class="relative">
|
||||
<BatchDropdown emojis={selected}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
// @ts-expect-error types don't include title
|
||||
title="Open menu"
|
||||
disabled={selected.length === 0}
|
||||
>
|
||||
<Ellipsis class="size-4" />
|
||||
</Button>
|
||||
</BatchDropdown>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const emoji = row.original;
|
||||
|
||||
return (
|
||||
<div class="relative">
|
||||
<Dropdown emoji={emoji} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const sorting = ref<SortingState>([
|
||||
{
|
||||
id: "shortcode",
|
||||
desc: false,
|
||||
},
|
||||
]);
|
||||
const columnFilters = ref<ColumnFiltersState>([]);
|
||||
const columnVisibility = ref<VisibilityState>({});
|
||||
const rowSelection = ref({});
|
||||
const expanded = ref<ExpandedState>({});
|
||||
|
||||
const table = useVueTable({
|
||||
data: emojisRef,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
onSortingChange: (updaterOrValue) => valueUpdater(updaterOrValue, sorting),
|
||||
onColumnFiltersChange: (updaterOrValue) =>
|
||||
valueUpdater(updaterOrValue, columnFilters),
|
||||
onColumnVisibilityChange: (updaterOrValue) =>
|
||||
valueUpdater(updaterOrValue, columnVisibility),
|
||||
onRowSelectionChange: (updaterOrValue) =>
|
||||
valueUpdater(updaterOrValue, rowSelection),
|
||||
onExpandedChange: (updaterOrValue) =>
|
||||
valueUpdater(updaterOrValue, expanded),
|
||||
state: {
|
||||
get sorting() {
|
||||
return sorting.value;
|
||||
},
|
||||
get columnFilters() {
|
||||
return columnFilters.value;
|
||||
},
|
||||
get columnVisibility() {
|
||||
return columnVisibility.value;
|
||||
},
|
||||
get rowSelection() {
|
||||
return rowSelection.value;
|
||||
},
|
||||
get expanded() {
|
||||
return expanded.value;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="flex gap-2 items-center py-4">
|
||||
<Input class="max-w-52 mr-auto" placeholder="Filter emojis..."
|
||||
:model-value="(table.getColumn('shortcode')?.getFilterValue() as string)"
|
||||
@update:model-value="table.getColumn('shortcode')?.setFilterValue($event)" />
|
||||
<Uploader v-if="props.canUpload">
|
||||
<Button variant="outline" size="icon" title="Upload emoji">
|
||||
<Plus class="size-4" />
|
||||
</Button>
|
||||
</Uploader>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline">
|
||||
Columns
|
||||
<ChevronDown class="ml-2 size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuCheckboxItem
|
||||
v-for="column in table.getAllColumns().filter((column) => column.getCanHide())" :key="column.id"
|
||||
class="capitalize" :model-value="column.getIsVisible()" @update:model-value="(value) => {
|
||||
column.toggleVisibility(!!value)
|
||||
}">
|
||||
{{ column.id }}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead v-for="header in headerGroup.headers" :key="header.id" class="">
|
||||
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header"
|
||||
:props="header.getContext()" />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template v-if="table.getRowModel().rows?.length">
|
||||
<template v-for="row in table.getRowModel().rows" :key="row.id">
|
||||
<TableRow :data-state="row.getIsSelected() && 'selected'">
|
||||
<TableCell v-for="cell in row.getVisibleCells()" :key="cell.id">
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="row.getIsExpanded()">
|
||||
<TableCell :colspan="row.getAllCells().length">
|
||||
{{ JSON.stringify(row.original) }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<TableRow v-else>
|
||||
<TableCell :colspan="columns.length" class="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
{{ table.getFilteredSelectedRowModel().rows.length }} of
|
||||
{{ table.getFilteredRowModel().rows.length }} row(s) selected.
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button variant="outline" size="sm" :disabled="!table.getCanPreviousPage()"
|
||||
@click="table.previousPage()">
|
||||
Previous
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" :disabled="!table.getCanNextPage()" @click="table.nextPage()">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
283
app/components/preferences/emojis/uploader.vue
Normal file
283
app/components/preferences/emojis/uploader.vue
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogTrigger>
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogTitle>
|
||||
{{ m.whole_icy_puffin_smile() }}
|
||||
</DialogTitle>
|
||||
<DialogDescription class="sr-only">
|
||||
{{ m.frail_great_marten_pet() }}
|
||||
</DialogDescription>
|
||||
<form class="grid gap-6" @submit="submit">
|
||||
<div
|
||||
v-if="values.image"
|
||||
class="flex items-center justify-around *:size-20 *:p-2 *:rounded *:border *:shadow"
|
||||
>
|
||||
<div class="bg-background">
|
||||
<img
|
||||
class="h-full object-cover"
|
||||
:src="createObjectURL(values.image as File)"
|
||||
:alt="values.alt"
|
||||
/>
|
||||
</div>
|
||||
<div class="bg-zinc-700">
|
||||
<img
|
||||
class="h-full object-cover"
|
||||
:src="createObjectURL(values.image as File)"
|
||||
:alt="values.alt"
|
||||
/>
|
||||
</div>
|
||||
<div class="bg-zinc-400">
|
||||
<img
|
||||
class="h-full object-cover"
|
||||
:src="createObjectURL(values.image as File)"
|
||||
:alt="values.alt"
|
||||
/>
|
||||
</div>
|
||||
<div class="bg-foreground">
|
||||
<img
|
||||
class="h-full object-cover"
|
||||
:src="createObjectURL(values.image as File)"
|
||||
:alt="values.alt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField v-slot="{ handleChange, handleBlur }" name="image">
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{{ m.active_direct_bear_compose() }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@change="(e: any) => {
|
||||
handleChange(e);
|
||||
|
||||
if (!values.shortcode) {
|
||||
setFieldValue('shortcode', e.target.files[0].name.replace(/\.[^/.]+$/, ''));
|
||||
}
|
||||
}"
|
||||
@blur="handleBlur"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{{ m.lime_late_millipede_urge() }}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="shortcode">
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{{ m.happy_mild_fox_gleam() }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
v-bind="componentField"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{{ m.glad_day_kestrel_amaze() }}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="category">
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{{ m.short_cute_jackdaw_comfort() }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
v-bind="componentField"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="alt">
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{{ m.watery_left_shrimp_bless() }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
rows="2"
|
||||
v-bind="componentField"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{{ m.weird_fun_jurgen_arise() }}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
v-slot="{ value, handleChange }"
|
||||
v-if="hasEmojiAdmin"
|
||||
name="global"
|
||||
as-child
|
||||
>
|
||||
<FormSwitch :title="m.pink_sharp_carp_work()" :description="m.dark_pretty_hyena_link()">
|
||||
<Switch
|
||||
:model-value="value"
|
||||
@update:model-value="handleChange"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
</FormSwitch>
|
||||
</FormField>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose :as-child="true">
|
||||
<Button variant="outline" :disabled="isSubmitting">
|
||||
{{ m.soft_bold_ant_attend() }}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
{{ m.flat_safe_haddock_gaze() }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { useForm } from "vee-validate";
|
||||
import { toast } from "vue-sonner";
|
||||
import { z } from "zod";
|
||||
import FormSwitch from "~/components/form/switch.vue";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
|
||||
const open = ref(false);
|
||||
const permissions = usePermissions();
|
||||
const hasEmojiAdmin = permissions.value.includes(RolePermission.ManageEmojis);
|
||||
const createObjectURL = URL.createObjectURL;
|
||||
|
||||
const formSchema = toTypedSchema(
|
||||
z.object({
|
||||
image: z
|
||||
.instanceof(File, {
|
||||
message: m.sound_topical_gopher_offer(),
|
||||
})
|
||||
.refine(
|
||||
(v) =>
|
||||
v.size <=
|
||||
(identity.value?.instance.configuration.emojis
|
||||
.emoji_size_limit ?? Number.POSITIVE_INFINITY),
|
||||
m.orange_weird_parakeet_hug({
|
||||
count:
|
||||
identity.value?.instance.configuration.emojis
|
||||
.emoji_size_limit ?? Number.POSITIVE_INFINITY,
|
||||
}),
|
||||
),
|
||||
shortcode: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(
|
||||
identity.value?.instance.configuration.emojis
|
||||
.max_shortcode_characters ?? Number.POSITIVE_INFINITY,
|
||||
m.solid_inclusive_owl_hug({
|
||||
count:
|
||||
identity.value?.instance.configuration.emojis
|
||||
.max_shortcode_characters ??
|
||||
Number.POSITIVE_INFINITY,
|
||||
}),
|
||||
)
|
||||
.regex(emojiValidator),
|
||||
global: z.boolean().default(false),
|
||||
category: z
|
||||
.string()
|
||||
.max(
|
||||
64,
|
||||
m.home_cool_orangutan_hug({
|
||||
count: 64,
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
alt: z
|
||||
.string()
|
||||
.max(
|
||||
identity.value?.instance.configuration.emojis
|
||||
.max_description_characters ?? Number.POSITIVE_INFINITY,
|
||||
m.key_ago_hound_emerge({
|
||||
count:
|
||||
identity.value?.instance.configuration.emojis
|
||||
.max_description_characters ??
|
||||
Number.POSITIVE_INFINITY,
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
);
|
||||
const { isSubmitting, handleSubmit, values, setFieldValue } = useForm({
|
||||
validationSchema: formSchema,
|
||||
});
|
||||
|
||||
const submit = handleSubmit(async (values) => {
|
||||
if (!identity.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = toast.loading(m.factual_gray_mouse_believe());
|
||||
|
||||
try {
|
||||
const { data } = await client.value.uploadEmoji(
|
||||
values.shortcode,
|
||||
values.image,
|
||||
{
|
||||
alt: values.alt,
|
||||
category: values.category,
|
||||
global: values.global,
|
||||
},
|
||||
);
|
||||
|
||||
toast.dismiss(id);
|
||||
toast.success(m.cool_trite_gull_quiz());
|
||||
|
||||
identity.value.emojis = [...identity.value.emojis, data];
|
||||
open.value = false;
|
||||
} catch {
|
||||
toast.dismiss(id);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
7
app/components/preferences/index.vue
Normal file
7
app/components/preferences/index.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<Dialog />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Dialog from "./dialog.vue";
|
||||
</script>
|
||||
15
app/components/preferences/page.vue
Normal file
15
app/components/preferences/page.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<section class="gap-4 flex flex-col">
|
||||
<h2 class="text-xl font-bold tracking-tight">
|
||||
{{ title }}
|
||||
</h2>
|
||||
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const { title } = defineProps<{
|
||||
title: string;
|
||||
}>();
|
||||
</script>
|
||||
150
app/components/preferences/preferences.ts
Normal file
150
app/components/preferences/preferences.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import * as m from "~~/paraglide/messages.js";
|
||||
import {
|
||||
BooleanPreference,
|
||||
CodePreference,
|
||||
MultiSelectPreference,
|
||||
NumberPreference,
|
||||
SelectPreference,
|
||||
UrlPreference,
|
||||
} from "./types";
|
||||
|
||||
export const preferences = {
|
||||
render_mfm: new BooleanPreference({
|
||||
name: m.quaint_clear_boar_attend(),
|
||||
description: m.aloof_helpful_larva_spur(),
|
||||
defaultValue: true,
|
||||
category: "Behaviour/Notes",
|
||||
}),
|
||||
default_visibility: new SelectPreference<
|
||||
"public" | "unlisted" | "private" | "direct"
|
||||
>({
|
||||
name: m.loud_tense_kitten_exhale(),
|
||||
description: m.vivid_last_crocodile_offer(),
|
||||
defaultValue: "public",
|
||||
options: {
|
||||
public: m.lost_trick_dog_grace(),
|
||||
unlisted: m.funny_slow_jannes_walk(),
|
||||
private: m.grassy_empty_raven_startle(),
|
||||
direct: m.pretty_bold_baboon_wave(),
|
||||
},
|
||||
category: "Behaviour/Posting",
|
||||
}),
|
||||
language: new SelectPreference<"en" | "fr">({
|
||||
name: m.pretty_born_jackal_dial(),
|
||||
description: m.tired_happy_lobster_pet(),
|
||||
defaultValue: "en",
|
||||
options: {
|
||||
en: m.keen_aware_goldfish_thrive(
|
||||
{},
|
||||
{
|
||||
locale: "en",
|
||||
},
|
||||
),
|
||||
fr: m.vivid_mellow_sawfish_approve(
|
||||
{},
|
||||
{
|
||||
locale: "fr",
|
||||
},
|
||||
),
|
||||
},
|
||||
category: "Behaviour/Globals",
|
||||
}),
|
||||
border_radius: new NumberPreference({
|
||||
name: "Border radius",
|
||||
description:
|
||||
"Global border radius that all elements inheritt from (rem units).",
|
||||
defaultValue: 0.625,
|
||||
step: 0.025,
|
||||
min: 0,
|
||||
max: 2,
|
||||
category: "Appearance/Globals",
|
||||
}),
|
||||
custom_css: new CodePreference({
|
||||
name: m.smart_awake_dachshund_view(),
|
||||
description: m.loved_topical_rat_coax(),
|
||||
defaultValue: "",
|
||||
language: "css",
|
||||
category: "Appearance/Globals",
|
||||
}),
|
||||
color_theme: new SelectPreference<"dark" | "light" | "system">({
|
||||
name: m.hour_elegant_mink_grip(),
|
||||
defaultValue: "system",
|
||||
options: {
|
||||
dark: m.wise_neat_ox_buzz(),
|
||||
light: m.each_strong_snail_aid(),
|
||||
system: m.helpful_raw_seal_nurture(),
|
||||
},
|
||||
category: "Appearance/Globals",
|
||||
}),
|
||||
custom_emojis: new BooleanPreference({
|
||||
name: m.loud_raw_sheep_imagine(),
|
||||
description: m.inclusive_pink_tuna_enjoy(),
|
||||
defaultValue: true,
|
||||
category: "Behaviour/Notes",
|
||||
}),
|
||||
show_content_warning: new BooleanPreference({
|
||||
name: m.fair_swift_elephant_hunt(),
|
||||
description: m.gray_minor_bee_endure(),
|
||||
defaultValue: true,
|
||||
category: "Behaviour/Notes",
|
||||
}),
|
||||
popup_avatar_hover: new BooleanPreference({
|
||||
name: m.north_nimble_turkey_transform(),
|
||||
description: m.bold_moving_fly_savor(),
|
||||
defaultValue: false,
|
||||
category: "Behaviour/Timelines",
|
||||
}),
|
||||
infinite_scroll: new BooleanPreference({
|
||||
name: m.sleek_this_earthworm_hug(),
|
||||
description: m.plane_dark_salmon_pout(),
|
||||
defaultValue: true,
|
||||
category: "Behaviour/Timelines",
|
||||
}),
|
||||
confirm_actions: new MultiSelectPreference<
|
||||
"delete" | "follow" | "like" | "reblog"
|
||||
>({
|
||||
name: "Confirm actions",
|
||||
description: "Confirm actions before performing them.",
|
||||
defaultValue: ["delete"],
|
||||
options: {
|
||||
delete: m.trite_salty_eel_race(),
|
||||
follow: m.jolly_empty_bullock_mend(),
|
||||
like: m.patchy_basic_alligator_inspire(),
|
||||
reblog: m.honest_great_rooster_taste(),
|
||||
},
|
||||
category: "Behaviour/Notes",
|
||||
}),
|
||||
ctrl_enter_send: new BooleanPreference({
|
||||
name: m.equal_blue_zebra_launch(),
|
||||
description: m.heavy_pink_meerkat_affirm(),
|
||||
defaultValue: true,
|
||||
category: "Behaviour/Posting",
|
||||
}),
|
||||
emoji_theme: new SelectPreference<
|
||||
"native" | "twemoji" | "noto" | "fluent" | "fluent-flat"
|
||||
>({
|
||||
name: m.weak_bad_martin_glow(),
|
||||
description: m.warm_round_dove_skip(),
|
||||
defaultValue: "native",
|
||||
options: {
|
||||
native: m.slimy_sound_termite_hug(),
|
||||
twemoji: m.new_brave_maggot_relish(),
|
||||
noto: m.shy_clear_spider_cook(),
|
||||
fluent: m.many_tasty_midge_zoom(),
|
||||
"fluent-flat": m.less_early_lionfish_honor(),
|
||||
},
|
||||
category: "Appearance/Globals",
|
||||
}),
|
||||
background_url: new UrlPreference({
|
||||
name: m.stock_large_marten_comfort(),
|
||||
description: m.mean_weird_donkey_stab(),
|
||||
defaultValue: "",
|
||||
category: "Appearance/Globals",
|
||||
}),
|
||||
display_notifications_sidebar: new BooleanPreference({
|
||||
name: m.tired_jumpy_rook_slurp(),
|
||||
description: m.wide_new_robin_empower(),
|
||||
defaultValue: true,
|
||||
category: "Appearance/Globals",
|
||||
}),
|
||||
} as const;
|
||||
63
app/components/preferences/profile.ts
Normal file
63
app/components/preferences/profile.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import { z } from "zod";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
|
||||
const characterRegex = new RegExp(/^[a-z0-9_-]+$/);
|
||||
|
||||
export const formSchema = (identity: Identity) =>
|
||||
toTypedSchema(
|
||||
z.strictObject({
|
||||
banner: z
|
||||
.instanceof(File)
|
||||
.refine(
|
||||
(v) =>
|
||||
v.size <=
|
||||
(identity.instance.configuration.accounts
|
||||
.header_limit ?? Number.POSITIVE_INFINITY),
|
||||
m.civil_icy_ant_mend({
|
||||
size: identity.instance.configuration.accounts
|
||||
.header_limit,
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
avatar: z
|
||||
.instanceof(File)
|
||||
.refine(
|
||||
(v) =>
|
||||
v.size <=
|
||||
(identity.instance.configuration.accounts
|
||||
.avatar_limit ?? Number.POSITIVE_INFINITY),
|
||||
m.zippy_caring_raven_edit({
|
||||
size: identity.instance.configuration.accounts
|
||||
.avatar_limit,
|
||||
}),
|
||||
)
|
||||
.or(z.string().url())
|
||||
.optional(),
|
||||
name: z
|
||||
.string()
|
||||
.max(
|
||||
identity.instance.configuration.accounts
|
||||
.max_displayname_characters,
|
||||
),
|
||||
username: z
|
||||
.string()
|
||||
.regex(characterRegex, m.still_upper_otter_dine())
|
||||
.max(
|
||||
identity.instance.configuration.accounts
|
||||
.max_username_characters,
|
||||
),
|
||||
bio: z
|
||||
.string()
|
||||
.max(
|
||||
identity.instance.configuration.accounts
|
||||
.max_note_characters,
|
||||
),
|
||||
bot: z.boolean().default(false),
|
||||
locked: z.boolean().default(false),
|
||||
discoverable: z.boolean().default(true),
|
||||
fields: z.array(
|
||||
z.strictObject({ name: z.string(), value: z.string() }),
|
||||
),
|
||||
}),
|
||||
);
|
||||
185
app/components/preferences/profile.vue
Normal file
185
app/components/preferences/profile.vue
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
<template>
|
||||
<form v-if="identity" class="grid gap-6" @submit="save">
|
||||
<Transition name="slide-up">
|
||||
<Alert v-if="dirty" layout="button" class="absolute bottom-2 z-10 inset-x-2 w-[calc(100%-1rem)]">
|
||||
<SaveOff class="size-4" />
|
||||
<AlertTitle>Unsaved changes</AlertTitle>
|
||||
<AlertDescription>
|
||||
Click "apply" to save your changes.
|
||||
</AlertDescription>
|
||||
<Button variant="secondary" class="w-full" typ="submit" :disabled="submitting">Apply</Button>
|
||||
</Alert>
|
||||
</Transition>
|
||||
|
||||
<FormField v-slot="{ handleChange, handleBlur }" name="banner">
|
||||
<TextInput :title="m.bright_late_osprey_renew()" :description="m.great_level_lamb_sway()">
|
||||
<Input type="file" accept="image/*" @change="handleChange" @blur="handleBlur" />
|
||||
</TextInput>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ setValue }" name="avatar">
|
||||
<TextInput :title="m.safe_icy_bulldog_quell()">
|
||||
<ImageUploader v-model:image="identity.account.avatar" @submit-file="(file) => setValue(file)"
|
||||
@submit-url="(url) => setValue(url)" />
|
||||
</TextInput>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="name">
|
||||
<TextInput :title="m.mild_known_mallard_jolt()" :description="m.lime_dry_skunk_loop()">
|
||||
<Input v-bind="componentField" />
|
||||
</TextInput>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="username">
|
||||
<TextInput :title="m.neat_silly_dog_prosper()" :description="m.petty_plane_tadpole_earn()">
|
||||
<Input v-bind="componentField" />
|
||||
</TextInput>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="bio">
|
||||
<TextInput :title="m.next_caring_ladybug_hack()" :description="m.stale_just_anaconda_earn()">
|
||||
<Textarea rows="10" v-bind="componentField" />
|
||||
</TextInput>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="fields">
|
||||
<Fields :title="m.aqua_mealy_toucan_pride()" :value="value" @update:value="handleChange" />
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="bot" as-child>
|
||||
<SwitchInput :title="m.gaudy_each_opossum_play()" :description="m.grassy_acidic_gadfly_cure()">
|
||||
<Switch :model-value="value" @update:model-value="handleChange" />
|
||||
</SwitchInput>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="locked" as-child>
|
||||
<SwitchInput :title="m.dirty_moving_shark_emerge()" :description="m.bright_fun_mouse_boil()">
|
||||
<Switch :model-value="value" @update:model-value="handleChange" />
|
||||
</SwitchInput>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="discoverable" as-child>
|
||||
<SwitchInput :title="m.red_vivid_cuckoo_spark()" :description="m.plain_zany_donkey_dart()">
|
||||
<Switch :model-value="value" @update:model-value="handleChange" />
|
||||
</SwitchInput>
|
||||
</FormField>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { SaveOff } from "lucide-vue-next";
|
||||
import { useForm } from "vee-validate";
|
||||
import { toast } from "vue-sonner";
|
||||
import SwitchInput from "~/components/form/switch.vue";
|
||||
import TextInput from "~/components/form/text.vue";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
|
||||
import { Button } from "../ui/button";
|
||||
import { FormField } from "../ui/form";
|
||||
import { Input } from "../ui/input";
|
||||
import { Switch } from "../ui/switch";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import { formSchema } from "./profile";
|
||||
import Fields from "./profile/fields.vue";
|
||||
import ImageUploader from "./profile/image-uploader.vue";
|
||||
|
||||
const dirty = computed(() => form.meta.value.dirty);
|
||||
const submitting = ref(false);
|
||||
|
||||
if (!identity.value) {
|
||||
throw new Error("Identity not found.");
|
||||
}
|
||||
|
||||
const account = computed(() => identity.value?.account as Identity["account"]);
|
||||
const schema = formSchema(identity.value);
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: schema,
|
||||
initialValues: {
|
||||
bio: account.value.source?.note ?? "",
|
||||
bot: account.value.bot ?? false,
|
||||
locked: account.value.locked ?? false,
|
||||
discoverable: account.value.discoverable ?? true,
|
||||
username: account.value.username,
|
||||
name: account.value.display_name,
|
||||
fields:
|
||||
account.value.source?.fields.map((f) => ({
|
||||
name: f.name,
|
||||
value: f.value,
|
||||
})) ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
const save = form.handleSubmit(async (values) => {
|
||||
if (submitting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
const id = toast.loading(m.jolly_noble_sloth_breathe());
|
||||
|
||||
const changedData = {
|
||||
display_name:
|
||||
values.name === account.value.display_name
|
||||
? undefined
|
||||
: values.name,
|
||||
username:
|
||||
values.username === account.value.username
|
||||
? undefined
|
||||
: values.username,
|
||||
note:
|
||||
values.bio === account.value.source?.note ? undefined : values.bio,
|
||||
bot: values.bot === account.value.bot ? undefined : values.bot,
|
||||
locked:
|
||||
values.locked === account.value.locked ? undefined : values.locked,
|
||||
discoverable:
|
||||
values.discoverable === account.value.discoverable
|
||||
? undefined
|
||||
: values.discoverable,
|
||||
// Can't compare two arrays directly in JS, so we need to check if all fields are the same
|
||||
fields_attributes: values.fields.every((field) =>
|
||||
account.value.source?.fields?.some(
|
||||
(f) => f.name === field.name && f.value === field.value,
|
||||
),
|
||||
)
|
||||
? undefined
|
||||
: values.fields,
|
||||
header: values.banner ? values.banner : undefined,
|
||||
avatar: values.avatar ? values.avatar : undefined,
|
||||
};
|
||||
|
||||
if (
|
||||
Object.values(changedData).filter((v) => v !== undefined).length === 0
|
||||
) {
|
||||
toast.dismiss(id);
|
||||
toast.error(m.tough_alive_niklas_promise());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await client.value.updateCredentials(
|
||||
Object.fromEntries(
|
||||
Object.entries(changedData).filter(([, v]) => v !== undefined),
|
||||
),
|
||||
);
|
||||
|
||||
toast.dismiss(id);
|
||||
toast.success(m.spry_honest_kestrel_arrive());
|
||||
|
||||
if (identity.value) {
|
||||
identity.value.account = data;
|
||||
}
|
||||
|
||||
form.resetForm({
|
||||
values: {
|
||||
...form.values,
|
||||
...values,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
toast.dismiss(id);
|
||||
}
|
||||
|
||||
submitting.value = false;
|
||||
});
|
||||
</script>
|
||||
104
app/components/preferences/profile/fields.vue
Normal file
104
app/components/preferences/profile/fields.vue
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<template>
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{{ title }}
|
||||
<Button type="button" variant="secondary" size="icon" class="ml-auto" @click="addField()" :title="m.front_north_eel_gulp()">
|
||||
<Plus />
|
||||
</Button>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<VueDraggable class="grid gap-4" v-model="list" :animation="200" handle=".drag-handle">
|
||||
<div v-for="(field, index) in list" :key="field.id"
|
||||
class="grid items-center grid-cols-[auto_repeat(3,minmax(0,1fr))_auto] gap-2">
|
||||
<Button as="span" variant="ghost" size="icon" class="drag-handle cursor-grab">
|
||||
<GripVertical />
|
||||
</Button>
|
||||
<Input :model-value="field.name" placeholder="Name" @update:model-value="
|
||||
(e) => updateKey(index, String(e))
|
||||
" />
|
||||
<Input :model-value="field.value" placeholder="Value" class="col-span-2" @update:model-value="
|
||||
(e) => updateValue(index, String(e))
|
||||
" />
|
||||
<Button type="button" variant="secondary" size="icon" @click="removeField(index)">
|
||||
<Trash />
|
||||
</Button>
|
||||
</div>
|
||||
</VueDraggable>
|
||||
<FormMessage />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { GripVertical, Plus, Trash } from "lucide-vue-next";
|
||||
import { VueDraggable } from "vue-draggable-plus";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
FormControl,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
|
||||
const { title } = defineProps<{
|
||||
title: string;
|
||||
}>();
|
||||
|
||||
const value = defineModel<{ name: string; value: string }[]>("value", {
|
||||
default: [],
|
||||
});
|
||||
|
||||
const list = ref<
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
value: string;
|
||||
}[]
|
||||
>(
|
||||
value.value.map((item, index) => ({
|
||||
id: String(index),
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
})),
|
||||
);
|
||||
|
||||
watch(
|
||||
list,
|
||||
(newList) => {
|
||||
value.value = newList.map((item) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
}));
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
},
|
||||
);
|
||||
|
||||
const updateKey = (index: number, key: string) => {
|
||||
if (!list.value[index]) {
|
||||
return;
|
||||
}
|
||||
|
||||
list.value[index].name = key;
|
||||
};
|
||||
|
||||
const updateValue = (index: number, val: string) => {
|
||||
if (!list.value[index]) {
|
||||
return;
|
||||
}
|
||||
|
||||
list.value[index].value = val;
|
||||
};
|
||||
|
||||
const removeField = (index: number) => {
|
||||
list.value.splice(index, 1);
|
||||
};
|
||||
|
||||
const addField = () => {
|
||||
list.value.push({ name: "", value: "", id: String(list.value.length) });
|
||||
};
|
||||
</script>
|
||||
243
app/components/preferences/profile/image-uploader.vue
Normal file
243
app/components/preferences/profile/image-uploader.vue
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogTrigger :as-child="true">
|
||||
<Button
|
||||
v-bind="$attrs"
|
||||
variant="ghost"
|
||||
class="h-fit w-fit p-0 m-0 relative group border overflow-hidden"
|
||||
>
|
||||
<Avatar class="size-32" :src="image" :name="displayName" />
|
||||
<div
|
||||
class="absolute inset-0 bg-background/80 flex group-hover:opacity-100 opacity-0 duration-200 items-center justify-center"
|
||||
>
|
||||
<Upload />
|
||||
</div>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogTitle>
|
||||
{{ m.due_hour_husky_prosper() }}
|
||||
</DialogTitle>
|
||||
<DialogDescription class="sr-only">
|
||||
{{ m.suave_broad_albatross_drop() }}
|
||||
</DialogDescription>
|
||||
<form class="grid gap-6" @submit="submit">
|
||||
<Tabs
|
||||
default-value="upload"
|
||||
class="mt-2 *:data-[slot=tabs-content]:mt-2"
|
||||
>
|
||||
<TabsList class="w-full *:w-full">
|
||||
<TabsTrigger value="upload">
|
||||
{{ m.flat_safe_haddock_gaze() }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="gravatar">
|
||||
{{ m.inclusive_long_lizard_boost() }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="url">
|
||||
{{ m.proud_next_elk_beam() }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="upload">
|
||||
<FormField
|
||||
v-slot="{ handleChange, handleBlur }"
|
||||
name="image"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel class="sr-only">
|
||||
{{ m.flat_safe_haddock_gaze() }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@change="handleChange"
|
||||
@blur="handleBlur"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{{ m.lime_late_millipede_urge() }}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</TabsContent>
|
||||
<TabsContent value="gravatar">
|
||||
<FormField
|
||||
v-slot="{ componentField, value }"
|
||||
name="email"
|
||||
@update:model-value="
|
||||
async (value) => {
|
||||
gravatarUrl = await emailToGravatar(value);
|
||||
}
|
||||
"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{{ m.lower_formal_kudu_lift() }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
v-bind="componentField"
|
||||
:disabled="isSubmitting"
|
||||
placeholder="peter.griffin@fox.com"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<div v-if="value" class="grid gap-4 !mt-4">
|
||||
<Label>{{
|
||||
m.witty_honest_wallaby_support()
|
||||
}}</Label>
|
||||
<Avatar class="size-32" :src="gravatarUrl" />
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</TabsContent>
|
||||
<TabsContent value="url">
|
||||
<FormField
|
||||
v-slot="{ componentField, value }"
|
||||
name="url"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{{ m.proud_next_elk_beam() }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
v-bind="componentField"
|
||||
:disabled="isSubmitting"
|
||||
placeholder="https://mysite.com/avatar.webp"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<div v-if="value" class="grid gap-4 !mt-4">
|
||||
<Label>{{
|
||||
m.witty_honest_wallaby_support()
|
||||
}}</Label>
|
||||
<Avatar class="size-32" :src="value" />
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<DialogFooter>
|
||||
<DialogClose :as-child="true">
|
||||
<Button variant="outline" :disabled="isSubmitting">
|
||||
{{ m.soft_bold_ant_attend() }}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
{{ m.teary_antsy_panda_aid() }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import { Upload } from "lucide-vue-next";
|
||||
import { useForm } from "vee-validate";
|
||||
import { z } from "zod";
|
||||
import Avatar from "~/components/profiles/avatar.vue";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
|
||||
const { maxSize } = defineProps<{
|
||||
displayName?: string;
|
||||
maxSize?: number;
|
||||
}>();
|
||||
|
||||
const image = defineModel<string>("image", {
|
||||
required: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
submitFile: [file: File];
|
||||
submitUrl: [url: string];
|
||||
}>();
|
||||
|
||||
const schema = toTypedSchema(
|
||||
z
|
||||
.object({
|
||||
image: z
|
||||
.instanceof(File, {
|
||||
message: m.sound_topical_gopher_offer(),
|
||||
})
|
||||
.refine(
|
||||
(v) => v.size <= (maxSize ?? Number.MAX_SAFE_INTEGER),
|
||||
m.zippy_caring_raven_edit({
|
||||
size: maxSize ?? Number.MAX_SAFE_INTEGER,
|
||||
}),
|
||||
),
|
||||
})
|
||||
.or(
|
||||
z.object({
|
||||
url: z.string().url(),
|
||||
}),
|
||||
)
|
||||
.or(
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const emailToGravatar = async (email: string) => {
|
||||
const sha256 = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(email),
|
||||
);
|
||||
|
||||
return `https://www.gravatar.com/avatar/${Array.from(new Uint8Array(sha256))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("")}?size=512`;
|
||||
};
|
||||
|
||||
const open = ref(false);
|
||||
const gravatarUrl = ref<string | undefined>(undefined);
|
||||
|
||||
const { handleSubmit, isSubmitting } = useForm({
|
||||
validationSchema: schema,
|
||||
});
|
||||
|
||||
const submit = handleSubmit(async (values) => {
|
||||
if ((values as { image: File }).image) {
|
||||
emit("submitFile", (values as { image: File }).image);
|
||||
} else if ((values as { url: string }).url) {
|
||||
emit("submitUrl", (values as { url: string }).url);
|
||||
} else if ((values as { email: string }).email) {
|
||||
emit(
|
||||
"submitUrl",
|
||||
await emailToGravatar((values as { email: string }).email),
|
||||
);
|
||||
}
|
||||
|
||||
open.value = false;
|
||||
});
|
||||
</script>
|
||||
37
app/components/preferences/stats.vue
Normal file
37
app/components/preferences/stats.vue
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<template>
|
||||
<Card class="grid gap-3 text-sm max-w-sm">
|
||||
<dl class="grid gap-3">
|
||||
<div v-for="[key, value] of data" :key="key" class="flex flex-row items-baseline justify-between gap-4 truncate">
|
||||
<dt class="text-muted-foreground">
|
||||
{{ key }}
|
||||
</dt>
|
||||
<dd class="font-mono" v-if="typeof value === 'string'">{{ value }}</dd>
|
||||
<dd class="font-mono" v-else>
|
||||
<component :is="value" />
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script lang="tsx" setup>
|
||||
import type { VNode } from "vue";
|
||||
import pkg from "~~/package.json";
|
||||
import { Card } from "../ui/card";
|
||||
|
||||
const data: [string, string | VNode][] = [
|
||||
["Version", pkg.version],
|
||||
["Licence", pkg.license],
|
||||
["Author", pkg.author.name],
|
||||
[
|
||||
"Repository",
|
||||
<a
|
||||
href={pkg.repository.url.replace("git+", "")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{pkg.repository.url.replace("git+", "").replace("https://", "")}
|
||||
</a>,
|
||||
],
|
||||
];
|
||||
</script>
|
||||
71
app/components/preferences/types.ts
Normal file
71
app/components/preferences/types.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
export interface PreferenceOptions<ValueType> {
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
defaultValue: ValueType;
|
||||
}
|
||||
|
||||
export abstract class Preference<ValueType> {
|
||||
public abstract options: PreferenceOptions<ValueType>;
|
||||
}
|
||||
|
||||
export class TextPreference extends Preference<string> {
|
||||
constructor(public options: PreferenceOptions<string>) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class NumberPreference extends Preference<number> {
|
||||
constructor(
|
||||
public options: PreferenceOptions<number> & {
|
||||
integer?: boolean;
|
||||
step?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class BooleanPreference extends Preference<boolean> {
|
||||
constructor(public options: PreferenceOptions<boolean>) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class SelectPreference<T extends string> extends Preference<T> {
|
||||
constructor(
|
||||
public options: PreferenceOptions<T> & {
|
||||
options: Record<T, string>;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class CodePreference extends Preference<string> {
|
||||
constructor(
|
||||
public options: PreferenceOptions<string> & {
|
||||
language?: "css";
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class MultiSelectPreference<T extends string> extends Preference<T[]> {
|
||||
constructor(
|
||||
public options: PreferenceOptions<T[]> & {
|
||||
options: Record<T, string>;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class UrlPreference extends Preference<string> {
|
||||
constructor(public options: PreferenceOptions<string>) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
40
app/components/preferences/types/base.vue
Normal file
40
app/components/preferences/types/base.vue
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<template>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_auto] gap-2 hover:bg-muted/40 duration-75 p-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-sm font-semibold tracking-tight">{{ pref.options.name }}</h3>
|
||||
<small v-if="pref.options.description" class="text-xs font-medium leading-none text-muted-foreground">{{
|
||||
pref.options.description }}</small>
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<slot :value="value" :set-value="setValue" />
|
||||
</div>
|
||||
<slot name="extra" :value="value" :set-value="setValue" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { preferences as prefs } from "../preferences";
|
||||
import type { Preference } from "../types";
|
||||
|
||||
const { pref, name } = defineProps<{
|
||||
pref: Preference<any>;
|
||||
name: keyof typeof prefs;
|
||||
}>();
|
||||
|
||||
const value = ref<any>(preferences[name].value);
|
||||
const setValue = (newValue: MaybeRef<any>) => {
|
||||
value.value = toValue(newValue);
|
||||
};
|
||||
|
||||
watch(value, (newVal) => {
|
||||
preferences[name].value = newVal;
|
||||
});
|
||||
|
||||
defineSlots<{
|
||||
default(props: {
|
||||
value: any;
|
||||
setValue: (value: MaybeRef<any>) => void;
|
||||
}): any;
|
||||
extra(props: { value: any; setValue: (value: MaybeRef<any>) => void }): any;
|
||||
}>();
|
||||
</script>
|
||||
17
app/components/preferences/types/boolean.vue
Normal file
17
app/components/preferences/types/boolean.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<template>
|
||||
<Base :pref="pref" :name="name" v-slot="{ setValue, value }">
|
||||
<Switch @update:model-value="setValue" :model-value="value" />
|
||||
</Base>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import type { preferences as prefs } from "../preferences";
|
||||
import type { BooleanPreference } from "../types";
|
||||
import Base from "./base.vue";
|
||||
|
||||
const { pref, name } = defineProps<{
|
||||
pref: BooleanPreference;
|
||||
name: keyof typeof prefs;
|
||||
}>();
|
||||
</script>
|
||||
36
app/components/preferences/types/code.vue
Normal file
36
app/components/preferences/types/code.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<template>
|
||||
<Collapsible as-child>
|
||||
<Base :name="name" :pref="pref">
|
||||
<template #default>
|
||||
<CollapsibleTrigger as-child>
|
||||
<Button variant="outline">
|
||||
Open code
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</template>
|
||||
<template #extra="{ setValue, value }">
|
||||
<CollapsibleContent class="col-span-2 mt-2">
|
||||
<Textarea :rows="10" :model-value="value" @update:model-value="setValue" />
|
||||
</CollapsibleContent>
|
||||
</template>
|
||||
</Base>
|
||||
</Collapsible>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "~/components/ui/collapsible";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import type { preferences as prefs } from "../preferences";
|
||||
import type { CodePreference } from "../types";
|
||||
import Base from "./base.vue";
|
||||
|
||||
const { pref, name } = defineProps<{
|
||||
pref: CodePreference;
|
||||
name: keyof typeof prefs;
|
||||
}>();
|
||||
</script>
|
||||
41
app/components/preferences/types/multiselect.vue
Normal file
41
app/components/preferences/types/multiselect.vue
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<template>
|
||||
<Base :pref="pref" :name="name" v-slot="{ setValue, value }">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline">
|
||||
Pick
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-56">
|
||||
<DropdownMenuCheckboxItem v-for="[option, title] in Object.entries(pref.options.options)" :key="option"
|
||||
:model-value="value.includes(option)" @update:model-value="checked => {
|
||||
if (checked) {
|
||||
setValue([...value, option]);
|
||||
} else {
|
||||
setValue(value.filter((v: any) => v !== option));
|
||||
}
|
||||
}">
|
||||
{{ title }}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Base>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import type { preferences as prefs } from "../preferences";
|
||||
import type { MultiSelectPreference } from "../types";
|
||||
import Base from "./base.vue";
|
||||
|
||||
const { pref, name } = defineProps<{
|
||||
pref: MultiSelectPreference<string>;
|
||||
name: keyof typeof prefs;
|
||||
}>();
|
||||
</script>
|
||||
29
app/components/preferences/types/number.vue
Normal file
29
app/components/preferences/types/number.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<template>
|
||||
<Base :pref="pref" :name="name" v-slot="{ setValue, value }">
|
||||
<NumberField :model-value="value" @update:model-value="setValue" :min="pref.options.min" :max="pref.options.max" :step="pref.options.integer ? 1 : pref.options.step">
|
||||
<NumberFieldContent>
|
||||
<NumberFieldDecrement />
|
||||
<NumberFieldInput />
|
||||
<NumberFieldIncrement />
|
||||
</NumberFieldContent>
|
||||
</NumberField>
|
||||
</Base>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldContent,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from "~/components/ui/number-field";
|
||||
import type { preferences as prefs } from "../preferences";
|
||||
import type { NumberPreference } from "../types";
|
||||
import Base from "./base.vue";
|
||||
|
||||
const { pref, name } = defineProps<{
|
||||
pref: NumberPreference;
|
||||
name: keyof typeof prefs;
|
||||
}>();
|
||||
</script>
|
||||
35
app/components/preferences/types/select.vue
Normal file
35
app/components/preferences/types/select.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<Base :pref="pref" :name="name" v-slot="{ setValue, value }">
|
||||
<Select :model-value="value" @update:model-value="setValue">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an option" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="[val, title] in Object.entries(pref.options.options)" :value="val">
|
||||
{{ title }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Base>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import type { preferences as prefs } from "../preferences";
|
||||
import type { SelectPreference } from "../types";
|
||||
import Base from "./base.vue";
|
||||
|
||||
const { pref, name } = defineProps<{
|
||||
pref: SelectPreference<string>;
|
||||
name: keyof typeof prefs;
|
||||
}>();
|
||||
</script>
|
||||
17
app/components/preferences/types/text.vue
Normal file
17
app/components/preferences/types/text.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<template>
|
||||
<Base :pref="pref" :name="name" v-slot="{ setValue, value }">
|
||||
<Input placeholder="Content here..." :model-value="value" @update:model-value="setValue" />
|
||||
</Base>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Input } from "~/components/ui/input";
|
||||
import type { preferences as prefs } from "../preferences";
|
||||
import type { TextPreference } from "../types";
|
||||
import Base from "./base.vue";
|
||||
|
||||
const { pref, name } = defineProps<{
|
||||
pref: TextPreference;
|
||||
name: keyof typeof prefs;
|
||||
}>();
|
||||
</script>
|
||||
36
app/components/preferences/types/url.vue
Normal file
36
app/components/preferences/types/url.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<template>
|
||||
<Collapsible as-child>
|
||||
<Base :pref="pref" :name="name">
|
||||
<template #default>
|
||||
<CollapsibleTrigger as-child>
|
||||
<Button variant="outline">
|
||||
Edit URL
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</template>
|
||||
<template #extra="{ setValue, value }">
|
||||
<CollapsibleContent class="col-span-2 mt-2">
|
||||
<UrlInput placeholder="Type URL or domain here..." :model-value="value" @update:model-value="setValue" />
|
||||
</CollapsibleContent>
|
||||
</template>
|
||||
</Base>
|
||||
</Collapsible>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "~/components/ui/collapsible";
|
||||
import { Input, UrlInput } from "~/components/ui/input";
|
||||
import type { preferences as prefs } from "../preferences";
|
||||
import type { TextPreference } from "../types";
|
||||
import Base from "./base.vue";
|
||||
|
||||
const { pref, name } = defineProps<{
|
||||
pref: TextPreference;
|
||||
name: keyof typeof prefs;
|
||||
}>();
|
||||
</script>
|
||||
15
app/components/profiles/address.vue
Normal file
15
app/components/profiles/address.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<Text class="font-semibold text-sm tracking-tight">
|
||||
<span class="text-accent-foreground">@{{ username }}</span>
|
||||
<span v-if="domain" class="text-muted-foreground">@{{ domain }}</span>
|
||||
</Text>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Text from "../typography/text.vue";
|
||||
|
||||
const { username, domain } = defineProps<{
|
||||
username?: string;
|
||||
domain?: string;
|
||||
}>();
|
||||
</script>
|
||||
31
app/components/profiles/avatar.vue
Normal file
31
app/components/profiles/avatar.vue
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<template>
|
||||
<Avatar :class="['rounded-md bg-secondary']">
|
||||
<AvatarFallback v-if="name">
|
||||
{{ getInitials(name) }}
|
||||
</AvatarFallback>
|
||||
<AvatarImage v-if="src" :src="src" :alt="`${name}'s avatar`" />
|
||||
</Avatar>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
||||
|
||||
const { name } = defineProps<{
|
||||
src?: string;
|
||||
name?: string;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* Gets the initials of any string, even if it's not a name.
|
||||
* If not a name, it will return the first two characters.
|
||||
* @param name
|
||||
*/
|
||||
const getInitials = (name: string): string => {
|
||||
const initials = name.match(/\b\w/g) || [];
|
||||
|
||||
const firstLetter = initials.shift() || name[0] || "";
|
||||
const secondLetter = initials.pop() || name[1] || "";
|
||||
|
||||
return `${firstLetter}${secondLetter}`.toUpperCase();
|
||||
};
|
||||
</script>
|
||||
129
app/components/profiles/profile-actions.vue
Normal file
129
app/components/profiles/profile-actions.vue
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="min-w-56">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem as="button" @click="copyText(account.username)">
|
||||
<AtSign />
|
||||
{{ m.cool_dark_tapir_belong() }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem as="button" @click="copyText(JSON.stringify(account, null, 4))">
|
||||
<Code />
|
||||
{{ m.yummy_moving_scallop_sail() }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem as="button" @click="copyText(account.id)">
|
||||
<Hash />
|
||||
{{ m.sunny_zany_jellyfish_pop() }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem as="button" @click="copyText(url)">
|
||||
<Link />
|
||||
{{ m.ago_new_pelican_drip() }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem as="button" @click="copyText(account.url)">
|
||||
<Link />
|
||||
{{ m.solid_witty_zebra_walk() }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem as="a" v-if="isRemote" target="_blank" rel="noopener noreferrer" :href="account.url">
|
||||
<ExternalLink />
|
||||
{{ m.active_trite_lark_inspire() }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator v-if="isLoggedIn && !isMe" />
|
||||
<DropdownMenuGroup v-if="isLoggedIn && !isMe">
|
||||
<DropdownMenuItem as="button" @click="muteUser(account.id)">
|
||||
<VolumeX />
|
||||
{{ m.spare_wild_mole_intend() }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem as="button" @click="blockUser(account.id)">
|
||||
<Ban />
|
||||
{{ m.misty_soft_sparrow_vent() }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator v-if="isRemote" />
|
||||
<DropdownMenuGroup v-if="isRemote">
|
||||
<DropdownMenuItem as="button" @click="refresh">
|
||||
<RefreshCw />
|
||||
{{ m.slow_chunky_chipmunk_hush() }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator v-if="isLoggedIn && !isMe" />
|
||||
<DropdownMenuGroup v-if="isLoggedIn && !isMe">
|
||||
<DropdownMenuItem as="button" :disabled="true">
|
||||
<Flag />
|
||||
{{ m.great_few_jaguar_rise() }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Account } from "@versia/client/schemas";
|
||||
import {
|
||||
AtSign,
|
||||
Ban,
|
||||
Code,
|
||||
ExternalLink,
|
||||
Flag,
|
||||
Hash,
|
||||
Link,
|
||||
RefreshCw,
|
||||
VolumeX,
|
||||
} from "lucide-vue-next";
|
||||
import { toast } from "vue-sonner";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
|
||||
const { account } = defineProps<{
|
||||
account: z.infer<typeof Account>;
|
||||
}>();
|
||||
|
||||
const isMe = identity.value?.account.id === account.id;
|
||||
const isLoggedIn = !!identity.value;
|
||||
|
||||
const { copy } = useClipboard();
|
||||
const copyText = (text: string) => {
|
||||
copy(text);
|
||||
toast.success(m.flat_nice_worm_dream());
|
||||
};
|
||||
|
||||
const url = wrapUrl(`/@${account.acct}`);
|
||||
const isRemote = account.acct.includes("@");
|
||||
|
||||
const muteUser = async (userId: string) => {
|
||||
const id = toast.loading(m.ornate_tidy_coyote_grow());
|
||||
await client.value.muteAccount(userId);
|
||||
toast.dismiss(id);
|
||||
|
||||
toast.success("User muted");
|
||||
};
|
||||
|
||||
const blockUser = async (userId: string) => {
|
||||
const id = toast.loading(m.empty_smug_raven_bloom());
|
||||
await client.value.blockAccount(userId);
|
||||
toast.dismiss(id);
|
||||
|
||||
toast.success("User blocked");
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
const id = toast.loading(m.real_every_macaw_wish());
|
||||
await client.value.refetchAccount(account.id);
|
||||
toast.dismiss(id);
|
||||
|
||||
toast.success(m.many_cool_fox_love());
|
||||
};
|
||||
</script>
|
||||
32
app/components/profiles/profile-badge.vue
Normal file
32
app/components/profiles/profile-badge.vue
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<template>
|
||||
<Tooltip>
|
||||
<TooltipTrigger :as-child="true">
|
||||
<Badge variant="default" class="gap-1">
|
||||
<BadgeCheck v-if="verified" />
|
||||
<img v-else-if="icon" :src="icon" alt="" class="size-4 rounded" />
|
||||
{{ name }}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent v-if="description">
|
||||
<Text>{{ description }}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { BadgeCheck } from "lucide-vue-next";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "~/components/ui/tooltip";
|
||||
import Text from "../typography/text.vue";
|
||||
|
||||
defineProps<{
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
verified?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
48
app/components/profiles/profile-badges.vue
Normal file
48
app/components/profiles/profile-badges.vue
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<Row class="gap-2" wrap
|
||||
v-if="isDeveloper || account.bot || roles.length > 0"
|
||||
>
|
||||
<ProfileBadge
|
||||
v-if="isDeveloper"
|
||||
:name="m.nice_bad_grizzly_coax()"
|
||||
:description="m.honest_jolly_shell_blend()"
|
||||
:verified="true"
|
||||
/>
|
||||
<ProfileBadge
|
||||
v-if="account.bot"
|
||||
:name="m.merry_red_shrimp_bump()"
|
||||
:description="m.sweet_mad_jannes_create()"
|
||||
/>
|
||||
<ProfileBadge
|
||||
v-for="role in roles"
|
||||
:key="role.id"
|
||||
:name="role.name"
|
||||
:description="role.description"
|
||||
:icon="role.icon"
|
||||
/>
|
||||
</Row>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Account } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
import Row from "../typography/layout/row.vue";
|
||||
import ProfileBadge from "./profile-badge.vue";
|
||||
|
||||
const { account } = defineProps<{
|
||||
account: z.infer<typeof Account>;
|
||||
}>();
|
||||
|
||||
const config = useConfig();
|
||||
const roles = account.roles.filter((r) => r.visible);
|
||||
// Get user handle in username@instance format
|
||||
const handle = account.acct.includes("@")
|
||||
? account.acct
|
||||
: `${account.acct}@${
|
||||
identity.value?.instance.domain ?? window.location.host
|
||||
}`;
|
||||
const isDeveloper = config.DEVELOPER_HANDLES.includes(handle);
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
21
app/components/profiles/profile-fields.vue
Normal file
21
app/components/profiles/profile-fields.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<template>
|
||||
<Col class="gap-y-4">
|
||||
<Col v-for="field in fields" :key="field.name" class="gap-1 break-words">
|
||||
<HeadingSmall v-render-emojis="emojis">{{ field.name }}</HeadingSmall>
|
||||
<Html v-html="field.value" v-render-emojis="emojis" />
|
||||
</Col>
|
||||
</Col>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { CustomEmoji, Field } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import HeadingSmall from "~/components/typography/headings/small.vue";
|
||||
import Html from "../typography/html.vue";
|
||||
import Col from "../typography/layout/col.vue";
|
||||
|
||||
defineProps<{
|
||||
fields: z.infer<typeof Field>[];
|
||||
emojis: z.infer<typeof CustomEmoji>[];
|
||||
}>();
|
||||
</script>
|
||||
36
app/components/profiles/profile-header.vue
Normal file
36
app/components/profiles/profile-header.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<template>
|
||||
<CardHeader class="relative w-full">
|
||||
<div class="bg-muted rounded overflow-hidden h-48 md:h-72 w-full">
|
||||
<img
|
||||
v-if="header"
|
||||
:src="header"
|
||||
alt=""
|
||||
class="object-cover w-full h-full"
|
||||
/>
|
||||
<!-- Shadow overlay at the bottom -->
|
||||
<div
|
||||
class="absolute bottom-0 w-full h-1/3 bg-gradient-to-b from-black/0 to-black/40"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-0 translate-y-1/3 left-4 flex flex-row items-start gap-2"
|
||||
>
|
||||
<Avatar
|
||||
class="size-32 border"
|
||||
:src="avatar"
|
||||
:name="displayName"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { CardHeader } from "~/components/ui/card";
|
||||
import Avatar from "./avatar.vue";
|
||||
|
||||
defineProps<{
|
||||
header: string;
|
||||
avatar: string;
|
||||
displayName: string;
|
||||
}>();
|
||||
</script>
|
||||
80
app/components/profiles/profile-relationship-actions.vue
Normal file
80
app/components/profiles/profile-relationship-actions.vue
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<template>
|
||||
<Button variant="secondary" :disabled="isLoading || relationship?.requested" v-if="!isMe && identity"
|
||||
@click="relationship?.following ? unfollow() : follow()">
|
||||
<Loader v-if="isLoading" class="animate-spin" />
|
||||
<span v-else>
|
||||
{{
|
||||
relationship?.following
|
||||
? m.brief_upper_otter_cuddle()
|
||||
: relationship?.requested
|
||||
? m.weak_bright_larva_grasp()
|
||||
: m.lazy_major_loris_grasp()
|
||||
}}
|
||||
</span>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Account } from "@versia/client/schemas";
|
||||
import { Loader } from "lucide-vue-next";
|
||||
import { toast } from "vue-sonner";
|
||||
import type { z } from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
import { confirmModalService } from "../modals/composable";
|
||||
|
||||
const { account } = defineProps<{
|
||||
account: z.infer<typeof Account>;
|
||||
}>();
|
||||
|
||||
const { relationship, isLoading } = useRelationship(client, account.id);
|
||||
const isMe = identity.value?.account.id === account.id;
|
||||
|
||||
const follow = async () => {
|
||||
if (preferences.confirm_actions.value.includes("follow")) {
|
||||
const confirmation = await confirmModalService.confirm({
|
||||
title: m.many_fair_capybara_imagine(),
|
||||
message: m.mellow_yummy_jannes_cuddle({
|
||||
acct: `@${account.acct}`,
|
||||
}),
|
||||
confirmText: m.cuddly_even_tern_loop(),
|
||||
cancelText: m.soft_bold_ant_attend(),
|
||||
});
|
||||
|
||||
if (!confirmation.confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const id = toast.loading(m.quick_basic_peacock_bubble());
|
||||
const { data } = await client.value.followAccount(account.id);
|
||||
toast.dismiss(id);
|
||||
|
||||
relationship.value = data;
|
||||
toast.success(m.awake_quick_cuckoo_smile());
|
||||
};
|
||||
|
||||
const unfollow = async () => {
|
||||
if (preferences.confirm_actions.value.includes("follow")) {
|
||||
const confirmation = await confirmModalService.confirm({
|
||||
title: m.funny_aloof_swan_loop(),
|
||||
message: m.white_best_dolphin_catch({
|
||||
acct: `@${account.acct}`,
|
||||
}),
|
||||
confirmText: m.cute_polite_oryx_blend(),
|
||||
cancelText: m.soft_bold_ant_attend(),
|
||||
});
|
||||
|
||||
if (!confirmation.confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const id = toast.loading(m.big_safe_guppy_mix());
|
||||
const { data } = await client.value.unfollowAccount(account.id);
|
||||
toast.dismiss(id);
|
||||
|
||||
relationship.value = data;
|
||||
toast.success(m.misty_level_stingray_expand());
|
||||
};
|
||||
</script>
|
||||
30
app/components/profiles/profile-stats.vue
Normal file
30
app/components/profiles/profile-stats.vue
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<template>
|
||||
<Row class="gap-2 w-full justify-around">
|
||||
<Col centered>
|
||||
<Bold>{{ noteCount }}</Bold>
|
||||
<Small muted>{{ m.real_gray_stork_seek() }}</Small>
|
||||
</Col>
|
||||
<Col centered>
|
||||
<Bold>{{ followerCount }}</Bold>
|
||||
<Small muted>{{ m.teal_helpful_parakeet_hike() }}</Small>
|
||||
</Col>
|
||||
<Col centered>
|
||||
<Bold>{{ followingCount }}</Bold>
|
||||
<Small muted>{{ m.aloof_royal_samuel_startle() }}</Small>
|
||||
</Col>
|
||||
</Row>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import * as m from "~~/paraglide/messages.js";
|
||||
import Bold from "../typography/bold.vue";
|
||||
import Col from "../typography/layout/col.vue";
|
||||
import Row from "../typography/layout/row.vue";
|
||||
import Small from "../typography/small.vue";
|
||||
|
||||
const { noteCount, followerCount, followingCount } = defineProps<{
|
||||
noteCount: number;
|
||||
followerCount: number;
|
||||
followingCount: number;
|
||||
}>();
|
||||
</script>
|
||||
65
app/components/profiles/profile.vue
Normal file
65
app/components/profiles/profile.vue
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<template>
|
||||
<Card class="gap-4">
|
||||
<ProfileHeader :header="account.header" :avatar="account.avatar" :display-name="account.display_name" />
|
||||
<Row class="justify-end gap-2">
|
||||
<ProfileRelationshipActions :account="account" />
|
||||
<ProfileActions :account="account">
|
||||
<Button variant="secondary" size="icon">
|
||||
<Ellipsis />
|
||||
</Button>
|
||||
</ProfileActions>
|
||||
</Row>
|
||||
<Col class="justify-center">
|
||||
<Text class="font-bold" v-render-emojis="account.emojis">
|
||||
{{ account.display_name }}
|
||||
</Text>
|
||||
<Address :username="username" :domain="domain" />
|
||||
</Col>
|
||||
<ProfileBadges :account="account" />
|
||||
<Html v-html="account.note" v-render-emojis="account.emojis" />
|
||||
<Separator />
|
||||
<ProfileFields v-if="account.fields.length > 0" :fields="account.fields" :emojis="account.emojis" />
|
||||
<Separator v-if="account.fields.length > 0" />
|
||||
<Row>
|
||||
<HeadingSmall class="flex items-center gap-1">
|
||||
<CalendarDays class="size-4" /> {{ formattedCreationDate }}
|
||||
</HeadingSmall>
|
||||
</Row>
|
||||
<Separator />
|
||||
<ProfileStats :follower-count="account.followers_count" :following-count="account.following_count"
|
||||
:note-count="account.statuses_count" />
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Account } from "@versia/client/schemas";
|
||||
import { CalendarDays, Ellipsis } from "lucide-vue-next";
|
||||
import type { z } from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { getLocale } from "~~/paraglide/runtime";
|
||||
import HeadingSmall from "../typography/headings/small.vue";
|
||||
import Html from "../typography/html.vue";
|
||||
import Col from "../typography/layout/col.vue";
|
||||
import Row from "../typography/layout/row.vue";
|
||||
import Text from "../typography/text.vue";
|
||||
import Address from "./address.vue";
|
||||
import ProfileActions from "./profile-actions.vue";
|
||||
import ProfileBadges from "./profile-badges.vue";
|
||||
import ProfileFields from "./profile-fields.vue";
|
||||
import ProfileHeader from "./profile-header.vue";
|
||||
import ProfileRelationshipActions from "./profile-relationship-actions.vue";
|
||||
import ProfileStats from "./profile-stats.vue";
|
||||
|
||||
const { account } = defineProps<{
|
||||
account: z.infer<typeof Account>;
|
||||
}>();
|
||||
|
||||
const [username, domain] = account.acct.split("@");
|
||||
|
||||
const formattedCreationDate = new Intl.DateTimeFormat(getLocale(), {
|
||||
dateStyle: "long",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(account.created_at || 0));
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue