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

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

View file

@ -0,0 +1,26 @@
import type { Client } from "@versia/client";
import type { Account } from "@versia/client/schemas";
import type { z } from "zod";
export const useAccount = (
client: MaybeRef<Client | null>,
accountId: MaybeRef<string | null>,
) => {
if (!client) {
return ref(null as z.infer<typeof Account> | null);
}
const output = ref(null as z.infer<typeof Account> | null);
watchEffect(() => {
if (toValue(accountId)) {
toValue(client)
?.getAccount(toValue(accountId) ?? "")
.then((res) => {
output.value = res.data;
});
}
});
return output;
};

View file

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

View file

@ -0,0 +1,20 @@
import type { Client } from "@versia/client";
import type { Account } from "@versia/client/schemas";
import type { z } from "zod";
export const useAccountSearch = (
client: MaybeRef<Client | null>,
q: string,
): Ref<z.infer<typeof Account>[] | null> => {
const output = ref(null as z.infer<typeof Account>[] | null);
ref(client)
.value?.searchAccount(q, {
resolve: true,
})
.then((res) => {
output.value = res.data;
});
return output;
};

View file

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

View file

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

64
app/composables/Audio.ts Normal file
View file

@ -0,0 +1,64 @@
export type AudioNames = "publish" | "like";
export type AudioManifest = Record<AudioNames, { src: string[] }>;
export const useAudio = (): {
play: (name: AudioNames) => void;
} => {
const audio = new Audio();
const play = (name: AudioNames) => {
const audioData = audioManifest.manifest.value?.[name];
if (!audioData) {
throw new Error(`Audio not found: ${name}`);
}
const src = audioData.src[
Math.floor(Math.random() * audioData.src.length)
] as string;
audio.src = src;
audio.play();
};
return { play };
};
export const useAudioManifest = () => {
const audioTheme = ref("misskey" as const);
const url = computed(() => `/packs/audio/${audioTheme.value}.json`);
// Fetch from /packs/audio/:name.json
const manifest = ref(null as null | AudioManifest);
// Fetch the manifest
watch(
url,
async (url) => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch audio theme manifest at ${url}`,
);
}
manifest.value = await response.json();
// Preload all audio files
if (manifest.value) {
for (const audioData of Object.values(manifest.value)) {
for (const src of audioData.src) {
new Audio(src);
}
}
}
},
{ immediate: true },
);
return { audioTheme, manifest, url };
};
export const audioManifest = useAudioManifest();

View file

@ -0,0 +1,67 @@
import type { Client } from "@versia/client";
import type { RolePermission } from "@versia/client/schemas";
import { toast } from "vue-sonner";
import * as m from "~~/paraglide/messages.js";
export const useCacheRefresh = (client: MaybeRef<Client | null>) => {
// Refresh custom emojis and instance data and me on every reload
watch(
[identity, client],
async () => {
console.info("Refreshing emoji, instance and account cache");
if (identity.value) {
toValue(client)
?.verifyAccountCredentials()
.then((res) => {
if (identity.value) {
identity.value.account = res.data;
}
})
.catch((err) => {
const code = err.response.status;
if (code === 401) {
// Reset tokenData
identity.value = null;
toast.error(m.fancy_this_wasp_renew(), {
description: m.real_weird_deer_stop(),
});
}
});
toValue(client)
?.getInstanceCustomEmojis()
.then((res) => {
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 },
);
};

33
app/composables/Client.ts Normal file
View file

@ -0,0 +1,33 @@
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();

85
app/composables/Config.ts Normal file
View file

@ -0,0 +1,85 @@
export const useConfig = () => {
return {
NOTES_PER_PAGE: 20,
RECOMMENDED_CLIENTS: [
{
name: "Megalodon",
icon: "https://sk22.github.io/megalodon/mastodon/src/main/res/mipmap-xhdpi/ic_launcher_round.png",
link: "https://sk22.github.io/megalodon/",
},
],
COMPOSER_SPLASHES: [
"What's on your mind?",
"What's happening?",
"Meow meow meow meow meow",
"Just arrived at Luna Nova Academy",
"I'm a little teapot, short and stout",
"Hey, you. You're finally awake. You were trying to cross the border, right?",
"Aren't you a little short for a stormtrooper?",
"I'm a leaf on the wind. Watch how I soar.",
"I am Groot.",
"Hello everybody, my name is Markiplier",
"I'm Commander Shepard, and this is my favorite website on the Citadel",
"I'm sorry, Dave. I'm afraid I can't do that.",
"I am the Senate",
"Check out my geek code!",
"You've got mail!",
"Dude, Where's My Potato?",
"eau de potato",
"The bee's knees!",
"12 herbs and spices!",
"Ceci n'est pas une post!",
"sqrt(-1) love you!",
"Cthulhu is mad... and is missing an eye!",
"Versia: Build on blackbox technology!",
"A goblin army is approaching from the west!",
"git gud!",
"Must have been the wind..",
"I am the milkman, my milk is delicious.",
"It's not out of the question, that you might have a very minor case, of serious brain damage",
"I died",
"Remember, switching to your secondary is faster than reloading!",
"Everybody in america is a female.",
"I am going to test in production",
"War... war never changes.",
"Fedi... fedi never changes.",
"Finish him!",
"The dopamine is a lie.",
"I'll be back.",
"My name is Guybrush Threepwood, and I want to be a pirate!",
"It's dangerous to post alone! Take this.",
"I used to be an poster like you, then I took an arrow to the knee.",
"All your post are belong to us.",
"I'm here to shitpost and chew bubblegum... and I'm all outta gum.",
"Houston, we have a problem.",
"Clever girl.",
"Wibbly wobbly, timey wimey... stuff.",
"Bow ties are cool.",
"I'm the Doctor, and you're probably not.",
"I'm a madman with a box.",
"Geronimo!",
"It's bigger on the inside!",
"I'm going to make him an offer he can't refuse.",
"Hello there.",
"I'll post what she's posting.",
"To infinity... and beyond!",
"E.T. phone home.",
"Just keep posting!",
"Just one more post bro",
"I am the one who knocks.",
"You've lost the game!",
],
DEVELOPER_HANDLES: [
"jessew@social.lysand.org",
"jessew@beta.versia.social",
"jessew@versia.social",
"jessew@vs.cpluspatch.com",
"aprl@social.lysand.org",
"aprl@beta.versia.social",
"aprl@versia.social",
"graphite@social.lysand.org",
"jessew@mk.cpluspatch.com",
"graphite@shonk.phite.ro",
],
};
};

View file

@ -0,0 +1,38 @@
import type { Account, Attachment, Status } from "@versia/client/schemas";
import mitt from "mitt";
import type { z } from "zod";
import type { Identity } from "./Identities";
type ApplicationEvents = {
"note:reply": z.infer<typeof Status>;
"note:delete": z.infer<typeof Status>;
"note:edit": z.infer<typeof Status>;
"note:like": z.infer<typeof Status>;
"note:unlike": z.infer<typeof Status>;
"note:reblog": z.infer<typeof Status>;
"note:unreblog": z.infer<typeof Status>;
"note:quote": z.infer<typeof Status>;
"note:report": z.infer<typeof Status>;
"composer:open": undefined;
"composer:reply": z.infer<typeof Status>;
"composer:quote": z.infer<typeof Status>;
"composer:edit": z.infer<typeof Status>;
"composer:send": z.infer<typeof Status>;
"composer:send-edit": z.infer<typeof Status>;
"composer:close": undefined;
"account:report": z.infer<typeof Account>;
"account:update": z.infer<typeof Account>;
"attachment:view": z.infer<typeof Attachment>;
"identity:change": Identity;
"preferences:open": undefined;
error: {
code: string;
title: string;
message: string;
} | null;
};
const emitter = mitt<ApplicationEvents>();
export const useEvent = emitter.emit;
export const useListen = emitter.on;

View file

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

View file

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

View file

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

View file

@ -0,0 +1,104 @@
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

@ -0,0 +1,43 @@
import type { Client } from "@versia/client";
import type { Instance, TermsOfService } from "@versia/client/schemas";
import type { z } from "zod";
export const useInstance = () => {
return computed(() => identity.value?.instance);
};
export const useInstanceFromClient = (client: MaybeRef<Client>) => {
if (!client) {
return ref(null as z.infer<typeof Instance> | null);
}
const output = ref(null as z.infer<typeof Instance> | null);
watchEffect(() => {
toValue(client)
?.getInstance()
.then((res) => {
output.value = res.data;
});
});
return output;
};
export const useTos = (client: MaybeRef<Client>) => {
if (!client) {
return ref(null as z.infer<typeof TermsOfService> | null);
}
const output = ref(null as z.infer<typeof TermsOfService> | null);
watchEffect(() => {
toValue(client)
?.getInstanceTermsOfService()
.then((res) => {
output.value = res.data;
});
});
return output;
};

View file

@ -0,0 +1,3 @@
export const useLanguage = () => {
return computed(() => preferences.language.value);
};

View file

@ -0,0 +1,25 @@
import type { Client } from "@versia/client";
type SSOProvider = {
id: string;
name: string;
icon: string;
};
export const useLinkedSSO = (client: MaybeRef<Client>) => {
if (!client) {
return ref([] as SSOProvider[]);
}
const output = ref([] as SSOProvider[]);
watchEffect(() => {
toValue(client)
?.get<SSOProvider[]>("/api/v1/sso")
.then((res) => {
output.value = res.data;
});
});
return output;
};

View file

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

25
app/composables/Note.ts Normal file
View file

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

View file

@ -0,0 +1,26 @@
import type { Client } from "@versia/client";
import type { Context } from "@versia/client/schemas";
import type { z } from "zod";
export const useNoteContext = (
client: MaybeRef<Client | null>,
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);
watchEffect(() => {
if (toValue(noteId)) {
ref(client)
.value?.getStatusContext(toValue(noteId) ?? "")
.then((res) => {
output.value = res.data;
});
}
});
return output;
};

View file

@ -0,0 +1,14 @@
import type { Client } from "@versia/client";
import type { Notification } from "@versia/client/schemas";
import type { z } from "zod";
import { type TimelineOptions, useTimeline } from "./Timeline";
export function useNotificationTimeline(
client: Client,
options: Partial<TimelineOptions<z.infer<typeof Notification>>> = {},
) {
return useTimeline(client, {
fetchFunction: (client, opts) => client.getNotifications(opts),
...options,
});
}

View file

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

View file

@ -0,0 +1,56 @@
import { StorageSerializers } from "@vueuse/core";
import { preferences as prefs } from "~/components/preferences/preferences";
type SerializedPreferences = {
[K in keyof typeof prefs]: (typeof prefs)[K]["options"]["defaultValue"];
};
const usePreferences = (): {
[K in keyof typeof prefs]: WritableComputedRef<
(typeof prefs)[K]["options"]["defaultValue"]
>;
} => {
const localStorage = useLocalStorage<SerializedPreferences>(
"versia:preferences",
Object.fromEntries(
Object.entries(prefs).map(([key, value]) => [
key,
value.options.defaultValue,
]),
) as SerializedPreferences,
{
serializer: {
read(raw) {
return StorageSerializers.object.read(raw);
},
write(value) {
return StorageSerializers.object.write(value);
},
},
},
);
return Object.fromEntries(
Object.entries(prefs).map(([key, value]) => [
key,
computed({
get() {
return (
localStorage.value[key as keyof typeof prefs] ??
value.options.defaultValue
);
},
set(newValue) {
// @ts-expect-error Key is marked as readonly in the type
localStorage.value[key] = newValue;
},
}),
]),
) as {
[K in keyof typeof prefs]: WritableComputedRef<
(typeof prefs)[K]["options"]["defaultValue"]
>;
};
};
export const preferences = usePreferences();

View file

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

View file

@ -0,0 +1,49 @@
import type { Client } from "@versia/client";
import type { Relationship } from "@versia/client/schemas";
import type { z } from "zod";
export const useRelationship = (
client: MaybeRef<Client | null>,
accountId: MaybeRef<string | null>,
) => {
const relationship = ref(null as z.infer<typeof Relationship> | null);
const isLoading = ref(false);
if (!identity.value) {
return { relationship, isLoading };
}
watchEffect(() => {
if (toValue(accountId)) {
toValue(client)
?.getRelationship(toValue(accountId) ?? "")
.then((res) => {
relationship.value = res.data;
});
}
});
watch(relationship, (newOutput, oldOutput) => {
if (newOutput !== oldOutput && newOutput && oldOutput) {
if (newOutput?.following !== oldOutput?.following) {
isLoading.value = true;
if (newOutput?.following) {
toValue(client)
?.followAccount(toValue(accountId) ?? "")
.finally(() => {
isLoading.value = false;
});
} else {
toValue(client)
?.unfollowAccount(toValue(accountId) ?? "")
.finally(() => {
isLoading.value = false;
});
}
}
// FIXME: Add more relationship changes
}
});
return { relationship, isLoading };
};

View file

@ -0,0 +1,29 @@
import type { Client } from "@versia/client";
import type { Account, Mention } from "@versia/client/schemas";
import type { z } from "zod";
export const useResolveMentions = (
mentions: Ref<z.infer<typeof Mention>[]>,
client: Client | null,
): Ref<z.infer<typeof Account>[]> => {
if (!client) {
return ref([]);
}
const output = ref<z.infer<typeof Account>[]>([]);
watch(
mentions,
async () => {
output.value = await Promise.all(
toValue(mentions).map(async (mention) => {
const response = await client.getAccount(mention.id);
return response.data;
}),
);
},
{ immediate: true },
);
return output;
};

View file

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

113
app/composables/Timeline.ts Normal file
View file

@ -0,0 +1,113 @@
import type { Client, Output } from "@versia/client";
import type { Notification, Status } from "@versia/client/schemas";
import { useIntervalFn } from "@vueuse/core";
import type { z } from "zod";
export interface TimelineOptions<T> {
fetchFunction: (client: Client, options: object) => Promise<Output<T[]>>;
updateInterval?: number;
limit?: number;
}
export function useTimeline<
T extends z.infer<typeof Status> | z.infer<typeof Notification>,
>(client: Client, options: TimelineOptions<T>) {
const items = ref<T[]>([]) as Ref<T[]>;
const isLoading = ref(false);
const hasReachedEnd = ref(false);
const error = ref<Error | null>(null);
const nextMaxId = ref<string | undefined>(undefined);
const prevMinId = ref<string | undefined>(undefined);
const fetchItems = async (direction: "next" | "prev") => {
if (isLoading.value || (direction === "next" && hasReachedEnd.value)) {
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await options.fetchFunction(client, {
limit: options.limit || 20,
max_id: direction === "next" ? nextMaxId.value : undefined,
min_id: direction === "prev" ? prevMinId.value : undefined,
});
const newItems = response.data.filter(
(item: T) =>
!items.value.some((existing) => existing.id === item.id),
);
if (direction === "next") {
items.value.push(...newItems);
if (newItems.length < (options.limit || 20)) {
hasReachedEnd.value = true;
}
if (newItems.length > 0) {
nextMaxId.value = newItems[newItems.length - 1]?.id;
}
} else {
items.value.unshift(...newItems);
if (newItems.length > 0) {
prevMinId.value = newItems[0]?.id;
}
}
} catch (e) {
error.value =
e instanceof Error ? e : new Error("An error occurred");
} finally {
isLoading.value = false;
}
};
const loadNext = () => fetchItems("next");
const loadPrev = () => fetchItems("prev");
const addItem = (newItem: T) => {
items.value.unshift(newItem);
};
const removeItem = (id: string) => {
const index = items.value.findIndex((item) => item.id === id);
if (index !== -1) {
items.value.splice(index, 1);
}
};
const updateItem = (updatedItem: T) => {
const index = items.value.findIndex(
(item) => item.id === updatedItem.id,
);
if (index !== -1) {
items.value[index] = updatedItem;
}
};
// Set up periodic updates
const { pause, resume } = useIntervalFn(() => {
loadPrev();
}, options.updateInterval || 30000);
onMounted(() => {
loadNext();
resume();
});
onUnmounted(() => {
pause();
});
return {
items,
isLoading,
hasReachedEnd,
error,
loadNext,
loadPrev,
addItem,
removeItem,
updateItem,
};
}