Add rewrite

This commit is contained in:
2025-05-22 13:48:15 +03:00
parent 71d360ea1f
commit 16a4ea0949
19 changed files with 746 additions and 61 deletions

View File

@@ -1,8 +1,6 @@
<template> <template>
<NuxtRouteAnnouncer /> <NuxtRouteAnnouncer />
<UApp :toaster="{ position: 'bottom-center' }">
<NuxtLayout> <NuxtLayout>
<NuxtPage /> <NuxtPage />
</NuxtLayout> </NuxtLayout>
</UApp>
</template> </template>

View File

@@ -1,5 +1,27 @@
<script setup lang="ts">
interface Props {
vertical?: boolean;
}
const props = defineProps<Props>();
</script>
<template> <template>
<div class="flex justify-between items-center gap-4"> <div
v-if="props.vertical"
class="flex justify-between items-start gap-2 flex-col"
>
<div>
<div class="font-semibold"><slot name="title" /></div>
<div class="text-slate-600 text-sm hidden md:block">
<slot name="description" />
</div>
</div>
<div class="text-nowrap w-full">
<slot name="value" />
</div>
</div>
<div v-else class="flex justify-between items-center gap-4">
<div> <div>
<div class="font-semibold"><slot name="title" /></div> <div class="font-semibold"><slot name="title" /></div>
<div class="text-slate-600 text-sm hidden md:block"> <div class="text-slate-600 text-sm hidden md:block">

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import type { FormSubmitEvent } from "@nuxt/ui";
const { user } = useUserSession();
const toast = useToast();
const form = useTemplateRef("form");
const submitting = ref(false);
const formData = {
activityUrl: "",
};
const validate = ({
activityUrl,
}: Partial<{
activityUrl: string;
}>) => {
if (
[
"https://strava.com/activities/",
"https://www.strava.com/activities/",
].some((u) => activityUrl!.includes(u))
) {
return [];
}
return [
{
name: "activityUrl",
message: "Please write a legit Strava activity URL.",
},
];
};
const submit = async (event: FormSubmitEvent<typeof formData>) => {
await $fetch("/api/rewrite", {
method: "POST",
query: {
activity: event.data.activityUrl,
},
})
.then(() =>
toast.add({
title: "Success",
description: "Activity has been rewritten.",
color: "success",
}),
)
.catch(() =>
toast.add({
title: "Error",
description: "Something wrong has happened. Maybe try again later.",
color: "error",
}),
)
.finally(() => {
formData.activityUrl = "";
});
};
</script>
<template>
<UForm
:disabled="!user.premium"
ref="form"
@submit="submit"
:validate="validate"
:state="formData"
class="flex flex-col gap-8"
>
<template v-slot:default="errors">
<UContainer class="max-w-2xl py-8 flex flex-col gap-4">
<div class="flex justify-between items-center">
<div class="font-bold text-lg">🔄 Re-write activity</div>
<UTooltip
arrow
:disabled="user.premium"
text="This feature is enabled for premium users. You can upgrade to
premium by supporting Ghostwriter."
>
<UBadge variant="soft">Premium only</UBadge>
</UTooltip>
</div>
<UCard>
<div class="grid gap-4">
<CardField vertical>
<template #title> Activity URL </template>
<template #description>
Paste your Strava activity URL to rewrite it.
</template>
<template #value>
<div class="grid gap-2">
<UInput
:disabled="submitting"
v-model="formData.activityUrl"
class="w-full"
placeholder="https://www.strava.com/activities/12345678901"
/>
<div class="text-error-500 text-sm">
{{ errors?.errors[0]?.message }}
</div>
</div>
</template>
</CardField>
</div>
<template #footer>
<UButton
:disabled="!user.premium"
loading-auto
label="Rewrite"
color="neutral"
variant="soft"
@click="form!.submit()"
/>
</template>
</UCard>
</UContainer>
</template>
</UForm>
</template>

View File

@@ -3,7 +3,6 @@ const error = useError();
</script> </script>
<template> <template>
<UApp>
<UContainer class="max-w-lg p-8 md:p-16"> <UContainer class="max-w-lg p-8 md:p-16">
<UCard> <UCard>
<div class="grid gap-4"> <div class="grid gap-4">
@@ -39,5 +38,4 @@ const error = useError();
</UContainer> </UContainer>
<AppFooter /> <AppFooter />
</UApp>
</template> </template>

View File

@@ -1,5 +1,5 @@
<template> <template>
<UApp> <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">

View File

@@ -110,6 +110,8 @@ const saveOp = watchPausable(
</UCard> </UCard>
</UContainer> </UContainer>
<RewriteCard />
<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">

1
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"@nuxt/ui": "3.0.2", "@nuxt/ui": "3.0.2",
"@vee-validate/nuxt": "^4.15.0", "@vee-validate/nuxt": "^4.15.0",
"@vueuse/nuxt": "^13.0.0", "@vueuse/nuxt": "^13.0.0",
"destr": "^2.0.5",
"drizzle-orm": "^0.41.0", "drizzle-orm": "^0.41.0",
"nuxt": "^3.16.2", "nuxt": "^3.16.2",
"nuxt-auth-utils": "0.5.18", "nuxt-auth-utils": "0.5.18",

View File

@@ -16,6 +16,7 @@
"@nuxt/ui": "3.0.2", "@nuxt/ui": "3.0.2",
"@vee-validate/nuxt": "^4.15.0", "@vee-validate/nuxt": "^4.15.0",
"@vueuse/nuxt": "^13.0.0", "@vueuse/nuxt": "^13.0.0",
"destr": "^2.0.5",
"drizzle-orm": "^0.41.0", "drizzle-orm": "^0.41.0",
"nuxt": "^3.16.2", "nuxt": "^3.16.2",
"nuxt-auth-utils": "0.5.18", "nuxt-auth-utils": "0.5.18",

View File

@@ -0,0 +1,53 @@
export default defineEventHandler(async (event) => {
const session = await requireUserSession(event);
const db = useDrizzle();
const query = getQuery(event);
const user = await db.query.users.findFirst({
where: (f, o) => o.eq(f.id, session.user.id),
with: {
preferences: true,
},
});
if (!user?.premium) {
throw createError({
statusCode: 400,
message: "Premium membership required.",
});
}
const activityId = (query.activity as string).replace(
/https:\/\/(www\.)?strava\.com\/activities\//,
"",
);
const strava = await useStrava(session.user.id);
const activity = await strava!<any>(`/activities/${activityId}`);
const [aiError, stravaRequestBody] = await createActivityContent(
activity,
user!,
);
if (aiError) {
throw createError({
statusCode: 500,
message: `OPENAI API: ${aiError.message}`,
});
}
await strava!(`activities/${activityId}`, {
method: "PUT",
body: stravaRequestBody,
}).catch((error) => {
throw createError({
statusCode: 500,
message: `Strava API: ${error.message}`,
});
});
sendNoContent(event);
});

1
server/auth.d.ts vendored
View File

@@ -16,6 +16,7 @@ declare module "#auth-utils" {
sex: string; sex: string;
weight: number; weight: number;
avatar: string; avatar: string;
premium: boolean;
}; };
} }

View File

@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "premium" boolean;

View File

@@ -0,0 +1 @@
ALTER TABLE "users" ALTER COLUMN "premium" SET NOT NULL;

View File

@@ -0,0 +1,228 @@
{
"id": "912c6e36-57b1-4e7f-a29b-586b187b1c32",
"prevId": "c8519a52-b999-48f3-b532-42a5678e3905",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.preferences": {
"name": "preferences",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "preferences_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"data": {
"name": "data",
"type": "jsonb",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"preferences_user_id_users_id_fk": {
"name": "preferences_user_id_users_id_fk",
"tableFrom": "preferences",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"preferences_user_id_unique": {
"name": "preferences_user_id_unique",
"nullsNotDistinct": false,
"columns": [
"user_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tokens": {
"name": "tokens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "tokens_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"tokens_user_id_users_id_fk": {
"name": "tokens_user_id_users_id_fk",
"tableFrom": "tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tokens_user_id_unique": {
"name": "tokens_user_id_unique",
"nullsNotDistinct": false,
"columns": [
"user_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"avatar": {
"name": "avatar",
"type": "text",
"primaryKey": false,
"notNull": true
},
"city": {
"name": "city",
"type": "text",
"primaryKey": false,
"notNull": false
},
"country": {
"name": "country",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sex": {
"name": "sex",
"type": "text",
"primaryKey": false,
"notNull": false
},
"premium": {
"name": "premium",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"weight": {
"name": "weight",
"type": "numeric",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,228 @@
{
"id": "7b607167-551b-4aa3-8305-afe3c4d72ace",
"prevId": "912c6e36-57b1-4e7f-a29b-586b187b1c32",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.preferences": {
"name": "preferences",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "preferences_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"data": {
"name": "data",
"type": "jsonb",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"preferences_user_id_users_id_fk": {
"name": "preferences_user_id_users_id_fk",
"tableFrom": "preferences",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"preferences_user_id_unique": {
"name": "preferences_user_id_unique",
"nullsNotDistinct": false,
"columns": [
"user_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tokens": {
"name": "tokens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "tokens_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"tokens_user_id_users_id_fk": {
"name": "tokens_user_id_users_id_fk",
"tableFrom": "tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tokens_user_id_unique": {
"name": "tokens_user_id_unique",
"nullsNotDistinct": false,
"columns": [
"user_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"avatar": {
"name": "avatar",
"type": "text",
"primaryKey": false,
"notNull": true
},
"city": {
"name": "city",
"type": "text",
"primaryKey": false,
"notNull": false
},
"country": {
"name": "country",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sex": {
"name": "sex",
"type": "text",
"primaryKey": false,
"notNull": false
},
"premium": {
"name": "premium",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"weight": {
"name": "weight",
"type": "numeric",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -8,6 +8,20 @@
"when": 1745335305323, "when": 1745335305323,
"tag": "0000_slim_blonde_phantom", "tag": "0000_slim_blonde_phantom",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1747547391071,
"tag": "0001_smooth_jazinda",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1747564780270,
"tag": "0002_many_marauders",
"breakpoints": true
} }
] ]
} }

View File

@@ -6,6 +6,7 @@ import {
numeric, numeric,
timestamp, timestamp,
jsonb, jsonb,
boolean,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
export const users = pgTable("users", { export const users = pgTable("users", {
@@ -15,6 +16,9 @@ export const users = pgTable("users", {
city: text("city"), city: text("city"),
country: text("country"), country: text("country"),
sex: text("sex"), sex: text("sex"),
premium: boolean("premium")
.notNull()
.$defaultFn(() => false),
weight: numeric("weight", { weight: numeric("weight", {
mode: "number", mode: "number",
}), }),

View File

@@ -32,6 +32,7 @@ export default defineOAuthStravaEventHandler({
sex: auth.user.sex, sex: auth.user.sex,
weight: auth.user.weight, weight: auth.user.weight,
avatar: auth.user.profile, avatar: auth.user.profile,
premium: false,
}; };
const db = useDrizzle(); const db = useDrizzle();
@@ -41,7 +42,7 @@ export default defineOAuthStravaEventHandler({
.values(userPayload) .values(userPayload)
.onConflictDoUpdate({ .onConflictDoUpdate({
target: tables.users.id, target: tables.users.id,
set: omit(userPayload, ["id"]), set: omit(userPayload, ["id", "premium"]),
}) })
.returning(); .returning();
@@ -77,7 +78,7 @@ export default defineOAuthStravaEventHandler({
.onConflictDoNothing(); .onConflictDoNothing();
await setUserSession(event, { await setUserSession(event, {
user: userPayload, user: { ...userPayload, premium: user.premium },
}); });
sendRedirect(event, "/"); sendRedirect(event, "/");

View File

@@ -20,7 +20,10 @@ export default defineEventHandler(async (event) => {
const activity = await strava!<any>(`/activities/${body.object_id}`); const activity = await strava!<any>(`/activities/${body.object_id}`);
const [aiError, aiResponse] = await createActivityContent(activity, user); const [aiError, stravaRequestBody] = await createActivityContent(
activity,
user,
);
if (aiError) { if (aiError) {
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
@@ -28,20 +31,6 @@ export default defineEventHandler(async (event) => {
}); });
} }
const responseObject = JSON.parse(
get(aiResponse, "output.0.content.0.text"),
) as {
title: string;
description: string;
};
const promo = "Written by https://ghostwriter.rocks 👻";
const stravaRequestBody = {
name: responseObject.title,
description: [responseObject.description, promo].join("\n"),
};
await strava!(`activities/${body.object_id}`, { await strava!(`activities/${body.object_id}`, {
method: "PUT", method: "PUT",
body: stravaRequestBody, body: stravaRequestBody,

View File

@@ -1,6 +1,9 @@
import { chain, draw, omit } from "radash"; import { chain, draw, get, omit, tryit } from "radash";
import { safeDestr } from "destr";
import { User } from "./drizzle"; import { User } from "./drizzle";
const promo = "Written by https://ghostwriter.rocks 👻";
type Activity = Record<string, any>; type Activity = Record<string, any>;
const movingActivityTypes = [ const movingActivityTypes = [
@@ -116,7 +119,13 @@ export const createActivityContent = async (
"snarky", "snarky",
]); ]);
const length = draw(["short", "short", "short", "medium", "a-little-more-than-medium"]); const length = draw([
"short",
"short",
"short",
"medium",
"a-little-more-than-medium",
]);
const prompt = ` const prompt = `
Generate a short title and a ${length}-lengthed description for my strava activity. Use my preferred language and unit system. Generate a short title and a ${length}-lengthed description for my strava activity. Use my preferred language and unit system.
@@ -168,5 +177,17 @@ export const createActivityContent = async (
}, },
}); });
return [aiError, aiResponse] as const; const [parseError, responseObject] = tryit(
chain(
(r) => get(r, "output.0.content.0.text"),
(r) => safeDestr<{ title: string; description: string }>(r),
),
)(aiResponse);
const stravaRequestBody = {
name: responseObject!.title,
description: [responseObject!.description, promo].join("\n"),
};
return [aiError || parseError, stravaRequestBody] as const;
}; };