refactor: ♻️ Rewrite state system to use Pinia for composer and auth

This commit is contained in:
Jesse Wierzbinski 2025-08-28 07:41:51 +02:00
parent a6db9e059d
commit b510782a30
No known key found for this signature in database
80 changed files with 999 additions and 1011 deletions

View file

@ -24,14 +24,10 @@ import { TooltipProvider } from "./components/ui/tooltip";
//import "~/styles/mcdonalds.css"; //import "~/styles/mcdonalds.css";
const lang = useLanguage(); const lang = useLanguage();
const authStore = useAuthStore();
overwriteGetLocale(() => lang.value); overwriteGetLocale(() => lang.value);
const code = useRequestURL().searchParams.get("code"); const description = useExtendedDescription();
const origin = useRequestURL().searchParams.get("origin");
const appData = useAppData();
const instance = useInstance();
const description = useExtendedDescription(client);
const route = useRoute();
// Theme switcher // Theme switcher
const colorMode = useColorMode(); const colorMode = useColorMode();
@ -62,13 +58,13 @@ useSeoMeta({
titleTemplate: (titleChunk) => { titleTemplate: (titleChunk) => {
return titleChunk ? `${titleChunk} · Versia` : "Versia"; return titleChunk ? `${titleChunk} · Versia` : "Versia";
}, },
title: computed(() => instance.value?.title ?? ""), title: computed(() => authStore.instance?.title ?? ""),
ogImage: computed(() => instance.value?.banner?.url), ogImage: computed(() => authStore.instance?.banner?.url),
twitterTitle: computed(() => instance.value?.title ?? ""), twitterTitle: computed(() => authStore.instance?.title ?? ""),
twitterDescription: computed(() => twitterDescription: computed(() =>
convert(description.value?.content ?? ""), convert(description.value?.content ?? ""),
), ),
twitterImage: computed(() => instance.value?.banner?.url), twitterImage: computed(() => authStore.instance?.banner?.url),
description: computed(() => convert(description.value?.content ?? "")), description: computed(() => convert(description.value?.content ?? "")),
ogDescription: computed(() => convert(description.value?.content ?? "")), ogDescription: computed(() => convert(description.value?.content ?? "")),
ogSiteName: "Versia", ogSiteName: "Versia",
@ -82,28 +78,7 @@ useHead({
}, },
}); });
if (code && origin && appData.value && route.path !== "/oauth/code") { useCacheRefresh();
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> </script>
<style> <style>

View file

@ -32,7 +32,7 @@
<TriangleAlert class="!size-5" /> <TriangleAlert class="!size-5" />
</Toggle> </Toggle>
</ComposerButton> </ComposerButton>
<CharacterCounter class="ml-auto" :max="(identity as Identity).instance.configuration.statuses.max_characters" :current="rawContent.length" /> <CharacterCounter class="ml-auto" :max="authStore.instance?.configuration.statuses.max_characters ?? 0" :current="rawContent.length" />
<Button type="submit" size="lg" :disabled="sending || !canSend" @click="emit('submit')"> <Button type="submit" size="lg" :disabled="sending || !canSend" @click="emit('submit')">
<Loader v-if="sending" class="!size-5 animate-spin" /> <Loader v-if="sending" class="!size-5 animate-spin" />
{{ {{
@ -57,7 +57,7 @@ import { Button } from "../ui/button";
import { Toggle } from "../ui/toggle"; import { Toggle } from "../ui/toggle";
import ComposerButton from "./button.vue"; import ComposerButton from "./button.vue";
import CharacterCounter from "./character-counter.vue"; import CharacterCounter from "./character-counter.vue";
import { type ComposerState, visibilities } from "./composer"; import { visibilities } from "./visibilities";
import VisibilityPicker from "./visibility-picker.vue"; import VisibilityPicker from "./visibility-picker.vue";
const { relation, sending, canSend, rawContent } = defineProps<{ const { relation, sending, canSend, rawContent } = defineProps<{
@ -66,6 +66,7 @@ const { relation, sending, canSend, rawContent } = defineProps<{
canSend: boolean; canSend: boolean;
rawContent: string; rawContent: string;
}>(); }>();
const authStore = useAuthStore();
const contentType = defineModel<ComposerState["contentType"]>("contentType", { const contentType = defineModel<ComposerState["contentType"]>("contentType", {
required: true, required: true,

View file

@ -1,240 +0,0 @@
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;
}
};

View file

@ -3,19 +3,19 @@
<Note :note="relation.note" :hide-actions="true" :small-layout="true" /> <Note :note="relation.note" :hide-actions="true" :small-layout="true" />
</div> </div>
<ContentWarning v-if="state.sensitive" v-model="state.contentWarning" /> <ContentWarning v-if="store.sensitive" v-model="store.contentWarning" />
<EditorContent @paste-files="uploadFiles" v-model:content="state.content" v-model:raw-content="state.rawContent" :placeholder="getRandomSplash()" <EditorContent @paste-files="uploadFiles" v-model:content="store.content" v-model:raw-content="store.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" 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'" /> :disabled="store.sending" :mode="store.contentType === 'text/html' ? 'rich' : 'plain'" />
<div class="w-full flex flex-row gap-2 overflow-x-auto *:shrink-0 pb-2"> <div class="w-full flex flex-row gap-2 overflow-x-auto *:shrink-0 pb-2">
<input type="file" ref="fileInput" @change="uploadFileFromEvent" class="hidden" multiple /> <input type="file" ref="fileInput" @change="uploadFileFromEvent" class="hidden" multiple />
<Files v-model:files="state.files" /> <Files v-model:files="store.files" :composer-key="composerKey" />
</div> </div>
<DialogFooter class="items-center flex-row overflow-x-auto"> <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" /> <ComposerButtons @submit="send" @pick-file="fileInput?.click()" v-model:content-type="store.contentType" v-model:sensitive="store.sensitive" v-model:visibility="store.visibility" :relation="store.relation" :sending="store.sending" :can-send="store.canSend" :raw-content="store.rawContent" />
</DialogFooter> </DialogFooter>
</template> </template>
@ -24,37 +24,53 @@ import Note from "~/components/notes/note.vue";
import EditorContent from "../editor/content.vue"; import EditorContent from "../editor/content.vue";
import { DialogFooter } from "../ui/dialog"; import { DialogFooter } from "../ui/dialog";
import ComposerButtons from "./buttons.vue"; import ComposerButtons from "./buttons.vue";
import {
type ComposerState,
getRandomSplash,
send,
state,
stateFromRelation,
uploadFile,
} from "./composer";
import ContentWarning from "./content-warning.vue"; import ContentWarning from "./content-warning.vue";
import Files from "./files.vue"; import Files from "./files.vue";
const props = defineProps<{
relation?: ComposerState["relation"];
}>();
const { Control_Enter, Command_Enter } = useMagicKeys(); const { Control_Enter, Command_Enter } = useMagicKeys();
const { play } = useAudio();
const fileInput = useTemplateRef<HTMLInputElement>("fileInput"); const fileInput = useTemplateRef<HTMLInputElement>("fileInput");
const composerKey = props.relation
? (`${props.relation.type}-${props.relation.note.id}` as const)
: "blank";
const store = useComposerStore(composerKey)();
watch([Control_Enter, Command_Enter], () => { watch([Control_Enter, Command_Enter], () => {
if (state.sending || !preferences.ctrl_enter_send.value) { if (store.sending || !preferences.ctrl_enter_send.value) {
return; return;
} }
send(); send();
}); });
const props = defineProps<{ const getRandomSplash = (): string => {
relation?: ComposerState["relation"]; const splashes = useConfig().COMPOSER_SPLASHES;
}>();
return splashes[Math.floor(Math.random() * splashes.length)] as string;
};
const send = async () => {
const result =
store.relation?.type === "edit"
? await store.sendEdit()
: await store.send();
if (result) {
play("publish");
store.$reset();
useEvent("composer:close");
}
};
watch( watch(
props, props,
async (props) => { async (props) => {
if (props.relation) { if (props.relation) {
await stateFromRelation( store.stateFromRelation(
props.relation.type, props.relation.type,
props.relation.note, props.relation.note,
props.relation.source, props.relation.source,
@ -69,7 +85,7 @@ const uploadFileFromEvent = (e: Event) => {
const files = Array.from(target.files ?? []); const files = Array.from(target.files ?? []);
for (const file of files) { for (const file of files) {
uploadFile(file); store.uploadFile(file);
} }
target.value = ""; target.value = "";
@ -77,7 +93,7 @@ const uploadFileFromEvent = (e: Event) => {
const uploadFiles = (files: File[]) => { const uploadFiles = (files: File[]) => {
for (const file of files) { for (const file of files) {
uploadFile(file); store.uploadFile(file);
} }
}; };
</script> </script>

View file

@ -11,8 +11,10 @@ import {
import * as m from "~~/paraglide/messages.js"; import * as m from "~~/paraglide/messages.js";
import Composer from "./composer.vue"; import Composer from "./composer.vue";
const authStore = useAuthStore();
useListen("composer:open", () => { useListen("composer:open", () => {
if (identity.value) { if (authStore.isSignedIn) {
open.value = true; open.value = true;
} }
}); });
@ -21,7 +23,7 @@ useListen("composer:edit", async (note) => {
const id = toast.loading(m.wise_late_fireant_walk(), { const id = toast.loading(m.wise_late_fireant_walk(), {
duration: 0, duration: 0,
}); });
const { data: source } = await client.value.getStatusSource(note.id); const { data: source } = await authStore.client.getStatusSource(note.id);
relation.value = { relation.value = {
type: "edit", type: "edit",
note, note,

View file

@ -5,22 +5,19 @@
:disabled="file.uploading || file.updating" :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" 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" /> <img v-if="file.file?.type.startsWith('image/')" :src="createObjectURL(file.file)" class="object-contain h-28 w-full" :alt="file.alt" />
<FileIcon v-else class="size-6 m-auto text-muted-foreground" />
<Badge <Badge
v-if="!(file.uploading || file.updating)" v-if="file.file && !(file.uploading || file.updating)"
class="absolute bottom-1 right-1" class="absolute bottom-1 right-1"
variant="default" variant="default"
>{{ formatBytes(file.file.size) }}</Badge >{{ formatBytes(file.file.size) }}</Badge
> >
<Spinner v-else class="absolute bottom-1 right-1 size-8 p-1.5" /> <Spinner v-else-if="file.file" class="absolute bottom-1 right-1 size-8 p-1.5" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent class="min-w-48"> <DropdownMenuContent class="min-w-48">
<DropdownMenuLabel>{{ file.file.name }}</DropdownMenuLabel> <DropdownMenuLabel v-if="file.file">{{ file.file.name }}</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem @click="editName">
<TextCursorInput />
Rename
</DropdownMenuItem>
<DropdownMenuItem @click="editCaption"> <DropdownMenuItem @click="editCaption">
<Captions /> <Captions />
Add caption Add caption
@ -35,7 +32,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Captions, Delete, TextCursorInput } from "lucide-vue-next"; import { Captions, Delete, FileIcon } from "lucide-vue-next";
import Spinner from "~/components/graphics/spinner.vue"; import Spinner from "~/components/graphics/spinner.vue";
import { confirmModalService } from "~/components/modals/composable.ts"; import { confirmModalService } from "~/components/modals/composable.ts";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
@ -47,45 +44,22 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import type { ComposerState } from "./composer"; import type { ComposerStateKey } from "~/stores/composer";
const file = defineModel<ComposerState["files"][number]>("file", { const { composerKey } = defineProps<{
composerKey: ComposerStateKey;
}>();
const file = defineModel<ComposerFile>("file", {
required: true, required: true,
}); });
const composerStore = useComposerStore(composerKey)();
const emit = defineEmits<{ const emit = defineEmits<{
remove: []; 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 editCaption = async () => {
const result = await confirmModalService.confirm({ const result = await confirmModalService.confirm({
title: "Enter a caption", title: "Enter a caption",
@ -97,16 +71,10 @@ const editCaption = async () => {
}); });
if (result.confirmed) { if (result.confirmed) {
file.value.updating = true; await composerStore.updateFileDescription(
file.value.alt = result.value; file.value.id,
result.value ?? "",
try { );
await client.value.updateMedia(file.value.apiId ?? "", {
description: file.value.alt,
});
} finally {
file.value.updating = false;
}
} }
}; };

View file

@ -1,13 +1,17 @@
<template> <template>
<FilePreview v-for="(file, index) in files" :key="file.apiId" :file="file" @update:file="files[index] = $event" <FilePreview v-for="(file, index) in files" :key="file.apiId" :file="file" @update:file="files[index] = $event" :composer-key="composerKey"
@remove="files.splice(index, 1)" /> @remove="files.splice(index, 1)" />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { ComposerState } from "./composer"; import type { ComposerStateKey } from "~/stores/composer";
import FilePreview from "./file-preview.vue"; import FilePreview from "./file-preview.vue";
const files = defineModel<ComposerState["files"]>("files", { const { composerKey } = defineProps<{
composerKey: ComposerStateKey;
}>();
const files = defineModel<ComposerFile[]>("files", {
required: true, required: true,
}); });
</script> </script>

View file

@ -0,0 +1,35 @@
import type { Status } from "@versia/client/schemas";
import { AtSign, Globe, Lock, LockOpen } from "lucide-vue-next";
import type { FunctionalComponent } from "vue";
import type { z } from "zod";
import * as m from "~~/paraglide/messages.js";
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(),
},
};

View file

@ -18,14 +18,13 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Button } from "~/components/ui/button";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
} from "~/components/ui/select"; } from "~/components/ui/select";
import { type ComposerState, visibilities } from "./composer"; import { visibilities } from "./visibilities";
const visibility = defineModel<ComposerState["visibility"]>("visibility", { const visibility = defineModel<ComposerState["visibility"]>("visibility", {
required: true, required: true,

View file

@ -68,9 +68,10 @@ watch(active, (value) => {
</script> </script>
<template> <template>
<BubbleMenu :editor="editor" class="bg-popover rounded-md"> <BubbleMenu :editor="editor">
<ToggleGroup type="multiple" <ToggleGroup type="multiple"
v-model="active" v-model="active"
class="bg-popover rounded-md"
> >
<ToggleGroupItem value="bold"> <ToggleGroupItem value="bold">
<BoldIcon /> <BoldIcon />

View file

@ -29,6 +29,7 @@ const {
mode?: "rich" | "plain"; mode?: "rich" | "plain";
disabled?: boolean; disabled?: boolean;
}>(); }>();
const authStore = useAuthStore();
const emit = defineEmits<{ const emit = defineEmits<{
pasteFiles: [files: File[]]; pasteFiles: [files: File[]];
@ -53,13 +54,13 @@ const editor = new Editor({
}), }),
Emoji.configure({ Emoji.configure({
emojis: emojis.concat( emojis: emojis.concat(
identity.value?.emojis.map((emoji) => ({ authStore.emojis.map((emoji) => ({
name: emoji.shortcode, name: emoji.shortcode,
shortcodes: [emoji.shortcode], shortcodes: [emoji.shortcode],
group: emoji.category ?? undefined, group: emoji.category ?? undefined,
tags: [], tags: [],
fallbackImage: emoji.url, fallbackImage: emoji.url,
})) || [], })),
), ),
HTMLAttributes: { HTMLAttributes: {
class: "emoji not-prose", class: "emoji not-prose",

View file

@ -26,14 +26,13 @@ const { items, command } = defineProps<{
items: string[]; items: string[];
command: (value: { name: string }) => void; command: (value: { name: string }) => void;
}>(); }>();
const authStore = useAuthStore();
const selectedIndex = ref(0); const selectedIndex = ref(0);
const emojis = computed(() => { const emojis = computed(() => {
return items return items
.map((item) => { .map((item) => {
return identity.value?.emojis.find( return authStore.emojis.find((emoji) => emoji.shortcode === item);
(emoji) => emoji.shortcode === item,
);
}) })
.filter((emoji) => emoji !== undefined); .filter((emoji) => emoji !== undefined);
}); });

View file

@ -14,6 +14,8 @@ export type UserData = {
value: z.infer<typeof Account>; value: z.infer<typeof Account>;
}; };
const authStore = useAuthStore();
const updatePosition = (editor: Editor, element: HTMLElement): void => { const updatePosition = (editor: Editor, element: HTMLElement): void => {
const virtualElement = { const virtualElement = {
getBoundingClientRect: () => getBoundingClientRect: () =>
@ -42,7 +44,9 @@ export const mentionSuggestion = {
return []; return [];
} }
const users = await client.value.searchAccount(query, { limit: 20 }); const users = await authStore.client.searchAccount(query, {
limit: 20,
});
return go( return go(
query, query,
@ -122,7 +126,7 @@ export const emojiSuggestion = {
return []; return [];
} }
const emojis = (identity.value as Identity).emojis; const emojis = authStore.emojis;
return go( return go(
query, query,

View file

@ -17,12 +17,23 @@
<script lang="ts" setup> <script lang="ts" setup>
import { LogIn } from "lucide-vue-next"; import { LogIn } from "lucide-vue-next";
import { toast } from "vue-sonner";
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import * as m from "~~/paraglide/messages.js"; import * as m from "~~/paraglide/messages.js";
const appData = useAppData(); const authStore = useAuthStore();
const signInAction = async () => signIn(appData, await askForInstance()); const signInAction = async () => {
const instance = await askForInstance();
const id = toast.loading(m.level_due_ox_greet());
try {
await authStore.startSignIn(instance);
} catch (e) {
toast.dismiss(id);
}
};
</script> </script>
<style></style> <style></style>

View file

@ -2,7 +2,7 @@
<Tabs v-model:model-value="current"> <Tabs v-model:model-value="current">
<TabsList> <TabsList>
<TabsTrigger v-for="timeline in timelines.filter( <TabsTrigger v-for="timeline in timelines.filter(
i => i.requiresLogin ? !!identity : true, i => i.requiresLogin ? authStore.isSignedIn : true,
)" :key="timeline.value" :value="timeline.value" :as="NuxtLink" :href="timeline.url"> )" :key="timeline.value" :value="timeline.value" :as="NuxtLink" :href="timeline.url">
{{ timeline.name }} {{ timeline.name }}
</TabsTrigger> </TabsTrigger>
@ -47,12 +47,12 @@ const timelines = [
}, },
]; ];
const { beforeEach } = useRouter();
const { path } = useRoute(); const { path } = useRoute();
const authStore = useAuthStore();
const current = computed(() => { const current = computed(() => {
if (path === "/") { if (path === "/") {
return identity.value ? "home" : "public"; return authStore.isSignedIn ? "home" : "public";
} }
const timeline = timelines.find((i) => i.url === path); const timeline = timelines.find((i) => i.url === path);

View file

@ -1,17 +1,17 @@
<template> <template>
<div class="flex flex-row w-full max-w-sm items-stretch justify-between"> <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"> <ActionButton :icon="Reply" @click="emit('reply')" :title="m.drab_tense_turtle_comfort()" :disabled="!authStore.isSignedIn">
{{ numberFormat(replyCount) }} {{ numberFormat(replyCount) }}
</ActionButton> </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'"> <ActionButton :icon="Heart" @click="liked ? unlike() : like()" :title="liked ? m.vexed_fluffy_clownfish_dance() : m.royal_close_samuel_scold()" :disabled="!authStore.isSignedIn" :class="liked && '*:fill-red-600 *:text-red-600'">
{{ numberFormat(likeCount) }} {{ numberFormat(likeCount) }}
</ActionButton> </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'"> <ActionButton :icon="Repeat" @click="reblogged ? unreblog() : reblog()" :title="reblogged ? m.lime_neat_ox_stab() : m.aware_helpful_marlin_drop()" :disabled="!authStore.isSignedIn" :class="reblogged && '*:text-green-600'">
{{ numberFormat(reblogCount) }} {{ numberFormat(reblogCount) }}
</ActionButton> </ActionButton>
<ActionButton :icon="Quote" @click="emit('quote')" :title="m.true_shy_jackal_drip()" :disabled="!identity" /> <ActionButton :icon="Quote" @click="emit('quote')" :title="m.true_shy_jackal_drip()" :disabled="!authStore.isSignedIn" />
<Picker @pick="react"> <Picker @pick="react">
<ActionButton :icon="Smile" :title="m.bald_cool_kangaroo_jump()" :disabled="!identity" /> <ActionButton :icon="Smile" :title="m.bald_cool_kangaroo_jump()" :disabled="!authStore.isSignedIn" />
</Picker> </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')"> <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()" /> <ActionButton :icon="Ellipsis" :title="m.busy_merry_cowfish_absorb()" />
@ -54,6 +54,7 @@ const emit = defineEmits<{
react: []; react: [];
}>(); }>();
const { play } = useAudio(); const { play } = useAudio();
const authStore = useAuthStore();
const like = async () => { const like = async () => {
if (preferences.confirm_actions.value.includes("like")) { if (preferences.confirm_actions.value.includes("like")) {
@ -71,7 +72,7 @@ const like = async () => {
play("like"); play("like");
const id = toast.loading(m.slimy_candid_tiger_read()); const id = toast.loading(m.slimy_candid_tiger_read());
const { data } = await client.value.favouriteStatus(noteId); const { data } = await authStore.client.favouriteStatus(noteId);
toast.dismiss(id); toast.dismiss(id);
toast.success(m.mealy_slow_buzzard_commend()); toast.success(m.mealy_slow_buzzard_commend());
useEvent("note:edit", data); useEvent("note:edit", data);
@ -92,7 +93,7 @@ const unlike = async () => {
} }
const id = toast.loading(m.busy_active_leopard_strive()); const id = toast.loading(m.busy_active_leopard_strive());
const { data } = await client.value.unfavouriteStatus(noteId); const { data } = await authStore.client.unfavouriteStatus(noteId);
toast.dismiss(id); toast.dismiss(id);
toast.success(m.fresh_direct_bear_affirm()); toast.success(m.fresh_direct_bear_affirm());
useEvent("note:edit", data); useEvent("note:edit", data);
@ -113,7 +114,7 @@ const reblog = async () => {
} }
const id = toast.loading(m.late_sunny_cobra_scold()); const id = toast.loading(m.late_sunny_cobra_scold());
const { data } = await client.value.reblogStatus(noteId); const { data } = await authStore.client.reblogStatus(noteId);
toast.dismiss(id); toast.dismiss(id);
toast.success(m.weird_moving_hawk_lift()); toast.success(m.weird_moving_hawk_lift());
useEvent( useEvent(
@ -137,7 +138,7 @@ const unreblog = async () => {
} }
const id = toast.loading(m.white_sharp_gorilla_embrace()); const id = toast.loading(m.white_sharp_gorilla_embrace());
const { data } = await client.value.unreblogStatus(noteId); const { data } = await authStore.client.unreblogStatus(noteId);
toast.dismiss(id); toast.dismiss(id);
toast.success(m.royal_polite_moose_catch()); toast.success(m.royal_polite_moose_catch());
useEvent("note:edit", data); useEvent("note:edit", data);
@ -149,7 +150,7 @@ const react = async (emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji) => {
? (emoji as UnicodeEmoji).unicode ? (emoji as UnicodeEmoji).unicode
: `:${(emoji as z.infer<typeof CustomEmoji>).shortcode}:`; : `:${(emoji as z.infer<typeof CustomEmoji>).shortcode}:`;
const { data } = await client.value.createEmojiReaction(noteId, text); const { data } = await authStore.client.createEmojiReaction(noteId, text);
toast.dismiss(id); toast.dismiss(id);
toast.success(m.main_least_turtle_fall()); toast.success(m.main_least_turtle_fall());

View file

@ -37,8 +37,8 @@ const emit = defineEmits<{
}>(); }>();
const { copy } = useClipboard(); const { copy } = useClipboard();
const loggedIn = !!identity.value; const authStore = useAuthStore();
const authorIsMe = loggedIn && authorId === identity.value?.account.id; const authorIsMe = authStore.isSignedIn && authorId === authStore.account?.id;
const copyText = (text: string) => { const copyText = (text: string) => {
copy(text); copy(text);
@ -47,7 +47,7 @@ const copyText = (text: string) => {
const blockUser = async (userId: string) => { const blockUser = async (userId: string) => {
const id = toast.loading(m.top_cute_bison_nudge()); const id = toast.loading(m.top_cute_bison_nudge());
await client.value.blockAccount(userId); await authStore.client.blockAccount(userId);
toast.dismiss(id); toast.dismiss(id);
toast.success(m.main_weary_racoon_peek()); toast.success(m.main_weary_racoon_peek());
@ -68,7 +68,7 @@ const _delete = async () => {
} }
const id = toast.loading(m.new_funny_fox_boil()); const id = toast.loading(m.new_funny_fox_boil());
await client.value.deleteStatus(noteId); await authStore.client.deleteStatus(noteId);
toast.dismiss(id); toast.dismiss(id);
toast.success(m.green_tasty_bumblebee_beam()); toast.success(m.green_tasty_bumblebee_beam());
@ -122,8 +122,8 @@ const _delete = async () => {
{{ m.tense_quick_cod_favor() }} {{ m.tense_quick_cod_favor() }}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator v-if="loggedIn && !authorIsMe" /> <DropdownMenuSeparator v-if="authStore.isSignedIn && !authorIsMe" />
<DropdownMenuGroup v-if="loggedIn && !authorIsMe"> <DropdownMenuGroup v-if="authStore.isSignedIn && !authorIsMe">
<DropdownMenuItem as="button" :disabled="true"> <DropdownMenuItem as="button" :disabled="true">
<Flag /> <Flag />
{{ m.great_few_jaguar_rise() }} {{ m.great_few_jaguar_rise() }}

View file

@ -52,6 +52,7 @@ const emit = defineEmits<{
pick: [emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji]; pick: [emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji];
}>(); }>();
const authStore = useAuthStore();
const open = ref(false); const open = ref(false);
const selectedEmoji = ref<z.infer<typeof CustomEmoji> | UnicodeEmoji | null>( const selectedEmoji = ref<z.infer<typeof CustomEmoji> | UnicodeEmoji | null>(
null, null,
@ -59,12 +60,10 @@ const selectedEmoji = ref<z.infer<typeof CustomEmoji> | UnicodeEmoji | null>(
const emojiContainer = useTemplateRef<HTMLDivElement>("emojiContainer"); const emojiContainer = useTemplateRef<HTMLDivElement>("emojiContainer");
const filter = ref(""); const filter = ref("");
const customEmojis = computed(() => identity.value?.emojis ?? []);
const customEmojiCategories = computed(() => { const customEmojiCategories = computed(() => {
const categories: Record<string, z.infer<typeof CustomEmoji>[]> = {}; const categories: Record<string, z.infer<typeof CustomEmoji>[]> = {};
for (const emoji of customEmojis.value) { for (const emoji of authStore.emojis) {
const categoryName = emoji.category || "Uncategorized"; const categoryName = emoji.category || "Uncategorized";
if (!categories[categoryName]) { if (!categories[categoryName]) {

View file

@ -53,6 +53,8 @@ const { reaction, emoji, statusId } = defineProps<{
emoji?: z.infer<typeof CustomEmoji>; emoji?: z.infer<typeof CustomEmoji>;
}>(); }>();
const authStore = useAuthStore();
const formatNumber = (number: number) => const formatNumber = (number: number) =>
new Intl.NumberFormat(getLocale(), { new Intl.NumberFormat(getLocale(), {
notation: "compact", notation: "compact",
@ -63,12 +65,13 @@ const formatNumber = (number: number) =>
const accounts = ref<z.infer<typeof Account>[] | null>(null); const accounts = ref<z.infer<typeof Account>[] | null>(null);
const refreshReactions = async () => { const refreshReactions = async () => {
const { data } = await client.value.getStatusReactions(statusId); const { data } = await authStore.client.getStatusReactions(statusId);
const accountIds = const accountIds =
data.find((r) => r.name === reaction.name)?.account_ids.slice(0, 10) ?? data.find((r) => r.name === reaction.name)?.account_ids.slice(0, 10) ??
[]; [];
const { data: accountsData } = await client.value.getAccounts(accountIds); const { data: accountsData } =
await authStore.client.getAccounts(accountIds);
accounts.value = accountsData; accounts.value = accountsData;
}; };
@ -76,7 +79,7 @@ const refreshReactions = async () => {
const react = async () => { const react = async () => {
const id = toast.loading(m.gray_stale_antelope_roam()); const id = toast.loading(m.gray_stale_antelope_roam());
const { data } = await client.value.createEmojiReaction( const { data } = await authStore.client.createEmojiReaction(
statusId, statusId,
reaction.name, reaction.name,
); );
@ -89,7 +92,7 @@ const react = async () => {
const unreact = async () => { const unreact = async () => {
const id = toast.loading(m.many_weary_bat_intend()); const id = toast.loading(m.many_weary_bat_intend());
const { data } = await client.value.deleteEmojiReaction( const { data } = await authStore.client.deleteEmojiReaction(
statusId, statusId,
reaction.name, reaction.name,
); );

View file

@ -25,5 +25,5 @@ const { note } = defineProps<{
note: z.infer<typeof Status>; note: z.infer<typeof Status>;
}>(); }>();
const parent = useNote(client, note.in_reply_to_id); const parent = useNote(note.in_reply_to_id);
</script> </script>

View file

@ -45,7 +45,8 @@ const { follower } = defineProps<{
const loading = ref(true); const loading = ref(true);
const followerUrl = `/@${follower.acct}`; const followerUrl = `/@${follower.acct}`;
const [username, domain] = follower.acct.split("@"); const [username, domain] = follower.acct.split("@");
const { relationship } = useRelationship(client, follower.id); const { relationship } = useRelationship(follower.id);
const authStore = useAuthStore();
// TODO: Add "followed" notification // TODO: Add "followed" notification
watch(relationship, () => { watch(relationship, () => {
@ -58,7 +59,7 @@ const accept = async () => {
const id = toast.loading(m.cool_slimy_coyote_affirm()); const id = toast.loading(m.cool_slimy_coyote_affirm());
loading.value = true; loading.value = true;
const { data } = await client.value.acceptFollowRequest(follower.id); const { data } = await authStore.client.acceptFollowRequest(follower.id);
toast.dismiss(id); toast.dismiss(id);
toast.success(m.busy_awful_mouse_jump()); toast.success(m.busy_awful_mouse_jump());
@ -70,7 +71,7 @@ const reject = async () => {
const id = toast.loading(m.front_sunny_penguin_flip()); const id = toast.loading(m.front_sunny_penguin_flip());
loading.value = true; loading.value = true;
const { data } = await client.value.rejectFollowRequest(follower.id); const { data } = await authStore.client.rejectFollowRequest(follower.id);
toast.dismiss(id); toast.dismiss(id);
toast.success(m.green_flat_mayfly_trust()); toast.success(m.green_flat_mayfly_trust());

View file

@ -61,7 +61,7 @@ for (const name of [
} }
const issuerRedirectUrl = (issuerId: string) => { const issuerRedirectUrl = (issuerId: string) => {
const url = new URL("/oauth/sso", client.value.url); const url = new URL("/oauth/sso", useRequestURL().origin);
for (const name of [ for (const name of [
"redirect_uri", "redirect_uri",

View file

@ -25,13 +25,13 @@ const copy = (data: string) => {
toast.success("Copied to clipboard"); toast.success("Copied to clipboard");
}; };
const appData = useAppData(); const authStore = useAuthStore();
const data: [string, string | VNode][] = [ const data: [string, string | VNode][] = [
["User ID", identity.value?.account.id ?? ""], ["User ID", authStore.account?.id ?? ""],
["Instance domain", identity.value?.instance.domain ?? ""], ["Instance domain", authStore.instance?.domain ?? ""],
["Instance version", identity.value?.instance.versia_version ?? ""], ["Instance version", authStore.instance?.versia_version ?? ""],
["Client ID", appData.value?.client_id ?? ""], ["Client ID", authStore.application?.client_id ?? ""],
[ [
"Client secret", "Client secret",
<Button <Button
@ -39,7 +39,7 @@ const data: [string, string | VNode][] = [
class="font-sans" class="font-sans"
size="sm" size="sm"
// @ts-expect-error missing onClick types // @ts-expect-error missing onClick types
onClick={() => copy(appData.value?.client_secret ?? "")} onClick={() => copy(authStore.application?.client_secret ?? "")}
> >
Click to copy Click to copy
</Button>, </Button>,
@ -51,7 +51,7 @@ const data: [string, string | VNode][] = [
class="font-sans" class="font-sans"
size="sm" size="sm"
// @ts-expect-error missing onClick types // @ts-expect-error missing onClick types
onClick={() => copy(identity.value?.tokens.access_token ?? "")} onClick={() => copy(authStore.token?.access_token ?? "")}
> >
Click to copy Click to copy
</Button>, </Button>,

View file

@ -62,22 +62,11 @@ const categories = Object.fromEntries(
}), }),
); );
const { account: author1 } = useAccountFromAcct( const { account: author1 } = useAccountFromAcct("jessew@vs.cpluspatch.com");
client, const { account: author2 } = useAccountFromAcct("aprl@social.lysand.org");
"jessew@vs.cpluspatch.com", const { account: author3 } = useAccountFromAcct("lina@social.lysand.org");
); const { account: author4 } = useAccountFromAcct("nyx@v.everypizza.im");
const authStore = useAuthStore();
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); const open = ref(false);
@ -87,14 +76,14 @@ useListen("preferences:open", () => {
</script> </script>
<template> <template>
<Dialog v-model:open="open" v-if="identity"> <Dialog v-model:open="open" v-if="authStore.isSignedIn">
<DialogContent class="md:max-w-5xl w-full h-full p-0 md:max-h-[70dvh] overflow-hidden"> <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" <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]"> :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"> <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)]"> <div class="grid gap-3 items-center grid-cols-[auto_minmax(0,1fr)]">
<Avatar :name="identity.account.display_name || identity.account.username" <Avatar :name="authStore.account!.display_name || authStore.account!.username"
:src="identity.account.avatar" /> :src="authStore.account!.avatar" />
<DialogTitle>Preferences</DialogTitle> <DialogTitle>Preferences</DialogTitle>
</div> </div>
<DialogDescription class="sr-only"> <DialogDescription class="sr-only">

View file

@ -30,14 +30,14 @@ const { emojis } = defineProps<{
emojis: z.infer<typeof CustomEmoji>[]; emojis: z.infer<typeof CustomEmoji>[];
}>(); }>();
const permissions = usePermissions(); const authStore = useAuthStore();
const canEdit = const canEdit =
(!emojis.some((e) => e.global) && (!emojis.some((e) => e.global) &&
permissions.value.includes(RolePermission.ManageOwnEmojis)) || authStore.permissions.includes(RolePermission.ManageOwnEmojis)) ||
permissions.value.includes(RolePermission.ManageEmojis); authStore.permissions.includes(RolePermission.ManageEmojis);
const deleteAll = async () => { const deleteAll = async () => {
if (!identity.value) { if (!authStore.isSignedIn) {
return; return;
} }
@ -57,14 +57,16 @@ const deleteAll = async () => {
); );
try { try {
await Promise.all( await Promise.all(
emojis.map((emoji) => client.value.deleteEmoji(emoji.id)), emojis.map((emoji) => authStore.client.deleteEmoji(emoji.id)),
); );
toast.dismiss(id); toast.dismiss(id);
toast.success("Emojis deleted"); toast.success("Emojis deleted");
identity.value.emojis = identity.value.emojis.filter( authStore.updateActiveIdentity({
(e) => !emojis.some((emoji) => e.id === emoji.id), emojis: authStore.emojis.filter(
); (e) => !emojis.some((emoji) => e.id === emoji.id),
),
});
} catch { } catch {
toast.dismiss(id); toast.dismiss(id);
} }

View file

@ -42,14 +42,10 @@ const { emoji } = defineProps<{
emoji: z.infer<typeof CustomEmoji>; emoji: z.infer<typeof CustomEmoji>;
}>(); }>();
const permissions = usePermissions(); const authStore = useAuthStore();
const canEdit =
(!emoji.global &&
permissions.value.includes(RolePermission.ManageOwnEmojis)) ||
permissions.value.includes(RolePermission.ManageEmojis);
const editName = async () => { const editName = async () => {
if (!identity.value) { if (!authStore.isSignedIn) {
return; return;
} }
@ -63,16 +59,18 @@ const editName = async () => {
if (result.confirmed) { if (result.confirmed) {
const id = toast.loading(m.teary_tame_gull_bless()); const id = toast.loading(m.teary_tame_gull_bless());
try { try {
const { data } = await client.value.updateEmoji(emoji.id, { const { data } = await authStore.client.updateEmoji(emoji.id, {
shortcode: result.value, shortcode: result.value,
}); });
toast.dismiss(id); toast.dismiss(id);
toast.success(m.gaudy_lime_bison_adore()); toast.success(m.gaudy_lime_bison_adore());
identity.value.emojis = identity.value.emojis.map((e) => authStore.updateActiveIdentity({
e.id === emoji.id ? data : e, emojis: authStore.emojis.map((e) =>
); e.id === emoji.id ? data : e,
),
});
} catch { } catch {
toast.dismiss(id); toast.dismiss(id);
} }
@ -80,7 +78,7 @@ const editName = async () => {
}; };
const _delete = async () => { const _delete = async () => {
if (!identity.value) { if (!authStore.isSignedIn) {
return; return;
} }
@ -93,13 +91,13 @@ const _delete = async () => {
if (confirmed) { if (confirmed) {
const id = toast.loading(m.weary_away_liger_zip()); const id = toast.loading(m.weary_away_liger_zip());
try { try {
await client.value.deleteEmoji(emoji.id); await authStore.client.deleteEmoji(emoji.id);
toast.dismiss(id); toast.dismiss(id);
toast.success(m.crisp_whole_canary_tear()); toast.success(m.crisp_whole_canary_tear());
identity.value.emojis = identity.value.emojis.filter( authStore.updateActiveIdentity({
(e) => e.id !== emoji.id, emojis: authStore.emojis.filter((e) => e.id !== emoji.id),
); });
} catch { } catch {
toast.dismiss(id); toast.dismiss(id);
} }

View file

@ -1,6 +1,6 @@
<template> <template>
<div v-if="emojis.length > 0" class="grow"> <div v-if="authStore.emojis.length > 0" class="grow">
<Table :emojis="emojis" :can-upload="canUpload" /> <Table :emojis="authStore.emojis" :can-upload="canUpload" />
</div> </div>
</template> </template>
@ -8,12 +8,10 @@
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import Table from "./table.vue"; import Table from "./table.vue";
const permissions = usePermissions(); const authStore = useAuthStore();
const canUpload = computed( const canUpload = computed(
() => () =>
permissions.value.includes(RolePermission.ManageOwnEmojis) || authStore.permissions.includes(RolePermission.ManageOwnEmojis) ||
permissions.value.includes(RolePermission.ManageEmojis), authStore.permissions.includes(RolePermission.ManageEmojis),
); );
const emojis = computed(() => identity.value?.emojis ?? []);
</script> </script>

View file

@ -189,8 +189,10 @@ import { Textarea } from "~/components/ui/textarea";
import * as m from "~~/paraglide/messages.js"; import * as m from "~~/paraglide/messages.js";
const open = ref(false); const open = ref(false);
const permissions = usePermissions(); const authStore = useAuthStore();
const hasEmojiAdmin = permissions.value.includes(RolePermission.ManageEmojis); const hasEmojiAdmin = authStore.permissions.includes(
RolePermission.ManageEmojis,
);
const createObjectURL = URL.createObjectURL; const createObjectURL = URL.createObjectURL;
const formSchema = toTypedSchema( const formSchema = toTypedSchema(
@ -202,11 +204,11 @@ const formSchema = toTypedSchema(
.refine( .refine(
(v) => (v) =>
v.size <= v.size <=
(identity.value?.instance.configuration.emojis (authStore.instance?.configuration.emojis
.emoji_size_limit ?? Number.POSITIVE_INFINITY), .emoji_size_limit ?? Number.POSITIVE_INFINITY),
m.orange_weird_parakeet_hug({ m.orange_weird_parakeet_hug({
count: count:
identity.value?.instance.configuration.emojis authStore.instance?.configuration.emojis
.emoji_size_limit ?? Number.POSITIVE_INFINITY, .emoji_size_limit ?? Number.POSITIVE_INFINITY,
}), }),
), ),
@ -214,11 +216,11 @@ const formSchema = toTypedSchema(
.string() .string()
.min(1) .min(1)
.max( .max(
identity.value?.instance.configuration.emojis authStore.instance?.configuration.emojis
.max_shortcode_characters ?? Number.POSITIVE_INFINITY, .max_shortcode_characters ?? Number.POSITIVE_INFINITY,
m.solid_inclusive_owl_hug({ m.solid_inclusive_owl_hug({
count: count:
identity.value?.instance.configuration.emojis authStore.instance?.configuration.emojis
.max_shortcode_characters ?? .max_shortcode_characters ??
Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY,
}), }),
@ -237,11 +239,11 @@ const formSchema = toTypedSchema(
alt: z alt: z
.string() .string()
.max( .max(
identity.value?.instance.configuration.emojis authStore.instance?.configuration.emojis
.max_description_characters ?? Number.POSITIVE_INFINITY, .max_description_characters ?? Number.POSITIVE_INFINITY,
m.key_ago_hound_emerge({ m.key_ago_hound_emerge({
count: count:
identity.value?.instance.configuration.emojis authStore.instance?.configuration.emojis
.max_description_characters ?? .max_description_characters ??
Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY,
}), }),
@ -254,14 +256,14 @@ const { isSubmitting, handleSubmit, values, setFieldValue } = useForm({
}); });
const submit = handleSubmit(async (values) => { const submit = handleSubmit(async (values) => {
if (!identity.value) { if (!authStore.isSignedIn) {
return; return;
} }
const id = toast.loading(m.factual_gray_mouse_believe()); const id = toast.loading(m.factual_gray_mouse_believe());
try { try {
const { data } = await client.value.uploadEmoji( const { data } = await authStore.client.uploadEmoji(
values.shortcode, values.shortcode,
values.image, values.image,
{ {
@ -274,7 +276,10 @@ const submit = handleSubmit(async (values) => {
toast.dismiss(id); toast.dismiss(id);
toast.success(m.cool_trite_gull_quiz()); toast.success(m.cool_trite_gull_quiz());
identity.value.emojis = [...identity.value.emojis, data]; authStore.updateActiveIdentity({
emojis: [...authStore.emojis, data],
});
open.value = false; open.value = false;
} catch { } catch {
toast.dismiss(id); toast.dismiss(id);

View file

@ -1,10 +1,11 @@
import { toTypedSchema } from "@vee-validate/zod"; import { toTypedSchema } from "@vee-validate/zod";
import type { Instance } from "@versia/client/schemas";
import { z } from "zod"; import { z } from "zod";
import * as m from "~~/paraglide/messages.js"; import * as m from "~~/paraglide/messages.js";
const characterRegex = new RegExp(/^[a-z0-9_-]+$/); const characterRegex = new RegExp(/^[a-z0-9_-]+$/);
export const formSchema = (identity: Identity) => export const formSchema = (instance: z.infer<typeof Instance>) =>
toTypedSchema( toTypedSchema(
z.strictObject({ z.strictObject({
banner: z banner: z
@ -12,11 +13,10 @@ export const formSchema = (identity: Identity) =>
.refine( .refine(
(v) => (v) =>
v.size <= v.size <=
(identity.instance.configuration.accounts (instance.configuration.accounts.header_limit ??
.header_limit ?? Number.POSITIVE_INFINITY), Number.POSITIVE_INFINITY),
m.civil_icy_ant_mend({ m.civil_icy_ant_mend({
size: identity.instance.configuration.accounts size: instance.configuration.accounts.header_limit,
.header_limit,
}), }),
) )
.optional(), .optional(),
@ -25,11 +25,10 @@ export const formSchema = (identity: Identity) =>
.refine( .refine(
(v) => (v) =>
v.size <= v.size <=
(identity.instance.configuration.accounts (instance.configuration.accounts.avatar_limit ??
.avatar_limit ?? Number.POSITIVE_INFINITY), Number.POSITIVE_INFINITY),
m.zippy_caring_raven_edit({ m.zippy_caring_raven_edit({
size: identity.instance.configuration.accounts size: instance.configuration.accounts.avatar_limit,
.avatar_limit,
}), }),
) )
.or(z.string().url()) .or(z.string().url())
@ -37,22 +36,15 @@ export const formSchema = (identity: Identity) =>
name: z name: z
.string() .string()
.max( .max(
identity.instance.configuration.accounts instance.configuration.accounts.max_displayname_characters,
.max_displayname_characters,
), ),
username: z username: z
.string() .string()
.regex(characterRegex, m.still_upper_otter_dine()) .regex(characterRegex, m.still_upper_otter_dine())
.max( .max(instance.configuration.accounts.max_username_characters),
identity.instance.configuration.accounts
.max_username_characters,
),
bio: z bio: z
.string() .string()
.max( .max(instance.configuration.accounts.max_note_characters),
identity.instance.configuration.accounts
.max_note_characters,
),
bot: z.boolean().default(false), bot: z.boolean().default(false),
locked: z.boolean().default(false), locked: z.boolean().default(false),
discoverable: z.boolean().default(true), discoverable: z.boolean().default(true),

View file

@ -1,5 +1,5 @@
<template> <template>
<form v-if="identity" class="grid gap-6" @submit="save"> <form class="grid gap-6" @submit="save">
<Transition name="slide-up"> <Transition name="slide-up">
<Alert v-if="dirty" layout="button" class="absolute bottom-2 z-10 inset-x-2 w-[calc(100%-1rem)]"> <Alert v-if="dirty" layout="button" class="absolute bottom-2 z-10 inset-x-2 w-[calc(100%-1rem)]">
<SaveOff class="size-4" /> <SaveOff class="size-4" />
@ -19,7 +19,7 @@
<FormField v-slot="{ setValue }" name="avatar"> <FormField v-slot="{ setValue }" name="avatar">
<TextInput :title="m.safe_icy_bulldog_quell()"> <TextInput :title="m.safe_icy_bulldog_quell()">
<ImageUploader v-model:image="identity.account.avatar" @submit-file="(file) => setValue(file)" <ImageUploader v-model:image="authStore.account!.avatar" @submit-file="(file) => setValue(file)"
@submit-url="(url) => setValue(url)" /> @submit-url="(url) => setValue(url)" />
</TextInput> </TextInput>
</FormField> </FormField>
@ -85,25 +85,25 @@ import ImageUploader from "./profile/image-uploader.vue";
const dirty = computed(() => form.meta.value.dirty); const dirty = computed(() => form.meta.value.dirty);
const submitting = ref(false); const submitting = ref(false);
const authStore = useAuthStore();
if (!identity.value) { if (!(authStore.instance && authStore.account)) {
throw new Error("Identity not found."); throw new Error("Not signed in.");
} }
const account = computed(() => identity.value?.account as Identity["account"]); const schema = formSchema(authStore.instance);
const schema = formSchema(identity.value);
const form = useForm({ const form = useForm({
validationSchema: schema, validationSchema: schema,
initialValues: { initialValues: {
bio: account.value.source?.note ?? "", bio: authStore.account.source?.note ?? "",
bot: account.value.bot ?? false, bot: authStore.account.bot ?? false,
locked: account.value.locked ?? false, locked: authStore.account.locked ?? false,
discoverable: account.value.discoverable ?? true, discoverable: authStore.account.discoverable ?? true,
username: account.value.username, username: authStore.account.username,
name: account.value.display_name, name: authStore.account.display_name,
fields: fields:
account.value.source?.fields.map((f) => ({ authStore.account.source?.fields.map((f) => ({
name: f.name, name: f.name,
value: f.value, value: f.value,
})) ?? [], })) ?? [],
@ -111,7 +111,7 @@ const form = useForm({
}); });
const save = form.handleSubmit(async (values) => { const save = form.handleSubmit(async (values) => {
if (submitting.value) { if (submitting.value || !authStore.account) {
return; return;
} }
@ -120,25 +120,29 @@ const save = form.handleSubmit(async (values) => {
const changedData = { const changedData = {
display_name: display_name:
values.name === account.value.display_name values.name === authStore.account.display_name
? undefined ? undefined
: values.name, : values.name,
username: username:
values.username === account.value.username values.username === authStore.account.username
? undefined ? undefined
: values.username, : values.username,
note: note:
values.bio === account.value.source?.note ? undefined : values.bio, values.bio === authStore.account.source?.note
bot: values.bot === account.value.bot ? undefined : values.bot, ? undefined
: values.bio,
bot: values.bot === authStore.account.bot ? undefined : values.bot,
locked: locked:
values.locked === account.value.locked ? undefined : values.locked, values.locked === authStore.account.locked
? undefined
: values.locked,
discoverable: discoverable:
values.discoverable === account.value.discoverable values.discoverable === authStore.account.discoverable
? undefined ? undefined
: values.discoverable, : values.discoverable,
// Can't compare two arrays directly in JS, so we need to check if all fields are the same // 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) => fields_attributes: values.fields.every((field) =>
account.value.source?.fields?.some( authStore.account?.source?.fields?.some(
(f) => f.name === field.name && f.value === field.value, (f) => f.name === field.name && f.value === field.value,
), ),
) )
@ -157,7 +161,7 @@ const save = form.handleSubmit(async (values) => {
} }
try { try {
const { data } = await client.value.updateCredentials( const { data } = await authStore.client.updateCredentials(
Object.fromEntries( Object.fromEntries(
Object.entries(changedData).filter(([, v]) => v !== undefined), Object.entries(changedData).filter(([, v]) => v !== undefined),
), ),
@ -166,9 +170,9 @@ const save = form.handleSubmit(async (values) => {
toast.dismiss(id); toast.dismiss(id);
toast.success(m.spry_honest_kestrel_arrive()); toast.success(m.spry_honest_kestrel_arrive());
if (identity.value) { authStore.updateActiveIdentity({
identity.value.account = data; account: data,
} });
form.resetForm({ form.resetForm({
values: { values: {

View file

@ -33,8 +33,8 @@
{{ m.active_trite_lark_inspire() }} {{ m.active_trite_lark_inspire() }}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator v-if="isLoggedIn && !isMe" /> <DropdownMenuSeparator v-if="authStore.isSignedIn && !isMe" />
<DropdownMenuGroup v-if="isLoggedIn && !isMe"> <DropdownMenuGroup v-if="authStore.isSignedIn && !isMe">
<DropdownMenuItem as="button" @click="muteUser(account.id)"> <DropdownMenuItem as="button" @click="muteUser(account.id)">
<VolumeX /> <VolumeX />
{{ m.spare_wild_mole_intend() }} {{ m.spare_wild_mole_intend() }}
@ -51,8 +51,8 @@
{{ m.slow_chunky_chipmunk_hush() }} {{ m.slow_chunky_chipmunk_hush() }}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator v-if="isLoggedIn && !isMe" /> <DropdownMenuSeparator v-if="authStore.isSignedIn && !isMe" />
<DropdownMenuGroup v-if="isLoggedIn && !isMe"> <DropdownMenuGroup v-if="authStore.isSignedIn && !isMe">
<DropdownMenuItem as="button" :disabled="true"> <DropdownMenuItem as="button" :disabled="true">
<Flag /> <Flag />
{{ m.great_few_jaguar_rise() }} {{ m.great_few_jaguar_rise() }}
@ -91,8 +91,8 @@ const { account } = defineProps<{
account: z.infer<typeof Account>; account: z.infer<typeof Account>;
}>(); }>();
const isMe = identity.value?.account.id === account.id; const authStore = useAuthStore();
const isLoggedIn = !!identity.value; const isMe = authStore.account?.id === account.id;
const { copy } = useClipboard(); const { copy } = useClipboard();
const copyText = (text: string) => { const copyText = (text: string) => {
@ -105,7 +105,7 @@ const isRemote = account.acct.includes("@");
const muteUser = async (userId: string) => { const muteUser = async (userId: string) => {
const id = toast.loading(m.ornate_tidy_coyote_grow()); const id = toast.loading(m.ornate_tidy_coyote_grow());
await client.value.muteAccount(userId); await authStore.client.muteAccount(userId);
toast.dismiss(id); toast.dismiss(id);
toast.success("User muted"); toast.success("User muted");
@ -113,7 +113,7 @@ const muteUser = async (userId: string) => {
const blockUser = async (userId: string) => { const blockUser = async (userId: string) => {
const id = toast.loading(m.empty_smug_raven_bloom()); const id = toast.loading(m.empty_smug_raven_bloom());
await client.value.blockAccount(userId); await authStore.client.blockAccount(userId);
toast.dismiss(id); toast.dismiss(id);
toast.success("User blocked"); toast.success("User blocked");
@ -121,7 +121,7 @@ const blockUser = async (userId: string) => {
const refresh = async () => { const refresh = async () => {
const id = toast.loading(m.real_every_macaw_wish()); const id = toast.loading(m.real_every_macaw_wish());
await client.value.refetchAccount(account.id); await authStore.client.refetchAccount(account.id);
toast.dismiss(id); toast.dismiss(id);
toast.success(m.many_cool_fox_love()); toast.success(m.many_cool_fox_love());

View file

@ -33,15 +33,14 @@ import ProfileBadge from "./profile-badge.vue";
const { account } = defineProps<{ const { account } = defineProps<{
account: z.infer<typeof Account>; account: z.infer<typeof Account>;
}>(); }>();
const authStore = useAuthStore();
const config = useConfig(); const config = useConfig();
const roles = account.roles.filter((r) => r.visible); const roles = account.roles.filter((r) => r.visible);
// Get user handle in username@instance format // Get user handle in username@instance format
const handle = account.acct.includes("@") const handle = account.acct.includes("@")
? account.acct ? account.acct
: `${account.acct}@${ : `${account.acct}@${authStore.instance?.domain ?? window.location.host}`;
identity.value?.instance.domain ?? window.location.host
}`;
const isDeveloper = config.DEVELOPER_HANDLES.includes(handle); const isDeveloper = config.DEVELOPER_HANDLES.includes(handle);
</script> </script>

View file

@ -1,5 +1,5 @@
<template> <template>
<Button variant="secondary" :disabled="isLoading || relationship?.requested" v-if="!isMe && identity" <Button variant="secondary" :disabled="isLoading || relationship?.requested" v-if="!isMe && authStore.isSignedIn"
@click="relationship?.following ? unfollow() : follow()"> @click="relationship?.following ? unfollow() : follow()">
<Loader v-if="isLoading" class="animate-spin" /> <Loader v-if="isLoading" class="animate-spin" />
<span v-else> <span v-else>
@ -27,8 +27,9 @@ const { account } = defineProps<{
account: z.infer<typeof Account>; account: z.infer<typeof Account>;
}>(); }>();
const { relationship, isLoading } = useRelationship(client, account.id); const { relationship, isLoading } = useRelationship(account.id);
const isMe = identity.value?.account.id === account.id; const authStore = useAuthStore();
const isMe = authStore.account?.id === account.id;
const follow = async () => { const follow = async () => {
if (preferences.confirm_actions.value.includes("follow")) { if (preferences.confirm_actions.value.includes("follow")) {
@ -47,7 +48,7 @@ const follow = async () => {
} }
const id = toast.loading(m.quick_basic_peacock_bubble()); const id = toast.loading(m.quick_basic_peacock_bubble());
const { data } = await client.value.followAccount(account.id); const { data } = await authStore.client.followAccount(account.id);
toast.dismiss(id); toast.dismiss(id);
relationship.value = data; relationship.value = data;
@ -71,7 +72,7 @@ const unfollow = async () => {
} }
const id = toast.loading(m.big_safe_guppy_mix()); const id = toast.loading(m.big_safe_guppy_mix());
const { data } = await client.value.unfollowAccount(account.id); const { data } = await authStore.client.unfollowAccount(account.id);
toast.dismiss(id); toast.dismiss(id);
relationship.value = data; relationship.value = data;

View file

@ -10,15 +10,15 @@
Manage your accounts and settings. Manage your accounts and settings.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div v-if="identities.length > 0" class="grid gap-4 py-2"> <div v-if="authStore.identities.length > 0" class="grid gap-4 py-2">
<div v-for="identity of identities" :key="identity.account.id" <div v-for="identity of authStore.identities" :key="identity.account.id"
class="grid grid-cols-[1fr_auto] has-[>[data-switch]]:grid-cols-[1fr_auto_auto] gap-2"> class="grid grid-cols-[1fr_auto] has-[>[data-switch]]:grid-cols-[1fr_auto_auto] gap-2">
<TinyCard :account="identity.account" :domain="identity.instance.domain" naked /> <TinyCard :account="identity.account" :domain="identity.instance.domain" naked />
<Button data-switch v-if="currentIdentity?.id !== identity.id" <Button data-switch v-if="authStore.identity?.id !== identity.id"
@click="switchAccount(identity.account.id)" variant="outline"> @click="authStore.setActiveIdentity(identity.id)" variant="outline">
Switch Switch
</Button> </Button>
<Button @click="signOut(appData, identity)" variant="outline" size="icon" <Button @click="signOutAction(identity.id)" variant="outline" size="icon"
:title="m.sharp_big_mallard_reap()"> :title="m.sharp_big_mallard_reap()">
<LogOut /> <LogOut />
</Button> </Button>
@ -47,7 +47,6 @@
import { LogIn, LogOut, UserPlus } from "lucide-vue-next"; import { LogIn, LogOut, UserPlus } from "lucide-vue-next";
import { toast } from "vue-sonner"; import { toast } from "vue-sonner";
import { NuxtLink } from "#components"; import { NuxtLink } from "#components";
import { identity as currentIdentity } from "#imports";
import TinyCard from "~/components/profiles/tiny-card.vue"; import TinyCard from "~/components/profiles/tiny-card.vue";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
@ -61,31 +60,25 @@ import {
} from "~/components/ui/dialog"; } from "~/components/ui/dialog";
import * as m from "~~/paraglide/messages.js"; import * as m from "~~/paraglide/messages.js";
const appData = useAppData(); const authStore = useAuthStore();
const signInAction = async () => {
const instance = await askForInstance();
const signInAction = async () => signIn(appData, await askForInstance()); const id = toast.loading(m.level_due_ox_greet());
const switchAccount = async (userId: string) => { try {
if (userId === currentIdentity.value?.account.id) { await authStore.startSignIn(instance);
return await navigateTo(`/@${currentIdentity.value.account.username}`); } catch (e) {
} console.error(e);
const id = toast.loading("Switching account...");
const identityToSwitch = identities.value.find(
(i) => i.account.id === userId,
);
if (!identityToSwitch) {
toast.dismiss(id); toast.dismiss(id);
toast.error("No identity to switch to");
return;
} }
};
currentIdentity.value = identityToSwitch; const signOutAction = async (identityId: string) => {
const id = toast.loading("Signing out...");
await authStore.signOut(identityId);
toast.dismiss(id); toast.dismiss(id);
toast.success("Switched account"); toast.success("Signed out");
window.location.href = "/";
}; };
</script> </script>

View file

@ -18,6 +18,7 @@ import * as m from "~~/paraglide/messages.js";
import AccountManager from "../account/account-manager.vue"; import AccountManager from "../account/account-manager.vue";
const { $pwa } = useNuxtApp(); const { $pwa } = useNuxtApp();
const authStore = useAuthStore();
</script> </script>
<template> <template>
@ -25,8 +26,8 @@ const { $pwa } = useNuxtApp();
<SidebarMenu class="gap-3"> <SidebarMenu class="gap-3">
<SidebarMenuItem> <SidebarMenuItem>
<AccountManager> <AccountManager>
<SidebarMenuButton v-if="identity" size="lg"> <SidebarMenuButton v-if="authStore.account && authStore.instance" size="lg">
<TinyCard :account="identity.account" :domain="identity.instance.domain" naked /> <TinyCard :account="authStore.account" :domain="authStore.instance.domain" naked />
<ChevronsUpDown class="ml-auto size-4" /> <ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton> </SidebarMenuButton>
<SidebarMenuButton v-else> <SidebarMenuButton v-else>
@ -37,14 +38,14 @@ const { $pwa } = useNuxtApp();
</AccountManager> </AccountManager>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem class="flex flex-col gap-2"> <SidebarMenuItem class="flex flex-col gap-2">
<Button v-if="identity" variant="default" size="lg" class="w-full group-data-[collapsible=icon]:px-4" <Button v-if="authStore.isSignedIn" variant="default" size="lg" class="w-full group-data-[collapsible=icon]:px-4"
@click="useEvent('composer:open')"> @click="useEvent('composer:open')">
<Pen /> <Pen />
<span class="group-data-[collapsible=icon]:hidden"> <span class="group-data-[collapsible=icon]:hidden">
{{ m.salty_aloof_turkey_nudge() }} {{ m.salty_aloof_turkey_nudge() }}
</span> </span>
</Button> </Button>
<Button v-if="identity" size="lg" variant="secondary" @click="useEvent('preferences:open')"> <Button v-if="authStore.isSignedIn" size="lg" variant="secondary" @click="useEvent('preferences:open')">
<Cog /> <Cog />
Preferences Preferences
</Button> </Button>

View file

@ -6,7 +6,7 @@ import {
SidebarMenuItem, SidebarMenuItem,
} from "~/components/ui/sidebar"; } from "~/components/ui/sidebar";
const instance = useInstance(); const authStore = useAuthStore();
</script> </script>
<template> <template>
@ -14,7 +14,7 @@ const instance = useInstance();
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<NuxtLink href="/"> <NuxtLink href="/">
<InstanceSmallCard v-if="instance" :instance="instance" /> <InstanceSmallCard v-if="authStore.instance" :instance="authStore.instance" />
</NuxtLink> </NuxtLink>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>

View file

@ -9,7 +9,7 @@
<NavItems <NavItems
:items=" :items="
sidebarConfig.other.filter((i) => sidebarConfig.other.filter((i) =>
i.requiresLogin ? !!identity : true i.requiresLogin ? authStore.isSignedIn : true
) )
" "
/> />
@ -33,4 +33,6 @@ import * as m from "~~/paraglide/messages.js";
import FooterActions from "./footer/footer-actions.vue"; import FooterActions from "./footer/footer-actions.vue";
import InstanceHeader from "./instance/instance-header.vue"; import InstanceHeader from "./instance/instance-header.vue";
import NavItems from "./navigation/nav-items.vue"; import NavItems from "./navigation/nav-items.vue";
const authStore = useAuthStore();
</script> </script>

View file

@ -10,6 +10,7 @@ const showTimelines = computed(
["/", "/home", "/local", "/public", "/global"].includes(route.path) && ["/", "/home", "/local", "/public", "/global"].includes(route.path) &&
isMd.value, isMd.value,
); );
const authStore = useAuthStore();
</script> </script>
<template> <template>
@ -23,5 +24,5 @@ const showTimelines = computed(
</header> </header>
<slot /> <slot />
</main> </main>
<RightSidebar v-if="identity" v-show="preferences.display_notifications_sidebar" /> <RightSidebar v-if="authStore.isSignedIn" v-show="preferences.display_notifications_sidebar" />
</template> </template>

View file

@ -21,7 +21,7 @@ const {
loadPrev, loadPrev,
removeItem, removeItem,
updateItem, updateItem,
} = useAccountTimeline(client.value, props.id); } = useAccountTimeline(props.id);
useListen("note:delete", ({ id }) => { useListen("note:delete", ({ id }) => {
removeItem(id); removeItem(id);

View file

@ -18,7 +18,7 @@ const {
loadPrev, loadPrev,
removeItem, removeItem,
updateItem, updateItem,
} = useGlobalTimeline(client.value); } = useGlobalTimeline();
useListen("note:delete", ({ id }) => { useListen("note:delete", ({ id }) => {
removeItem(id); removeItem(id);

View file

@ -4,9 +4,9 @@
:update-item="updateItem" /> :update-item="updateItem" />
</template> </template>
<script lang="ts" setup>import { useHomeTimeline } from "~/composables/HomeTimeline"; <script lang="ts" setup>
import { useHomeTimeline } from "~/composables/HomeTimeline";
import Timeline from "./timeline.vue"; import Timeline from "./timeline.vue";
import type { z } from "zod";
const { const {
error, error,
@ -17,7 +17,7 @@ const {
loadPrev, loadPrev,
removeItem, removeItem,
updateItem, updateItem,
} = useHomeTimeline(client.value); } = useHomeTimeline();
useListen("note:delete", ({ id }) => { useListen("note:delete", ({ id }) => {
removeItem(id); removeItem(id);

View file

@ -18,7 +18,7 @@ const {
loadPrev, loadPrev,
removeItem, removeItem,
updateItem, updateItem,
} = useLocalTimeline(client.value); } = useLocalTimeline();
useListen("note:delete", ({ id }) => { useListen("note:delete", ({ id }) => {
removeItem(id); removeItem(id);

View file

@ -17,5 +17,5 @@ const {
loadPrev, loadPrev,
removeItem, removeItem,
updateItem, updateItem,
} = useNotificationTimeline(client.value); } = useNotificationTimeline();
</script> </script>

View file

@ -17,7 +17,7 @@ const {
loadPrev, loadPrev,
removeItem, removeItem,
updateItem, updateItem,
} = usePublicTimeline(client.value); } = usePublicTimeline();
useListen("note:delete", ({ id }) => { useListen("note:delete", ({ id }) => {
removeItem(id); removeItem(id);

View file

@ -1,9 +1,7 @@
import type { Client } from "@versia/client";
import type { Account } from "@versia/client/schemas"; import type { Account } from "@versia/client/schemas";
import type { z } from "zod"; import type { z } from "zod";
export const useAccountFromAcct = ( export const useAccountFromAcct = (
client: MaybeRef<Client | null>,
acct: string, acct: string,
): { ): {
account: Ref<z.infer<typeof Account> | null>; account: Ref<z.infer<typeof Account> | null>;
@ -11,13 +9,12 @@ export const useAccountFromAcct = (
} => { } => {
const output = ref(null as z.infer<typeof Account> | null); const output = ref(null as z.infer<typeof Account> | null);
const isLoading = ref(true); const isLoading = ref(true);
const authStore = useAuthStore();
ref(client) authStore.client.lookupAccount(acct).then((res) => {
.value?.lookupAccount(acct) isLoading.value = false;
.then((res) => { output.value = res.data;
isLoading.value = false; });
output.value = res.data;
});
return { account: output, isLoading }; return { account: output, isLoading };
}; };

View file

@ -1,16 +1,16 @@
import type { Client } from "@versia/client";
import type { Status } from "@versia/client/schemas"; import type { Status } from "@versia/client/schemas";
import type { z } from "zod"; import type { z } from "zod";
import { type TimelineOptions, useTimeline } from "./Timeline"; import { type TimelineOptions, useTimeline } from "./Timeline";
export function useAccountTimeline( export function useAccountTimeline(
client: Client,
accountId: string, accountId: string,
options: Partial<TimelineOptions<z.infer<typeof Status>>> = {}, options: Partial<TimelineOptions<z.infer<typeof Status>>> = {},
) { ) {
return useTimeline(client, { const authStore = useAuthStore();
fetchFunction: (client, opts) =>
client.getAccountStatuses(accountId, opts), return useTimeline({
fetchFunction: (opts) =>
authStore.client.getAccountStatuses(accountId, opts),
...options, ...options,
}); });
} }

View file

@ -1,13 +0,0 @@
import type { CredentialApplication } from "@versia/client/schemas";
import { StorageSerializers } from "@vueuse/core";
import type { z } from "zod";
export const useAppData = () => {
return useLocalStorage<z.infer<typeof CredentialApplication> | null>(
"versia:app_data",
null,
{
serializer: StorageSerializers.object,
},
);
};

View file

@ -1,66 +1,47 @@
import type { Client } from "@versia/client";
import type { RolePermission } from "@versia/client/schemas";
import { toast } from "vue-sonner"; import { toast } from "vue-sonner";
import * as m from "~~/paraglide/messages.js"; import * as m from "~~/paraglide/messages.js";
export const useCacheRefresh = (client: MaybeRef<Client | null>) => { export const useCacheRefresh = () => {
const authStore = useAuthStore();
const { identity } = storeToRefs(authStore);
authStore.client.getInstance().then((res) => {
authStore.updateActiveIdentity({
instance: res.data,
});
});
// Refresh custom emojis and instance data and me on every reload // Refresh custom emojis and instance data and me on every reload
watch( watch(
[identity, client], identity,
async () => { async (oldIdentity, newIdentity) => {
console.info("Refreshing emoji, instance and account cache"); if (newIdentity && newIdentity.id !== oldIdentity?.id) {
if (identity.value) { console.info("Refreshing emoji, instance and account cache");
toValue(client) authStore.client
?.verifyAccountCredentials() .verifyAccountCredentials()
.then((res) => { .then((res) => {
if (identity.value) { authStore.updateActiveIdentity({
identity.value.account = res.data; account: res.data,
} });
}) })
.catch((err) => { .catch((err) => {
const code = err.response.status; const code = err.response.status;
if (code === 401) { if (code === 401) {
// Reset tokenData // Reset tokenData
identity.value = null; authStore.setActiveIdentity(null);
toast.error(m.fancy_this_wasp_renew(), { toast.error(m.fancy_this_wasp_renew(), {
description: m.real_weird_deer_stop(), description: m.real_weird_deer_stop(),
}); });
} }
}); });
toValue(client) authStore.client.getInstanceCustomEmojis().then((res) => {
?.getInstanceCustomEmojis() authStore.updateActiveIdentity({
.then((res) => { emojis: res.data,
if (identity.value) {
identity.value.emojis = res.data;
}
}); });
toValue(client)
?.getAccountRoles(identity.value.account.id)
.then((res) => {
const roles = res.data;
// Get all permissions and deduplicate
const permissions = roles
?.flatMap((r) => r.permissions)
.filter((p, i, arr) => arr.indexOf(p) === i);
if (identity.value) {
identity.value.permissions =
permissions as unknown as RolePermission[];
}
});
}
toValue(client)
?.getInstance()
.then((res) => {
if (identity.value) {
identity.value.instance = res.data;
}
}); });
}
}, },
{ flush: "sync", immediate: true }, { flush: "sync", immediate: true },
); );

View file

@ -1,33 +0,0 @@
import { Client } from "@versia/client";
import type { Token } from "@versia/client/schemas";
import { toast } from "vue-sonner";
import type { z } from "zod";
export const useClient = (
origin?: MaybeRef<URL>,
customToken: MaybeRef<z.infer<typeof Token> | null> = null,
): Ref<Client> => {
const apiHost = window.location.origin;
const domain = identity.value?.instance.domain;
return ref(
new Client(
toValue(origin) ??
(domain ? new URL(`https://${domain}`) : new URL(apiHost)),
toValue(customToken)?.access_token ??
identity.value?.tokens.access_token ??
undefined,
{
globalCatch: (error) => {
toast.error(
error.response.data.error ??
"No error message provided",
);
},
throwOnError: false,
},
),
) as Ref<Client>;
};
export const client = useClient();

View file

@ -1,7 +1,6 @@
import type { Account, Attachment, Status } from "@versia/client/schemas"; import type { Account, Attachment, Status } from "@versia/client/schemas";
import mitt from "mitt"; import mitt from "mitt";
import type { z } from "zod"; import type { z } from "zod";
import type { Identity } from "./Identities";
type ApplicationEvents = { type ApplicationEvents = {
"note:reply": z.infer<typeof Status>; "note:reply": z.infer<typeof Status>;
@ -23,7 +22,6 @@ type ApplicationEvents = {
"account:report": z.infer<typeof Account>; "account:report": z.infer<typeof Account>;
"account:update": z.infer<typeof Account>; "account:update": z.infer<typeof Account>;
"attachment:view": z.infer<typeof Attachment>; "attachment:view": z.infer<typeof Attachment>;
"identity:change": Identity;
"preferences:open": undefined; "preferences:open": undefined;
error: { error: {
code: string; code: string;

View file

@ -1,19 +1,14 @@
import type { Client } from "@versia/client";
import type { ExtendedDescription } from "@versia/client/schemas"; import type { ExtendedDescription } from "@versia/client/schemas";
import type { z } from "zod"; import type { z } from "zod";
export const useExtendedDescription = (client: MaybeRef<Client | null>) => { export const useExtendedDescription = () => {
if (!ref(client).value) { const store = useAuthStore();
return ref(null as z.infer<typeof ExtendedDescription> | null);
}
const output = ref(null as z.infer<typeof ExtendedDescription> | null); const output = ref(null as z.infer<typeof ExtendedDescription> | null);
ref(client) store.client.getInstanceExtendedDescription().then((res) => {
.value?.getInstanceExtendedDescription() output.value = res.data;
.then((res) => { });
output.value = res.data;
});
return output; return output;
}; };

View file

@ -1,15 +1,15 @@
import type { Client } from "@versia/client";
import type { Status } from "@versia/client/schemas"; import type { Status } from "@versia/client/schemas";
import type { z } from "zod"; import type { z } from "zod";
import { type TimelineOptions, useTimeline } from "./Timeline"; import { type TimelineOptions, useTimeline } from "./Timeline";
export function useGlobalTimeline( export function useGlobalTimeline(
client: Client,
options: Partial<TimelineOptions<z.infer<typeof Status>>> = {}, options: Partial<TimelineOptions<z.infer<typeof Status>>> = {},
) { ) {
return useTimeline(client, { const authStore = useAuthStore();
return useTimeline({
// TODO: Implement global timeline in client sdk // TODO: Implement global timeline in client sdk
fetchFunction: (client, opts) => client.getPublicTimeline(opts), fetchFunction: (opts) => authStore.client.getPublicTimeline(opts),
...options, ...options,
}); });
} }

View file

@ -1,14 +1,14 @@
import type { Client } from "@versia/client";
import type { Status } from "@versia/client/schemas"; import type { Status } from "@versia/client/schemas";
import type { z } from "zod"; import type { z } from "zod";
import { type TimelineOptions, useTimeline } from "./Timeline"; import { type TimelineOptions, useTimeline } from "./Timeline";
export function useHomeTimeline( export function useHomeTimeline(
client: Client,
options: Partial<TimelineOptions<z.infer<typeof Status>>> = {}, options: Partial<TimelineOptions<z.infer<typeof Status>>> = {},
) { ) {
return useTimeline(client, { const authStore = useAuthStore();
fetchFunction: (client, opts) => client.getHomeTimeline(opts),
return useTimeline({
fetchFunction: (opts) => authStore.client.getHomeTimeline(opts),
...options, ...options,
}); });
} }

View file

@ -1,104 +0,0 @@
import type {
Account,
CustomEmoji,
Instance,
RolePermission,
Token,
} from "@versia/client/schemas";
import { StorageSerializers, useLocalStorage } from "@vueuse/core";
import { ref, watch } from "vue";
import type { z } from "zod";
/**
* Represents an identity with associated tokens, account, instance, permissions, and emojis.
*/
export interface Identity {
id: string;
tokens: z.infer<typeof Token>;
account: z.infer<typeof Account>;
instance: z.infer<typeof Instance>;
permissions: RolePermission[];
emojis: z.infer<typeof CustomEmoji>[];
}
/**
* Composable to manage multiple identities.
* @returns A reactive reference to an array of identities.
*/
function useIdentities(): Ref<Identity[]> {
return useLocalStorage<Identity[]>("versia:identities", [], {
serializer: StorageSerializers.object,
});
}
export const identities = useIdentities();
const currentId = useLocalStorage<string | null>(
"versia:identities:current",
null,
);
const current = ref<Identity | null>(null);
/**
* Composable to manage the current identity.
* @returns A reactive reference to the current identity or null if not set.
*/
function useCurrentIdentity(): Ref<Identity | null> {
// Initialize current identity
function updateCurrentIdentity() {
current.value =
identities.value.find((i) => i.id === currentId.value) ?? null;
}
// Watch for changes in identities
watch(
identities,
(ids) => {
if (ids.length === 0) {
current.value = null;
currentId.value = null;
} else {
updateCurrentIdentity();
}
},
{ deep: true },
);
// Watch for changes in currentId
watch(currentId, updateCurrentIdentity);
// Watch for changes in current identity
watch(
current,
(newCurrent) => {
if (newCurrent) {
currentId.value = newCurrent.id;
const index = identities.value.findIndex(
(i) => i.id === newCurrent.id,
);
if (index !== -1) {
// Update existing identity
identities.value[index] = newCurrent;
} else {
// Add new identity
identities.value.push(newCurrent);
}
} else {
// Remove current identity
identities.value = identities.value.filter(
(i) => i.id !== currentId.value,
);
currentId.value = identities.value[0]?.id ?? null;
}
},
{ deep: true },
);
// Initial setup
updateCurrentIdentity();
return current;
}
export const identity = useCurrentIdentity();

View file

@ -2,10 +2,6 @@ import type { Client } from "@versia/client";
import type { Instance, TermsOfService } from "@versia/client/schemas"; import type { Instance, TermsOfService } from "@versia/client/schemas";
import type { z } from "zod"; import type { z } from "zod";
export const useInstance = () => {
return computed(() => identity.value?.instance);
};
export const useInstanceFromClient = (client: MaybeRef<Client>) => { export const useInstanceFromClient = (client: MaybeRef<Client>) => {
if (!client) { if (!client) {
return ref(null as z.infer<typeof Instance> | null); return ref(null as z.infer<typeof Instance> | null);

View file

@ -1,14 +1,14 @@
import type { Client } from "@versia/client";
import type { Status } from "@versia/client/schemas"; import type { Status } from "@versia/client/schemas";
import type { z } from "zod"; import type { z } from "zod";
import { type TimelineOptions, useTimeline } from "./Timeline"; import { type TimelineOptions, useTimeline } from "./Timeline";
export function useLocalTimeline( export function useLocalTimeline(
client: Client,
options: Partial<TimelineOptions<z.infer<typeof Status>>> = {}, options: Partial<TimelineOptions<z.infer<typeof Status>>> = {},
) { ) {
return useTimeline(client, { const authStore = useAuthStore();
fetchFunction: (client, opts) => client.getLocalTimeline(opts),
return useTimeline({
fetchFunction: (opts) => authStore.client.getLocalTimeline(opts),
...options, ...options,
}); });
} }

View file

@ -1,21 +1,19 @@
import type { Client } from "@versia/client";
import type { Status } from "@versia/client/schemas"; import type { Status } from "@versia/client/schemas";
import type { z } from "zod"; import type { z } from "zod";
export const useNote = ( export const useNote = (noteId: MaybeRef<string | null>) => {
client: MaybeRef<Client | null>, if (!toValue(noteId)) {
noteId: MaybeRef<string | null>,
) => {
if (!(toValue(client) && toValue(noteId))) {
return ref(null as z.infer<typeof Status> | null); return ref(null as z.infer<typeof Status> | null);
} }
const authStore = useAuthStore();
const output = ref(null as z.infer<typeof Status> | null); const output = ref(null as z.infer<typeof Status> | null);
watchEffect(() => { watchEffect(() => {
toValue(noteId) && toValue(noteId) &&
toValue(client) authStore.client
?.getStatus(toValue(noteId) as string) .getStatus(toValue(noteId) as string)
.then((res) => { .then((res) => {
output.value = res.data; output.value = res.data;
}); });

View file

@ -1,21 +1,15 @@
import type { Client } from "@versia/client";
import type { Context } from "@versia/client/schemas"; import type { Context } from "@versia/client/schemas";
import type { z } from "zod"; import type { z } from "zod";
export const useNoteContext = ( export const useNoteContext = (noteId: MaybeRef<string | null>) => {
client: MaybeRef<Client | null>, const authStore = useAuthStore();
noteId: MaybeRef<string | null>,
) => {
if (!ref(client).value) {
return ref(null as z.infer<typeof Context> | null);
}
const output = ref(null as z.infer<typeof Context> | null); const output = ref(null as z.infer<typeof Context> | null);
watchEffect(() => { watchEffect(() => {
if (toValue(noteId)) { if (toValue(noteId)) {
ref(client) authStore.client
.value?.getStatusContext(toValue(noteId) ?? "") .getStatusContext(toValue(noteId) ?? "")
.then((res) => { .then((res) => {
output.value = res.data; output.value = res.data;
}); });

View file

@ -4,11 +4,12 @@ import type { z } from "zod";
import { type TimelineOptions, useTimeline } from "./Timeline"; import { type TimelineOptions, useTimeline } from "./Timeline";
export function useNotificationTimeline( export function useNotificationTimeline(
client: Client,
options: Partial<TimelineOptions<z.infer<typeof Notification>>> = {}, options: Partial<TimelineOptions<z.infer<typeof Notification>>> = {},
) { ) {
return useTimeline(client, { const authStore = useAuthStore();
fetchFunction: (client, opts) => client.getNotifications(opts),
return useTimeline({
fetchFunction: (opts) => authStore.client.getNotifications(opts),
...options, ...options,
}); });
} }

View file

@ -1,3 +0,0 @@
export const usePermissions = () => {
return computed(() => identity.value?.permissions ?? []);
};

View file

@ -1,14 +1,14 @@
import type { Client } from "@versia/client";
import type { Status } from "@versia/client/schemas"; import type { Status } from "@versia/client/schemas";
import type { z } from "zod"; import type { z } from "zod";
import { type TimelineOptions, useTimeline } from "./Timeline"; import { type TimelineOptions, useTimeline } from "./Timeline";
export function usePublicTimeline( export function usePublicTimeline(
client: Client,
options: Partial<TimelineOptions<z.infer<typeof Status>>> = {}, options: Partial<TimelineOptions<z.infer<typeof Status>>> = {},
) { ) {
return useTimeline(client, { const authStore = useAuthStore();
fetchFunction: (client, opts) => client.getPublicTimeline(opts),
return useTimeline({
fetchFunction: (opts) => authStore.client.getPublicTimeline(opts),
...options, ...options,
}); });
} }

View file

@ -1,22 +1,19 @@
import type { Client } from "@versia/client";
import type { Relationship } from "@versia/client/schemas"; import type { Relationship } from "@versia/client/schemas";
import type { z } from "zod"; import type { z } from "zod";
export const useRelationship = ( export const useRelationship = (accountId: MaybeRef<string | null>) => {
client: MaybeRef<Client | null>,
accountId: MaybeRef<string | null>,
) => {
const relationship = ref(null as z.infer<typeof Relationship> | null); const relationship = ref(null as z.infer<typeof Relationship> | null);
const isLoading = ref(false); const isLoading = ref(false);
const authStore = useAuthStore();
if (!identity.value) { if (!authStore.isSignedIn) {
return { relationship, isLoading }; return { relationship, isLoading };
} }
watchEffect(() => { watchEffect(() => {
if (toValue(accountId)) { if (toValue(accountId)) {
toValue(client) authStore.client
?.getRelationship(toValue(accountId) ?? "") .getRelationship(toValue(accountId) ?? "")
.then((res) => { .then((res) => {
relationship.value = res.data; relationship.value = res.data;
}); });
@ -28,14 +25,14 @@ export const useRelationship = (
if (newOutput?.following !== oldOutput?.following) { if (newOutput?.following !== oldOutput?.following) {
isLoading.value = true; isLoading.value = true;
if (newOutput?.following) { if (newOutput?.following) {
toValue(client) authStore.client
?.followAccount(toValue(accountId) ?? "") .followAccount(toValue(accountId) ?? "")
.finally(() => { .finally(() => {
isLoading.value = false; isLoading.value = false;
}); });
} else { } else {
toValue(client) authStore.client
?.unfollowAccount(toValue(accountId) ?? "") .unfollowAccount(toValue(accountId) ?? "")
.finally(() => { .finally(() => {
isLoading.value = false; isLoading.value = false;
}); });

View file

@ -1,10 +0,0 @@
import type { Instance } from "@versia/client/schemas";
import type { z } from "zod";
export const useSSOConfig = (): Ref<z.infer<
typeof Instance.shape.sso
> | null> => {
const instance = useInstance();
return computed(() => instance.value?.sso || null);
};

View file

@ -4,18 +4,20 @@ import { useIntervalFn } from "@vueuse/core";
import type { z } from "zod"; import type { z } from "zod";
export interface TimelineOptions<T> { export interface TimelineOptions<T> {
fetchFunction: (client: Client, options: object) => Promise<Output<T[]>>; fetchFunction: (options: object) => Promise<Output<T[]>>;
updateInterval?: number; updateInterval?: number;
limit?: number; limit?: number;
} }
export function useTimeline< export function useTimeline<
T extends z.infer<typeof Status> | z.infer<typeof Notification>, T extends z.infer<typeof Status> | z.infer<typeof Notification>,
>(client: Client, options: TimelineOptions<T>) { >(options: TimelineOptions<T>) {
const items = ref<T[]>([]) as Ref<T[]>; const items = ref<T[]>([]) as Ref<T[]>;
const isLoading = ref(false); const isLoading = ref(false);
const hasReachedEnd = ref(false); const hasReachedEnd = ref(false);
const error = ref<Error | null>(null); const error = ref<Error | null>(null);
const authStore = useAuthStore();
const { identity } = storeToRefs(authStore);
const nextMaxId = ref<string | undefined>(undefined); const nextMaxId = ref<string | undefined>(undefined);
const prevMinId = ref<string | undefined>(undefined); const prevMinId = ref<string | undefined>(undefined);
@ -29,7 +31,7 @@ export function useTimeline<
error.value = null; error.value = null;
try { try {
const response = await options.fetchFunction(client, { const response = await options.fetchFunction({
limit: options.limit || 20, limit: options.limit || 20,
max_id: direction === "next" ? nextMaxId.value : undefined, max_id: direction === "next" ? nextMaxId.value : undefined,
min_id: direction === "prev" ? prevMinId.value : undefined, min_id: direction === "prev" ? prevMinId.value : undefined,
@ -99,6 +101,17 @@ export function useTimeline<
pause(); pause();
}); });
watch(identity, (newIdentity, oldIdentity) => {
if (newIdentity?.id !== oldIdentity?.id) {
// Reload timeline when identity changes
items.value = [];
nextMaxId.value = undefined;
prevMinId.value = undefined;
hasReachedEnd.value = false;
error.value = null;
}
});
return { return {
items, items,
isLoading, isLoading,

View file

@ -1,15 +1,15 @@
<template> <template>
<SidebarProvider> <SidebarProvider>
<AppSidebar> <AppSidebar>
<slot v-if="!route.meta.requiresAuth || identity" /> <slot v-if="!route.meta.requiresAuth || authStore.isSignedIn" />
<div class="mx-auto max-w-4xl p-4" v-else> <div class="mx-auto max-w-4xl p-4" v-else>
<AuthRequired /> <AuthRequired />
</div> </div>
</AppSidebar> </AppSidebar>
</SidebarProvider> </SidebarProvider>
<MobileNavbar v-if="identity" /> <MobileNavbar v-if="authStore.isSignedIn" />
<Preferences /> <Preferences />
<ComposerDialog v-if="identity" /> <ComposerDialog v-if="authStore.isSignedIn" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -23,6 +23,7 @@ import { SidebarProvider } from "~/components/ui/sidebar";
const colorMode = useColorMode(); const colorMode = useColorMode();
const { n, d } = useMagicKeys(); const { n, d } = useMagicKeys();
const activeElement = useActiveElement(); const activeElement = useActiveElement();
const authStore = useAuthStore();
const notUsingInput = computed( const notUsingInput = computed(
() => () =>
activeElement.value?.tagName !== "INPUT" && activeElement.value?.tagName !== "INPUT" &&

11
app/layouts/auth.vue Normal file
View file

@ -0,0 +1,11 @@
<template>
<div class="flex h-svh items-center justify-center px-6 py-12 lg:px-8 bg-center bg-no-repeat bg-cover" :style="{
backgroundImage: 'url(/images/banner.webp)',
}">
<slot />
</div>
</template>
<script lang="ts" setup>
</script>

View file

@ -1,8 +1,10 @@
<template> <template>
<slot /> <slot />
<ComposerDialog v-if="identity" /> <ComposerDialog v-if="authStore.isSignedIn" />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import ComposerDialog from "~/components/composer/dialog.vue"; import ComposerDialog from "~/components/composer/dialog.vue";
const authStore = useAuthStore();
</script> </script>

View file

@ -41,9 +41,9 @@ const element = ref<HTMLElement | null>(null);
const route = useRoute(); const route = useRoute();
const uuid = route.params.uuid as string; const uuid = route.params.uuid as string;
const note = useNote(client, uuid); const note = useNote(uuid);
const noteId = computed(() => note.value?.id ?? null); const noteId = computed(() => note.value?.id ?? null);
const context = useNoteContext(client, noteId); const context = useNoteContext(noteId);
const loaded = computed(() => note.value !== null && context.value !== null); const loaded = computed(() => note.value !== null && context.value !== null);
// If ancestors changes, scroll down so that the initial note stays at the same place // If ancestors changes, scroll down so that the initial note stays at the same place

View file

@ -39,7 +39,7 @@ definePageMeta({
], ],
}); });
const { account, isLoading } = useAccountFromAcct(client, username); const { account, isLoading } = useAccountFromAcct(username);
const accountId = computed(() => account.value?.id ?? undefined); const accountId = computed(() => account.value?.id ?? undefined);
useSeoMeta({ useSeoMeta({

43
app/pages/callback.vue Normal file
View file

@ -0,0 +1,43 @@
<template>
<Card v-if="code && domain" class="w-full max-w-md *:w-full">
<CardHeader>
<CardTitle>Signing in...</CardTitle>
<CardDescription>
You will be redirected shortly.
</CardDescription>
</CardHeader>
</Card>
<Card v-else class="w-full max-w-md *:w-full">
<CardHeader>
<CardTitle>Error</CardTitle>
<CardDescription>
Missing code or domain in the callback URL.
</CardDescription>
</CardHeader>
</Card>
</template>
<script lang="ts" setup>
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
// This page handles the OAuth callback and signs the user in
definePageMeta({
layout: "auth",
});
const code = useRequestURL().searchParams.get("code");
const domain = useRequestURL().searchParams.get("domain");
const authStore = useAuthStore();
if (code && domain) {
const newOrigin = new URL(`https://${domain}`);
await authStore.finishSignIn(code, newOrigin);
await navigateTo("/");
}
</script>

View file

@ -1,6 +1,6 @@
<template> <template>
<TimelineScroller> <TimelineScroller>
<Home v-if="identity" /> <Home v-if="authStore.isSignedIn" />
<Public v-else /> <Public v-else />
</TimelineScroller> </TimelineScroller>
</template> </template>
@ -11,8 +11,9 @@ import Public from "~/components/timelines/public.vue";
import TimelineScroller from "~/components/timelines/timeline-scroller.vue"; import TimelineScroller from "~/components/timelines/timeline-scroller.vue";
import * as m from "~~/paraglide/messages.js"; import * as m from "~~/paraglide/messages.js";
const authStore = useAuthStore();
useHead({ useHead({
title: identity.value title: authStore.isSignedIn
? m.bland_chunky_sparrow_propel() ? m.bland_chunky_sparrow_propel()
: m.lost_trick_dog_grace(), : m.lost_trick_dog_grace(),
}); });

View file

@ -1,10 +1,4 @@
<template> <template>
<div
class="flex h-svh items-center justify-center px-6 py-12 lg:px-8 bg-center bg-no-repeat bg-cover"
:style="{
backgroundImage: 'url(/images/banner.webp)',
}"
>
<Card v-if="params.success" class="w-full max-w-md *:w-full"> <Card v-if="params.success" class="w-full max-w-md *:w-full">
<CardHeader> <CardHeader>
<CardTitle>{{ m.late_mean_capybara_fade() }}</CardTitle> <CardTitle>{{ m.late_mean_capybara_fade() }}</CardTitle>
@ -103,7 +97,6 @@
</CardFooter> </CardFooter>
</form> </form>
</Card> </Card>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -135,8 +128,12 @@ import * as m from "~~/paraglide/messages.js";
useHead({ useHead({
title: m.arable_arable_herring_lead(), title: m.arable_arable_herring_lead(),
}); });
definePageMeta({
layout: "auth",
});
identity.value = null; const authStore = useAuthStore();
authStore.setActiveIdentity(null);
const formSchema = toTypedSchema( const formSchema = toTypedSchema(
z z

View file

@ -160,15 +160,17 @@ const schema = toTypedSchema(
}), }),
); );
const instance = useInstanceFromClient(new Client(client.value.url)); const client = new Client(new URL(useRequestURL().origin));
const instance = useInstanceFromClient(client);
const form = useForm({ const form = useForm({
validationSchema: schema, validationSchema: schema,
}); });
const authStore = useAuthStore();
const handleSubmit = form.handleSubmit((values) => { const handleSubmit = form.handleSubmit((values) => {
isLoading.value = true; isLoading.value = true;
ref(client) authStore.client
.value?.registerAccount( .registerAccount(
values.username, values.username,
values.email, values.email,
values.password, values.password,

221
app/stores/auth.ts Normal file
View file

@ -0,0 +1,221 @@
import { Client } from "@versia/client";
import type {
Account,
CredentialApplication,
CustomEmoji,
Instance,
RolePermission,
Token,
} from "@versia/client/schemas";
import { defineStore } from "pinia";
import { toast } from "vue-sonner";
import type { z } from "zod";
import pkg from "~~/package.json";
/**
* Represents an identity with associated tokens, account, instance, permissions, and emojis.
*/
export interface Identity {
id: string;
token: z.infer<typeof Token>;
account: z.infer<typeof Account>;
instance: z.infer<typeof Instance>;
emojis: z.infer<typeof CustomEmoji>[];
}
interface AuthStoreState {
identities: Identity[];
activeIdentityId: string | null;
applications: {
[domain: string]: z.infer<typeof CredentialApplication>;
};
}
export const useAuthStore = defineStore("auth", {
state: (): AuthStoreState => ({
identities: [],
activeIdentityId: null,
applications: {},
}),
getters: {
identity(state): Identity | null {
return state.activeIdentityId
? state.identities.find(
(id) => id.id === state.activeIdentityId,
) || null
: null;
},
emojis(): z.infer<typeof CustomEmoji>[] {
return this.identity?.emojis || [];
},
instance(): z.infer<typeof Instance> | null {
return this.identity?.instance || null;
},
account(): z.infer<typeof Account> | null {
return this.identity?.account || null;
},
application(): z.infer<typeof CredentialApplication> | null {
if (!this.identity) {
return null;
}
return this.applications[this.identity.instance.domain] || null;
},
token(): z.infer<typeof Token> | null {
return this.identity?.token || null;
},
permissions(): RolePermission[] {
const roles = this.account?.roles ?? [];
return roles
.flatMap((r) => r.permissions)
.filter((p, i, arr) => arr.indexOf(p) === i);
},
isSignedIn(state): boolean {
return state.activeIdentityId !== null;
},
client(): Client {
const apiHost = window.location.origin;
const domain = this.identity?.instance.domain;
return new Client(
domain ? new URL(`https://${domain}`) : new URL(apiHost),
this.identity?.token.access_token ?? undefined,
{
globalCatch: (error) => {
toast.error(
error.response?.data.error ??
"No error message provided",
);
},
throwOnError: false,
},
);
},
},
actions: {
setActiveIdentity(id: string | null) {
this.activeIdentityId = id;
},
updateActiveIdentity(data: Partial<Identity>) {
if (this.activeIdentityId) {
this.$patch({
identities: this.identities.map((id) =>
id.id === this.activeIdentityId
? { ...id, ...data }
: id,
),
});
}
},
async createApp(
origin: URL,
): Promise<z.infer<typeof CredentialApplication>> {
const redirectUri = new URL(
`/callback?${new URLSearchParams({ domain: origin.host }).toString()}`,
useRequestURL().origin,
);
const client = new Client(origin);
const output = await client.createApp("Versia-FE", {
scopes: ["read", "write", "follow", "push"],
redirect_uris: redirectUri.href,
// @ts-expect-error Package.json types are missing this field
website: pkg.homepage ?? undefined,
});
this.applications[origin.host] = output.data;
return output.data;
},
async startSignIn(origin: URL) {
const client = new Client(origin);
const appData =
this.applications[origin.host] ??
(await this.createApp(origin));
const url = await client.generateAuthUrl(
appData.client_id,
appData.client_secret,
{
scopes: ["read", "write", "follow", "push"],
redirect_uri: appData.redirect_uris[0],
},
);
window.location.href = url;
},
async finishSignIn(code: string, origin: URL): Promise<void> {
const appData = this.applications[origin.host];
if (!appData) {
toast.error(`No application data found for ${origin.host}`);
return;
}
const client = new Client(origin);
const token = await client.fetchAccessToken(
appData.client_id,
appData.client_secret,
code,
appData.redirect_uris[0],
);
const authClient = new Client(origin, token.data.access_token);
const [account, instance, emojis] = await Promise.all([
authClient.verifyAccountCredentials(),
authClient.getInstance(),
authClient.getInstanceCustomEmojis(),
]);
if (
!this.identities.find((i) => i.account.id === account.data.id)
) {
const newIdentity: Identity = {
id: crypto.randomUUID(),
token: token.data,
account: account.data,
instance: instance.data,
emojis: emojis.data,
};
this.identities.push(newIdentity);
this.activeIdentityId = newIdentity.id;
} else {
this.activeIdentityId = this.identities.find(
(i) => i.account.id === account.data.id,
)?.id as string;
}
},
async signOut(identityId?: string) {
const id = identityId ?? this.activeIdentityId;
const identity = this.identities.find((i) => i.id === id);
if (!identity) {
return;
}
const appData = this.applications[identity.instance.domain];
if (!appData) {
return;
}
await this.client.revokeToken(
appData.client_id,
appData.client_secret,
identity.token.access_token,
);
if (this.activeIdentityId === id) {
this.activeIdentityId = null;
}
this.identities = this.identities.filter((i) => i.id !== id);
},
},
persist: {
storage: localStorage,
},
});

260
app/stores/composer.ts Normal file
View file

@ -0,0 +1,260 @@
import type { Attachment, Status, StatusSource } from "@versia/client/schemas";
import { defineStore } from "pinia";
import type { z } from "zod";
export interface ComposerFile {
id: string;
apiId?: string;
file?: File;
alt?: string;
uploading: boolean;
updating: boolean;
}
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: ComposerFile[];
sending: boolean;
}
export type ComposerStateKey =
| "blank"
| `${NonNullable<ComposerState["relation"]>["type"]}-${string}`;
export const calculateMentionsFromReply = (
note: z.infer<typeof Status>,
): string => {
const authStore = useAuthStore();
const peopleToMention = note.mentions
.concat(note.account)
// Deduplicate mentions
.filter((men, i, a) => a.indexOf(men) === i)
// Remove self
.filter((men) => men.id !== authStore.identity?.account.id);
if (peopleToMention.length === 0) {
return "";
}
const mentions = peopleToMention.map((me) => `@${me.acct}`).join(" ");
return `${mentions} `;
};
export const useComposerStore = (key: ComposerStateKey) =>
defineStore(`composer-${key}`, {
state: (): ComposerState => ({
relation: undefined,
content: "",
rawContent: "",
sensitive: false,
contentWarning: "",
contentType: "text/html",
visibility: "public",
files: [],
sending: false,
}),
getters: {
characterCount: (state) => {
return state.rawContent.length;
},
isOverCharacterLimit(): boolean {
const authStore = useAuthStore();
const characterLimit =
authStore.identity?.instance.configuration.statuses
.max_characters ?? 0;
return this.characterCount > characterLimit;
},
/* Cannot send if content is empty or over character limit, unless media is attached */
canSend(state): boolean {
if (state.sending) {
return false;
}
if (this.isOverCharacterLimit) {
return false;
}
return this.characterCount > 0 || state.files.length > 0;
},
},
actions: {
async stateFromRelation(
relationType: "reply" | "quote" | "edit",
note: z.infer<typeof Status>,
source?: z.infer<typeof StatusSource>,
): Promise<ComposerStateKey> {
const key = `${relationType}-${note.id}` as const;
this.$patch({
relation: {
type: relationType,
note,
source,
},
content: calculateMentionsFromReply(note),
contentWarning: source?.spoiler_text || note.spoiler_text,
sensitive: note.sensitive,
files: [],
sending: false,
contentType: "text/html",
visibility: note.visibility,
});
if (relationType === "edit") {
this.content = source?.text || note.content;
this.rawContent = source?.text || "";
console.log(note.media_attachments);
this.files = await Promise.all(
note.media_attachments.map(async (file) => ({
id: crypto.randomUUID(),
apiId: file.id,
alt: file.description ?? undefined,
uploading: false,
updating: false,
})),
);
}
return key;
},
async uploadFile(file: File): Promise<void> {
const index =
this.files.push({
file,
uploading: true,
updating: false,
id: crypto.randomUUID(),
}) - 1;
const authStore = useAuthStore();
return authStore.client
.uploadMedia(file)
.then((media) => {
if (!this.files[index]) {
throw new Error("File not found");
}
this.files[index].uploading = false;
this.files[index].apiId = (
media.data as z.infer<typeof Attachment>
).id;
})
.catch(() => {
this.files.splice(index, 1);
});
},
async updateFileDescription(
id: string,
description: string,
): Promise<void> {
const index = this.files.findIndex((f) => f.id === id);
if (index === -1 || !this.files[index]) {
throw new Error("File not found");
}
this.files[index].updating = true;
const authStore = useAuthStore();
try {
await authStore.client.updateMedia(
this.files[index].apiId ?? "",
{
description: description,
},
);
} finally {
if (this.files[index]) {
this.files[index].updating = false;
this.files[index].alt = description;
}
}
},
async sendEdit(): Promise<z.infer<typeof Status> | null> {
if (!this.canSend || this.relation?.type !== "edit") {
return null;
}
const authStore = useAuthStore();
this.sending = true;
try {
const { data } = await authStore.client.editStatus(
this.relation.note.id,
{
status: this.content,
content_type: this.contentType,
sensitive: this.sensitive,
spoiler_text: this.sensitive
? this.contentWarning
: undefined,
media_ids: this.files
.map((f) => f.apiId)
.filter((f) => f !== undefined),
},
);
this.sending = false;
return data;
} catch (error) {
this.sending = false;
throw error;
}
},
async send(): Promise<z.infer<typeof Status> | null> {
if (!this.canSend) {
return null;
}
const authStore = useAuthStore();
this.sending = true;
try {
const { data } = await authStore.client.postStatus(
this.content,
{
content_type: this.contentType,
sensitive: this.sensitive,
spoiler_text: this.sensitive
? this.contentWarning
: undefined,
media_ids: this.files
.map((f) => f.apiId)
.filter((f) => f !== undefined),
quote_id:
this.relation?.type === "quote"
? this.relation.note.id
: undefined,
in_reply_to_id:
this.relation?.type === "reply"
? this.relation.note.id
: undefined,
visibility: this.visibility,
},
);
this.sending = false;
return data as z.infer<typeof Status>;
} catch (error) {
this.sending = false;
throw error;
}
},
},
});

View file

@ -1,13 +1,6 @@
import type { CredentialApplication } from "@versia/client/schemas";
import { nanoid } from "nanoid";
import { toast } from "vue-sonner";
import type { z } from "zod";
import { confirmModalService } from "~/components/modals/composable"; import { confirmModalService } from "~/components/modals/composable";
import pkg from "~~/package.json";
import * as m from "~~/paraglide/messages.js"; import * as m from "~~/paraglide/messages.js";
const getRedirectUri = () => new URL("/", useRequestURL().origin);
export const askForInstance = async (): Promise<URL> => { export const askForInstance = async (): Promise<URL> => {
const { confirmed, value } = await confirmModalService.confirm({ const { confirmed, value } = await confirmModalService.confirm({
title: m.sharp_alive_anteater_fade(), title: m.sharp_alive_anteater_fade(),
@ -21,136 +14,3 @@ export const askForInstance = async (): Promise<URL> => {
throw new Error("No instance provided"); throw new Error("No instance provided");
}; };
export const signIn = async (
appData: Ref<z.infer<typeof CredentialApplication> | null>,
origin: URL,
) => {
const id = toast.loading(m.level_due_ox_greet());
const client = useClient(origin);
const redirectUri = getRedirectUri();
redirectUri.searchParams.append("origin", origin.toString());
const output = await client.value.createApp("Versia-FE", {
scopes: ["read", "write", "follow", "push"],
redirect_uris: redirectUri.toString(),
// @ts-expect-error Package.json types are missing this field
website: pkg.homepage ?? undefined,
});
if (!output?.data) {
toast.dismiss(id);
toast.error(m.silly_sour_fireant_fear());
return;
}
appData.value = output.data;
const url = await client.value.generateAuthUrl(
output.data.client_id,
output.data.client_secret,
{
scopes: ["read", "write", "follow", "push"],
redirect_uri: redirectUri.toString(),
},
);
if (!url) {
toast.dismiss(id);
toast.error(m.candid_frail_lion_value());
return;
}
// Add "instance_switch_uri" parameter to URL
const toRedirect = new URL(url);
toRedirect.searchParams.append("instance_switch_uri", useRequestURL().href);
window.location.href = toRedirect.toString();
};
export const signInWithCode = (
code: string,
appData: z.infer<typeof CredentialApplication>,
origin: URL,
) => {
const client = useClient(origin);
const redirectUri = getRedirectUri();
redirectUri.searchParams.append("origin", origin.toString());
client.value
?.fetchAccessToken(
appData.client_id,
appData.client_secret,
code,
redirectUri.toString(),
)
.then(async (res) => {
const tempClient = useClient(origin, res.data).value;
const [accountOutput, instanceOutput] = await Promise.all([
tempClient.verifyAccountCredentials(),
tempClient.getInstance(),
]);
// Get account data
if (
!identities.value.find(
(i) => i.account.id === accountOutput.data.id,
)
) {
identity.value = {
id: nanoid(),
tokens: res.data,
account: accountOutput.data,
instance: instanceOutput.data,
permissions: [],
emojis: [],
};
}
// Remove code from URL
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
// Redirect to home
window.location.pathname = "/";
});
};
export const signOut = async (
appData: z.infer<typeof CredentialApplication> | null,
identityToRevoke: Identity,
) => {
const id = toast.loading("Signing out...");
if (!appData) {
toast.dismiss(id);
toast.error("No app or identity data to sign out");
return;
}
// Don't do anything on error, as Versia Server doesn't implement the revoke endpoint yet
await client.value
?.revokeToken(
appData.client_id,
identityToRevoke.tokens.access_token,
identityToRevoke.tokens.access_token,
)
.catch(() => {
// Do nothing
});
identities.value = identities.value.filter(
(i) => i.id !== identityToRevoke.id,
);
toast.dismiss(id);
toast.success("Signed out");
};

View file

@ -1,8 +1,10 @@
export const wrapUrl = (path: string) => { export const wrapUrl = (path: string) => {
const authStore = useAuthStore();
return new URL( return new URL(
path, path,
identity.value authStore.instance
? `https://${identity.value.instance.domain}` ? `https://${authStore.instance.domain}`
: window.location.origin, : window.location.origin,
).toString(); ).toString();
}; };

View file

@ -7,6 +7,7 @@
"@floating-ui/dom": "^1.7.4", "@floating-ui/dom": "^1.7.4",
"@nuxt/fonts": "^0.11.4", "@nuxt/fonts": "^0.11.4",
"@nuxtjs/color-mode": "3.5.2", "@nuxtjs/color-mode": "3.5.2",
"@pinia/nuxt": "^0.11.2",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.12", "@tailwindcss/vite": "^4.1.12",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
@ -43,6 +44,8 @@
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"nuxt": "^4.0.3", "nuxt": "^4.0.3",
"nuxt-security": "^2.4.0", "nuxt-security": "^2.4.0",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.5.0",
"reka-ui": "^2.4.1", "reka-ui": "^2.4.1",
"shadcn-nuxt": "2.2.0", "shadcn-nuxt": "2.2.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
@ -599,6 +602,8 @@
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="], "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="],
"@pinia/nuxt": ["@pinia/nuxt@0.11.2", "", { "dependencies": { "@nuxt/kit": "^3.9.0" }, "peerDependencies": { "pinia": "^3.0.3" } }, "sha512-CgvSWpbktxxWBV7ModhAcsExsQZqpPq6vMYEe9DexmmY6959ev8ukL4iFhr/qov2Nb9cQAWd7niFDnaWkN+FHg=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
@ -1167,6 +1172,8 @@
"dedent": ["dedent@1.5.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg=="], "dedent": ["dedent@1.5.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg=="],
"deep-pick-omit": ["deep-pick-omit@1.2.1", "", {}, "sha512-2J6Kc/m3irCeqVG42T+SaUMesaK7oGWaedGnQQK/+O0gYc+2SP5bKh/KKTE7d7SJ+GCA9UUE1GRzh6oDe0EnGw=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"default-browser": ["default-browser@5.2.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg=="], "default-browser": ["default-browser@5.2.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg=="],
@ -1909,6 +1916,10 @@
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"pinia": ["pinia@3.0.3", "", { "dependencies": { "@vue/devtools-api": "^7.7.2" }, "peerDependencies": { "typescript": ">=4.4.4", "vue": "^2.7.0 || ^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA=="],
"pinia-plugin-persistedstate": ["pinia-plugin-persistedstate@4.5.0", "", { "dependencies": { "deep-pick-omit": "^1.2.1", "defu": "^6.1.4", "destr": "^2.0.5" }, "peerDependencies": { "@nuxt/kit": ">=3.0.0", "@pinia/nuxt": ">=0.10.0", "pinia": ">=3.0.0" }, "optionalPeers": ["@nuxt/kit", "@pinia/nuxt", "pinia"] }, "sha512-QTkP1xJVyCdr2I2p3AKUZM84/e+IS+HktRxKGAIuDzkyaKKV48mQcYkJFVVDuvTxlI5j6X3oZObpqoVB8JnWpw=="],
"pkcs7": ["pkcs7@1.0.4", "", { "dependencies": { "@babel/runtime": "^7.5.5" }, "bin": { "pkcs7": "bin/cli.js" } }, "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ=="], "pkcs7": ["pkcs7@1.0.4", "", { "dependencies": { "@babel/runtime": "^7.5.5" }, "bin": { "pkcs7": "bin/cli.js" } }, "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ=="],
"pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],

View file

@ -9,6 +9,8 @@ export default defineNuxtConfig({
"@vite-pwa/nuxt", "@vite-pwa/nuxt",
"shadcn-nuxt", "shadcn-nuxt",
"@nuxtjs/color-mode", "@nuxtjs/color-mode",
"@pinia/nuxt",
"pinia-plugin-persistedstate/nuxt",
], ],
ssr: false, ssr: false,
components: { components: {

View file

@ -36,6 +36,7 @@
"@floating-ui/dom": "^1.7.4", "@floating-ui/dom": "^1.7.4",
"@nuxt/fonts": "^0.11.4", "@nuxt/fonts": "^0.11.4",
"@nuxtjs/color-mode": "3.5.2", "@nuxtjs/color-mode": "3.5.2",
"@pinia/nuxt": "^0.11.2",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.12", "@tailwindcss/vite": "^4.1.12",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
@ -72,6 +73,8 @@
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"nuxt": "^4.0.3", "nuxt": "^4.0.3",
"nuxt-security": "^2.4.0", "nuxt-security": "^2.4.0",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.5.0",
"reka-ui": "^2.4.1", "reka-ui": "^2.4.1",
"shadcn-nuxt": "2.2.0", "shadcn-nuxt": "2.2.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",