diff --git a/.containerignore b/.containerignore new file mode 100644 index 0000000..f0ba2e8 --- /dev/null +++ b/.containerignore @@ -0,0 +1,21 @@ +.git +.jj +.agents +.codex +.aws +.env +.env.* +!.env.example +**/node_modules +**/.output +**/.nitro +**/.tanstack +**/.turbo +**/dist +**/dist-ssr +**/*.tsbuildinfo +coverage +test-results +playwright-report +backups +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a488180 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Set the first administrator account. +STELLAR_ADMIN_EMAIL=admin@example.com +STELLAR_ADMIN_USERNAME=admin +# The deploy script generates a password when this field is empty. +STELLAR_ADMIN_PASSWORD= + +# Set the site address. +DOMAIN=localhost +APP_ORIGIN=http://localhost:8080 +HTTP_PORT=8080 +HTTPS_PORT=8443 + +# The deploy script generates these secrets when the fields are empty. +# Use hexadecimal characters for POSTGRES_PASSWORD. +POSTGRES_PASSWORD= +BETTER_AUTH_SECRET= + +# Set a key for each provider in AI_MODELS. +GOOGLE_GENERATIVE_AI_API_KEY= +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +AI_MODELS='[{"id":"gemini-flash","name":"Gemini Flash","provider":"google","model":"gemini-flash-latest"}]' + +# Set the RSS worker limits. +RSS_POLL_SECONDS=900 +RSS_CONCURRENCY=2 diff --git a/.gitignore b/.gitignore index b734661..24bae8d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,9 @@ todos.json # typescript *.tsbuildinfo + +!.env.example + +# Browser checks +test-results/ +playwright-report/ diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 6b00aa1..0000000 --- a/.prettierignore +++ /dev/null @@ -1,10 +0,0 @@ -dist/ -node_modules/ -.turbo/ -.output/ -.nitro/ -.tanstack/ -.vinxi/ -coverage/ -pnpm-lock.yaml -.pnpm-store/ \ No newline at end of file diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 40ce067..0000000 --- a/.prettierrc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "endOfLine": "lf", - "semi": false, - "singleQuote": false, - "tabWidth": 2, - "trailingComma": "es5", - "printWidth": 80, - "plugins": ["prettier-plugin-tailwindcss"], - "tailwindStylesheet": "packages/ui/src/styles/globals.css", - "tailwindFunctions": ["cn", "cva"] -} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..245db7a --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "biomejs.biome", + "typescriptteam.native-preview", + "effectful-tech.effect-vscode" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9ba76e6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.biome": "explicit", + "source.organizeImports.biome": "explicit" + }, + "js/ts.tsdk.additionalLocations": ["./node_modules/typescript/bin"], + "js/ts.tsdk.promptToUseWorkspaceVersion": true, + "js/ts.tsdk.path": "./node_modules/typescript/bin", + "js/ts.experimental.useTsgo": true +} diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..33d9b19 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,9 @@ +http://localhost, http://127.0.0.1 { + encode zstd gzip + reverse_proxy app:3000 +} + +{$DOMAIN:localhost} { + encode zstd gzip + reverse_proxy app:3000 +} diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..b3d5d08 --- /dev/null +++ b/Containerfile @@ -0,0 +1,29 @@ +FROM docker.io/library/node:26-bookworm-slim AS build + +WORKDIR /app +RUN npm install --global pnpm@11.25.0 + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/web/package.json apps/web/package.json +COPY packages/ui/package.json packages/ui/package.json +RUN pnpm install --frozen-lockfile + +COPY . . +RUN pnpm build + +FROM docker.io/library/node:26-bookworm-slim AS runtime + +ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000 +WORKDIR /app/apps/web + +COPY --from=build --chown=node:node /app/node_modules /app/node_modules +COPY --from=build --chown=node:node /app/package.json /app/package.json +COPY --from=build --chown=node:node /app/tsconfig.json /app/tsconfig.json +COPY --from=build --chown=node:node /app/apps/web /app/apps/web +COPY --from=build --chown=node:node /app/packages/ui /app/packages/ui +COPY --chown=node:node --chmod=0755 scripts/entrypoint.sh /app/scripts/entrypoint.sh + +USER node +EXPOSE 3000 +ENTRYPOINT ["/app/scripts/entrypoint.sh"] +CMD ["node", ".output/server/index.mjs"] diff --git a/README.md b/README.md index 4042488..d6afcb7 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,31 @@ -# shadcn/ui monorepo template +# Stellar -This is a TanStack Start monorepo template with shadcn/ui. +Stellar stores links and Markdown text as feeds. +Users can use AI models to make summaries and translations. +The RSS worker selects articles with each user's stored preferences. +Users can forward feeds and add comments to public feeds. +Only an administrator can create user accounts. -## Adding components +## Deploy -To add components to your app, run the following command at the root of your `web` app: +Install Podman, `podman-compose`, Python 3, `python-dotenv`, and curl. +Copy the configuration file. -```bash -pnpm dlx shadcn@latest add button -c apps/web +```sh +cp .env.example .env ``` -This will place the ui components in the `packages/ui/src/components` directory. +Set the AI provider keys in `.env`. +Then start the deployment. -## Using components - -To use the components in your app, import them from the `ui` package. - -```tsx -import { Button } from "@workspace/ui/components/button"; +```sh +./scripts/deploy.sh ``` + +Open `http://localhost:8080`. +Read the administrator name and password from `.env`. + +See [Deployment](docs/deployment.md) for domain names, HTTPS, backup, and restart procedures. +See [Configuration](docs/configuration.md) for environment variables. +See [Architecture](docs/architecture.md) for the program structure. +See [Development](docs/development.md) for local commands. diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts new file mode 100644 index 0000000..8bb05ab --- /dev/null +++ b/apps/web/drizzle.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "drizzle-kit" +export default defineConfig({ + schema: "./src/server/schema.ts", + out: "./drizzle", + dialect: "postgresql", +}) diff --git a/apps/web/drizzle/0000_stellar.sql b/apps/web/drizzle/0000_stellar.sql new file mode 100644 index 0000000..1b18067 --- /dev/null +++ b/apps/web/drizzle/0000_stellar.sql @@ -0,0 +1,164 @@ +CREATE TABLE "account" ( + "id" text PRIMARY KEY NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "user_id" text NOT NULL, + "access_token" text, + "refresh_token" text, + "id_token" text, + "access_token_expires_at" timestamp with time zone, + "refresh_token_expires_at" timestamp with time zone, + "scope" text, + "password" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "comments" ( + "id" text PRIMARY KEY NOT NULL, + "feed_id" text NOT NULL, + "author_id" text NOT NULL, + "content" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "feed_states" ( + "user_id" text NOT NULL, + "feed_id" text NOT NULL, + "saved" boolean DEFAULT false NOT NULL, + "interested" boolean, + "summary" text, + "translation" text, + "language" text, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "feed_states_user_id_feed_id_pk" PRIMARY KEY("user_id","feed_id") +); +--> statement-breakpoint +CREATE TABLE "feeds" ( + "id" text PRIMARY KEY NOT NULL, + "author_id" text NOT NULL, + "title" text NOT NULL, + "url" text, + "content" text NOT NULL, + "summary" text, + "kind" text NOT NULL, + "origin" text DEFAULT 'user' NOT NULL, + "is_public" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "forwards" ( + "feed_id" text NOT NULL, + "from_user_id" text NOT NULL, + "to_user_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "forwards_feed_id_from_user_id_to_user_id_pk" PRIMARY KEY("feed_id","from_user_id","to_user_id") +); +--> statement-breakpoint +CREATE TABLE "profiles" ( + "user_id" text PRIMARY KEY NOT NULL, + "preferences" text DEFAULT '' NOT NULL, + "learned_preferences" text DEFAULT '' NOT NULL, + "version" integer DEFAULT 0 NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "rss_items" ( + "subscription_id" text NOT NULL, + "item_key" text NOT NULL, + "matched" boolean NOT NULL, + "feed_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "rss_items_subscription_id_item_key_pk" PRIMARY KEY("subscription_id","item_key") +); +--> statement-breakpoint +CREATE TABLE "session" ( + "id" text PRIMARY KEY NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "token" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "ip_address" text, + "user_agent" text, + "user_id" text NOT NULL, + "impersonated_by" text, + CONSTRAINT "session_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "subscriptions" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "url" text NOT NULL, + "title" text NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "last_checked_at" timestamp with time zone, + "last_error" text, + "lease_until" timestamp with time zone, + "lease_token" text, + "next_poll_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "threads" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "title" text DEFAULT 'New conversation' NOT NULL, + "messages" jsonb DEFAULT '[]'::jsonb NOT NULL, + "lease_until" timestamp with time zone, + "lease_token" text, + "next_poll_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "email_verified" boolean DEFAULT false NOT NULL, + "image" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "username" text, + "display_username" text, + "role" text DEFAULT 'user' NOT NULL, + "banned" boolean DEFAULT false, + "ban_reason" text, + "ban_expires" timestamp with time zone, + CONSTRAINT "user_email_unique" UNIQUE("email"), + CONSTRAINT "user_username_unique" UNIQUE("username") +); +--> statement-breakpoint +CREATE TABLE "verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "comments" ADD CONSTRAINT "comments_feed_id_feeds_id_fk" FOREIGN KEY ("feed_id") REFERENCES "public"."feeds"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "comments" ADD CONSTRAINT "comments_author_id_user_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "feed_states" ADD CONSTRAINT "feed_states_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "feed_states" ADD CONSTRAINT "feed_states_feed_id_feeds_id_fk" FOREIGN KEY ("feed_id") REFERENCES "public"."feeds"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "feeds" ADD CONSTRAINT "feeds_author_id_user_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "forwards" ADD CONSTRAINT "forwards_feed_id_feeds_id_fk" FOREIGN KEY ("feed_id") REFERENCES "public"."feeds"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "forwards" ADD CONSTRAINT "forwards_from_user_id_user_id_fk" FOREIGN KEY ("from_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "forwards" ADD CONSTRAINT "forwards_to_user_id_user_id_fk" FOREIGN KEY ("to_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "profiles" ADD CONSTRAINT "profiles_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "rss_items" ADD CONSTRAINT "rss_items_subscription_id_subscriptions_id_fk" FOREIGN KEY ("subscription_id") REFERENCES "public"."subscriptions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "rss_items" ADD CONSTRAINT "rss_items_feed_id_feeds_id_fk" FOREIGN KEY ("feed_id") REFERENCES "public"."feeds"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "threads" ADD CONSTRAINT "threads_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "account_user_idx" ON "account" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "comments_feed_idx" ON "comments" USING btree ("feed_id");--> statement-breakpoint +CREATE UNIQUE INDEX "feeds_public_url_idx" ON "feeds" USING btree ("url") WHERE "feeds"."is_public" = true AND "feeds"."url" IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "feeds_author_url_idx" ON "feeds" USING btree ("author_id","url") WHERE "feeds"."url" IS NOT NULL;--> statement-breakpoint +CREATE INDEX "feeds_created_idx" ON "feeds" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "forwards_recipient_idx" ON "forwards" USING btree ("to_user_id");--> statement-breakpoint +CREATE INDEX "session_user_idx" ON "session" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "subscriptions_user_url_idx" ON "subscriptions" USING btree ("user_id","url");--> statement-breakpoint +CREATE INDEX "threads_user_idx" ON "threads" USING btree ("user_id"); \ No newline at end of file diff --git a/apps/web/drizzle/0001_profile-learning-and-recommendations.sql b/apps/web/drizzle/0001_profile-learning-and-recommendations.sql new file mode 100644 index 0000000..f6f21e6 --- /dev/null +++ b/apps/web/drizzle/0001_profile-learning-and-recommendations.sql @@ -0,0 +1,3 @@ +ALTER TABLE "feed_states" ADD COLUMN "ai_submitted" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "profiles" ADD COLUMN "learned_version" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "threads" DROP COLUMN "next_poll_at"; \ No newline at end of file diff --git a/apps/web/drizzle/meta/0000_snapshot.json b/apps/web/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..9d84233 --- /dev/null +++ b/apps/web/drizzle/meta/0000_snapshot.json @@ -0,0 +1,1127 @@ +{ + "id": "41a5fe61-5a5b-4af4-9d8a-c2e709c35b1f", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user_idx": { + "name": "account_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_feed_idx": { + "name": "comments_feed_idx", + "columns": [ + { + "expression": "feed_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_feed_id_feeds_id_fk": { + "name": "comments_feed_id_feeds_id_fk", + "tableFrom": "comments", + "tableTo": "feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_author_id_user_id_fk": { + "name": "comments_author_id_user_id_fk", + "tableFrom": "comments", + "tableTo": "user", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_states": { + "name": "feed_states", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "saved": { + "name": "saved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "interested": { + "name": "interested", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "translation": { + "name": "translation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "feed_states_user_id_user_id_fk": { + "name": "feed_states_user_id_user_id_fk", + "tableFrom": "feed_states", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feed_states_feed_id_feeds_id_fk": { + "name": "feed_states_feed_id_feeds_id_fk", + "tableFrom": "feed_states", + "tableTo": "feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "feed_states_user_id_feed_id_pk": { + "name": "feed_states_user_id_feed_id_pk", + "columns": ["user_id", "feed_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feeds": { + "name": "feeds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feeds_public_url_idx": { + "name": "feeds_public_url_idx", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"feeds\".\"is_public\" = true AND \"feeds\".\"url\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "feeds_author_url_idx": { + "name": "feeds_author_url_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"feeds\".\"url\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "feeds_created_idx": { + "name": "feeds_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feeds_author_id_user_id_fk": { + "name": "feeds_author_id_user_id_fk", + "tableFrom": "feeds", + "tableTo": "user", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.forwards": { + "name": "forwards", + "schema": "", + "columns": { + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_user_id": { + "name": "from_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_user_id": { + "name": "to_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "forwards_recipient_idx": { + "name": "forwards_recipient_idx", + "columns": [ + { + "expression": "to_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "forwards_feed_id_feeds_id_fk": { + "name": "forwards_feed_id_feeds_id_fk", + "tableFrom": "forwards", + "tableTo": "feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "forwards_from_user_id_user_id_fk": { + "name": "forwards_from_user_id_user_id_fk", + "tableFrom": "forwards", + "tableTo": "user", + "columnsFrom": ["from_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "forwards_to_user_id_user_id_fk": { + "name": "forwards_to_user_id_user_id_fk", + "tableFrom": "forwards", + "tableTo": "user", + "columnsFrom": ["to_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "forwards_feed_id_from_user_id_to_user_id_pk": { + "name": "forwards_feed_id_from_user_id_to_user_id_pk", + "columns": ["feed_id", "from_user_id", "to_user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "preferences": { + "name": "preferences", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "learned_preferences": { + "name": "learned_preferences", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profiles_user_id_user_id_fk": { + "name": "profiles_user_id_user_id_fk", + "tableFrom": "profiles", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rss_items": { + "name": "rss_items", + "schema": "", + "columns": { + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_key": { + "name": "item_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "matched": { + "name": "matched", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "rss_items_subscription_id_subscriptions_id_fk": { + "name": "rss_items_subscription_id_subscriptions_id_fk", + "tableFrom": "rss_items", + "tableTo": "subscriptions", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rss_items_feed_id_feeds_id_fk": { + "name": "rss_items_feed_id_feeds_id_fk", + "tableFrom": "rss_items", + "tableTo": "feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "rss_items_subscription_id_item_key_pk": { + "name": "rss_items_subscription_id_item_key_pk", + "columns": ["subscription_id", "item_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_idx": { + "name": "session_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_poll_at": { + "name": "next_poll_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscriptions_user_url_idx": { + "name": "subscriptions_user_url_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_user_id_user_id_fk": { + "name": "subscriptions_user_id_user_id_fk", + "tableFrom": "subscriptions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.threads": { + "name": "threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New conversation'" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_poll_at": { + "name": "next_poll_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "threads_user_idx": { + "name": "threads_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "threads_user_id_user_id_fk": { + "name": "threads_user_id_user_id_fk", + "tableFrom": "threads", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/web/drizzle/meta/0001_snapshot.json b/apps/web/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..038c080 --- /dev/null +++ b/apps/web/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1134 @@ +{ + "id": "abc6caac-fb1c-4332-a42a-7bbe4d7411e8", + "prevId": "41a5fe61-5a5b-4af4-9d8a-c2e709c35b1f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user_idx": { + "name": "account_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_feed_idx": { + "name": "comments_feed_idx", + "columns": [ + { + "expression": "feed_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_feed_id_feeds_id_fk": { + "name": "comments_feed_id_feeds_id_fk", + "tableFrom": "comments", + "tableTo": "feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_author_id_user_id_fk": { + "name": "comments_author_id_user_id_fk", + "tableFrom": "comments", + "tableTo": "user", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_states": { + "name": "feed_states", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "saved": { + "name": "saved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "interested": { + "name": "interested", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ai_submitted": { + "name": "ai_submitted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "translation": { + "name": "translation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "feed_states_user_id_user_id_fk": { + "name": "feed_states_user_id_user_id_fk", + "tableFrom": "feed_states", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feed_states_feed_id_feeds_id_fk": { + "name": "feed_states_feed_id_feeds_id_fk", + "tableFrom": "feed_states", + "tableTo": "feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "feed_states_user_id_feed_id_pk": { + "name": "feed_states_user_id_feed_id_pk", + "columns": ["user_id", "feed_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feeds": { + "name": "feeds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feeds_public_url_idx": { + "name": "feeds_public_url_idx", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"feeds\".\"is_public\" = true AND \"feeds\".\"url\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "feeds_author_url_idx": { + "name": "feeds_author_url_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"feeds\".\"url\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "feeds_created_idx": { + "name": "feeds_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feeds_author_id_user_id_fk": { + "name": "feeds_author_id_user_id_fk", + "tableFrom": "feeds", + "tableTo": "user", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.forwards": { + "name": "forwards", + "schema": "", + "columns": { + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_user_id": { + "name": "from_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_user_id": { + "name": "to_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "forwards_recipient_idx": { + "name": "forwards_recipient_idx", + "columns": [ + { + "expression": "to_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "forwards_feed_id_feeds_id_fk": { + "name": "forwards_feed_id_feeds_id_fk", + "tableFrom": "forwards", + "tableTo": "feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "forwards_from_user_id_user_id_fk": { + "name": "forwards_from_user_id_user_id_fk", + "tableFrom": "forwards", + "tableTo": "user", + "columnsFrom": ["from_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "forwards_to_user_id_user_id_fk": { + "name": "forwards_to_user_id_user_id_fk", + "tableFrom": "forwards", + "tableTo": "user", + "columnsFrom": ["to_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "forwards_feed_id_from_user_id_to_user_id_pk": { + "name": "forwards_feed_id_from_user_id_to_user_id_pk", + "columns": ["feed_id", "from_user_id", "to_user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "preferences": { + "name": "preferences", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "learned_preferences": { + "name": "learned_preferences", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "learned_version": { + "name": "learned_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profiles_user_id_user_id_fk": { + "name": "profiles_user_id_user_id_fk", + "tableFrom": "profiles", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rss_items": { + "name": "rss_items", + "schema": "", + "columns": { + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_key": { + "name": "item_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "matched": { + "name": "matched", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "rss_items_subscription_id_subscriptions_id_fk": { + "name": "rss_items_subscription_id_subscriptions_id_fk", + "tableFrom": "rss_items", + "tableTo": "subscriptions", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rss_items_feed_id_feeds_id_fk": { + "name": "rss_items_feed_id_feeds_id_fk", + "tableFrom": "rss_items", + "tableTo": "feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "rss_items_subscription_id_item_key_pk": { + "name": "rss_items_subscription_id_item_key_pk", + "columns": ["subscription_id", "item_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_idx": { + "name": "session_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_poll_at": { + "name": "next_poll_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscriptions_user_url_idx": { + "name": "subscriptions_user_url_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_user_id_user_id_fk": { + "name": "subscriptions_user_id_user_id_fk", + "tableFrom": "subscriptions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.threads": { + "name": "threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New conversation'" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "threads_user_idx": { + "name": "threads_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "threads_user_id_user_id_fk": { + "name": "threads_user_id_user_id_fk", + "tableFrom": "threads", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/web/drizzle/meta/_journal.json b/apps/web/drizzle/meta/_journal.json new file mode 100644 index 0000000..1ab135d --- /dev/null +++ b/apps/web/drizzle/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1791087264639, + "tag": "0000_stellar", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1791088233093, + "tag": "0001_profile-learning-and-recommendations", + "breakpoints": true + } + ] +} diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js deleted file mode 100644 index d49b5cb..0000000 --- a/apps/web/eslint.config.js +++ /dev/null @@ -1,21 +0,0 @@ -// @ts-check - -import { tanstackConfig } from "@tanstack/eslint-config" - -export default [ - ...tanstackConfig, - { - rules: { - "import/no-cycle": "off", - "import/order": "off", - "import/consistent-type-specifier-style": "off", - "sort-imports": "off", - "@typescript-eslint/array-type": "off", - "@typescript-eslint/require-await": "off", - "pnpm/json-enforce-catalog": "off", - }, - }, - { - ignores: ["eslint.config.js", ".prettierrc"], - }, -] diff --git a/apps/web/package.json b/apps/web/package.json index 5dee7b1..06055b7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,33 +8,53 @@ "generate-routes": "tsr generate", "build": "vite build", "preview": "vite preview", - "lint": "eslint", - "format": "prettier --write \"**/*.{ts,tsx}\"", - "typecheck": "tsc --noEmit" + "lint": "biome check .", + "format": "biome check --write .", + "typecheck": "tsc --noEmit", + "test": "node --import tsx --test --test-isolation=none src/server/*.test.ts tests/*.test.ts", + "start": "node .output/server/index.mjs", + "setup": "node --import tsx src/server/setup.ts", + "worker": "node --import tsx src/server/worker.ts", + "db:generate": "drizzle-kit generate" }, "dependencies": { + "@ai-sdk/anthropic": "^4.0.71", + "@ai-sdk/google": "^4.0.87", + "@ai-sdk/openai": "^4.0.83", + "@ai-sdk/react": "^4.0.130", + "@effect/platform-node": "^4.0.0", "@hugeicons/core-free-icons": "^4.3.5", "@hugeicons/react": "^1.1.10", + "@mozilla/readability": "^0.6.0", "@tailwindcss/vite": "^4", - "@tanstack/react-devtools": "latest", "@tanstack/react-router": "latest", - "@tanstack/react-router-devtools": "latest", "@tanstack/react-start": "latest", "@workspace/ui": "workspace:*", + "ai": "^7.0.127", + "better-auth": "^1.7.7", + "drizzle-orm": "^0.45.3", + "effect": "4.0.0", + "ipaddr.js": "^2.5.0", + "linkedom": "^0.18.13", + "postgres": "^3.4.9", "react": "^19.2.8", "react-dom": "^19.2.8", - "tailwindcss": "^4" + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "rss-parser": "^3.13.0", + "tailwindcss": "^4", + "undici": "^8.11.2", + "zod": "^4.6.5" }, "devDependencies": { - "@tanstack/devtools-vite": "latest", - "@tanstack/eslint-config": "latest", "@tanstack/router-cli": "^1.167.39", "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6", - "eslint": "^10", - "typescript": "^6.0.2", + "drizzle-kit": "^0.31.11", + "nitro": "3.0.260903-beta", + "tsx": "^4.23.15", "vite": "^8" } } diff --git a/apps/web/public/stellar.svg b/apps/web/public/stellar.svg new file mode 100644 index 0000000..5cd38c7 --- /dev/null +++ b/apps/web/public/stellar.svg @@ -0,0 +1 @@ +Stellar diff --git a/apps/web/src/components/chat.tsx b/apps/web/src/components/chat.tsx new file mode 100644 index 0000000..9551511 --- /dev/null +++ b/apps/web/src/components/chat.tsx @@ -0,0 +1,566 @@ +import { useChat } from "@ai-sdk/react" +import { + Add01Icon, + AiChat01Icon, + ArrowUp02Icon, + Delete02Icon, + StopIcon, +} from "@hugeicons/core-free-icons" +import { HugeiconsIcon } from "@hugeicons/react" +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@workspace/ui/components/accordion" +import { + Alert, + AlertDescription, + AlertTitle, +} from "@workspace/ui/components/alert" +import { Bubble, BubbleContent } from "@workspace/ui/components/bubble" +import { Button } from "@workspace/ui/components/button" +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@workspace/ui/components/empty" +import { Field, FieldGroup, FieldLabel } from "@workspace/ui/components/field" +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupTextarea, +} from "@workspace/ui/components/input-group" +import { Marker, MarkerContent } from "@workspace/ui/components/marker" +import { + Message, + MessageContent, + MessageHeader, +} from "@workspace/ui/components/message" +import { + MessageScroller, + MessageScrollerButton, + MessageScrollerContent, + MessageScrollerItem, + MessageScrollerProvider, + MessageScrollerViewport, +} from "@workspace/ui/components/message-scroller" +import { + NativeSelect, + NativeSelectOption, +} from "@workspace/ui/components/native-select" +import { Spinner } from "@workspace/ui/components/spinner" +import { DefaultChatTransport, type UIMessage } from "ai" +import { Effect } from "effect" +import { useEffect, useMemo, useState } from "react" +import { Markdown } from "@/components/markdown" +import { ModelPicker } from "@/components/model-picker" +import { api, ClientError, useOperation } from "@/lib/client" +import type { AppData, ModelOption } from "@/shared/contracts" + +const toolLabels: Record = { + listFeeds: "Find articles", + readFeed: "Read an article", + submitFeed: "Add a feed", + subscribe: "Add an RSS source", + listSubscriptions: "Read your sources", + setSubscription: "Update a source", + unsubscribe: "Remove a source", + updatePreferences: "Update your preferences", + listPeople: "Find a person", + forwardFeed: "Forward a feed", + comment: "Post a comment", + saveFeed: "Save an article", + feedback: "Learn from your feedback", + summarize: "Summarize an article", + translate: "Translate an article", +} + +export function ChatPanel({ + data, + onChanged, + onOpenFeed, +}: { + data: AppData + onChanged: () => void + onOpenFeed: (id: string) => void +}) { + const [threadId, setThreadId] = useState(data.threads[0]?.id ?? "") + const [thread, setThread] = useState<{ + id: string + title: string + messages: UIMessage[] + } | null>(null) + const [error, setError] = useState("") + const { run, busy } = useOperation() + useEffect(() => { + setThread(null) + setError("") + if (!threadId) return + return run( + api({ action: "getThread", id: threadId }), + setThread, + (failure) => setError(failure.message), + ) + }, [threadId, run]) + const create = () => + run(api({ action: "createThread" }), (result) => { + setThreadId(result.id) + onChanged() + }) + return ( +
+
+ setThreadId(event.target.value)} + > + {!threadId && ( + + Choose a conversation + + )} + {data.threads.map((item) => ( + + {item.title} + + ))} + {threadId && !data.threads.some((item) => item.id === threadId) && ( + + New conversation + + )} + +
+ {threadId && ( + + )} + +
+
+ {error ? ( +
+ + Cannot open conversation + {error} + +
+ ) : thread ? ( + + ) : threadId ? ( +
+ +
+ ) : ( + + + + + Follow a thought + + Ask about your articles, discover patterns, or shape your reading + preferences. Stellar can help manage your feeds and sources. + + + + + {data.models.length === 0 && ( +

+ Ask your administrator to configure an AI model. +

+ )} +
+
+ )} +
+ ) +} + +function ChatConversation({ + thread, + models, + onChanged, + onOpenFeed, +}: { + thread: { id: string; messages: UIMessage[] } + models: ModelOption[] + onChanged: () => void + onOpenFeed: (id: string) => void +}) { + const [modelId, setModelId] = useState(models[0]?.id ?? "") + const [input, setInput] = useState("") + const { run } = useOperation() + const transport = useMemo( + () => + new DefaultChatTransport({ + api: "/api/chat", + credentials: "same-origin", + }), + [], + ) + const { messages, sendMessage, status, stop, error, regenerate } = useChat({ + id: thread.id, + messages: thread.messages, + transport, + onFinish: onChanged, + }) + const busy = status === "submitted" || status === "streaming" + useEffect( + () => () => { + void stop() + }, + [stop], + ) + const send = (text: string) => { + if (!text.trim() || busy || !modelId) return + setInput("") + run( + Effect.tryPromise({ + try: () => + sendMessage({ text }, { body: { threadId: thread.id, modelId } }), + catch: (failure) => + new ClientError({ + message: + failure instanceof Error + ? failure.message + : "Cannot send message.", + }), + }), + ) + } + return ( +
+ {messages.length === 0 ? ( + + + + + What caught your curiosity? + + Your feeds, sources, and reading profile are part of this + conversation. + + + + {[ + "Find connections between my recent articles.", + "Help me refine my reading preferences.", + "What should I read next?", + ].map((prompt) => ( + + ))} + + + ) : ( + + + + + {messages.map((message) => ( + + + + ))} + {status === "submitted" && ( + + + + Thinking… + + + + )} + + + + + + )} +
+ {error && ( + + Cannot complete the response + + {error.message} + + + + )} +
{ + event.preventDefault() + send(input.trim()) + }} + > + + + + Message Stellar + + + setInput(event.target.value)} + rows={2} + maxLength={20000} + disabled={busy || !modelId} + onKeyDown={(event) => { + if ( + event.key === "Enter" && + !event.shiftKey && + !event.nativeEvent.isComposing + ) { + event.preventDefault() + send(input.trim()) + } + }} + /> + + +
+ {busy ? ( + { + void stop() + }} + > + + ) : ( + + + )} +
+
+
+
+
+
+

+ Check important facts. Press Enter to send, Shift + Enter for a new + line. +

+
+
+ ) +} + +function ChatMessage({ + message, + onOpenFeed, +}: { + message: UIMessage + onOpenFeed: (id: string) => void +}) { + const user = message.role === "user" + return ( + + + {user ? "You" : "Stellar"} + {message.parts.map((part, index) => { + // AI SDK streams append-only parts. Tool calls have their own stable IDs. + const partId = + "toolCallId" in part + ? part.toolCallId + : `${message.id}-${part.type}-${index}` + if (part.type === "text") + return ( + + + {part.text} + + + ) + if (part.type === "reasoning") + return ( + + + Thinking process + + {part.text} + + + + ) + if (part.type === "source-url") + return ( + + ) + if (part.type.startsWith("tool-") || part.type === "dynamic-tool") { + const toolName = + part.type === "dynamic-tool" && "toolName" in part + ? String(part.toolName) + : part.type.slice(5) + const state = "state" in part ? part.state : "" + const failed = state === "output-error" + const complete = state === "output-available" + return ( +
+ + + {!complete && !failed && }{" "} + {toolLabels[toolName] ?? "Use your library"} ·{" "} + {complete ? "Done" : failed ? "Failed" : "Working"} + + + {complete && "output" in part && ( + + )} + {failed && "errorText" in part && ( +

+ {String(part.errorText)} +

+ )} +
+ ) + } + return null + })} +
+
+ ) +} + +function ToolResult({ + output, + onOpenFeed, +}: { + output: unknown + onOpenFeed: (id: string) => void +}) { + const values = Array.isArray(output) ? output : [output] + return ( +
+ {values.slice(0, 6).map((value: unknown) => { + if ( + !value || + typeof value !== "object" || + !("id" in value) || + typeof value.id !== "string" + ) + return null + if (!("title" in value) || typeof value.title !== "string") return null + if (!("content" in value) && !("origin" in value)) return null + const id = value.id + return ( + + ) + })} +
+ ) +} diff --git a/apps/web/src/components/feeds.tsx b/apps/web/src/components/feeds.tsx new file mode 100644 index 0000000..42c1708 --- /dev/null +++ b/apps/web/src/components/feeds.tsx @@ -0,0 +1,879 @@ +import { + Add01Icon, + Bookmark02Icon, + File01Icon, + Link01Icon, + Message01Icon, + Search01Icon, + Share08Icon, + SparklesIcon, + ThumbsDownIcon, + ThumbsUpIcon, +} from "@hugeicons/core-free-icons" +import { HugeiconsIcon } from "@hugeicons/react" +import { + Alert, + AlertDescription, + AlertTitle, +} from "@workspace/ui/components/alert" +import { Avatar, AvatarFallback } from "@workspace/ui/components/avatar" +import { Badge } from "@workspace/ui/components/badge" +import { Button } from "@workspace/ui/components/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@workspace/ui/components/dialog" +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@workspace/ui/components/empty" +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@workspace/ui/components/field" +import { Input } from "@workspace/ui/components/input" +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "@workspace/ui/components/input-group" +import { + Item, + ItemContent, + ItemDescription, + ItemFooter, + ItemGroup, + ItemHeader, + ItemTitle, +} from "@workspace/ui/components/item" +import { + NativeSelect, + NativeSelectOption, +} from "@workspace/ui/components/native-select" +import { Separator } from "@workspace/ui/components/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@workspace/ui/components/sheet" +import { Skeleton } from "@workspace/ui/components/skeleton" +import { Spinner } from "@workspace/ui/components/spinner" +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@workspace/ui/components/tabs" +import { Textarea } from "@workspace/ui/components/textarea" +import { toast } from "@workspace/ui/components/toast" +import { + ToggleGroup, + ToggleGroupItem, +} from "@workspace/ui/components/toggle-group" +import { useEffect, useState } from "react" +import { + PageLayout, + type View, + viewTitles, +} from "@/components/layout/app-shell" +import { Markdown } from "@/components/markdown" +import { ModelPicker } from "@/components/model-picker" +import { api, useOperation } from "@/lib/client" +import type { AppData, FeedDetail, FeedView } from "@/shared/contracts" + +export const dateLabel = (value: string) => + new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format( + new Date(value), + ) +const descriptions: Record = { + all: "Everything you collect, and ideas shared with you.", + public: "Good ideas grow when you share them.", + saved: "A quiet place for the articles you want to revisit.", + ai: "Articles selected from your sources, shaped by your interests.", +} + +export function FeedList({ + data, + view, + onOpen, + onSubmit, +}: { + data: AppData + view: Extract + onOpen: (id: string) => void + onSubmit: () => void +}) { + const [feeds, setFeeds] = useState(data.feeds) + const [query, setQuery] = useState("") + const [error, setError] = useState("") + const { run, busy } = useOperation() + useEffect(() => { + if (view === "all" && !query.trim()) { + setFeeds(data.feeds) + return + } + let cancel: (() => void) | undefined + const timer = setTimeout(() => { + setError("") + cancel = run( + api({ action: "listFeeds", view, query }), + setFeeds, + (failure) => setError(failure.message), + ) + }, 180) + return () => { + clearTimeout(timer) + cancel?.() + } + }, [view, query, data.feeds, run]) + return ( + + {feeds.length} {feeds.length === 1 ? "article" : "articles"} + + } + > + + + + + + {busy && } + + + {error ? ( + + Cannot load feeds + {error} + + ) : feeds.length === 0 ? ( + + + + + + {query + ? "No matching articles" + : view === "ai" + ? "Your discoveries start here" + : "Make room for your first idea"} + + + {query + ? "Try a different search." + : view === "ai" + ? "Add an RSS source and tell Stellar what you want to read. Matching articles will appear here." + : view === "saved" + ? "Save an article to find it here later." + : "Add a link or write a note. Your collection starts with one feed."} + + + {!query && view === "all" && ( + + + + )} + + ) : ( + + {feeds.map((feed) => ( + { + event.preventDefault() + onOpen(feed.id) + }} + /> + } + className="py-4" + > + +
+ + {feed.url + ? new URL(feed.url).hostname.replace(/^www\./, "") + : "Markdown"} + + {feed.origin === "ai" && ( + + + )} + {feed.isPublic && Public} +
+ + {dateLabel(feed.createdAt)} + +
+ + {feed.title} + + {feed.summary ?? + feed.content.replace(/[#*_`>]/g, "").slice(0, 220)} + + + + + {feed.authorName} + +
+ {feed.interested !== null && ( + + {feed.interested ? "Interested" : "Not interested"} + + )} + {feed.saved && ( +
+
+
+ ))} +
+ )} +
+ ) +} + +export function SubmitFeed({ + open, + onOpenChange, + onCreated, +}: { + open: boolean + onOpenChange: (value: boolean) => void + onCreated: (id: string) => void +}) { + const [kind, setKind] = useState("link") + const [url, setUrl] = useState("") + const [title, setTitle] = useState("") + const [content, setContent] = useState("") + const { run, busy } = useOperation() + return ( + + + + Add to your feed + + Keep a link or an idea you want to return to. + + +
{ + event.preventDefault() + run( + api({ + action: "submitFeed", + ...(kind === "link" + ? { url: url.trim() } + : { title: title.trim(), content }), + }), + (result) => { + onOpenChange(false) + setUrl("") + setTitle("") + setContent("") + if (result.duplicate) + toast.add({ + title: "Already in the public space", + description: "The existing feed is open.", + type: "info", + }) + onCreated(result.id) + }, + ) + }} + > + + + Feed type + { + if (value[0]) setKind(value[0]) + }} + variant="outline" + > + + + + + + + {kind === "link" ? ( + + Article URL + + + Stellar will read the article. Existing public links open as + one feed. + + + ) : ( + <> + + Title + + + + Markdown +