This commit is contained in:
2026-03-05 15:28:55 +02:00
parent aad888ff2c
commit 1df8a3f452
13 changed files with 3059 additions and 10822 deletions

2830
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,46 +7,48 @@ const { openInPopup } = useUserSession();
</script> </script>
<template> <template>
<UContainer <UContainer
class="flex flex-col items-center gap-4 justify-center w-full p-16" class="flex flex-col items-center gap-4 justify-center w-full p-16"
> >
<UCard class="max-w-sm grid gap-6 justify-center items-center"> <UCard class="max-w-sm grid gap-6 justify-center items-center">
<div class="flex flex-col gap-10 items-center justify-center"> <div class="flex flex-col gap-10 items-center justify-center">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<div class="font-bold text-xl tracking-tight font-fira-code"> <div
Ghostwriter class="font-bold text-xl tracking-tight font-fira-code"
</div> >
<NuxtImg src="/ghostwriter-logo.png" class="size-9" /> Ghostwriter
</div> </div>
<div class="grid gap-2"> <NuxtImg src="/ghostwriter-logo.png" class="size-9" />
<div class="text-center text-2xl font-bold"> </div>
Sign in to your account. <div class="grid gap-2">
</div> <div class="text-center text-2xl font-bold">
<div class="text-center"> Sign in to your account.
Connect with Strava to automatically add personalized titles and </div>
descriptions to your activities. <div class="text-center">
</div> Connect with Strava to automatically add personalized
</div> titles and descriptions to your activities.
<div </div>
aria-role="button" </div>
@click="openInPopup('/auth/strava')" <div
class="cursor-pointer w-full max-w-[200px]" aria-role="button"
> @click="openInPopup('/auth/strava')"
<NuxtImg class="cursor-pointer w-full max-w-[200px]"
src="/images/connect-with-strava.svg" >
class="w-full max-w-[200px]" <NuxtImg
/> src="/images/connect-with-strava.svg"
</div> class="w-full max-w-[200px]"
<div class="text-sm text-gray-500"> />
By signing in, you agree to our </div>
<NuxtLink <div class="text-sm text-gray-500">
href="https://www.ghostwriter.rocks/privacy" By signing in, you agree to our
class="text-primary-500" <NuxtLink
>Privacy Policy</NuxtLink href="https://www.ghostwriter.rocks/privacy"
>. class="text-primary-500"
</div> >Privacy Policy</NuxtLink
</div> >.
</UCard> </div>
</UContainer> </div>
<AppFooter /> </UCard>
</UContainer>
<AppFooter />
</template> </template>

View File

@@ -1,12 +1,12 @@
<template> <template>
<UApp :toaster="{ position: 'bottom-center' }"> <UApp :toaster="{ position: 'bottom-center' }">
<AuthState v-slot="{ loggedIn }"> <AuthState v-slot="{ loggedIn }">
<Register v-if="!loggedIn" /> <Register v-if="!loggedIn" />
<template v-if="loggedIn"> <template v-if="loggedIn">
<AppBar /> <AppBar />
<slot /> <slot />
<AppFooter /> <AppFooter />
</template> </template>
</AuthState> </AuthState>
</UApp> </UApp>
</template> </template>

View File

@@ -14,11 +14,6 @@ export default defineNuxtConfig({
hookdeckKey: "", hookdeckKey: "",
openaiApiKey: "", openaiApiKey: "",
databaseUrl: "", databaseUrl: "",
public: {
posthogPublicKey: "",
posthogHost: "",
posthogDefaults: "2025-05-24",
},
}, },
future: { compatibilityVersion: 4 }, future: { compatibilityVersion: 4 },
compatibilityDate: "2025-03-01", compatibilityDate: "2025-03-01",

View File

@@ -22,8 +22,6 @@
"nuxt": "^4.0.3", "nuxt": "^4.0.3",
"nuxt-auth-utils": "0.5.23", "nuxt-auth-utils": "0.5.23",
"openai": "^5.12.2", "openai": "^5.12.2",
"posthog-js": "^1.259.0",
"posthog-node": "^5.6.0",
"radash": "^12.1.1", "radash": "^12.1.1",
"ts-pattern": "^5.8.0", "ts-pattern": "^5.8.0",
"url": "^0.11.4", "url": "^0.11.4",

View File

@@ -4,206 +4,213 @@ useHead({ title: "Ghostwriter" });
const { user } = useUserSession(); const { user } = useUserSession();
const stravaLink = computed(() => { const stravaLink = computed(() => {
return `https://www.strava.com/athletes/${toValue(user).id}`; return `https://www.strava.com/athletes/${toValue(user).id}`;
}); });
interface FormData { interface FormData {
enabled: boolean; enabled: boolean;
language: string; language: string;
units: string; units: string;
tone: string[]; tone: string[];
highlights: string[]; highlights: string[];
} }
const preferences = useState<FormData>("preferences", () => ({ const preferences = useState<FormData>("preferences", () => ({
enabled: false, enabled: false,
language: "English", language: "English",
units: "Metric", units: "Metric",
tone: [], tone: [],
highlights: [], highlights: [],
})); }));
const { data, status } = useFetch("/api/preferences", { const { data, status } = useFetch("/api/preferences", {
lazy: true, lazy: true,
}); });
// @ts-expect-error typing issue - missing transform // @ts-expect-error typing issue - missing transform
syncRef(data, preferences, { direction: "ltr", deep: true }); syncRef(data, preferences, { direction: "ltr", deep: true });
onMounted(() => { onMounted(() => {
saveOp.resume(); saveOp.resume();
}); });
const saveOp = watchPausable( const saveOp = watchPausable(
preferences, preferences,
async (newData) => { async (newData) => {
await $fetch("/api/preferences", { await $fetch("/api/preferences", {
method: "PUT", method: "PUT",
body: toValue(preferences), body: toValue(preferences),
}); });
}, },
{ {
initialState: "paused", initialState: "paused",
deep: true, deep: true,
}, },
); );
</script> </script>
<template> <template>
<UContainer class="max-w-2xl py-8 flex flex-col gap-4"> <UContainer class="max-w-2xl py-8 flex flex-col gap-4">
<div class="font-bold text-lg">Welcome to Ghostwriter!</div> <div class="font-bold text-lg">Welcome to Ghostwriter!</div>
<div> <div>
Let's generate fun and engaging titles and descriptions for your Strava Let's generate fun and engaging titles and descriptions for your
activities automatically, right when they are created. Customize your Strava activities automatically, right when they are created.
preferences below. Customize your preferences below.
</div> </div>
<div> <div>
Add a touch of creativity to your Strava workouts. Simply enable it and Add a touch of creativity to your Strava workouts. Simply enable it
choose your language, and we'll do the rest! and choose your language, and we'll do the rest!
</div> </div>
</UContainer> </UContainer>
<UContainer class="max-w-2xl py-8 flex flex-col gap-4"> <UContainer class="max-w-2xl py-8 flex flex-col gap-4">
<div class="font-bold text-lg"> Support</div> <div class="font-bold text-lg"> Support</div>
<UCard class=""> <UCard class="">
<div class="flex flex-col gap-8"> <div class="flex flex-col gap-8">
Ghostwriter 👻 is free to use, but it takes time and resources to keep Ghostwriter 👻 is free to use, but it takes time and resources
it running smoothly. If you enjoy it, consider supporting the app and to keep it running smoothly. If you enjoy it, consider
its creator - every bit helps! supporting the app and its creator - every bit helps!
</div> </div>
<template #footer> <template #footer>
<ULink href="https://buymeacoffee.com/mariosant" target="_blank"> <ULink
<NuxtImg href="https://buymeacoffee.com/mariosant"
src="images/bmac-orange-button.png" target="_blank"
height="32px" >
class="h-8" <NuxtImg
/> src="images/bmac-orange-button.png"
</ULink> height="32px"
</template> class="h-8"
</UCard> />
</UContainer> </ULink>
</template>
</UCard>
</UContainer>
<UContainer <UContainer
class="max-w-2xl py-8 flex flex-col gap-4" class="max-w-2xl py-8 flex flex-col gap-4"
v-if="status === 'success'" v-if="status === 'success'"
> >
<div class="font-bold text-lg">🛠 Preferences</div> <div class="font-bold text-lg">🛠 Preferences</div>
<UCard class=""> <UCard class="">
<div class="flex flex-col gap-8"> <div class="flex flex-col gap-8">
<CardField> <CardField>
<template #title> Enabled </template> <template #title> Enabled </template>
<template #description> <template #description>
Enable/disable automatic generation. Enable/disable automatic generation.
</template> </template>
<template #value> <template #value>
<USwitch size="lg" v-model="preferences.enabled" /> <USwitch size="lg" v-model="preferences.enabled" />
</template> </template>
</CardField> </CardField>
<CardField> <CardField>
<template #title> Model language </template> <template #title> Model language </template>
<template #description> <template #description>
Language for generated titles and descriptions. Language for generated titles and descriptions.
</template> </template>
<template #value> <template #value>
<USelect <USelect
class="min-w-28" class="min-w-28"
:items="languages" :items="languages"
v-model="preferences.language" v-model="preferences.language"
/> />
</template> </template>
</CardField> </CardField>
<CardField> <CardField>
<template #title> Unit system </template> <template #title> Unit system </template>
<template #description> <template #description>
Unit system preference, used in descriptions. Unit system preference, used in descriptions.
</template> </template>
<template #value> <template #value>
<USelect <USelect
class="min-w-28" class="min-w-28"
v-model="preferences.units" v-model="preferences.units"
:items="units" :items="units"
/> />
</template> </template>
</CardField> </CardField>
<CardField> <CardField>
<template #title> Tone </template> <template #title> Tone </template>
<template #description> <template #description>
Choose one or more writing styles you like. Choose one or more writing styles you like.
</template> </template>
<template #value> <template #value>
<USelect <USelect
multiple multiple
class="min-w-28 max-w-64" class="min-w-28 max-w-64"
v-model="preferences.tone" v-model="preferences.tone"
:items="tones" :items="tones"
placeholder="None specified (Use all)" placeholder="None specified (Use all)"
/> />
</template> </template>
</CardField> </CardField>
<CardField> <CardField>
<template #title> Highlight </template> <template #title> Highlight </template>
<template #description> <template #description>
Choose what Ghostwriter should focus on. Choose what Ghostwriter should focus on.
</template> </template>
<template #value> <template #value>
<USelect <USelect
multiple multiple
class="min-w-28 max-w-64" class="min-w-28 max-w-64"
:items="highlights" :items="highlights"
v-model="preferences.highlights" v-model="preferences.highlights"
placeholder="None specified (Use all)" placeholder="None specified (Use all)"
/> />
</template> </template>
</CardField> </CardField>
</div> </div>
</UCard> </UCard>
</UContainer> </UContainer>
<UContainer class="max-w-2xl py-8 flex flex-col gap-4"> <UContainer class="max-w-2xl py-8 flex flex-col gap-4">
<div class="font-bold text-lg">🪪 Your connected Strava account</div> <div class="font-bold text-lg">🪪 Your connected Strava account</div>
<UCard class="bg-neutral-50 dark:bg-slate-800"> <UCard class="bg-neutral-50 dark:bg-slate-800">
<div class="flex flex-col gap-8"> <div class="flex flex-col gap-8">
<CardField> <CardField>
<template #title> Full name </template> <template #title> Full name </template>
<template #description> <template #description>
Full name from your linked Strava account. Full name from your linked Strava account.
</template> </template>
<template #value> <template #value>
{{ user.name }} {{ user.name }}
</template> </template>
</CardField> </CardField>
<CardField> <CardField>
<template #title> Country </template> <template #title> Country </template>
<template #description> <template #description>
Country associated with your Strava account. Country associated with your Strava account.
</template> </template>
<template #value> {{ user.country }} </template> <template #value> {{ user.country }} </template>
</CardField> </CardField>
<CardField> <CardField>
<template #title> Athlete ID </template> <template #title> Athlete ID </template>
<template #description> <template #description>
Your Athlete ID. Click it to view your profile on Strava.</template Your Athlete ID. Click it to view your profile on
> Strava.</template
>
<template #value> <template #value>
<ULink :href="stravaLink" class="underline flex items-center gap-2"> <ULink
{{ user.id }} :href="stravaLink"
<UIcon class="underline flex items-center gap-2"
name="heroicons:arrow-top-right-on-square" >
class="size-4" {{ user.id }}
/> <UIcon
</ULink> name="heroicons:arrow-top-right-on-square"
</template> class="size-4"
</CardField> />
</div> </ULink>
</UCard> </template>
</UContainer> </CardField>
</div>
</UCard>
</UContainer>
</template> </template>

View File

@@ -1,20 +0,0 @@
import posthog from "posthog-js";
export default defineNuxtPlugin(() => {
const runtimeConfig = useRuntimeConfig();
const posthogClient = posthog.init(runtimeConfig.public.posthogPublicKey, {
api_host: runtimeConfig.public.posthogHost,
//@ts-expect-error typing is more explicit than what it should
defaults: runtimeConfig.public.posthogDefaults,
loaded: (posthog) => {
if (import.meta.env.MODE === "development") posthog.debug();
},
});
return {
provide: {
posthog: () => posthogClient,
},
};
});

10491
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +0,0 @@
onlyBuiltDependencies:
- '@parcel/watcher'
- '@tailwindcss/oxide'
- core-js
- esbuild
- sharp
- vue-demi

View File

@@ -20,22 +20,9 @@ export default defineEventHandler(async (event) => {
}, },
}); });
posthog.identifyImmediate({
distinctId: String(user!.id),
properties: {
name: user!.name,
country: user!.country,
},
});
await db await db
.delete(tables.users) .delete(tables.users)
.where(eq(tables.users.id, get(body, "object_id"))); .where(eq(tables.users.id, get(body, "object_id")));
posthog.captureImmediate({
distinctId: get(body, "object_id"),
event: "user deleted",
});
sendNoContent(event); sendNoContent(event);
}); });

View File

@@ -1,29 +0,0 @@
import { PostHog } from "posthog-node";
import { waitUntil } from "@vercel/functions";
export default defineNitroPlugin((nitroApp) => {
const runtimeConfig = useRuntimeConfig();
const posthog = new PostHog(runtimeConfig.public.posthogPublicKey, {
host: runtimeConfig.public.posthogHost,
flushAt: 1,
flushInterval: 0,
});
nitroApp.hooks.hook("request", (event) => {
event.context.posthog = posthog;
});
nitroApp.hooks.hook("beforeResponse", () => {
waitUntil(posthog.shutdown());
});
nitroApp.hooks.hook("close", () => {
waitUntil(posthog.shutdown());
});
});
declare module "h3" {
interface H3EventContext {
posthog: PostHog;
}
}

View File

@@ -24,8 +24,6 @@ export default defineOAuthStravaEventHandler({
}); });
} }
const posthog = event.context.posthog;
const userPayload = { const userPayload = {
id: auth.user.id, id: auth.user.id,
name: `${auth.user.firstname} ${auth.user.lastname}`, name: `${auth.user.firstname} ${auth.user.lastname}`,
@@ -82,19 +80,6 @@ export default defineOAuthStravaEventHandler({
user: userPayload, user: userPayload,
}); });
posthog.identifyImmediate({
distinctId: String(user!.id),
properties: {
name: user!.name,
country: user!.country,
},
});
posthog.captureImmediate({
distinctId: String(user!.id),
event: "user logged in",
});
sendRedirect(event, "/"); sendRedirect(event, "/");
}, },
}); });

View File

@@ -1,20 +0,0 @@
import { PostHog } from "posthog-node";
let client: PostHog;
export const usePosthog = () => {
const runtimeConfig = useRuntimeConfig();
client =
client ??
new PostHog(runtimeConfig.public.posthogPublicKey, {
host: runtimeConfig.public.posthogHost,
defaults: runtimeConfig.public.posthogDefaults,
});
if (process.dev) {
client.debug();
}
return client;
};