├── apps ├── web │ ├── public │ │ ├── sw.js │ │ ├── share.png │ │ ├── favicon.ico │ │ ├── timeline.mp4 │ │ ├── apple-icon.png │ │ ├── share-card.png │ │ ├── email-banner.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon-96x96.png │ │ ├── marketing │ │ │ ├── ui.png │ │ │ ├── mute.png │ │ │ ├── filters.png │ │ │ ├── ui-dark.png │ │ │ ├── post-example.png │ │ │ └── testimonials │ │ │ │ ├── nick.jpg │ │ │ │ └── charlie.jpg │ │ ├── ms-icon-70x70.png │ │ ├── ms-icon-144x144.png │ │ ├── ms-icon-150x150.png │ │ ├── ms-icon-310x310.png │ │ ├── android-icon-36x36.png │ │ ├── android-icon-48x48.png │ │ ├── android-icon-72x72.png │ │ ├── android-icon-96x96.png │ │ ├── apple-icon-114x114.png │ │ ├── apple-icon-120x120.png │ │ ├── apple-icon-144x144.png │ │ ├── apple-icon-152x152.png │ │ ├── apple-icon-180x180.png │ │ ├── apple-icon-57x57.png │ │ ├── apple-icon-60x60.png │ │ ├── apple-icon-72x72.png │ │ ├── apple-icon-76x76.png │ │ ├── apple-touch-icon.png │ │ ├── android-icon-144x144.png │ │ ├── android-icon-192x192.png │ │ ├── apple-icon-precomposed.png │ │ ├── apple-touch-icon-57x57.png │ │ ├── apple-touch-icon-72x72.png │ │ ├── apple-touch-icon-76x76.png │ │ ├── apple-touch-icon-114x114.png │ │ ├── apple-touch-icon-120x120.png │ │ ├── apple-touch-icon-144x144.png │ │ ├── apple-touch-icon-152x152.png │ │ ├── apple-touch-icon-180x180.png │ │ ├── apple-touch-icon-precomposed.png │ │ ├── robots.txt │ │ ├── browserconfig.xml │ │ ├── bluesky-logo.svg │ │ ├── manifest.json │ │ └── pwa-manifest.json │ ├── app │ │ ├── components │ │ │ ├── linkPosts │ │ │ │ ├── Toolbar.module.css │ │ │ │ ├── LinkRep.module.css │ │ │ │ ├── ToolDropdown.module.css │ │ │ │ ├── SharedByBug.module.css │ │ │ │ ├── NumberRanking.module.css │ │ │ │ ├── link │ │ │ │ │ ├── LinkDescription.tsx │ │ │ │ │ ├── XEmbed.tsx │ │ │ │ │ ├── YoutubeEmbed.tsx │ │ │ │ │ ├── LinkTitle.tsx │ │ │ │ │ ├── LinkMetadata.module.css │ │ │ │ │ ├── LinkImage.tsx │ │ │ │ │ └── DisplayHost.tsx │ │ │ │ ├── PostContent.module.css │ │ │ │ ├── NumberRanking.tsx │ │ │ │ ├── OpenLink.tsx │ │ │ │ ├── LinksList.tsx │ │ │ │ ├── CopyLink.tsx │ │ │ │ ├── BookmarkLink.tsx │ │ │ │ ├── Toolbar.tsx │ │ │ │ ├── PostContent.tsx │ │ │ │ ├── MuteActions.tsx │ │ │ │ └── RepostActor.tsx │ │ │ ├── forms │ │ │ │ ├── FilterButtonGroup.module.css │ │ │ │ ├── ErrorCallout.tsx │ │ │ │ ├── FilterCustomIndicator.tsx │ │ │ │ ├── ErrorList.tsx │ │ │ │ ├── SubmitButton.tsx │ │ │ │ ├── FilterButton.tsx │ │ │ │ ├── CheckboxField.tsx │ │ │ │ ├── TextInput.tsx │ │ │ │ ├── OTPInput.module.css │ │ │ │ ├── ListSwitch.tsx │ │ │ │ ├── OTPField.tsx │ │ │ │ └── OTPInput.tsx │ │ │ ├── marketing │ │ │ │ ├── Features.module.css │ │ │ │ ├── MainHero.module.css │ │ │ │ ├── MarketingFooter.module.css │ │ │ │ ├── Hero.module.css │ │ │ │ ├── WhySill.module.css │ │ │ │ ├── Pricing.module.css │ │ │ │ ├── WhySill.tsx │ │ │ │ └── MarketingFooter.tsx │ │ │ ├── download │ │ │ │ ├── ErrorState.tsx │ │ │ │ ├── LoadingState.tsx │ │ │ │ ├── WelcomeTrialBanner.tsx │ │ │ │ ├── ActionCard.tsx │ │ │ │ └── WelcomeContent.tsx │ │ │ ├── nav │ │ │ │ ├── Footer.module.css │ │ │ │ ├── TrialBanner.module.css │ │ │ │ ├── PageHeading.tsx │ │ │ │ ├── logo.module.css │ │ │ │ ├── Logo.tsx │ │ │ │ ├── Nav.module.css │ │ │ │ ├── TrialBanner.tsx │ │ │ │ ├── AgreeToTerms.tsx │ │ │ │ ├── Header.module.css │ │ │ │ ├── Layout.module.css │ │ │ │ ├── Footer.tsx │ │ │ │ └── Layout.tsx │ │ │ ├── subscription │ │ │ │ ├── FeatureCard.module.css │ │ │ │ ├── SubscriptionHeader.tsx │ │ │ │ ├── SubscriptionCallout.tsx │ │ │ │ ├── FeatureCard.tsx │ │ │ │ ├── SubscriptionPricingCard.tsx │ │ │ │ └── SubscriptionDetailsCard.tsx │ │ │ ├── rss │ │ │ │ ├── RSSRepost.tsx │ │ │ │ ├── RSSLinkPost.tsx │ │ │ │ └── RSSNotificationItem.tsx │ │ │ ├── settings │ │ │ │ └── SettingsTabNav.tsx │ │ │ └── archive │ │ │ │ └── MonthCollapsible.tsx │ │ ├── styles │ │ │ ├── fonts │ │ │ │ ├── Nacelle-Bold.ttf │ │ │ │ ├── Nacelle-Bold.woff │ │ │ │ ├── Nacelle-Bold.woff2 │ │ │ │ ├── Nacelle-Italic.ttf │ │ │ │ ├── Nacelle-Italic.woff │ │ │ │ ├── Nacelle-Regular.ttf │ │ │ │ ├── Nacelle-BoldItalic.ttf │ │ │ │ ├── Nacelle-Italic.woff2 │ │ │ │ ├── Nacelle-Regular.woff │ │ │ │ ├── Nacelle-Regular.woff2 │ │ │ │ ├── Nacelle-BlackItalic.ttf │ │ │ │ ├── Nacelle-BlackItalic.woff │ │ │ │ ├── Nacelle-BoldItalic.woff │ │ │ │ ├── Nacelle-BoldItalic.woff2 │ │ │ │ └── Nacelle-BlackItalic.woff2 │ │ │ └── reset.css │ │ ├── routes │ │ │ ├── email │ │ │ │ ├── index.tsx │ │ │ │ └── delete.tsx │ │ │ ├── settings │ │ │ │ ├── index.tsx │ │ │ │ └── portal.tsx │ │ │ ├── jwks.ts │ │ │ ├── client-metadata.ts │ │ │ ├── bookmarks │ │ │ │ ├── delete-tag.ts │ │ │ │ ├── delete.ts │ │ │ │ └── add.ts │ │ │ ├── bluesky │ │ │ │ ├── auth.revoke.ts │ │ │ │ └── auth.ts │ │ │ ├── accounts │ │ │ │ ├── user.delete.tsx │ │ │ │ └── logout.tsx │ │ │ ├── notifications │ │ │ │ ├── test.tsx │ │ │ │ └── delete.ts │ │ │ ├── api │ │ │ │ ├── bluesky.status.ts │ │ │ │ ├── polar.webhook.ts │ │ │ │ ├── agree-to-terms.ts │ │ │ │ ├── link.update-metadata.ts │ │ │ │ └── mute.delete.ts │ │ │ ├── mastodon │ │ │ │ ├── auth.revoke.ts │ │ │ │ ├── auth.ts │ │ │ │ └── auth.callback.ts │ │ │ ├── _index.tsx │ │ │ ├── links │ │ │ │ ├── topic.tsx │ │ │ │ ├── author.tsx │ │ │ │ └── domain.tsx │ │ │ └── download.tsx │ │ ├── utils │ │ │ ├── nonce-provider.ts │ │ │ ├── verify.server.ts │ │ │ ├── request-info.ts │ │ │ ├── verification.server.ts │ │ │ ├── polar.server.ts │ │ │ ├── honeypot.server.ts │ │ │ ├── filterUtils.ts │ │ │ ├── layout.server.ts │ │ │ ├── theme.ts │ │ │ ├── onboarding.server.ts │ │ │ ├── userValidation.ts │ │ │ ├── context.server.ts │ │ │ ├── client-hints.tsx │ │ │ ├── reset-password.server.ts │ │ │ └── change-email.server.tsx │ │ ├── entry.client.tsx │ │ └── context │ │ │ └── user-context.ts │ ├── react-router.config.ts │ ├── biome.json │ ├── .env.example │ ├── vite.config.ts │ └── tsconfig.json ├── worker │ ├── src │ │ └── build.ts │ ├── tsconfig.json │ └── package.json └── api │ ├── src │ ├── routes │ │ ├── maintain-partitions.ts │ │ └── update-accounts.ts │ └── utils │ │ ├── session.server.ts │ │ └── digestText.server.ts │ ├── tsconfig.json │ └── package.json ├── packages ├── emails │ ├── src │ │ ├── utils │ │ │ └── misc.ts │ │ ├── components │ │ │ ├── Lede.tsx │ │ │ ├── Heading.tsx │ │ │ ├── RSSRepost.tsx │ │ │ ├── OTPBlock.tsx │ │ │ ├── PlusTrial.tsx │ │ │ ├── Layout.tsx │ │ │ └── RSSNotificationItem.tsx │ │ └── emails │ │ │ ├── EmailChangeEmail.tsx │ │ │ ├── PasswordResetEmail.tsx │ │ │ ├── EmailChangeNoticeEmail.tsx │ │ │ ├── VerifyEmail.tsx │ │ │ └── BlueskyAuthErrorEmail.tsx │ ├── tsconfig.json │ └── package.json ├── schema │ ├── src │ │ └── migrations │ │ │ ├── 0015_free_plazm.sql │ │ │ ├── 0043_big_tomas.sql │ │ │ ├── 0016_breezy_mongoose.sql │ │ │ ├── 0025_perfect_stepford_cuckoos.sql │ │ │ ├── 0035_whole_roulette.sql │ │ │ ├── 0037_cold_shaman.sql │ │ │ ├── 0044_young_susan_delgado.sql │ │ │ ├── 0001_mysterious_shadowcat.sql │ │ │ ├── 0045_great_natasha_romanoff.sql │ │ │ ├── 0042_shocking_swordsman.sql │ │ │ ├── 0005_chubby_nick_fury.sql │ │ │ ├── 0047_chubby_maximus.sql │ │ │ ├── 0021_flaky_leopardon.sql │ │ │ ├── 0038_broad_wong.sql │ │ │ ├── 0026_quick_mesmero.sql │ │ │ ├── 0012_daily_spirit.sql │ │ │ ├── 0008_last_romulus.sql │ │ │ ├── 0046_next_kronos.sql │ │ │ ├── 0004_aspiring_luke_cage.sql │ │ │ ├── 0024_far_martin_li.sql │ │ │ ├── 0007_slippery_thunderbolt_ross.sql │ │ │ ├── 0014_jazzy_sue_storm.sql │ │ │ ├── 0031_living_synch.sql │ │ │ ├── 0023_daily_randall.sql │ │ │ ├── 0028_loose_stick.sql │ │ │ ├── 0018_low_wolfsbane.sql │ │ │ ├── 0013_last_black_knight.sql │ │ │ ├── 0019_careless_loners.sql │ │ │ ├── 0006_fantastic_domino.sql │ │ │ ├── 0003_giant_morph.sql │ │ │ ├── 0027_rich_lester.sql │ │ │ ├── 0022_nostalgic_blockbuster.sql │ │ │ ├── 0034_productive_korath.sql │ │ │ ├── 0002_goofy_shinko_yamashiro.sql │ │ │ ├── 0048_faulty_toro.sql │ │ │ ├── 0033_colossal_human_fly.sql │ │ │ ├── 0029_cultured_la_nuit.sql │ │ │ ├── 0029_redundant_doctor_faustus.sql │ │ │ ├── 0011_material_rage.sql │ │ │ ├── 0030_foamy_reavers.sql │ │ │ ├── 0010_shocking_stone_men.sql │ │ │ ├── 0041_parallel_nebula.sql │ │ │ └── 0036_married_sharon_carter.sql │ ├── tsconfig.json │ └── package.json ├── auth │ ├── src │ │ ├── totp.server.ts │ │ └── index.ts │ ├── tsconfig.json │ └── package.json └── links │ ├── tsconfig.json │ ├── src │ ├── normalizers │ │ └── giftLinkFormat.ts │ ├── index.ts │ └── cloudflare.ts │ └── package.json ├── pnpm-workspace.yaml ├── .dockerignore ├── .gitignore ├── scripts └── generate-secrets.sh ├── turbo.json ├── drizzle.config.ts ├── biome.json ├── package.json ├── .env.example ├── Dockerfile ├── docker-compose.yml └── .github └── workflows └── db-backup.yml /apps/web/public/sw.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/emails/src/utils/misc.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/web/app/components/linkPosts/Toolbar.module.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | - 'apps/*' -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | *.log 3 | .DS_Store 4 | .env 5 | /.cache 6 | /build -------------------------------------------------------------------------------- /apps/web/public/share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/share.png -------------------------------------------------------------------------------- /packages/schema/src/migrations/0015_free_plazm.sql: -------------------------------------------------------------------------------- 1 | DROP MATERIALIZED VIEW "public"."recent_link_posts"; -------------------------------------------------------------------------------- /packages/schema/src/migrations/0043_big_tomas.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "bookmark" ADD COLUMN "atprotoRkey" text; -------------------------------------------------------------------------------- /apps/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/favicon.ico -------------------------------------------------------------------------------- /apps/web/public/timeline.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/timeline.mp4 -------------------------------------------------------------------------------- /packages/schema/src/migrations/0016_breezy_mongoose.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "list" DROP CONSTRAINT "list_uri_unique"; -------------------------------------------------------------------------------- /apps/web/public/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/apple-icon.png -------------------------------------------------------------------------------- /apps/web/public/share-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/share-card.png -------------------------------------------------------------------------------- /packages/schema/src/migrations/0025_perfect_stepford_cuckoos.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "link" ADD COLUMN "giftUrl" text; -------------------------------------------------------------------------------- /packages/schema/src/migrations/0035_whole_roulette.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "bookmark" ADD COLUMN "posts" json NOT NULL; -------------------------------------------------------------------------------- /apps/web/app/components/forms/FilterButtonGroup.module.css: -------------------------------------------------------------------------------- 1 | .filter-heading { 2 | text-transform: uppercase; 3 | } 4 | -------------------------------------------------------------------------------- /apps/web/public/email-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/email-banner.png -------------------------------------------------------------------------------- /apps/web/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/favicon-16x16.png -------------------------------------------------------------------------------- /apps/web/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/favicon-32x32.png -------------------------------------------------------------------------------- /apps/web/public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/favicon-96x96.png -------------------------------------------------------------------------------- /apps/web/public/marketing/ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/marketing/ui.png -------------------------------------------------------------------------------- /apps/web/public/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/ms-icon-70x70.png -------------------------------------------------------------------------------- /packages/schema/src/migrations/0037_cold_shaman.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "polar_product" ADD COLUMN "polarId" text NOT NULL; -------------------------------------------------------------------------------- /packages/schema/src/migrations/0044_young_susan_delgado.sql: -------------------------------------------------------------------------------- 1 | ALTER TYPE "public"."post_type" ADD VALUE 'atbookmark'; -------------------------------------------------------------------------------- /apps/web/public/marketing/mute.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/marketing/mute.png -------------------------------------------------------------------------------- /apps/web/public/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/ms-icon-144x144.png -------------------------------------------------------------------------------- /apps/web/public/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/ms-icon-150x150.png -------------------------------------------------------------------------------- /apps/web/public/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/ms-icon-310x310.png -------------------------------------------------------------------------------- /apps/web/public/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/android-icon-36x36.png -------------------------------------------------------------------------------- /apps/web/public/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/android-icon-48x48.png -------------------------------------------------------------------------------- /apps/web/public/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/android-icon-72x72.png -------------------------------------------------------------------------------- /apps/web/public/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/android-icon-96x96.png -------------------------------------------------------------------------------- /apps/web/public/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/apple-icon-114x114.png -------------------------------------------------------------------------------- /apps/web/public/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/apple-icon-120x120.png -------------------------------------------------------------------------------- /apps/web/public/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/apple-icon-144x144.png -------------------------------------------------------------------------------- /apps/web/public/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/apple-icon-152x152.png -------------------------------------------------------------------------------- /apps/web/public/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/apple-icon-180x180.png -------------------------------------------------------------------------------- /apps/web/public/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/apple-icon-57x57.png -------------------------------------------------------------------------------- /apps/web/public/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/apple-icon-60x60.png -------------------------------------------------------------------------------- /apps/web/public/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/apple-icon-72x72.png -------------------------------------------------------------------------------- /apps/web/public/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/apple-icon-76x76.png -------------------------------------------------------------------------------- /apps/web/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/apple-touch-icon.png -------------------------------------------------------------------------------- /apps/web/public/marketing/filters.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/marketing/filters.png -------------------------------------------------------------------------------- /apps/web/public/marketing/ui-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/marketing/ui-dark.png -------------------------------------------------------------------------------- /packages/schema/src/migrations/0001_mysterious_shadowcat.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "link_post" ADD COLUMN "date" timestamp(3) NOT NULL; -------------------------------------------------------------------------------- /apps/web/public/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/android-icon-144x144.png -------------------------------------------------------------------------------- /apps/web/public/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/android-icon-192x192.png -------------------------------------------------------------------------------- /packages/schema/src/migrations/0045_great_natasha_romanoff.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "bluesky_account" ADD COLUMN "mostRecentBookmarkTid" text; -------------------------------------------------------------------------------- /apps/web/app/styles/fonts/Nacelle-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/app/styles/fonts/Nacelle-Bold.ttf -------------------------------------------------------------------------------- /apps/web/app/styles/fonts/Nacelle-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/app/styles/fonts/Nacelle-Bold.woff -------------------------------------------------------------------------------- /apps/web/public/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/apple-icon-precomposed.png -------------------------------------------------------------------------------- /apps/web/public/apple-touch-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/apple-touch-icon-57x57.png -------------------------------------------------------------------------------- /apps/web/public/apple-touch-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/apple-touch-icon-72x72.png -------------------------------------------------------------------------------- /apps/web/public/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /apps/web/public/marketing/post-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/marketing/post-example.png -------------------------------------------------------------------------------- /packages/schema/src/migrations/0042_shocking_swordsman.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "bookmark" ADD COLUMN "published" boolean DEFAULT false NOT NULL; -------------------------------------------------------------------------------- /apps/web/app/styles/fonts/Nacelle-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/app/styles/fonts/Nacelle-Bold.woff2 -------------------------------------------------------------------------------- /apps/web/app/styles/fonts/Nacelle-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/app/styles/fonts/Nacelle-Italic.ttf -------------------------------------------------------------------------------- /apps/web/app/styles/fonts/Nacelle-Italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/app/styles/fonts/Nacelle-Italic.woff -------------------------------------------------------------------------------- /apps/web/app/styles/fonts/Nacelle-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/app/styles/fonts/Nacelle-Regular.ttf -------------------------------------------------------------------------------- /apps/web/public/apple-touch-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/apple-touch-icon-114x114.png -------------------------------------------------------------------------------- /apps/web/public/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /apps/web/public/apple-touch-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/apple-touch-icon-144x144.png -------------------------------------------------------------------------------- /apps/web/public/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /apps/web/public/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /apps/web/app/components/linkPosts/LinkRep.module.css: -------------------------------------------------------------------------------- 1 | .inset { 2 | border-radius: 0; 3 | } 4 | 5 | .link-image { 6 | object-fit: cover; 7 | } 8 | -------------------------------------------------------------------------------- /apps/web/app/styles/fonts/Nacelle-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/app/styles/fonts/Nacelle-BoldItalic.ttf -------------------------------------------------------------------------------- /apps/web/app/styles/fonts/Nacelle-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/app/styles/fonts/Nacelle-Italic.woff2 -------------------------------------------------------------------------------- /apps/web/app/styles/fonts/Nacelle-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/app/styles/fonts/Nacelle-Regular.woff -------------------------------------------------------------------------------- /apps/web/app/styles/fonts/Nacelle-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/app/styles/fonts/Nacelle-Regular.woff2 -------------------------------------------------------------------------------- /apps/web/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /apps/web/public/marketing/testimonials/nick.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/marketing/testimonials/nick.jpg -------------------------------------------------------------------------------- /apps/web/app/components/marketing/Features.module.css: -------------------------------------------------------------------------------- 1 | /* app/components/marketing/Features.module.css */ 2 | .features { 3 | padding: 80px 0; 4 | } 5 | -------------------------------------------------------------------------------- /apps/web/app/styles/fonts/Nacelle-BlackItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/app/styles/fonts/Nacelle-BlackItalic.ttf -------------------------------------------------------------------------------- /apps/web/app/styles/fonts/Nacelle-BlackItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/app/styles/fonts/Nacelle-BlackItalic.woff -------------------------------------------------------------------------------- /apps/web/app/styles/fonts/Nacelle-BoldItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/app/styles/fonts/Nacelle-BoldItalic.woff -------------------------------------------------------------------------------- /apps/web/app/styles/fonts/Nacelle-BoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/app/styles/fonts/Nacelle-BoldItalic.woff2 -------------------------------------------------------------------------------- /apps/web/public/marketing/testimonials/charlie.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/public/marketing/testimonials/charlie.jpg -------------------------------------------------------------------------------- /apps/web/app/styles/fonts/Nacelle-BlackItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerFisher/sill/HEAD/apps/web/app/styles/fonts/Nacelle-BlackItalic.woff2 -------------------------------------------------------------------------------- /packages/schema/src/migrations/0005_chubby_nick_fury.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "mastodon_instance" ADD CONSTRAINT "mastodon_instance_instance_unique" UNIQUE("instance"); -------------------------------------------------------------------------------- /packages/schema/src/migrations/0047_chubby_maximus.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "bluesky_account" ADD COLUMN "authErrorNotificationSent" boolean DEFAULT false NOT NULL; -------------------------------------------------------------------------------- /apps/web/app/routes/email/index.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "react-router"; 2 | 3 | export const loader = async () => { 4 | return redirect("/digest"); 5 | }; 6 | -------------------------------------------------------------------------------- /packages/schema/src/migrations/0021_flaky_leopardon.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "digest_rss_feed" ADD CONSTRAINT "digest_rss_feed_digestSettings_unique" UNIQUE("digestSettings"); -------------------------------------------------------------------------------- /packages/schema/src/migrations/0038_broad_wong.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE "plan";--> statement-breakpoint 2 | ALTER TABLE "polar_product" ADD COLUMN "checkoutLinkUrl" text NOT NULL; -------------------------------------------------------------------------------- /packages/schema/src/migrations/0026_quick_mesmero.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX IF NOT EXISTS "link_post_denormalized_postDate_idx" ON "link_post_denormalized" USING btree ("postDate"); -------------------------------------------------------------------------------- /apps/web/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /links/ 3 | Disallow: /connect/ 4 | Disallow: /settings/ 5 | Disallow: /download/ 6 | Disallow: /moderation/ 7 | Crawl-delay: 10 -------------------------------------------------------------------------------- /apps/web/react-router.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@react-router/dev/config"; 2 | 3 | export default { 4 | future: { 5 | unstable_middleware: true, 6 | }, 7 | } satisfies Config; -------------------------------------------------------------------------------- /apps/web/app/components/linkPosts/ToolDropdown.module.css: -------------------------------------------------------------------------------- 1 | .submitButtonDropdown { 2 | color: var(--gray-12); 3 | } 4 | 5 | .submitButtonDropdown:hover { 6 | color: var(--accent-contrast); 7 | } 8 | -------------------------------------------------------------------------------- /packages/schema/src/migrations/0012_daily_spirit.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "list" ADD COLUMN "mostRecentPostDate" timestamp (3);--> statement-breakpoint 2 | ALTER TABLE "list" ADD COLUMN "mostRecentPostId" text; -------------------------------------------------------------------------------- /packages/schema/src/migrations/0008_last_romulus.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "bluesky_account" DROP COLUMN IF EXISTS "refreshJwt";--> statement-breakpoint 2 | ALTER TABLE "bluesky_account" DROP COLUMN IF EXISTS "accessJwt"; -------------------------------------------------------------------------------- /apps/web/app/components/linkPosts/SharedByBug.module.css: -------------------------------------------------------------------------------- 1 | .bug { 2 | top: 56px; 3 | scroll-margin-top: 56px; 4 | } 5 | @media (min-width: 1024px) { 6 | .bug { 7 | top: 0px; 8 | scroll-margin-top: 0px; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/schema/src/migrations/0046_next_kronos.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "bluesky_account" ADD COLUMN "mostRecentBookmarkDate" timestamp(3);--> statement-breakpoint 2 | ALTER TABLE "bluesky_account" DROP COLUMN IF EXISTS "mostRecentBookmarkTid"; -------------------------------------------------------------------------------- /packages/schema/src/migrations/0004_aspiring_luke_cage.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "mastodon_instance" RENAME COLUMN "client_token" TO "clientId";--> statement-breakpoint 2 | ALTER TABLE "mastodon_instance" RENAME COLUMN "client_secret" TO "clientSecret"; -------------------------------------------------------------------------------- /packages/schema/src/migrations/0024_far_martin_li.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE "public"."digest_layout" AS ENUM('default', 'dense');--> statement-breakpoint 2 | ALTER TABLE "digest_settings" ADD COLUMN "layout" "digest_layout" DEFAULT 'default' NOT NULL; -------------------------------------------------------------------------------- /apps/web/app/components/marketing/MainHero.module.css: -------------------------------------------------------------------------------- 1 | .heroWrapper { 2 | background-color: var(--accent-2); 3 | } 4 | 5 | .heroContainer { 6 | max-width: 1280px; 7 | margin: 0 auto; 8 | } 9 | 10 | .heroContent { 11 | flex: 1; 12 | } 13 | -------------------------------------------------------------------------------- /packages/auth/src/totp.server.ts: -------------------------------------------------------------------------------- 1 | // @epic-web/totp should be used server-side only. It imports `Crypto` which results in Remix 2 | // including a big polyfill. So we put the import in a `.server.ts` file to avoid that 3 | export * from "@epic-web/totp"; 4 | -------------------------------------------------------------------------------- /apps/web/app/utils/nonce-provider.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export const NonceContext = React.createContext(""); 4 | export const NonceProvider = NonceContext.Provider; 5 | export const useNonce = () => React.useContext(NonceContext); 6 | -------------------------------------------------------------------------------- /packages/schema/src/migrations/0007_slippery_thunderbolt_ross.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "user" DROP CONSTRAINT "user_username_unique";--> statement-breakpoint 2 | DROP INDEX IF EXISTS "user_username_key";--> statement-breakpoint 3 | ALTER TABLE "user" DROP COLUMN IF EXISTS "username"; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | .env 6 | postgres-data 7 | redis-data 8 | .DS_Store 9 | .vercel 10 | public/sw.js 11 | .env.production 12 | dozzle 13 | .react-router 14 | CLAUDE.md 15 | .turbo 16 | .tsc-build 17 | build 18 | dist 19 | tsconfig.tsbuildinfo -------------------------------------------------------------------------------- /apps/web/app/components/marketing/MarketingFooter.module.css: -------------------------------------------------------------------------------- 1 | /* app/components/marketing/MarketingFooter.module.css */ 2 | .footer { 3 | background-color: var(--accent-2); 4 | border-top: 1px solid var(--accent-6); 5 | padding: var(--space-6) 0; 6 | margin-top: var(--space-9); 7 | } 8 | -------------------------------------------------------------------------------- /packages/schema/src/migrations/0014_jazzy_sue_storm.sql: -------------------------------------------------------------------------------- 1 | CREATE MATERIALIZED VIEW "public"."recent_link_posts" AS (select "id", "linkUrl", "postId", "date" from "link_post" where "link_post"."date" >= now() - interval '1 day'); 2 | CREATE INDEX idx_recent_link_posts_date ON recent_link_posts(date); -------------------------------------------------------------------------------- /apps/web/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /apps/web/app/components/download/ErrorState.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Text } from "@radix-ui/themes"; 2 | 3 | export default function ErrorState() { 4 | return ( 5 | 6 | 7 | Failed to download your timeline. Please refresh the page to try again. 8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /apps/web/app/components/nav/Footer.module.css: -------------------------------------------------------------------------------- 1 | .footer { 2 | position: absolute; 3 | bottom: calc(80px + env(safe-area-inset-bottom)); 4 | } 5 | 6 | @media (min-width: 768px) { 7 | .footer { 8 | justify-content: flex-end; 9 | } 10 | } 11 | 12 | @media (min-width: 1025px) { 13 | .footer { 14 | bottom: 0; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/schema/src/migrations/0031_living_synch.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE "link_post_to_user";--> statement-breakpoint 2 | DROP TABLE "link_post";--> statement-breakpoint 3 | DROP TABLE "post_list_subscription"; 4 | DROP TABLE "post_image";--> statement-breakpoint 5 | DROP TABLE "post";--> statement-breakpoint 6 | DROP TABLE "actor";--> statement-breakpoint -------------------------------------------------------------------------------- /apps/web/app/components/nav/TrialBanner.module.css: -------------------------------------------------------------------------------- 1 | .banner { 2 | background-color: var(--accent-a3); 3 | color: var(--accent-a11); 4 | top: 57px; 5 | position: relative; 6 | } 7 | 8 | .text { 9 | font-size: 14px; 10 | text-align: center; 11 | } 12 | 13 | @media (min-width: 1024px) { 14 | .banner { 15 | top: 0; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/web/app/routes/settings/index.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "react-router"; 2 | import type { Route } from "./+types/index"; 3 | import { requireUserFromContext } from "~/utils/context.server"; 4 | 5 | export async function loader({ context }: Route.LoaderArgs) { 6 | await requireUserFromContext(context); 7 | return redirect("/settings/account"); 8 | } 9 | -------------------------------------------------------------------------------- /packages/schema/src/migrations/0023_daily_randall.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "digest_item" ADD COLUMN "userId" uuid NOT NULL;--> statement-breakpoint 2 | DO $$ BEGIN 3 | ALTER TABLE "digest_item" ADD CONSTRAINT "digest_item_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; 4 | EXCEPTION 5 | WHEN duplicate_object THEN null; 6 | END $$; 7 | -------------------------------------------------------------------------------- /scripts/generate-secrets.sh: -------------------------------------------------------------------------------- 1 | SESSION_SECRET=$(head -c20 /dev/urandom | base64) 2 | HONEYPOT_SECRET=$(head -c20 /dev/urandom | base64) 3 | PRIVATE_KEY_ES256_B64=$(openssl ecparam -name prime256v1 -genkey | openssl pkcs8 -topk8 -nocrypt | openssl base64 -A) 4 | 5 | echo "SESSION_SECRET=\"$SESSION_SECRET\"" >> .env 6 | echo "HONEYPOT_SECRET=\"$HONEYPOT_SECRET\"" >> .env 7 | echo "PRIVATE_KEY_ES256_B64=\"$PRIVATE_KEY_ES256_B64\"" >> .env 8 | -------------------------------------------------------------------------------- /apps/web/app/components/linkPosts/NumberRanking.module.css: -------------------------------------------------------------------------------- 1 | .ranking-wrapper { 2 | position: absolute; 3 | background-color: var(--accent-11); 4 | border-radius: 100%; 5 | z-index: 1; 6 | color: var(--accent-1); 7 | } 8 | 9 | .ranking-number { 10 | font-weight: 900; 11 | font-style: italic; 12 | position: relative; 13 | } 14 | 15 | @-moz-document url-prefix("") { 16 | .ranking-number { 17 | top: 1.5px; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/web/app/utils/verify.server.ts: -------------------------------------------------------------------------------- 1 | import type { Submission } from "@conform-to/react"; 2 | import { z } from "zod"; 3 | import { VerifySchema } from "~/routes/accounts/verify"; 4 | 5 | export type VerifyFunctionArgs = { 6 | request: Request; 7 | submission: Submission< 8 | z.input, 9 | string[], 10 | z.output 11 | >; 12 | body: FormData | URLSearchParams; 13 | }; 14 | -------------------------------------------------------------------------------- /packages/schema/src/migrations/0028_loose_stick.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "account_update_queue" DROP CONSTRAINT "account_update_queue_userId_user_id_fk"; 2 | --> statement-breakpoint 3 | DO $$ BEGIN 4 | ALTER TABLE "account_update_queue" ADD CONSTRAINT "account_update_queue_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; 5 | EXCEPTION 6 | WHEN duplicate_object THEN null; 7 | END $$; 8 | -------------------------------------------------------------------------------- /packages/emails/src/components/Lede.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Text } from "@react-email/components"; 3 | import type { PropsWithChildren } from "react"; 4 | 5 | const Lede = ({ children }: PropsWithChildren) => { 6 | return {children}; 7 | }; 8 | 9 | const ledeStyles = { 10 | fontSize: "16px", 11 | lineHeight: "24px", 12 | marginBottom: "24px", 13 | }; 14 | 15 | export default Lede; 16 | -------------------------------------------------------------------------------- /packages/schema/src/migrations/0018_low_wolfsbane.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "link_post_denormalized" DROP CONSTRAINT "link_post_denormalized_listId_list_id_fk"; 2 | --> statement-breakpoint 3 | DO $$ BEGIN 4 | ALTER TABLE "link_post_denormalized" ADD CONSTRAINT "link_post_denormalized_listId_list_id_fk" FOREIGN KEY ("listId") REFERENCES "public"."list"("id") ON DELETE cascade ON UPDATE no action; 5 | EXCEPTION 6 | WHEN duplicate_object THEN null; 7 | END $$; 8 | -------------------------------------------------------------------------------- /packages/schema/src/migrations/0013_last_black_knight.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "email_settings" ADD COLUMN "topAmount" integer DEFAULT 10 NOT NULL;--> statement-breakpoint 2 | ALTER TABLE "email_settings" ADD COLUMN "splitServices" boolean DEFAULT false NOT NULL;--> statement-breakpoint 3 | ALTER TABLE "email_settings" ADD COLUMN "hideReposts" boolean DEFAULT false NOT NULL;--> statement-breakpoint 4 | ALTER TABLE "list" ADD CONSTRAINT "list_uri_unique" UNIQUE("uri"); -------------------------------------------------------------------------------- /apps/web/app/routes/jwks.ts: -------------------------------------------------------------------------------- 1 | import { apiGetJwks } from "~/utils/api-client.server"; 2 | import type { Route } from "./+types/jwks"; 3 | 4 | export const headers: Route.HeadersFunction = () => ({ 5 | "Content-Type": "application/json", 6 | "Cache-Control": "public, max-age=3600", 7 | }); 8 | 9 | export const loader = async ({ request }: Route.LoaderArgs) => { 10 | const jwks = await apiGetJwks(request); 11 | return Response.json(jwks); 12 | }; 13 | -------------------------------------------------------------------------------- /apps/web/app/components/marketing/Hero.module.css: -------------------------------------------------------------------------------- 1 | .hero-wrapper { 2 | height: 100vh; 3 | margin: 0 auto; 4 | padding: 1rem 1rem 0; 5 | } 6 | 7 | .language { 8 | width: 100%; 9 | } 10 | 11 | .lede { 12 | color: var(--accent-11); 13 | } 14 | 15 | .intro-video-wrapper { 16 | max-height: 90vh; 17 | } 18 | 19 | @media (min-width: 768px) { 20 | .intro-video-wrapper { 21 | display: block; 22 | } 23 | 24 | .right-box { 25 | display: block; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/web/app/components/nav/PageHeading.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Heading, Text } from "@radix-ui/themes"; 2 | 3 | interface PageHeadingProps { 4 | title: string; 5 | dek?: string; 6 | } 7 | 8 | const PageHeading = ({ title, dek }: PageHeadingProps) => { 9 | return ( 10 | 11 | 12 | {title} 13 | 14 | {dek && {dek}} 15 | 16 | ); 17 | }; 18 | 19 | export default PageHeading; 20 | -------------------------------------------------------------------------------- /apps/web/app/components/forms/ErrorCallout.tsx: -------------------------------------------------------------------------------- 1 | import { Callout } from "@radix-ui/themes"; 2 | import { CircleAlert } from "lucide-react"; 3 | 4 | const ErrorCallout = ({ error }: { error: string }) => { 5 | return ( 6 | 7 | 8 | 9 | 10 | Error: {error} 11 | 12 | ); 13 | }; 14 | 15 | export default ErrorCallout; 16 | -------------------------------------------------------------------------------- /apps/web/app/components/linkPosts/link/LinkDescription.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "@radix-ui/themes"; 2 | 3 | const LinkDescription = ({ 4 | description, 5 | layout, 6 | }: { description: string; layout: "default" | "dense" }) => { 7 | return ( 8 | 14 | {description} 15 | 16 | ); 17 | }; 18 | 19 | export default LinkDescription; 20 | -------------------------------------------------------------------------------- /apps/web/app/components/nav/logo.module.css: -------------------------------------------------------------------------------- 1 | .big-logo-heading, 2 | .logo-heading { 3 | font-weight: 900; 4 | font-style: italic; 5 | color: var(--accent-11); 6 | text-transform: lowercase; 7 | padding-top: 0.5rem; 8 | } 9 | 10 | .big-logo-heading { 11 | padding-top: 0; 12 | font-size: 60px; 13 | } 14 | 15 | @media (min-width: 768px) { 16 | .big-logo-heading { 17 | font-size: 90px; 18 | } 19 | } 20 | 21 | .logo-link { 22 | color: inherit; 23 | text-decoration: none; 24 | } 25 | -------------------------------------------------------------------------------- /apps/web/app/utils/request-info.ts: -------------------------------------------------------------------------------- 1 | import { invariant } from "@epic-web/invariant"; 2 | import { useRouteLoaderData } from "react-router"; 3 | import type { loader as rootLoader } from "~/root"; 4 | 5 | /** 6 | * @returns the request info from the root loader 7 | */ 8 | export function useRequestInfo() { 9 | const data = useRouteLoaderData("root"); 10 | invariant(data?.requestInfo, "No requestInfo found in root loader"); 11 | 12 | return data.requestInfo; 13 | } 14 | -------------------------------------------------------------------------------- /apps/web/app/components/linkPosts/PostContent.module.css: -------------------------------------------------------------------------------- 1 | .post-content > p { 2 | overflow-wrap: anywhere; 3 | white-space: pre-line; 4 | } 5 | 6 | .post-content a { 7 | text-decoration: none; 8 | color: var(--accent-11); 9 | } 10 | 11 | .post-content a:hover { 12 | text-decoration: underline; 13 | text-decoration-color: var(--accent-a5); 14 | text-decoration-style: solid; 15 | text-decoration-thickness: min(2px, max(1px, 0.05em)); 16 | text-underline-offset: calc(0.025em + 2px); 17 | } 18 | -------------------------------------------------------------------------------- /apps/worker/src/build.ts: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import pkg from "../package.json"; 3 | 4 | esbuild 5 | .build({ 6 | entryPoints: ["./src/process-queue.tsx"], 7 | platform: "node", 8 | outfile: "./build/worker.js", 9 | format: "esm", 10 | bundle: true, 11 | external: [ 12 | ...Object.keys(pkg.dependencies || {}), 13 | ...Object.keys(pkg.devDependencies || {}), 14 | ], 15 | }) 16 | .catch((error) => { 17 | console.error(error); 18 | process.exit(1); 19 | }); 20 | -------------------------------------------------------------------------------- /apps/web/app/components/download/LoadingState.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Spinner, Text } from "@radix-ui/themes"; 2 | 3 | interface LoadingStateProps { 4 | service?: string | null; 5 | } 6 | 7 | export default function LoadingState({ service }: LoadingStateProps) { 8 | return ( 9 | 10 | 11 | Downloading the last 24 hours from your {service || "social media"}{" "} 12 | timeline. This may take a minute. 13 | 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /apps/web/app/components/subscription/FeatureCard.module.css: -------------------------------------------------------------------------------- 1 | .featureCard { 2 | transition: all 0.2s ease; 3 | } 4 | 5 | .featureCard:hover { 6 | border-color: var(--yellow-7); 7 | background-color: var(--yellow-2); 8 | transform: translateY(-1px); 9 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); 10 | } 11 | 12 | .featureCardLink { 13 | text-decoration: none; 14 | color: inherit; 15 | display: block; 16 | } 17 | 18 | .featureCardLink:hover { 19 | text-decoration: none; 20 | color: inherit; 21 | } 22 | -------------------------------------------------------------------------------- /apps/web/app/routes/client-metadata.ts: -------------------------------------------------------------------------------- 1 | import { apiGetClientMetadata } from "~/utils/api-client.server"; 2 | import type { Route } from "./+types/client-metadata"; 3 | 4 | export const headers: Route.HeadersFunction = () => ({ 5 | "Content-Type": "application/json", 6 | "Cache-Control": "public, max-age=3600", 7 | }); 8 | 9 | export const loader = async ({ request }: Route.LoaderArgs) => { 10 | const clientMetadata = await apiGetClientMetadata(request); 11 | return Response.json(clientMetadata); 12 | }; 13 | -------------------------------------------------------------------------------- /apps/web/app/utils/verification.server.ts: -------------------------------------------------------------------------------- 1 | import { createCookieSessionStorage } from "react-router"; 2 | 3 | export const verifySessionStorage = createCookieSessionStorage({ 4 | cookie: { 5 | name: "en_verification", 6 | sameSite: "lax", // CSRF protection is advised if changing to 'none' 7 | path: "/", 8 | httpOnly: true, 9 | maxAge: 60 * 10, // 10 minutes 10 | secrets: (process.env.SESSION_SECRET as string).split(","), 11 | secure: process.env.NODE_ENV === "production", 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /apps/web/app/components/linkPosts/link/XEmbed.tsx: -------------------------------------------------------------------------------- 1 | import * as ReactTweet from "react-tweet"; 2 | import { ClientOnly } from "remix-utils/client-only"; 3 | 4 | const { Tweet } = ReactTweet; 5 | 6 | interface XEmbedProps { 7 | url: URL; 8 | } 9 | 10 | const XEmbed = ({ url }: XEmbedProps) => { 11 | const adjusted = url.href.split("/photo/")[0]; 12 | return ( 13 | 14 | {() => } 15 | 16 | ); 17 | }; 18 | 19 | export default XEmbed; 20 | -------------------------------------------------------------------------------- /packages/emails/src/components/Heading.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Heading } from "@react-email/components"; 3 | import type { PropsWithChildren } from "react"; 4 | 5 | const EmailHeading = ({ children }: PropsWithChildren) => { 6 | return {children}; 7 | }; 8 | 9 | const headingStyles = { 10 | color: "#1d1c1d", 11 | fontSize: "30px", 12 | fontWeight: "700", 13 | margin: "30px 0", 14 | padding: "0", 15 | lineHeight: "36px", 16 | }; 17 | 18 | export default EmailHeading; 19 | -------------------------------------------------------------------------------- /apps/web/app/routes/settings/portal.tsx: -------------------------------------------------------------------------------- 1 | import { CustomerPortal } from "@polar-sh/remix"; 2 | import { apiGetUserProfile } from "~/utils/api-client.server"; 3 | 4 | export const loader = CustomerPortal({ 5 | getCustomerId: async (event) => { 6 | // context isn't available, need to make separate api call 7 | const dbUser = await apiGetUserProfile(event); 8 | if (!dbUser) throw new Error("Could not find user"); 9 | // We already checked that it isn't null 10 | return dbUser.customerId as string; 11 | }, 12 | server: "sandbox", 13 | }); 14 | -------------------------------------------------------------------------------- /packages/schema/src/migrations/0019_careless_loners.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "link_post_denormalized" DROP CONSTRAINT "link_post_denormalized_userId_user_id_fk"; 2 | --> statement-breakpoint 3 | DO $$ BEGIN 4 | ALTER TABLE "link_post_denormalized" ADD CONSTRAINT "link_post_denormalized_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; 5 | EXCEPTION 6 | WHEN duplicate_object THEN null; 7 | END $$; 8 | --> statement-breakpoint 9 | CREATE UNIQUE INDEX IF NOT EXISTS "link_url_index" ON "link" USING btree ("url"); -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "tasks": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "outputs": ["build/**", "dist/**"] 7 | }, 8 | "dev": { 9 | "cache": false, 10 | "persistent": true 11 | }, 12 | "dev:local": { 13 | "cache": false, 14 | "persistent": true 15 | }, 16 | "lint": { 17 | "dependsOn": ["^build"] 18 | }, 19 | "typecheck": { 20 | "dependsOn": ["^build"] 21 | }, 22 | "test": { 23 | "dependsOn": ["^build"], 24 | "outputs": ["coverage/**"] 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /packages/schema/src/migrations/0006_fantastic_domino.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "email_settings" ( 2 | "id" uuid PRIMARY KEY NOT NULL, 3 | "userId" uuid NOT NULL, 4 | "scheduledTime" time NOT NULL, 5 | CONSTRAINT "email_settings_userId_unique" UNIQUE("userId") 6 | ); 7 | --> statement-breakpoint 8 | DO $$ BEGIN 9 | ALTER TABLE "email_settings" ADD CONSTRAINT "email_settings_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action; 10 | EXCEPTION 11 | WHEN duplicate_object THEN null; 12 | END $$; 13 | -------------------------------------------------------------------------------- /apps/worker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "allowSyntheticDefaultImports": true, 7 | "esModuleInterop": true, 8 | "allowJs": true, 9 | "strict": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "baseUrl": ".", 13 | "jsx": "react-jsx", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "preserveWatchOutput": true 18 | }, 19 | "include": [ 20 | "src/**/*" 21 | ] 22 | } -------------------------------------------------------------------------------- /apps/web/app/components/forms/FilterCustomIndicator.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from "@radix-ui/themes"; 2 | 3 | interface FilterCustomIndicatorProps { 4 | isVisible: boolean; 5 | } 6 | 7 | const FilterCustomIndicator = ({ isVisible }: FilterCustomIndicatorProps) => { 8 | if (!isVisible) return null; 9 | 10 | return ( 11 | 20 | ); 21 | }; 22 | 23 | export default FilterCustomIndicator; 24 | -------------------------------------------------------------------------------- /packages/schema/src/migrations/0003_giant_morph.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "mastodon_account" RENAME COLUMN "instance_id" TO "instanceId";--> statement-breakpoint 2 | ALTER TABLE "mastodon_account" DROP CONSTRAINT "mastodon_account_instance_id_mastodon_instance_id_fk"; 3 | --> statement-breakpoint 4 | DO $$ BEGIN 5 | ALTER TABLE "mastodon_account" ADD CONSTRAINT "mastodon_account_instanceId_mastodon_instance_id_fk" FOREIGN KEY ("instanceId") REFERENCES "public"."mastodon_instance"("id") ON DELETE no action ON UPDATE no action; 6 | EXCEPTION 7 | WHEN duplicate_object THEN null; 8 | END $$; 9 | -------------------------------------------------------------------------------- /apps/web/app/components/download/WelcomeTrialBanner.tsx: -------------------------------------------------------------------------------- 1 | import { Callout, Text } from "@radix-ui/themes"; 2 | import { Sparkles } from "lucide-react"; 3 | 4 | export default function WelcomeTrialBanner() { 5 | return ( 6 | 7 | 8 | 9 | 10 | 11 | 12 | Free{" "} 13 | sill+{" "} 14 | trial active 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /packages/auth/src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | isSubscribed, 3 | getUserIdFromSession, 4 | getSessionExpirationDate, 5 | validateSession, 6 | deleteSession, 7 | login, 8 | signup, 9 | getPasswordHash, 10 | verifyUserPassword, 11 | resetUserPassword, 12 | hasAgreed, 13 | getUserProfile, 14 | } from "./auth.js"; 15 | export { 16 | isCodeValid, 17 | checkUserExists, 18 | prepareVerification, 19 | deleteVerification, 20 | } from "./verification.js"; 21 | export { createOAuthClient } from "./client.js"; 22 | export { SessionStore, StateStore } from "./storage.js"; 23 | -------------------------------------------------------------------------------- /apps/web/app/routes/bookmarks/delete-tag.ts: -------------------------------------------------------------------------------- 1 | import { apiDeleteBookmarkTag } from "~/utils/api-client.server"; 2 | import type { Route } from "./+types/delete-tag"; 3 | 4 | export async function action({ request }: Route.ActionArgs) { 5 | const formData = await request.formData(); 6 | const url = formData.get("url") as string; 7 | const tagName = formData.get("tagName") as string; 8 | 9 | const response = await apiDeleteBookmarkTag(request, { url, tagName }); 10 | 11 | if (!response.ok) { 12 | return { error: "Failed to delete tag" }; 13 | } 14 | 15 | return { success: true }; 16 | } 17 | -------------------------------------------------------------------------------- /packages/emails/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "allowSyntheticDefaultImports": true, 7 | "esModuleInterop": true, 8 | "allowJs": false, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "skipLibCheck": true, 12 | "declaration": true, 13 | "outDir": "./dist", 14 | "rootDir": "./src", 15 | "jsx": "react-jsx", 16 | "preserveWatchOutput": true 17 | }, 18 | "include": ["src/**/*"], 19 | "exclude": ["node_modules", "dist"] 20 | } -------------------------------------------------------------------------------- /apps/web/app/routes/bluesky/auth.revoke.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "react-router"; 2 | import type { Route } from "./+types/auth.revoke"; 3 | import { requireUserFromContext } from "~/utils/context.server"; 4 | import { apiBlueskyAuthRevoke } from "~/utils/api-client.server"; 5 | 6 | export const action = async ({ request, context }: Route.ActionArgs) => { 7 | const { id: userId } = await requireUserFromContext(context); 8 | 9 | if (!userId) { 10 | throw new Error("User not authenticated."); 11 | } 12 | 13 | await apiBlueskyAuthRevoke(request); 14 | 15 | return redirect("/settings/connections"); 16 | }; 17 | -------------------------------------------------------------------------------- /apps/web/app/components/linkPosts/link/YoutubeEmbed.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from "@radix-ui/themes"; 2 | import Youtube from "react-youtube"; 3 | import { ClientOnly } from "remix-utils/client-only"; 4 | 5 | interface YoutubeEmbedProps { 6 | url: URL; 7 | } 8 | 9 | const YoutubeEmbed = ({ url }: YoutubeEmbedProps) => { 10 | const id = url.searchParams.get("v") || url.pathname.split("/").pop(); 11 | const opts = { 12 | width: "100%", 13 | }; 14 | return ( 15 | 16 | {() => } 17 | 18 | ); 19 | }; 20 | 21 | export default YoutubeEmbed; 22 | -------------------------------------------------------------------------------- /apps/web/app/routes/accounts/user.delete.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "react-router"; 2 | import type { Route } from "./+types/user.delete"; 3 | import { requireUserFromContext } from "~/utils/context.server"; 4 | import { apiDeleteUser } from "~/utils/api-client.server"; 5 | 6 | export const action = async ({ context, request }: Route.ActionArgs) => { 7 | await requireUserFromContext(context); 8 | 9 | try { 10 | await apiDeleteUser(request); 11 | return redirect("/"); 12 | } catch (error) { 13 | console.error("Delete user error:", error); 14 | throw new Response("Failed to delete user", { status: 500 }); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /apps/web/app/components/forms/ErrorList.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Text } from "@radix-ui/themes"; 2 | import type { ListOfErrors } from "./TextInput"; 3 | const ErrorList = ({ 4 | id, 5 | errors, 6 | }: { 7 | errors?: ListOfErrors; 8 | id?: string; 9 | }) => { 10 | const errorsToRender = errors?.filter(Boolean); 11 | if (!errorsToRender?.length) return null; 12 | return ( 13 |
    14 | 15 | {errorsToRender.map((e) => ( 16 |
  • 17 | {e} 18 |
  • 19 | ))} 20 |
    21 |
22 | ); 23 | }; 24 | 25 | export default ErrorList; 26 | -------------------------------------------------------------------------------- /packages/schema/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2022"], 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "esModuleInterop": true, 9 | "allowJs": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "skipLibCheck": true, 13 | "declaration": true, 14 | "outDir": "./dist", 15 | "rootDir": "./src", 16 | "preserveWatchOutput": true 17 | }, 18 | "include": [ 19 | "src/**/*" 20 | ], 21 | "exclude": [ 22 | "node_modules", 23 | "dist" 24 | ] 25 | } -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; // make sure to install dotenv package 2 | import { defineConfig } from "drizzle-kit"; 3 | 4 | const databaseUrl = process.env.DATABASE_URL; 5 | 6 | if (!databaseUrl) { 7 | throw new Error("DATABASE_URL environment variable is not set"); 8 | } 9 | 10 | export default defineConfig({ 11 | dialect: "postgresql", 12 | out: "./packages/schema/src/migrations", 13 | schema: "./packages/schema/src/schema.ts", 14 | dbCredentials: { 15 | url: `${databaseUrl}`, 16 | ssl: "allow", 17 | }, 18 | // Print all statements 19 | verbose: true, 20 | // Always ask for confirmation 21 | strict: true, 22 | }); 23 | -------------------------------------------------------------------------------- /apps/web/app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle hydrating your app on the client for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.client 5 | */ 6 | 7 | import { StrictMode, startTransition } from "react"; 8 | import { hydrateRoot } from "react-dom/client"; 9 | import { HydratedRouter } from "react-router/dom"; 10 | 11 | startTransition(() => { 12 | hydrateRoot( 13 | document, 14 | 15 | 16 | , 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /apps/web/app/utils/polar.server.ts: -------------------------------------------------------------------------------- 1 | import { Polar } from "@polar-sh/sdk"; 2 | 3 | const polar = new Polar({ 4 | accessToken: process.env.POLAR_ACCESS_TOKEN ?? "", 5 | server: "sandbox", 6 | }); 7 | 8 | export const createCheckout = async ( 9 | productId: string, 10 | email: string, 11 | userId: string 12 | ) => { 13 | const session = await polar.checkouts.create({ 14 | products: [productId], 15 | customerEmail: email, 16 | externalCustomerId: userId, 17 | embedOrigin: process.env.VITE_PUBLIC_DOMAIN, 18 | successUrl: `${process.env.VITE_PUBLIC_DOMAIN}/settings/checkout`, 19 | }); 20 | 21 | return session; 22 | }; 23 | -------------------------------------------------------------------------------- /packages/schema/src/migrations/0027_rich_lester.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "account_update_queue" ( 2 | "id" uuid PRIMARY KEY NOT NULL, 3 | "userId" uuid NOT NULL, 4 | "status" text DEFAULT 'pending' NOT NULL, 5 | "createdAt" timestamp DEFAULT now(), 6 | "processedAt" timestamp, 7 | "error" text, 8 | "retries" integer DEFAULT 0 9 | ); 10 | --> statement-breakpoint 11 | DO $$ BEGIN 12 | ALTER TABLE "account_update_queue" ADD CONSTRAINT "account_update_queue_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action; 13 | EXCEPTION 14 | WHEN duplicate_object THEN null; 15 | END $$; 16 | -------------------------------------------------------------------------------- /apps/web/app/utils/honeypot.server.ts: -------------------------------------------------------------------------------- 1 | import { Honeypot, SpamError } from "remix-utils/honeypot/server"; 2 | 3 | export const honeypot = new Honeypot({ 4 | validFromFieldName: null, 5 | encryptionSeed: process.env.HONEYPOT_SECRET, 6 | }); 7 | 8 | /** 9 | * Helper function to check for honeypot in form data 10 | * @param formData Form data to check for honeypot 11 | */ 12 | export function checkHoneypot(formData: FormData) { 13 | try { 14 | honeypot.check(formData); 15 | } catch (error) { 16 | if (error instanceof SpamError) { 17 | throw new Response("Form not submitted properly", { status: 400 }); 18 | } 19 | throw error; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/auth/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "allowSyntheticDefaultImports": true, 7 | "esModuleInterop": true, 8 | "allowJs": true, 9 | "strict": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "declaration": true, 13 | "declarationMap": true, 14 | "outDir": "./dist", 15 | "rootDir": "./src", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "preserveWatchOutput": true 19 | }, 20 | "include": [ 21 | "src/**/*" 22 | ], 23 | "exclude": [ 24 | "dist", 25 | "node_modules" 26 | ] 27 | } -------------------------------------------------------------------------------- /packages/links/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "allowSyntheticDefaultImports": true, 7 | "esModuleInterop": true, 8 | "allowJs": true, 9 | "strict": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "declaration": true, 13 | "declarationMap": true, 14 | "outDir": "./dist", 15 | "rootDir": "./src", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "preserveWatchOutput": true 19 | }, 20 | "include": [ 21 | "src/**/*" 22 | ], 23 | "exclude": [ 24 | "dist", 25 | "node_modules" 26 | ] 27 | } -------------------------------------------------------------------------------- /apps/api/src/routes/maintain-partitions.ts: -------------------------------------------------------------------------------- 1 | import { sql } from "drizzle-orm"; 2 | import { Hono } from "hono"; 3 | import { db } from "@sill/schema"; 4 | 5 | const app = new Hono(); 6 | 7 | app.get("/", async (c) => { 8 | const authHeader = c.req.header("Authorization"); 9 | if (!authHeader || !authHeader.startsWith("Bearer ")) { 10 | return c.text("Unauthorized", 401); 11 | } 12 | 13 | const token = authHeader.split(" ")[1]; 14 | if (token !== process.env.CRON_API_KEY) { 15 | return c.text("Forbidden", 403); 16 | } 17 | 18 | await db.execute(sql`SELECT maintain_partitions()`); 19 | 20 | return Response.json({}); 21 | }); 22 | 23 | export default app; 24 | -------------------------------------------------------------------------------- /packages/emails/src/emails/EmailChangeEmail.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import EmailHeading from "../components/Heading.js"; 3 | import EmailLayout from "../components/Layout.js"; 4 | import Lede from "../components/Lede.js"; 5 | import OTPBlock from "../components/OTPBlock.js"; 6 | 7 | const EmailChangeEmail = ({ 8 | otp, 9 | }: { 10 | otp: string; 11 | }) => { 12 | return ( 13 | 14 | Confirm your email change request 15 | Here's your verification code: 16 | {otp} 17 | 18 | ); 19 | }; 20 | 21 | export default EmailChangeEmail; -------------------------------------------------------------------------------- /apps/web/app/styles/reset.css: -------------------------------------------------------------------------------- 1 | /* 2 | Josh's Custom CSS Reset 3 | https://www.joshwcomeau.com/css/custom-css-reset/ 4 | */ 5 | 6 | *, 7 | *::before, 8 | *::after { 9 | box-sizing: border-box; 10 | } 11 | 12 | * { 13 | margin: 0; 14 | } 15 | 16 | body { 17 | line-height: 1.5; 18 | -webkit-font-smoothing: antialiased; 19 | } 20 | 21 | img, 22 | picture, 23 | video, 24 | canvas, 25 | svg { 26 | display: block; 27 | max-width: 100%; 28 | } 29 | 30 | input, 31 | button, 32 | textarea, 33 | select { 34 | font: inherit; 35 | } 36 | 37 | p, 38 | h1, 39 | h2, 40 | h3, 41 | h4, 42 | h5, 43 | h6 { 44 | overflow-wrap: break-word; 45 | } 46 | 47 | body > div { 48 | isolation: isolate; 49 | } 50 | -------------------------------------------------------------------------------- /apps/web/app/components/forms/SubmitButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button, type ButtonProps, Spinner } from "@radix-ui/themes"; 2 | import { useNavigation } from "react-router"; 3 | 4 | interface SubmitButtonProps extends ButtonProps { 5 | label: string; 6 | } 7 | 8 | const SubmitButton = (props: SubmitButtonProps) => { 9 | const { label } = props; 10 | const navigation = useNavigation(); 11 | const isSubmitting = 12 | navigation.state === "submitting" || navigation.state === "loading"; 13 | return ( 14 | 18 | ); 19 | }; 20 | 21 | export default SubmitButton; 22 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.0/schema.json", 3 | "vcs": { 4 | "enabled": true, 5 | "clientKind": "git", 6 | "useIgnoreFile": true 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": [] 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "tab" 15 | }, 16 | "organizeImports": { 17 | "enabled": true 18 | }, 19 | "linter": { 20 | "enabled": true, 21 | "rules": { 22 | "recommended": true, 23 | "security": { 24 | "noDangerouslySetInnerHtml": "off" 25 | }, 26 | "a11y": { 27 | "noRedundantRoles": "off" 28 | }, 29 | "style": { 30 | "noNonNullAssertion": "off" 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/schema/src/migrations/0022_nostalgic_blockbuster.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "digest_rss_feed_item" RENAME TO "digest_item";--> statement-breakpoint 2 | ALTER TABLE "digest_item" DROP CONSTRAINT "digest_rss_feed_item_feedId_digest_rss_feed_id_fk"; 3 | --> statement-breakpoint 4 | ALTER TABLE "digest_item" ALTER COLUMN "feedId" DROP NOT NULL;--> statement-breakpoint 5 | ALTER TABLE "digest_item" ADD COLUMN "json" json;--> statement-breakpoint 6 | DO $$ BEGIN 7 | ALTER TABLE "digest_item" ADD CONSTRAINT "digest_item_feedId_digest_rss_feed_id_fk" FOREIGN KEY ("feedId") REFERENCES "public"."digest_rss_feed"("id") ON DELETE cascade ON UPDATE no action; 8 | EXCEPTION 9 | WHEN duplicate_object THEN null; 10 | END $$; 11 | -------------------------------------------------------------------------------- /apps/api/src/utils/session.server.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from "hono"; 2 | 3 | /** 4 | * Sets the session cookie with standard options 5 | */ 6 | export function setSessionCookie( 7 | c: Context, 8 | sessionId: string, 9 | expirationDate: string, 10 | remember = true 11 | ) { 12 | const cookieOptions = { 13 | httpOnly: true, 14 | secure: process.env.NODE_ENV === "production", 15 | sameSite: "lax" as const, 16 | path: "/", 17 | ...(remember ? { expires: expirationDate } : {}), 18 | }; 19 | 20 | c.header( 21 | "Set-Cookie", 22 | `sessionId=${sessionId}; ${Object.entries(cookieOptions) 23 | .map(([k, v]) => `${k}=${v}`) 24 | .join("; ")}` 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /apps/web/biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.0/schema.json", 3 | "vcs": { 4 | "enabled": true, 5 | "clientKind": "git", 6 | "useIgnoreFile": true 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": [] 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "tab" 15 | }, 16 | "organizeImports": { 17 | "enabled": true 18 | }, 19 | "linter": { 20 | "enabled": true, 21 | "rules": { 22 | "recommended": true, 23 | "security": { 24 | "noDangerouslySetInnerHtml": "off" 25 | }, 26 | "a11y": { 27 | "noRedundantRoles": "off" 28 | }, 29 | "style": { 30 | "noNonNullAssertion": "off" 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/emails/src/components/RSSRepost.tsx: -------------------------------------------------------------------------------- 1 | import type { MostRecentLinkPosts } from "@sill/schema"; 2 | 3 | const RSSRepost = ({ group }: { group: MostRecentLinkPosts["posts"] }) => { 4 | if (!group) return null; 5 | const reposters = group.filter((post) => post.repostActorHandle); 6 | 7 | if (!reposters.length) return null; 8 | 9 | return ( 10 |

11 | 12 | Reposted by{" "} 13 | {reposters.map((post, index) => ( 14 | 15 | {post.repostActorName} 16 | {index < reposters.length - 1 ? ", " : ""} 17 | 18 | ))} 19 | 20 |

21 | ); 22 | }; 23 | 24 | export default RSSRepost; 25 | -------------------------------------------------------------------------------- /packages/emails/src/components/OTPBlock.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Section, Text } from "@react-email/components"; 3 | import type { PropsWithChildren } from "react"; 4 | 5 | const OTPBlock = ({ children }: PropsWithChildren) => { 6 | return ( 7 |
8 | {children} 9 |
10 | ); 11 | }; 12 | 13 | const otpBlockStyles = { 14 | background: "rgb(245, 244, 245)", 15 | borderRadius: "4px", 16 | marginBottom: "30px", 17 | padding: "40px 10px", 18 | }; 19 | 20 | const otpTextStyles = { 21 | fontSize: "30px", 22 | textAlign: "center" as const, 23 | verticalAlign: "middle", 24 | }; 25 | 26 | export default OTPBlock; 27 | -------------------------------------------------------------------------------- /apps/web/app/components/marketing/WhySill.module.css: -------------------------------------------------------------------------------- 1 | .whySill { 2 | padding: 80px 0; 3 | text-align: center; 4 | } 5 | 6 | .benefit { 7 | display: flex; 8 | flex-direction: column; 9 | align-items: center; 10 | padding: 24px; 11 | border-radius: var(--radius-4); 12 | transition: transform 0.2s ease; 13 | } 14 | 15 | .benefit:hover { 16 | transform: translateY(-4px); 17 | } 18 | 19 | .iconWrapper { 20 | background: var(--accent-3); 21 | border-radius: 50%; 22 | width: 64px; 23 | height: 64px; 24 | display: flex; 25 | align-items: center; 26 | justify-content: center; 27 | margin-bottom: 24px; 28 | } 29 | 30 | .iconWrapper svg { 31 | width: 32px; 32 | height: 32px; 33 | color: var(--accent-11); 34 | } 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sill-monorepo", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "turbo build", 7 | "dev": "turbo dev", 8 | "dev:local": "turbo dev:local", 9 | "dev:docker": "docker-compose -f docker-compose.dev.yml up -d && turbo dev", 10 | "lint": "turbo lint", 11 | "typecheck": "turbo typecheck", 12 | "test": "turbo test", 13 | "generate-secrets": "sh ./scripts/generate-secrets.sh" 14 | }, 15 | "devDependencies": { 16 | "turbo": "^2.3.0" 17 | }, 18 | "engines": { 19 | "node": ">=22.0.0" 20 | }, 21 | "packageManager": "pnpm@10.17.1", 22 | "pnpm": { 23 | "overrides": { 24 | "drizzle-orm": "0.44.4" 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /apps/web/.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="postgresql://postgres:postgres@db:5432/sill" 2 | MASTODON_REDIRECT_URI="http://localhost:3000/mastodon/auth/callback/" 3 | ENVIRONMENT=local 4 | NODE_ENV=development 5 | USE_SSL=false 6 | TRAEFIK_DASHBOARD_AUTH=admin:user:password 7 | WEB_DOMAIN=localhost 8 | DOZZLE_DOMAIN=dozzle.localhost 9 | POSTGRES_USER=postgres 10 | POSTGRES_PASSWORD=postgres 11 | POSTGRES_DB=sill 12 | UPDATE_BATCH_SIZE=50 13 | VITE_PUBLIC_DOMAIN=http://localhost:3000 14 | 15 | # Fill these out 16 | MAILGUN_API_KEY= 17 | APP_DIR= # Absolute path to the root of the project 18 | VITE_ADMIN_EMAIL= 19 | EMAIL_DOMAIN= 20 | 21 | # Optional 22 | # STRIPE_PUBLISHABLE_KEY= 23 | # STRIPE_SECRET_KEY= 24 | # STRIPE_WEBHOOK_ENDPOINT= 25 | -------------------------------------------------------------------------------- /apps/web/app/components/subscription/SubscriptionHeader.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Heading, Text } from "@radix-ui/themes"; 2 | 3 | export default function SubscriptionHeader() { 4 | return ( 5 | 6 | 17 | Stop doomscrolling. Get{" "} 18 | 25 | sill+ 26 | 27 | . 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /apps/web/app/context/user-context.ts: -------------------------------------------------------------------------------- 1 | import { unstable_createContext } from "react-router"; 2 | import type { SubscriptionStatus } from "@sill/schema"; 3 | import type { AppType } from "@sill/api"; 4 | import type { InferResponseType } from "hono/client"; 5 | import type { hc } from "hono/client"; 6 | 7 | // Create a client type and extract the profile response type 8 | type Client = ReturnType>; 9 | type ProfileResponse = InferResponseType< 10 | Client["api"]["auth"]["profile"]["$get"], 11 | 200 12 | >; 13 | 14 | export interface UserProfile extends ProfileResponse { 15 | subscriptionStatus: SubscriptionStatus; 16 | } 17 | 18 | export const userContext = unstable_createContext(null); 19 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="postgresql://postgres:postgres@db:5432/sill" 2 | MASTODON_REDIRECT_URI="http://localhost:3000/mastodon/auth/callback/" 3 | ENVIRONMENT=local 4 | NODE_ENV=development 5 | USE_SSL=false 6 | TRAEFIK_DASHBOARD_AUTH=admin:user:password 7 | WEB_DOMAIN=localhost 8 | DOZZLE_DOMAIN=dozzle.localhost 9 | POSTGRES_USER=postgres 10 | POSTGRES_PASSWORD=postgres 11 | POSTGRES_DB=sill 12 | UPDATE_BATCH_SIZE=50 13 | VITE_PUBLIC_DOMAIN=http://localhost:3000 14 | 15 | # Fill these out 16 | MAILGUN_API_KEY= 17 | VITE_ADMIN_EMAIL= 18 | EMAIL_DOMAIN= 19 | 20 | # Cloudflare 21 | CLOUDFLARE_API_TOKEN= 22 | CLOUDFLARE_ACCOUNT_ID= 23 | SCRAPE_SHARE_THRESHOLD=5 24 | 25 | # Optional 26 | # POLAR_WEBHOOK_SECRET= 27 | # POLAR_ACCESS_TOKEN= 28 | # SUCCESS_URL= 29 | -------------------------------------------------------------------------------- /apps/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2023", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "allowSyntheticDefaultImports": true, 7 | "esModuleInterop": true, 8 | "allowJs": true, 9 | "strict": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "verbatimModuleSyntax": true, 15 | "declaration": true, 16 | "outDir": "dist", 17 | "rootDir": "src", 18 | "baseUrl": ".", 19 | "jsx": "react-jsx", 20 | "composite": true, 21 | "typeRoots": ["../../node_modules/@types"], 22 | "preserveWatchOutput": true 23 | }, 24 | "include": ["src/**/*"], 25 | "exclude": ["node_modules", "dist"] 26 | } 27 | -------------------------------------------------------------------------------- /apps/web/app/components/marketing/Pricing.module.css: -------------------------------------------------------------------------------- 1 | .card { 2 | flex: 1; 3 | } 4 | 5 | .cardHighlighted { 6 | background: linear-gradient(to bottom right, var(--accent-3), var(--accent-1)); 7 | } 8 | 9 | .cardHighlighted:after { 10 | box-shadow: 0 0 0 2px var(--accent-8); 11 | } 12 | 13 | .cardHighlighted h4 { 14 | color: var(--accent-11); 15 | font-weight: 900; 16 | font-style: italic; 17 | } 18 | 19 | .featureList { 20 | list-style: none; 21 | padding: 0; 22 | margin: 0; 23 | } 24 | 25 | .featureList li { 26 | display: flex; 27 | align-items: center; 28 | gap: var(--space-2); 29 | margin-bottom: var(--space-2); 30 | } 31 | 32 | .checkIcon { 33 | width: 20px; 34 | height: 20px; 35 | color: var(--accent-11); 36 | vertical-align: middle; 37 | } 38 | -------------------------------------------------------------------------------- /apps/web/public/bluesky-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /apps/web/app/components/rss/RSSRepost.tsx: -------------------------------------------------------------------------------- 1 | import { Heading, Link, Text } from "@radix-ui/themes"; 2 | import type { MostRecentLinkPosts } from "@sill/schema"; 3 | 4 | const RSSRepost = ({ group }: { group: MostRecentLinkPosts["posts"] }) => { 5 | if (!group) return null; 6 | const reposters = group.filter((post) => post.repostActorHandle); 7 | 8 | if (!reposters.length) return null; 9 | 10 | return ( 11 | 12 | 13 | Reposted by{" "} 14 | {reposters.map((post, index) => ( 15 | 16 | {post.repostActorName} 17 | {index < reposters.length - 1 ? ", " : ""} 18 | 19 | ))} 20 | 21 | 22 | ); 23 | }; 24 | 25 | export default RSSRepost; 26 | -------------------------------------------------------------------------------- /apps/worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sill/worker", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "tsx src/build.ts", 7 | "dev": "tsx watch ./src/process-queue.tsx", 8 | "dev:local": "tsx watch --env-file ../../.env ./src/process-queue.tsx", 9 | "start": "node ./build/worker.js", 10 | "typecheck": "tsc --noEmit" 11 | }, 12 | "dependencies": { 13 | "@sill/schema": "workspace:*", 14 | "@sill/links": "workspace:*", 15 | "@sill/auth": "workspace:*", 16 | "@sill/emails": "workspace:*", 17 | "@sill/api": "workspace:*", 18 | "drizzle-orm": "^0.44.4", 19 | "uuidv7-js": "^1.1.4" 20 | }, 21 | "devDependencies": { 22 | "esbuild": "^0.25.8", 23 | "tsx": "^4.19.2", 24 | "typescript": "^5.1.6" 25 | } 26 | } -------------------------------------------------------------------------------- /apps/web/app/routes/accounts/logout.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "react-router"; 2 | import { apiLogout } from "~/utils/api-client.server"; 3 | import type { Route } from "./+types/logout"; 4 | 5 | export async function loader({ request }: Route.LoaderArgs) { 6 | try { 7 | const response = await apiLogout(request); 8 | 9 | // Forward the Set-Cookie headers from the API response to clear the session 10 | const headers = new Headers(); 11 | const apiSetCookie = response.headers.get("set-cookie"); 12 | if (apiSetCookie) { 13 | headers.append("set-cookie", apiSetCookie); 14 | } 15 | 16 | return redirect("/", { headers }); 17 | } catch (error) { 18 | // If logout fails, still redirect to home 19 | console.error("Logout error:", error); 20 | return redirect("/"); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Adjust NODE_VERSION as desired 2 | ARG NODE_VERSION=22.17.1 3 | FROM node:${NODE_VERSION}-slim AS base 4 | 5 | # Remix app lives here 6 | WORKDIR /app 7 | # Throw-away build stage to reduce size of final image 8 | FROM base AS build 9 | 10 | # Install node modules 11 | COPY --link package-lock.json package.json ./ 12 | RUN npm ci --include=dev 13 | 14 | # Copy application code 15 | ADD . . 16 | 17 | # Build application 18 | RUN npm run build 19 | RUN npm run build:worker 20 | 21 | 22 | # Remove development dependencies 23 | RUN npm prune --omit-dev 24 | 25 | # Final stage for app image 26 | FROM base 27 | 28 | # Copy built application 29 | COPY --from=build /app /app 30 | 31 | # Start the server by default, this can be overwritten at runtime 32 | EXPOSE 3000 33 | CMD [ "npm", "run", "start" ] -------------------------------------------------------------------------------- /packages/schema/src/migrations/0034_productive_korath.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "bookmark" ( 2 | "id" uuid PRIMARY KEY NOT NULL, 3 | "userId" uuid NOT NULL, 4 | "linkUrl" text NOT NULL, 5 | "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL 6 | ); 7 | --> statement-breakpoint 8 | DO $$ BEGIN 9 | ALTER TABLE "bookmark" ADD CONSTRAINT "bookmark_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; 10 | EXCEPTION 11 | WHEN duplicate_object THEN null; 12 | END $$; 13 | --> statement-breakpoint 14 | DO $$ BEGIN 15 | ALTER TABLE "bookmark" ADD CONSTRAINT "bookmark_linkUrl_link_url_fk" FOREIGN KEY ("linkUrl") REFERENCES "public"."link"("url") ON DELETE no action ON UPDATE no action; 16 | EXCEPTION 17 | WHEN duplicate_object THEN null; 18 | END $$; 19 | -------------------------------------------------------------------------------- /apps/web/app/components/linkPosts/NumberRanking.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Text } from "@radix-ui/themes"; 2 | import styles from "./NumberRanking.module.css"; 3 | 4 | const NumberRanking = ({ 5 | ranking, 6 | layout, 7 | }: { ranking: number; layout: "default" | "dense" }) => { 8 | return ( 9 | 18 | 22 | {ranking} 23 | 24 | 25 | ); 26 | }; 27 | 28 | export default NumberRanking; 29 | -------------------------------------------------------------------------------- /apps/web/app/components/nav/Logo.tsx: -------------------------------------------------------------------------------- 1 | import { Heading } from "@radix-ui/themes"; 2 | import { Link } from "react-router"; 3 | import type { SubscriptionStatus } from "@sill/schema"; 4 | import styles from "./logo.module.css"; 5 | 6 | const Logo = ({ 7 | extraBig, 8 | subscribed, 9 | }: { extraBig?: boolean; subscribed?: SubscriptionStatus | null }) => { 10 | return ( 11 | 19 | {/* 20 | Sill{subscribed === "plus" && "+"} 21 | */} 22 | 23 | Sill 24 | 25 | 26 | ); 27 | }; 28 | 29 | export default Logo; 30 | -------------------------------------------------------------------------------- /packages/schema/src/migrations/0002_goofy_shinko_yamashiro.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "mastodon_instance" ( 2 | "id" uuid PRIMARY KEY NOT NULL, 3 | "instance" text NOT NULL, 4 | "client_token" text NOT NULL, 5 | "client_secret" text NOT NULL, 6 | "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL 7 | ); 8 | --> statement-breakpoint 9 | ALTER TABLE "mastodon_account" ADD COLUMN "instance_id" uuid NOT NULL;--> statement-breakpoint 10 | DO $$ BEGIN 11 | ALTER TABLE "mastodon_account" ADD CONSTRAINT "mastodon_account_instance_id_mastodon_instance_id_fk" FOREIGN KEY ("instance_id") REFERENCES "public"."mastodon_instance"("id") ON DELETE no action ON UPDATE no action; 12 | EXCEPTION 13 | WHEN duplicate_object THEN null; 14 | END $$; 15 | --> statement-breakpoint 16 | ALTER TABLE "mastodon_account" DROP COLUMN IF EXISTS "instance"; -------------------------------------------------------------------------------- /apps/web/app/utils/filterUtils.ts: -------------------------------------------------------------------------------- 1 | export const getCustomizedFilters = (searchParams: URLSearchParams) => { 2 | const customized: string[] = []; 3 | const defaults = { 4 | sort: "popularity", 5 | time: "24h", 6 | reposts: "false", 7 | service: "all", 8 | list: "all", 9 | }; 10 | 11 | // Check URL params against defaults 12 | for (const [key, defaultValue] of Object.entries(defaults)) { 13 | const currentValue = searchParams.get(key); 14 | if (currentValue && currentValue !== defaultValue) { 15 | customized.push(key); 16 | } 17 | } 18 | 19 | // Check minShares (default is empty/undefined) 20 | if (searchParams.get("minShares")) { 21 | customized.push("minShares"); 22 | } 23 | 24 | // Check search query 25 | if (searchParams.get("query")) { 26 | customized.push("search"); 27 | } 28 | 29 | return customized; 30 | }; 31 | -------------------------------------------------------------------------------- /apps/api/src/routes/update-accounts.ts: -------------------------------------------------------------------------------- 1 | import { asc } from "drizzle-orm"; 2 | import { Hono } from "hono"; 3 | import { user, db } from "@sill/schema"; 4 | import { enqueueJob } from "@sill/links"; 5 | 6 | const app = new Hono(); 7 | 8 | app.get("/", async (c) => { 9 | const authHeader = c.req.header("Authorization"); 10 | if (!authHeader || !authHeader.startsWith("Bearer ")) { 11 | return c.text("Unauthorized", 401); 12 | } 13 | 14 | const token = authHeader.split(" ")[1]; 15 | if (token !== process.env.CRON_API_KEY) { 16 | return c.text("Forbidden", 403); 17 | } 18 | 19 | const users = await db.query.user.findMany({ 20 | orderBy: asc(user.createdAt), 21 | }); 22 | 23 | await Promise.all(users.map((user) => enqueueJob(user.id))); 24 | 25 | return c.json({ queued: users.length }); 26 | }); 27 | 28 | export default app; 29 | -------------------------------------------------------------------------------- /apps/web/app/components/settings/SettingsTabNav.tsx: -------------------------------------------------------------------------------- 1 | import { TabNav } from "@radix-ui/themes"; 2 | import { Link, useLocation } from "react-router"; 3 | 4 | export default function SettingsTabNav() { 5 | const location = useLocation(); 6 | 7 | return ( 8 | 9 | 10 | Account 11 | 12 | 16 | Connections 17 | 18 | 22 | Moderation 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /apps/web/app/components/subscription/SubscriptionCallout.tsx: -------------------------------------------------------------------------------- 1 | import { Callout, Link, Text } from "@radix-ui/themes"; 2 | import { Sparkles } from "lucide-react"; 3 | import type { ComponentPropsWithoutRef } from "react"; 4 | 5 | interface SubscriptionCalloutProps 6 | extends ComponentPropsWithoutRef { 7 | featureName: string; 8 | } 9 | 10 | const SubscriptionCallout = ({ 11 | featureName, 12 | color = "yellow", 13 | ...calloutProps 14 | }: SubscriptionCalloutProps) => { 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | {featureName} will be part of Sill's paid features. 22 | 23 | 24 | ); 25 | }; 26 | 27 | export default SubscriptionCallout; 28 | -------------------------------------------------------------------------------- /packages/emails/src/emails/PasswordResetEmail.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import EmailHeading from "../components/Heading.js"; 3 | import EmailLayout from "../components/Layout.js"; 4 | import Lede from "../components/Lede.js"; 5 | import OTPBlock from "../components/OTPBlock.js"; 6 | 7 | interface PasswordResetEmailProps { 8 | otp: string; 9 | } 10 | 11 | const PasswordResetEmail = ({ otp }: PasswordResetEmailProps) => { 12 | return ( 13 | 14 | Password reset request 15 | 16 | Someone, hopefully you, requested a password reset. If this was you, use 17 | the verification code below to confirm your request: 18 | 19 | {otp} 20 | 21 | ); 22 | }; 23 | 24 | export default PasswordResetEmail; -------------------------------------------------------------------------------- /apps/web/app/routes/notifications/test.tsx: -------------------------------------------------------------------------------- 1 | import type { NotificationQuery } from "~/components/forms/NotificationQueryItem"; 2 | import type { Route } from "./+types/test"; 3 | import { requireUserFromContext } from "~/utils/context.server"; 4 | import { apiTestNotifications } from "~/utils/api-client.server"; 5 | 6 | export const action = async ({ request, context }: Route.ActionArgs) => { 7 | await requireUserFromContext(context); 8 | 9 | const formData = await request.formData(); 10 | const queries: NotificationQuery[] = JSON.parse( 11 | String(formData.get("queries")), 12 | ); 13 | 14 | if (!queries.length) { 15 | return 0; 16 | } 17 | 18 | try { 19 | const result = await apiTestNotifications(request, queries); 20 | return result.count; 21 | } catch (error) { 22 | console.error("Test notifications error:", error); 23 | return 0; 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /packages/schema/src/migrations/0048_faulty_toro.sql: -------------------------------------------------------------------------------- 1 | -- Create the new enum type 2 | CREATE TYPE "public"."repost_filter" AS ENUM('include', 'exclude', 'only');--> statement-breakpoint 3 | 4 | -- Add new column with enum type 5 | ALTER TABLE "digest_settings" ADD COLUMN "hideReposts_new" repost_filter NOT NULL DEFAULT 'include';--> statement-breakpoint 6 | 7 | -- Migrate data: false -> 'include', true -> 'exclude' 8 | UPDATE "digest_settings" SET "hideReposts_new" = 9 | CASE 10 | WHEN "hideReposts" = false THEN 'include'::repost_filter 11 | WHEN "hideReposts" = true THEN 'exclude'::repost_filter 12 | END;--> statement-breakpoint 13 | 14 | -- Drop old column 15 | ALTER TABLE "digest_settings" DROP COLUMN "hideReposts";--> statement-breakpoint 16 | 17 | -- Rename new column to old name 18 | ALTER TABLE "digest_settings" RENAME COLUMN "hideReposts_new" TO "hideReposts"; -------------------------------------------------------------------------------- /apps/web/app/components/nav/Nav.module.css: -------------------------------------------------------------------------------- 1 | .nav-list { 2 | list-style: none; 3 | padding: 0; 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: space-around; 7 | } 8 | /* 9 | .nav-list-item-label { 10 | display: none; 11 | } */ 12 | 13 | .nav-list-item-icon { 14 | width: 24px; 15 | height: 24px; 16 | vertical-align: middle; 17 | line-height: 1; 18 | position: relative; 19 | } 20 | 21 | .nav-list { 22 | width: 100%; 23 | } 24 | 25 | .nav-list-item-label { 26 | display: inline; 27 | } 28 | 29 | @-moz-document url-prefix("") { 30 | .nav-list-item-icon { 31 | transform: translateY(-1px); 32 | } 33 | } 34 | 35 | .nav-list-item-btn { 36 | width: 100%; 37 | justify-content: flex-start; 38 | } 39 | 40 | .nav-list-item { 41 | margin-bottom: 0.75rem; 42 | width: 100%; 43 | } 44 | .nav-list-item-icon { 45 | width: 20px; 46 | height: 20px; 47 | } 48 | -------------------------------------------------------------------------------- /apps/web/app/routes/api/bluesky.status.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "react-router"; 2 | import { apiCheckBlueskyStatus } from "~/utils/api-client.server"; 3 | import type { Route } from "./+types/bluesky.status"; 4 | 5 | export const loader = async ({ request }: Route.LoaderArgs) => { 6 | try { 7 | const result = await apiCheckBlueskyStatus(request); 8 | 9 | // If re-authorization is needed, redirect to the OAuth URL 10 | if (result.needsAuth && "redirectUrl" in result && result.redirectUrl) { 11 | return redirect(result.redirectUrl); 12 | } 13 | 14 | // Return the status result 15 | return result; 16 | } catch (error) { 17 | console.error("Bluesky status check error:", error); 18 | return { 19 | status: "error", 20 | needsAuth: false, 21 | error: 22 | error instanceof Error ? error.message : "Failed to check Bluesky status", 23 | }; 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /apps/web/vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { reactRouter } from "@react-router/dev/vite"; 3 | import react from "@vitejs/plugin-react"; 4 | import { defineConfig, loadEnv } from "vite"; 5 | import tsconfigPaths from "vite-tsconfig-paths"; 6 | 7 | export default defineConfig({ 8 | build: { 9 | target: "esnext", 10 | }, 11 | plugins: [!process.env.VITEST ? reactRouter() : react(), tsconfigPaths()], 12 | server: { 13 | port: 3000, 14 | }, 15 | ssr: { 16 | target: "node", 17 | noExternal: [/react-tweet.*/], 18 | external: ["@duckdb/node-bindings", "@duckdb/node-api"], 19 | }, 20 | assetsInclude: ["**/*.node"], 21 | optimizeDeps: { 22 | exclude: ["@duckdb/node-bindings", "@duckdb/node-api"], 23 | }, 24 | test: { 25 | environment: "happy-dom", 26 | // Additionally, this is to load ".env.test" during vitest 27 | env: loadEnv("test", process.cwd(), ""), 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /apps/web/app/routes/notifications/delete.ts: -------------------------------------------------------------------------------- 1 | import { data } from "react-router"; 2 | import type { Route } from "./+types/delete"; 3 | import { requireUserFromContext } from "~/utils/context.server"; 4 | import { apiDeleteNotificationGroup } from "~/utils/api-client.server"; 5 | 6 | export const action = async ({ request, context }: Route.ActionArgs) => { 7 | const { id: userId } = await requireUserFromContext(context); 8 | 9 | if (!userId) { 10 | return data({ result: "Unauthorized" }, { status: 401 }); 11 | } 12 | 13 | const formData = await request.formData(); 14 | const groupId = String(formData.get("groupId")); 15 | 16 | try { 17 | await apiDeleteNotificationGroup(request, groupId); 18 | return { result: "success" }; 19 | } catch (error) { 20 | console.error("Delete notification group error:", error); 21 | return data({ result: "Error deleting notification group" }, { status: 500 }); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /apps/web/app/utils/layout.server.ts: -------------------------------------------------------------------------------- 1 | import * as cookie from "cookie"; 2 | 3 | const cookieName = "en_layout"; 4 | export type Layout = "default" | "dense"; 5 | 6 | /** 7 | * Gets the theme string from the cookie 8 | * @param request Request object 9 | * @returns Theme string from cookie 10 | */ 11 | export function getLayout(request: Request): Layout | null { 12 | const cookieHeader = request.headers.get("cookie"); 13 | const parsed = cookieHeader 14 | ? cookie.parse(cookieHeader)[cookieName] 15 | : "default"; 16 | if (parsed === "default" || parsed === "dense") return parsed; 17 | return null; 18 | } 19 | 20 | /** 21 | * Sets the theme string in the cookie 22 | * @param theme Theme string to set in cookie 23 | * @returns Cookie string to set in response 24 | */ 25 | export function setLayout(theme: Layout | "default") { 26 | return cookie.serialize(cookieName, theme, { path: "/", maxAge: 31536000 }); 27 | } 28 | -------------------------------------------------------------------------------- /packages/emails/src/emails/EmailChangeNoticeEmail.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Container, Html, Text } from "@react-email/components"; 3 | 4 | const EmailChangeNoticeEmail = ({ userId }: { userId: string }) => { 5 | return ( 6 | 7 | 8 |

9 | Your Sill email has been changed 10 |

11 |

12 | 13 | We're writing to let you know that your Sill email has been changed. 14 | 15 |

16 |

17 | 18 | If you changed your email address, then you can safely ignore this. 19 | But if you did not change your email address, then please contact 20 | support immediately. 21 | 22 |

23 |

24 | Your Account ID: {userId} 25 |

26 |
27 | 28 | ); 29 | }; 30 | 31 | export default EmailChangeNoticeEmail; -------------------------------------------------------------------------------- /packages/emails/src/emails/VerifyEmail.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Text } from "@react-email/components"; 3 | import EmailHeading from "../components/Heading.js"; 4 | import EmailLayout from "../components/Layout.js"; 5 | import Lede from "../components/Lede.js"; 6 | import OTPBlock from "../components/OTPBlock.js"; 7 | 8 | interface VerifyEmailProps { 9 | otp: string; 10 | } 11 | 12 | const VerifyEmail = ({ otp }: VerifyEmailProps) => { 13 | return ( 14 | 15 | Verify your email for your new Sill account 16 | Here's your verification code: 17 | {otp} 18 | 19 | This token will expire in five minutes. If you need a new token, fill 20 | out the form you used to generate this token again. 21 | 22 | 23 | ); 24 | }; 25 | 26 | export default VerifyEmail; 27 | -------------------------------------------------------------------------------- /apps/web/app/components/linkPosts/link/LinkTitle.tsx: -------------------------------------------------------------------------------- 1 | import { Heading, Link, Text } from "@radix-ui/themes"; 2 | 3 | interface LinkTitleProps { 4 | title: string; 5 | href: string; 6 | layout: "default" | "dense"; 7 | host: string; 8 | siteName: string | null; 9 | } 10 | 11 | const LinkTitle = ({ 12 | title, 13 | href, 14 | layout = "default", 15 | host, 16 | siteName, 17 | }: LinkTitleProps) => { 18 | const displayTitle = title.endsWith(".pdf") 19 | ? `PDF from ${siteName || host}` 20 | : title; 21 | 22 | return ( 23 | 33 | 34 | {displayTitle} 35 | 36 | 37 | ); 38 | }; 39 | 40 | export default LinkTitle; 41 | -------------------------------------------------------------------------------- /apps/web/app/routes/api/polar.webhook.ts: -------------------------------------------------------------------------------- 1 | import { Webhooks } from "@polar-sh/remix"; 2 | 3 | export const action = Webhooks({ 4 | webhookSecret: process.env.POLAR_WEBHOOK_SECRET!, 5 | onCustomerStateChanged: async (payload) => { 6 | try { 7 | const response = await fetch(`${process.env.API_BASE_URL}/api/subscription/webhook`, { 8 | method: "POST", 9 | headers: { 10 | "Content-Type": "application/json", 11 | }, 12 | body: JSON.stringify(payload), 13 | }); 14 | 15 | if (!response.ok) { 16 | console.error("[POLAR WEBHOOK] API call failed:", response.status, response.statusText); 17 | const errorText = await response.text(); 18 | console.error("[POLAR WEBHOOK] API error response:", errorText); 19 | } else { 20 | console.log("[POLAR WEBHOOK] Successfully processed webhook"); 21 | } 22 | } catch (error) { 23 | console.error("[POLAR WEBHOOK] Error calling API:", error); 24 | } 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /apps/web/app/routes/email/delete.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "react-router"; 2 | import { apiDeleteDigestSettings } from "~/utils/api-client.server"; 3 | import { requireUserFromContext } from "~/utils/context.server"; 4 | import type { Route } from "./+types/delete"; 5 | 6 | export const action = async ({ request, context }: Route.ActionArgs) => { 7 | await requireUserFromContext(context); 8 | 9 | try { 10 | const response = await apiDeleteDigestSettings(request); 11 | 12 | if (!response.ok) { 13 | const errorData = await response.json(); 14 | if ("error" in errorData) { 15 | throw new Error(errorData.error as string); 16 | } 17 | throw new Error("Failed to delete settings"); 18 | } 19 | 20 | return redirect("/email"); 21 | } catch (error) { 22 | // In case of error, still redirect but could handle this better 23 | console.error("Failed to delete digest settings:", error); 24 | return redirect("/email"); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /packages/links/src/normalizers/giftLinkFormat.ts: -------------------------------------------------------------------------------- 1 | interface GiftLinkFormat { 2 | [key: string]: string[]; 3 | } 4 | 5 | export const giftLinkFormats: GiftLinkFormat = { 6 | "bloomberg.com": ["accessToken"], 7 | "ft.com": ["accessToken"], 8 | "nytimes.com": ["unlocked_article_code"], 9 | "washingtonpost.com": ["pwapi_token"], 10 | "oregonlive.com": ["gift"], 11 | "wsj.com": ["st"], 12 | "puck.news": ["sharer", "token"], 13 | "theatlantic.com": ["gift"], 14 | "medium.com": ["sk"], 15 | "thetimes.com": ["shareToken"], 16 | "defector.com": ["giftLink"], 17 | "aftermath.site": ["giftLink"], 18 | "nj.com": ["gift"], 19 | "mercurynews.com": ["share"], 20 | "haaretz.com": ["gift"], 21 | "economist.com": ["giftId"], 22 | "chicagotribune.com": ["share"], 23 | "zeit.de": ["freebie"], 24 | "sueddeutsche.de": ["token"], 25 | "foreignpolicy.com": ["utm_content", "gifting_article"], 26 | "courant.com": ["share"], 27 | "racketmn.com": ["giftLink"], 28 | }; 29 | -------------------------------------------------------------------------------- /packages/links/src/index.ts: -------------------------------------------------------------------------------- 1 | export { getBlueskyLists, clearOAuthSessionCache } from "./bluesky.js"; 2 | export { getMastodonLists } from "./mastodon.js"; 3 | export { 4 | type ProcessedResult, 5 | evaluateNotifications, 6 | fetchLinks, 7 | filterLinkOccurrences, 8 | insertNewLinks, 9 | findLinksByAuthor, 10 | findLinksByDomain, 11 | findLinksByTopic, 12 | networkTopTen, 13 | conflictUpdateSetAllColumns, 14 | } from "./links.js"; 15 | export { dequeueJobs, enqueueJob, processJob } from "./queue.js"; 16 | export { 17 | fetchHtmlViaProxy, 18 | extractHtmlMetadata, 19 | processUrl, 20 | getHighActivityUrls, 21 | } from "./metadata.js"; 22 | export { renderPageContent } from "./cloudflare.js"; 23 | export { 24 | fetchLatestBookmarks, 25 | formatBookmark, 26 | evaluateBookmark, 27 | updateBookmarkPosts, 28 | getUserBookmarks, 29 | addNewBookmarks, 30 | } from "./bookmarks.js"; 31 | export { processNotificationGroup } from "./notifications.js"; 32 | -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "references": [ 3 | { "path": "../api" } 4 | ], 5 | "include": [ 6 | "**/*.ts", 7 | "**/*.tsx", 8 | "**/.server/**/*.ts", 9 | "**/.server/**/*.tsx", 10 | "**/.client/**/*.ts", 11 | "**/.client/**/*.tsx", 12 | ".react-router/types/**/*" 13 | ], 14 | "compilerOptions": { 15 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 16 | "types": ["@react-router/node", "vite/client"], 17 | "isolatedModules": true, 18 | "esModuleInterop": true, 19 | "jsx": "react-jsx", 20 | "module": "ESNext", 21 | "moduleResolution": "Bundler", 22 | "resolveJsonModule": true, 23 | "target": "ES2022", 24 | "strict": true, 25 | "allowJs": true, 26 | "skipLibCheck": true, 27 | "forceConsistentCasingInFileNames": true, 28 | "baseUrl": ".", 29 | "paths": { 30 | "~/*": ["app/*"] 31 | }, 32 | "rootDirs": [".", "./.react-router/types"], 33 | "verbatimModuleSyntax": true, 34 | "outDir": ".tsc-build", 35 | "preserveWatchOutput": true 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /apps/api/src/utils/digestText.server.ts: -------------------------------------------------------------------------------- 1 | import type { MostRecentLinkPosts } from "@sill/schema"; 2 | 3 | export const subject = "Your Sill Daily Digest"; 4 | 5 | export const preview = (linkPosts: MostRecentLinkPosts[]) => { 6 | if (linkPosts.length === 0) { 7 | return "Sill is having trouble syncing with your Bluesky and/or Mastodon accounts"; 8 | } 9 | const hosts = linkPosts 10 | .map((linkPost) => new URL(linkPost.link?.url || "").hostname) 11 | .slice(0, 3); 12 | 13 | const hostString = hosts.join(", "); 14 | return `Today's top links from ${hostString}`; 15 | }; 16 | 17 | export const title = "Your Sill Daily Digest"; 18 | 19 | export const intro = (name: string | null) => 20 | `Hello${name ? ` ${name}` : ""}, here are your top links from the past 24 hours across your social networks.`; 21 | 22 | export const firstFeedItem = (name: string | null) => 23 | `Welcome to Sill's Daily Digest${name ? `, ${name}` : ""}! We'll send your first Daily Digest at your scheduled time.`; 24 | -------------------------------------------------------------------------------- /packages/emails/src/components/PlusTrial.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link, Section, Text } from "@react-email/components"; 3 | 4 | export const daysRemaining = (end: Date) => 5 | Math.round((end.getTime() - Date.now()) / (1000 * 60 * 60 * 24)); 6 | 7 | const PlusTrial = ({ type, endDate }: { type: string; endDate: Date }) => { 8 | const remaining = daysRemaining(endDate); 9 | const days = remaining === 1 ? "day" : "days"; 10 | return ( 11 |
12 | 13 | You have {remaining} {days} remaining in your Sill+ free trial.{" "} 14 | 15 | Subscribe today 16 | {" "} 17 | to maintain access to your {type}. 18 | 19 |
20 | ); 21 | }; 22 | 23 | const section = { 24 | padding: "16px", 25 | backgroundColor: "#FEFCE9", 26 | margin: "16px 0", 27 | }; 28 | 29 | const link = { 30 | color: "#9E6C00", 31 | }; 32 | 33 | export default PlusTrial; 34 | -------------------------------------------------------------------------------- /apps/web/app/utils/theme.ts: -------------------------------------------------------------------------------- 1 | import * as cookie from "cookie"; 2 | 3 | const cookieName = "en_theme"; 4 | export type Theme = "light" | "dark"; 5 | 6 | /** 7 | * Gets the theme string from the cookie 8 | * @param request Request object 9 | * @returns Theme string from cookie 10 | */ 11 | export function getTheme(request: Request): Theme | null { 12 | const cookieHeader = request.headers.get("cookie"); 13 | const parsed = cookieHeader 14 | ? cookie.parse(cookieHeader)[cookieName] 15 | : "light"; 16 | if (parsed === "light" || parsed === "dark") return parsed; 17 | return null; 18 | } 19 | 20 | /** 21 | * Sets the theme string in the cookie 22 | * @param theme Theme string to set in cookie 23 | * @returns Cookie string to set in response 24 | */ 25 | export function setTheme(theme: Theme | "system") { 26 | if (theme === "system") { 27 | return cookie.serialize(cookieName, "", { path: "/", maxAge: -1 }); 28 | } 29 | return cookie.serialize(cookieName, theme, { path: "/", maxAge: 31536000 }); 30 | } 31 | -------------------------------------------------------------------------------- /packages/schema/src/migrations/0033_colossal_human_fly.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "terms_agreement" ( 2 | "id" uuid PRIMARY KEY NOT NULL, 3 | "userId" uuid NOT NULL, 4 | "createdAt" timestamp DEFAULT now(), 5 | "termsUpdateId" uuid NOT NULL 6 | ); 7 | --> statement-breakpoint 8 | CREATE TABLE IF NOT EXISTS "terms_update" ( 9 | "id" uuid PRIMARY KEY NOT NULL, 10 | "termsDate" timestamp NOT NULL 11 | ); 12 | --> statement-breakpoint 13 | DO $$ BEGIN 14 | ALTER TABLE "terms_agreement" ADD CONSTRAINT "terms_agreement_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; 15 | EXCEPTION 16 | WHEN duplicate_object THEN null; 17 | END $$; 18 | --> statement-breakpoint 19 | DO $$ BEGIN 20 | ALTER TABLE "terms_agreement" ADD CONSTRAINT "terms_agreement_termsUpdateId_terms_update_id_fk" FOREIGN KEY ("termsUpdateId") REFERENCES "public"."terms_update"("id") ON DELETE cascade ON UPDATE no action; 21 | EXCEPTION 22 | WHEN duplicate_object THEN null; 23 | END $$; 24 | -------------------------------------------------------------------------------- /apps/web/app/components/linkPosts/link/LinkMetadata.module.css: -------------------------------------------------------------------------------- 1 | .linkTagWrapper { 2 | background-color: var(--gray-a3); 3 | border-radius: var(--radius-4); 4 | } 5 | 6 | .linkTag { 7 | transition: opacity 0.15s ease-in-out; 8 | cursor: pointer; 9 | padding: 0 var(--space-2); 10 | height: 32px; 11 | display: flex; 12 | align-items: center; 13 | } 14 | 15 | .linkTag:hover { 16 | opacity: 0.8; 17 | } 18 | 19 | .deleteButtonWrapper { 20 | height: 32px; 21 | border-left: 1px solid var(--gray-a5); 22 | } 23 | 24 | .deleteButton { 25 | border: none; 26 | background: transparent; 27 | cursor: pointer; 28 | padding: 0 var(--space-1); 29 | color: var(--gray-11); 30 | transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out; 31 | } 32 | 33 | .deleteButton:hover:not(:disabled) { 34 | background-color: var(--red-a3); 35 | color: var(--red-11); 36 | } 37 | 38 | .deleteButton:disabled { 39 | cursor: default; 40 | } 41 | 42 | .deleteButton[data-deleting="true"] { 43 | cursor: default; 44 | } 45 | -------------------------------------------------------------------------------- /apps/web/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Sill", 3 | "short_name": "Sill", 4 | "icons": [ 5 | { 6 | "src": "/android-icon-36x36.png", 7 | "sizes": "36x36", 8 | "type": "image/png", 9 | "density": "0.75" 10 | }, 11 | { 12 | "src": "/android-icon-48x48.png", 13 | "sizes": "48x48", 14 | "type": "image/png", 15 | "density": "1.0" 16 | }, 17 | { 18 | "src": "/android-icon-72x72.png", 19 | "sizes": "72x72", 20 | "type": "image/png", 21 | "density": "1.5" 22 | }, 23 | { 24 | "src": "/android-icon-96x96.png", 25 | "sizes": "96x96", 26 | "type": "image/png", 27 | "density": "2.0" 28 | }, 29 | { 30 | "src": "/android-icon-144x144.png", 31 | "sizes": "144x144", 32 | "type": "image/png", 33 | "density": "3.0" 34 | }, 35 | { 36 | "src": "/android-icon-192x192.png", 37 | "sizes": "192x192", 38 | "type": "image/png", 39 | "density": "4.0" 40 | } 41 | ], 42 | "theme_color": "#9E6C00", 43 | "background_color": "#FFFAB8", 44 | "display": "standalone" 45 | } 46 | -------------------------------------------------------------------------------- /apps/web/public/pwa-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Sill", 3 | "short_name": "Sill", 4 | "icons": [ 5 | { 6 | "src": "/android-icon-36x36.png", 7 | "sizes": "36x36", 8 | "type": "image/png", 9 | "density": "0.75" 10 | }, 11 | { 12 | "src": "/android-icon-48x48.png", 13 | "sizes": "48x48", 14 | "type": "image/png", 15 | "density": "1.0" 16 | }, 17 | { 18 | "src": "/android-icon-72x72.png", 19 | "sizes": "72x72", 20 | "type": "image/png", 21 | "density": "1.5" 22 | }, 23 | { 24 | "src": "/android-icon-96x96.png", 25 | "sizes": "96x96", 26 | "type": "image/png", 27 | "density": "2.0" 28 | }, 29 | { 30 | "src": "/android-icon-144x144.png", 31 | "sizes": "144x144", 32 | "type": "image/png", 33 | "density": "3.0" 34 | }, 35 | { 36 | "src": "/android-icon-192x192.png", 37 | "sizes": "192x192", 38 | "type": "image/png", 39 | "density": "4.0" 40 | } 41 | ], 42 | "theme_color": "#9E6C00", 43 | "background_color": "#FFFAB8", 44 | "display": "standalone" 45 | } 46 | -------------------------------------------------------------------------------- /apps/web/app/components/nav/TrialBanner.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Link, Text } from "@radix-ui/themes"; 2 | import { daysRemaining } from "~/utils/misc"; 3 | import styles from "./TrialBanner.module.css"; 4 | 5 | const TrialBanner = ({ endDate }: { endDate: Date }) => { 6 | const remaining = daysRemaining(new Date(endDate)); 7 | const days = remaining === 1 ? "day" : "days"; 8 | 9 | const sillPlusText = ( 10 | 17 | sill+ 18 | 19 | ); 20 | 21 | const subscribeLink = ( 22 | 23 | Subscribe now 24 | 25 | ); 26 | 27 | return ( 28 | 35 | 36 | {remaining} {days} left in your {sillPlusText} trial. {subscribeLink}. 37 | 38 | 39 | ); 40 | }; 41 | 42 | export default TrialBanner; 43 | -------------------------------------------------------------------------------- /apps/web/app/routes/bookmarks/delete.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "react-router"; 2 | import { apiDeleteBookmark } from "~/utils/api-client.server"; 3 | import type { Route } from "./+types/delete"; 4 | import { requireUserFromContext } from "~/utils/context.server"; 5 | 6 | export const action = async ({ request, context }: Route.ActionArgs) => { 7 | const { id: userId } = await requireUserFromContext(context); 8 | 9 | if (!userId) { 10 | return redirect("/login"); 11 | } 12 | 13 | const formData = await request.formData(); 14 | const url = String(formData.get("url")); 15 | 16 | if (!url) { 17 | return redirect("/bookmarks"); 18 | } 19 | 20 | try { 21 | const response = await apiDeleteBookmark(request, { url }); 22 | 23 | if (!response.ok) { 24 | const errorData = await response.json(); 25 | if ("error" in errorData) { 26 | throw new Error(errorData.error); 27 | } 28 | } 29 | 30 | return { success: true }; 31 | } catch (error) { 32 | console.error("Delete bookmark error:", error); 33 | return redirect("/bookmarks"); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /apps/web/app/utils/onboarding.server.ts: -------------------------------------------------------------------------------- 1 | import { invariant } from "@epic-web/invariant"; 2 | import { redirect } from "react-router"; 3 | import { onboardingEmailSessionKey } from "~/routes/accounts/onboarding"; 4 | import { verifySessionStorage } from "~/utils/verification.server"; 5 | import type { VerifyFunctionArgs } from "~/utils/verify.server"; 6 | 7 | /** 8 | * Handles verification of email and redirects to onboarding page 9 | * @param param0 Parameters for verification including submission data 10 | * @returns Redirect response to onboarding page 11 | */ 12 | export async function handleVerification({ submission }: VerifyFunctionArgs) { 13 | invariant( 14 | submission.status === "success", 15 | "Submission should be successful by now", 16 | ); 17 | const verifySession = await verifySessionStorage.getSession(); 18 | verifySession.set(onboardingEmailSessionKey, submission.value.target); 19 | return redirect("/accounts/onboarding", { 20 | headers: { 21 | "set-cookie": await verifySessionStorage.commitSession(verifySession), 22 | }, 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /packages/schema/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sill/schema", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "main": "./dist/index.js", 7 | "types": "./dist/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "development": { 11 | "types": "./src/index.ts", 12 | "import": "./src/index.ts" 13 | }, 14 | "production": { 15 | "types": "./dist/index.d.ts", 16 | "import": "./dist/index.js" 17 | }, 18 | "default": { 19 | "types": "./dist/index.d.ts", 20 | "import": "./dist/index.js" 21 | } 22 | } 23 | }, 24 | "files": [ 25 | "src", 26 | "dist" 27 | ], 28 | "scripts": { 29 | "build": "tsc", 30 | "dev": "tsc --watch", 31 | "dev:local": "tsc --watch", 32 | "typecheck": "tsc --noEmit" 33 | }, 34 | "dependencies": { 35 | "drizzle-orm": "^0.44.4", 36 | "pg": "^8.13.1", 37 | "zod": "^3.23.8" 38 | }, 39 | "devDependencies": { 40 | "@types/node": "^22.0.0", 41 | "@types/pg": "^8.11.10", 42 | "typescript": "^5.0.0" 43 | } 44 | } -------------------------------------------------------------------------------- /apps/web/app/components/nav/AgreeToTerms.tsx: -------------------------------------------------------------------------------- 1 | import { AlertDialog, Flex, Link } from "@radix-ui/themes"; 2 | import { Form, useFetcher } from "react-router"; 3 | import SubmitButton from "../forms/SubmitButton"; 4 | 5 | const AgreeToTerms = () => { 6 | const fetcher = useFetcher(); 7 | return ( 8 | 9 | 10 | 11 | We've updated our terms and conditions 12 | 13 | 14 | Please read and agree to our updated{" "} 15 | 16 | terms and conditions 17 | {" "} 18 | to continue using Sill. 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | }; 31 | 32 | export default AgreeToTerms; 33 | -------------------------------------------------------------------------------- /apps/web/app/components/linkPosts/OpenLink.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton, Link } from "@radix-ui/themes"; 2 | import { ExternalLink, Gift } from "lucide-react"; 3 | 4 | const OpenLink = ({ 5 | url, 6 | isGift, 7 | layout, 8 | }: { url: string; isGift: boolean; layout: "default" | "dense" }) => { 9 | const label = isGift ? "Open gift link" : "Open in new tab"; 10 | return ( 11 | 18 | 29 | {isGift ? ( 30 | 34 | ) : ( 35 | 39 | )} 40 | 41 | 42 | ); 43 | }; 44 | 45 | export default OpenLink; 46 | -------------------------------------------------------------------------------- /apps/web/app/routes/bluesky/auth.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "react-router"; 2 | import { apiBlueskyAuthStart } from "~/utils/api-client.server"; 3 | import type { Route } from "./+types/auth"; 4 | 5 | export const loader = async ({ request }: Route.LoaderArgs) => { 6 | const requestUrl = new URL(request.url); 7 | const referrer = 8 | request.headers.get("referer") || "/accounts/onboarding/social"; 9 | const handle = requestUrl.searchParams.get("handle"); 10 | 11 | try { 12 | const result = await apiBlueskyAuthStart(request, handle || undefined); 13 | return redirect(result.redirectUrl); 14 | } catch (error) { 15 | console.error("Bluesky auth error:", error); 16 | 17 | // Handle specific error codes 18 | if (error instanceof Error && error.message.includes("resolver")) { 19 | const errorUrl = new URL(referrer); 20 | errorUrl.searchParams.set("error", "resolver"); 21 | return redirect(errorUrl.toString()); 22 | } 23 | 24 | // Generic error fallback 25 | const errorUrl = new URL(referrer); 26 | errorUrl.searchParams.set("error", "oauth"); 27 | return redirect(errorUrl.toString()); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /apps/web/app/components/forms/FilterButton.tsx: -------------------------------------------------------------------------------- 1 | import type { ButtonProps } from "@radix-ui/themes"; 2 | import { RadioCards, Spinner } from "@radix-ui/themes"; 3 | import { useLocation, useNavigation } from "react-router"; 4 | interface FilterButtonProps { 5 | param: string; 6 | value: string; 7 | setter: (param: string, value: string) => void; 8 | label: string; 9 | } 10 | 11 | const FilterButton = ({ 12 | param, 13 | value, 14 | setter, 15 | label, 16 | }: ButtonProps & 17 | React.RefAttributes & 18 | FilterButtonProps) => { 19 | const navigation = useNavigation(); 20 | const location = useLocation(); 21 | 22 | const oldParams = new URLSearchParams(location.search); 23 | const newParams = new URLSearchParams(navigation.location?.search); 24 | 25 | const buttonSelected = 26 | oldParams.get(param) !== newParams.get(param) && 27 | newParams.get(param) === value; 28 | 29 | return ( 30 | setter(param, value)} value={value}> 31 | {navigation.state === "loading" && buttonSelected && } 32 | {label} 33 | 34 | ); 35 | }; 36 | 37 | export default FilterButton; 38 | -------------------------------------------------------------------------------- /apps/web/app/utils/userValidation.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const PasswordSchema = z 4 | .string({ required_error: "Password is required" }) 5 | .min(6, { message: "Password is too short" }) 6 | .max(100, { message: "Password is too long" }); 7 | export const NameSchema = z 8 | .string({ required_error: "Name is required" }) 9 | .min(3, { message: "Name is too short" }) 10 | .max(40, { message: "Name is too long" }); 11 | export const EmailSchema = z 12 | .string({ required_error: "Email is required" }) 13 | .email({ message: "Email is invalid" }) 14 | .min(3, { message: "Email is too short" }) 15 | .max(100, { message: "Email is too long" }) 16 | // users can type the email in any case, but we store it in lowercase 17 | .transform((value) => value.toLowerCase()); 18 | 19 | export const PasswordAndConfirmPasswordSchema = z 20 | .object({ password: PasswordSchema, confirmPassword: PasswordSchema }) 21 | .superRefine(({ confirmPassword, password }, ctx) => { 22 | if (confirmPassword !== password) { 23 | ctx.addIssue({ 24 | path: ["confirmPassword"], 25 | code: "custom", 26 | message: "The passwords must match", 27 | }); 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /packages/auth/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sill/auth", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "main": "./dist/index.js", 7 | "types": "./dist/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "development": { 11 | "types": "./src/index.ts", 12 | "import": "./src/index.ts" 13 | }, 14 | "production": { 15 | "types": "./dist/index.d.ts", 16 | "import": "./dist/index.js" 17 | }, 18 | "default": { 19 | "types": "./dist/index.d.ts", 20 | "import": "./dist/index.js" 21 | } 22 | } 23 | }, 24 | "scripts": { 25 | "build": "tsc", 26 | "dev": "tsc --watch", 27 | "dev:local": "tsc --watch", 28 | "typecheck": "tsc --noEmit" 29 | }, 30 | "dependencies": { 31 | "@sill/schema": "workspace:*", 32 | "@atproto/oauth-client-node": "^0.2.5", 33 | "@atproto/jwk-jose": "^0.1.9", 34 | "@epic-web/totp": "^1.1.0", 35 | "bcryptjs": "^2.4.3", 36 | "drizzle-orm": "^0.44.4", 37 | "uuidv7-js": "^1.1.4", 38 | "zod": "^3.23.8" 39 | }, 40 | "devDependencies": { 41 | "@types/bcryptjs": "^2.4.6", 42 | "typescript": "^5.1.6" 43 | } 44 | } -------------------------------------------------------------------------------- /apps/web/app/routes/bookmarks/add.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "react-router"; 2 | import { apiAddBookmark } from "~/utils/api-client.server"; 3 | import type { Route } from "./+types/add"; 4 | import { requireUserFromContext } from "~/utils/context.server"; 5 | 6 | export const action = async ({ request, context }: Route.ActionArgs) => { 7 | const { id: userId } = await requireUserFromContext(context); 8 | 9 | if (!userId) { 10 | return redirect("/login"); 11 | } 12 | 13 | const formData = await request.formData(); 14 | const url = String(formData.get("url")); 15 | const tags = formData.get("tags") ? String(formData.get("tags")) : undefined; 16 | const publishToAtproto = formData.get("publishToAtproto") === "true"; 17 | 18 | if (!url) { 19 | return redirect("/bookmarks"); 20 | } 21 | 22 | try { 23 | const response = await apiAddBookmark(request, { url, tags, publishToAtproto }); 24 | 25 | if (!response.ok) { 26 | const errorData = await response.json(); 27 | if ("error" in errorData) { 28 | throw new Error(errorData.error); 29 | } 30 | } 31 | 32 | return { success: true }; 33 | } catch (error) { 34 | console.error("Add bookmark error:", error); 35 | return redirect("/bookmarks"); 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /apps/web/app/components/forms/CheckboxField.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Checkbox, 4 | type CheckboxProps, 5 | Flex, 6 | Text, 7 | type TextProps, 8 | } from "@radix-ui/themes"; 9 | import type React from "react"; 10 | import { useId } from "react"; 11 | 12 | interface CheckboxFieldProps { 13 | labelProps: TextProps & React.LabelHTMLAttributes; 14 | inputProps: CheckboxProps & React.InputHTMLAttributes; 15 | errors?: ListOfErrors; 16 | } 17 | export type ListOfErrors = Array | null | undefined; 18 | 19 | const CheckboxField = ({ 20 | labelProps, 21 | inputProps, 22 | errors, 23 | }: CheckboxFieldProps) => { 24 | const fallbackId = useId(); 25 | const id = inputProps.id ?? fallbackId; 26 | const errorId = errors?.length ? `${id}-error` : undefined; 27 | return ( 28 | 29 | 30 | 35 | 36 | {labelProps.children} 37 | 38 | 39 | {errors} 40 | 41 | ); 42 | }; 43 | 44 | export default CheckboxField; 45 | -------------------------------------------------------------------------------- /apps/web/app/routes/mastodon/auth.revoke.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "react-router"; 2 | import { apiMastodonRevoke } from "~/utils/api-client.server"; 3 | import type { Route } from "./+types/auth.revoke"; 4 | 5 | export const action = async ({ request }: Route.ActionArgs) => { 6 | try { 7 | const response = await apiMastodonRevoke(request); 8 | const result = await response.json(); 9 | 10 | if ("error" in result) { 11 | throw new Error(result.error); 12 | } 13 | 14 | if (result.success) { 15 | return redirect("/settings/connections"); 16 | } 17 | 18 | // Handle specific error cases 19 | return { message: result.message || "Failed to revoke Mastodon account" }; 20 | } catch (error) { 21 | console.error("Mastodon revoke error:", error); 22 | 23 | // Handle specific error codes from API 24 | if (error instanceof Error) { 25 | if (error.message.includes("Not authenticated")) { 26 | return redirect("/accounts/login?redirectTo=/settings"); 27 | } 28 | if (error.message.includes("not found")) { 29 | return { message: "No Mastodon account to revoke." }; 30 | } 31 | } 32 | 33 | // Generic error fallback 34 | return { message: "Failed to revoke Mastodon account. Please try again." }; 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /apps/web/app/routes/api/agree-to-terms.ts: -------------------------------------------------------------------------------- 1 | import { requireUserFromContext } from "~/utils/context.server"; 2 | import { apiGetLatestTermsUpdate, apiGetTermsAgreement, apiInsertTermsAgreement } from "~/utils/api-client.server"; 3 | import type { Route } from "./+types/agree-to-terms"; 4 | 5 | export const action = async ({ request, context }: Route.ActionArgs) => { 6 | await requireUserFromContext(context); 7 | 8 | try { 9 | // Get latest terms update 10 | const latestTerms = await apiGetLatestTermsUpdate(request); 11 | 12 | // Check if user has already agreed to these terms 13 | const { agreement } = await apiGetTermsAgreement(request, latestTerms.id); 14 | 15 | if (agreement) { 16 | return new Response("Already agreed", { status: 200 }); 17 | } 18 | 19 | // Insert new terms agreement 20 | await apiInsertTermsAgreement(request, latestTerms.id); 21 | 22 | return new Response("Agreed", { status: 200 }); 23 | } catch (error) { 24 | console.error("Terms agreement error:", error); 25 | 26 | if (error instanceof Error && error.message.includes("No terms found")) { 27 | return new Response("No terms found", { status: 400 }); 28 | } 29 | 30 | return new Response("Internal server error", { status: 500 }); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /packages/emails/src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Body, 4 | Container, 5 | Head, 6 | Html, 7 | Img, 8 | Preview, 9 | } from "@react-email/components"; 10 | import type { PropsWithChildren } from "react"; 11 | 12 | interface EmailLayoutProps extends PropsWithChildren { 13 | preview: string; 14 | } 15 | 16 | const EmailLayout = ({ children, preview }: EmailLayoutProps) => { 17 | return ( 18 | 19 | 20 | {preview} 21 | 22 | 23 | Sill logo 28 | {children} 29 | 30 | 31 | 32 | ); 33 | }; 34 | 35 | const bodyStyles = { 36 | backgroundColor: "#ffffff", 37 | margin: "0 auto", 38 | fontFamily: 39 | "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", 40 | }; 41 | 42 | const containerStyles = { 43 | margin: "0 auto", 44 | padding: "0px 20px", 45 | maxWidth: "500px", 46 | }; 47 | 48 | const imgStyles = { width: "100%", height: "auto" }; 49 | 50 | export default EmailLayout; 51 | -------------------------------------------------------------------------------- /packages/links/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sill/links", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "main": "./dist/index.js", 7 | "types": "./dist/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "development": { 11 | "types": "./src/index.ts", 12 | "import": "./src/index.ts" 13 | }, 14 | "production": { 15 | "types": "./dist/index.d.ts", 16 | "import": "./dist/index.js" 17 | }, 18 | "default": { 19 | "types": "./dist/index.d.ts", 20 | "import": "./dist/index.js" 21 | } 22 | } 23 | }, 24 | "scripts": { 25 | "build": "tsc", 26 | "dev": "tsc --watch", 27 | "dev:local": "tsc --watch", 28 | "typecheck": "tsc --noEmit" 29 | }, 30 | "dependencies": { 31 | "@atproto/api": "^0.14.0", 32 | "@atproto/oauth-client-node": "^0.2.5", 33 | "@aws-sdk/client-s3": "^3.864.0", 34 | "@sill/schema": "workspace:*", 35 | "@sill/auth": "workspace:*", 36 | "@sill/emails": "workspace:*", 37 | "cloudflare": "^4.4.1", 38 | "drizzle-orm": "^0.44.4", 39 | "masto": "^7.4.0", 40 | "open-graph-scraper-lite": "^2.1.0", 41 | "uuidv7-js": "^1.1.4", 42 | "zod": "^3.23.8" 43 | }, 44 | "devDependencies": { 45 | "typescript": "^5.1.6" 46 | } 47 | } -------------------------------------------------------------------------------- /apps/web/app/components/linkPosts/link/LinkImage.tsx: -------------------------------------------------------------------------------- 1 | import { AspectRatio, Inset, Link } from "@radix-ui/themes"; 2 | import styles from "../LinkRep.module.css"; 3 | import type { MostRecentLinkPosts } from "@sill/schema"; 4 | 5 | interface LinkImageProps { 6 | link: MostRecentLinkPosts["link"]; 7 | url: URL; 8 | layout: "dense" | "default"; 9 | } 10 | 11 | const LinkImage = ({ link, url, layout }: LinkImageProps) => { 12 | if (!link) return null; 13 | const shouldShowMainImage = 14 | link.imageUrl && 15 | layout === "default" && 16 | url.hostname !== "www.youtube.com" && 17 | url.hostname !== "youtu.be" && 18 | url.hostname !== "twitter.com"; 19 | 20 | return ( 21 | <> 22 | {shouldShowMainImage && ( 23 | 24 | 25 | 31 | 40 | 41 | 42 | 43 | )} 44 | 45 | ); 46 | }; 47 | 48 | export default LinkImage; 49 | -------------------------------------------------------------------------------- /packages/schema/src/migrations/0029_cultured_la_nuit.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "notification_group" ( 2 | "id" uuid PRIMARY KEY NOT NULL, 3 | "name" text NOT NULL, 4 | "query" json NOT NULL, 5 | "notificationType" "digest_type" DEFAULT 'email' NOT NULL, 6 | "feedUrl" text, 7 | "seenLinks" json DEFAULT '[]'::json NOT NULL, 8 | "userId" uuid NOT NULL, 9 | "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL 10 | ); 11 | --> statement-breakpoint 12 | CREATE TABLE IF NOT EXISTS "notification_item" ( 13 | "id" uuid PRIMARY KEY NOT NULL, 14 | "notificationGroupId" uuid NOT NULL, 15 | "itemData" json NOT NULL, 16 | "itemHtml" text, 17 | "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL 18 | ); 19 | --> statement-breakpoint 20 | DO $$ BEGIN 21 | ALTER TABLE "notification_group" ADD CONSTRAINT "notification_group_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; 22 | EXCEPTION 23 | WHEN duplicate_object THEN null; 24 | END $$; 25 | --> statement-breakpoint 26 | DO $$ BEGIN 27 | ALTER TABLE "notification_item" ADD CONSTRAINT "notification_item_notificationGroupId_notification_group_id_fk" FOREIGN KEY ("notificationGroupId") REFERENCES "public"."notification_group"("id") ON DELETE cascade ON UPDATE no action; 28 | EXCEPTION 29 | WHEN duplicate_object THEN null; 30 | END $$; 31 | -------------------------------------------------------------------------------- /apps/web/app/routes/_index.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Container } from "@radix-ui/themes"; 2 | import Features from "~/components/marketing/Features"; 3 | import HeroAnimation from "~/components/marketing/HeroAnimation"; 4 | import MainHero from "~/components/marketing/MainHero"; 5 | import MarketingFooter from "~/components/marketing/MarketingFooter"; 6 | import { requireAnonymousFromContext } from "~/utils/context.server"; 7 | import { TestimonialSection } from "../components/marketing/Testimonial"; 8 | import type { Route } from "./+types/_index"; 9 | 10 | export const meta: Route.MetaFunction = () => [ 11 | { title: "Sill | Top news shared by the people you trust" }, 12 | { 13 | name: "description", 14 | content: 15 | "Sill watches your Bluesky and Mastodon feeds to find the most popular links from your network.", 16 | }, 17 | ]; 18 | 19 | export const loader = async ({ context }: Route.LoaderArgs) => { 20 | await requireAnonymousFromContext(context); 21 | return {}; 22 | }; 23 | 24 | const Index = () => { 25 | return ( 26 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | ); 40 | }; 41 | 42 | export default Index; 43 | -------------------------------------------------------------------------------- /apps/web/app/components/download/ActionCard.tsx: -------------------------------------------------------------------------------- 1 | import { Badge, Button, Card, Flex, Heading, Text } from "@radix-ui/themes"; 2 | import type { ReactNode } from "react"; 3 | import { NavLink } from "react-router"; 4 | 5 | interface ActionCardProps { 6 | title: string; 7 | description: string; 8 | buttonText: string; 9 | buttonTo: string; 10 | isPremium?: boolean; 11 | buttonVariant?: "solid" | "outline"; 12 | } 13 | 14 | export default function ActionCard({ 15 | title, 16 | description, 17 | buttonText, 18 | buttonTo, 19 | isPremium = false, 20 | buttonVariant = "outline", 21 | }: ActionCardProps) { 22 | return ( 23 | 24 | 25 | 26 | 27 | {title} 28 | 29 | {isPremium && ( 30 | 31 | 32 | sill+ 33 | 34 | 35 | )} 36 | 37 | 38 | {description} 39 | 40 | 43 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /apps/web/app/components/linkPosts/LinksList.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Separator } from "@radix-ui/themes"; 2 | import LinkPostRep from "~/components/linkPosts/LinkPostRep"; 3 | import type { bookmark, MostRecentLinkPosts } from "@sill/schema"; 4 | import { useLayout } from "~/routes/resources/layout-switch"; 5 | import type { SubscriptionStatus } from "@sill/schema"; 6 | 7 | interface LinksListProps { 8 | links: MostRecentLinkPosts[]; 9 | instance: string | undefined; 10 | bsky: string | undefined; 11 | bookmarks: (typeof bookmark.$inferSelect)[]; 12 | subscribed: SubscriptionStatus; 13 | } 14 | 15 | const LinksList = ({ 16 | links, 17 | instance, 18 | bsky, 19 | bookmarks, 20 | subscribed, 21 | }: LinksListProps) => { 22 | const layout = useLayout(); 23 | 24 | return ( 25 |
26 | {links.map((linkPost, index) => ( 27 |
28 | 36 | {index < links.length - 1 && 37 | (layout === "default" ? ( 38 | 39 | ) : ( 40 | 41 | ))} 42 |
43 | ))} 44 |
45 | ); 46 | }; 47 | 48 | export default LinksList; 49 | -------------------------------------------------------------------------------- /packages/schema/src/migrations/0029_redundant_doctor_faustus.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "notification_group" ( 2 | "id" uuid PRIMARY KEY NOT NULL, 3 | "name" text NOT NULL, 4 | "query" json NOT NULL, 5 | "notificationType" "digest_type" DEFAULT 'email' NOT NULL, 6 | "feedUrl" text, 7 | "seenLinks" json DEFAULT '[]'::json NOT NULL, 8 | "userId" uuid NOT NULL, 9 | "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL 10 | ); 11 | --> statement-breakpoint 12 | CREATE TABLE IF NOT EXISTS "notification_item" ( 13 | "id" uuid PRIMARY KEY NOT NULL, 14 | "notificationGroupId" uuid NOT NULL, 15 | "itemData" json NOT NULL, 16 | "itemHtml" text, 17 | "createdAt" timestamp (3) DEFAULT CURRENT_TIMESTAMP NOT NULL 18 | ); 19 | --> statement-breakpoint 20 | DO $$ BEGIN 21 | ALTER TABLE "notification_group" ADD CONSTRAINT "notification_group_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; 22 | EXCEPTION 23 | WHEN duplicate_object THEN null; 24 | END $$; 25 | --> statement-breakpoint 26 | DO $$ BEGIN 27 | ALTER TABLE "notification_item" ADD CONSTRAINT "notification_item_notificationGroupId_notification_group_id_fk" FOREIGN KEY ("notificationGroupId") REFERENCES "public"."notification_group"("id") ON DELETE cascade ON UPDATE no action; 28 | EXCEPTION 29 | WHEN duplicate_object THEN null; 30 | END $$; 31 | -------------------------------------------------------------------------------- /apps/web/app/components/nav/Header.module.css: -------------------------------------------------------------------------------- 1 | .mobile-logo, 2 | .onboarding-logo { 3 | display: block; 4 | text-align: center; 5 | margin-top: 1rem; 6 | } 7 | 8 | .header-wrapper { 9 | border-bottom: 1px solid var(--gray-a6); 10 | z-index: 10; 11 | } 12 | 13 | .desktop-logo { 14 | display: none; 15 | text-align: left; 16 | margin-top: 1rem; 17 | margin-bottom: 1rem; 18 | } 19 | 20 | .marketing-logo { 21 | text-align: center; 22 | } 23 | 24 | @media (min-width: 1025px) { 25 | .mobile-logo { 26 | display: none; 27 | } 28 | 29 | .desktop-logo { 30 | display: block; 31 | } 32 | 33 | .marketing-logo h1 { 34 | font-size: 120px; 35 | } 36 | 37 | .header-wrapper { 38 | border-bottom: none; 39 | background: var(--gray-2); 40 | } 41 | .onboarding-logo { 42 | margin-bottom: -2rem; 43 | } 44 | } 45 | 46 | @keyframes fadeIn { 47 | from { 48 | left: -50%; 49 | } 50 | to { 51 | left: 0; 52 | } 53 | } 54 | 55 | @keyframes fadeOut { 56 | from { 57 | left: 0; 58 | } 59 | to { 60 | left: -50%; 61 | } 62 | } 63 | 64 | .dialog-content { 65 | height: 100vh; 66 | width: 50%; 67 | left: 0; 68 | position: fixed; 69 | top: 0; 70 | border-radius: 0; 71 | } 72 | 73 | .dialog-content[data-state="open"] { 74 | animation: fadeIn 150ms ease-out; 75 | } 76 | 77 | .dialog-content[data-state="closed"] { 78 | animation: fadeOut 150ms ease-in; 79 | } 80 | -------------------------------------------------------------------------------- /packages/schema/src/migrations/0011_material_rage.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "bluesky_list" RENAME TO "list";--> statement-breakpoint 2 | ALTER TABLE "list" DROP CONSTRAINT "bluesky_list_blueskyAccountId_bluesky_account_id_fk"; 3 | --> statement-breakpoint 4 | ALTER TABLE "list" DROP CONSTRAINT "bluesky_list_mastodonAccountId_mastodon_account_id_fk"; 5 | --> statement-breakpoint 6 | ALTER TABLE "post_list_subscription" DROP CONSTRAINT "post_list_subscription_listId_bluesky_list_id_fk"; 7 | --> statement-breakpoint 8 | DO $$ BEGIN 9 | ALTER TABLE "list" ADD CONSTRAINT "list_blueskyAccountId_bluesky_account_id_fk" FOREIGN KEY ("blueskyAccountId") REFERENCES "public"."bluesky_account"("id") ON DELETE cascade ON UPDATE no action; 10 | EXCEPTION 11 | WHEN duplicate_object THEN null; 12 | END $$; 13 | --> statement-breakpoint 14 | DO $$ BEGIN 15 | ALTER TABLE "list" ADD CONSTRAINT "list_mastodonAccountId_mastodon_account_id_fk" FOREIGN KEY ("mastodonAccountId") REFERENCES "public"."mastodon_account"("id") ON DELETE cascade ON UPDATE no action; 16 | EXCEPTION 17 | WHEN duplicate_object THEN null; 18 | END $$; 19 | --> statement-breakpoint 20 | DO $$ BEGIN 21 | ALTER TABLE "post_list_subscription" ADD CONSTRAINT "post_list_subscription_listId_list_id_fk" FOREIGN KEY ("listId") REFERENCES "public"."list"("id") ON DELETE cascade ON UPDATE no action; 22 | EXCEPTION 23 | WHEN duplicate_object THEN null; 24 | END $$; 25 | -------------------------------------------------------------------------------- /apps/web/app/routes/mastodon/auth.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "react-router"; 2 | import { apiMastodonAuthStart } from "~/utils/api-client.server"; 3 | import { createInstanceCookie } from "~/utils/session.server"; 4 | import type { Route } from "./+types/auth"; 5 | 6 | export const loader = async ({ request }: Route.LoaderArgs) => { 7 | const requestUrl = new URL(request.url); 8 | const referrer = 9 | request.headers.get("referer") || "/accounts/onboarding/social"; 10 | const instance = requestUrl.searchParams.get("instance"); 11 | 12 | if (!instance) { 13 | return null; 14 | } 15 | 16 | try { 17 | const result = await apiMastodonAuthStart(request, { instance }); 18 | 19 | // Create instance cookie and redirect to authorization URL 20 | return await createInstanceCookie( 21 | request, 22 | result.instance, 23 | result.redirectUrl, 24 | ); 25 | } catch (error) { 26 | console.error("Mastodon auth error:", error); 27 | 28 | // Handle specific error codes 29 | if (error instanceof Error && error.message.includes("instance")) { 30 | const errorUrl = new URL(referrer); 31 | errorUrl.searchParams.set("error", "instance"); 32 | return redirect(errorUrl.toString()); 33 | } 34 | 35 | // Generic error fallback 36 | const errorUrl = new URL(referrer); 37 | errorUrl.searchParams.set("error", "oauth"); 38 | return redirect(errorUrl.toString()); 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /apps/web/app/components/forms/TextInput.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex, Text, TextField } from "@radix-ui/themes"; 2 | import type React from "react"; 3 | import { useId } from "react"; 4 | import ErrorCallout from "./ErrorCallout"; 5 | 6 | interface FieldProps { 7 | labelProps: React.LabelHTMLAttributes; 8 | inputProps: TextField.RootProps & React.InputHTMLAttributes; 9 | errors?: ListOfErrors; 10 | } 11 | 12 | export type ListOfErrors = Array | null | undefined; 13 | 14 | const TextInput = ({ labelProps, inputProps, errors }: FieldProps) => { 15 | const fallbackId = useId(); 16 | const id = inputProps.id ?? fallbackId; 17 | const errorId = errors?.length ? `${id}-error` : undefined; 18 | return ( 19 | 20 | 21 | {/* biome-ignore lint/a11y/noLabelWithoutControl: will be used in a form elsewhere */} 22 | 27 | 28 | 34 | 35 | 36 | {errorId && errors && errors[0] && } 37 | 38 | ); 39 | }; 40 | 41 | export default TextInput; 42 | -------------------------------------------------------------------------------- /apps/web/app/components/forms/OTPInput.module.css: -------------------------------------------------------------------------------- 1 | .otp-container { 2 | display: flex; 3 | align-items: center; 4 | gap: 0.5rem; 5 | } 6 | 7 | .otp-container:disabled { 8 | opacity: 50%; 9 | } 10 | 11 | .otp:disabled { 12 | cursor: not-allowed; 13 | } 14 | 15 | .otp-group { 16 | display: flex; 17 | align-items: center; 18 | } 19 | 20 | .otp-slot { 21 | position: relative; 22 | display: flex; 23 | height: 2.5rem; 24 | width: 2.5rem; 25 | align-items: center; 26 | justify-content: center; 27 | border-top: 1px solid var(--accent-11); 28 | border-bottom: 1px solid var(--accent-11); 29 | border-right: 1px solid var(--accent-11); 30 | transition-property: all; 31 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 32 | transition-duration: 150ms; 33 | } 34 | 35 | .otp-slot:first-child { 36 | border-left: 1px solid var(--accent-11); 37 | border-top-left-radius: 0.375rem; 38 | border-bottom-left-radius: 0.375rem; 39 | } 40 | 41 | .otp-slot:last-child { 42 | border-top-right-radius: 0.375rem; 43 | border-bottom-right-radius: 0.375rem; 44 | } 45 | 46 | .otp-slot:active { 47 | z-index: 10; 48 | } 49 | 50 | .otp-caret-container { 51 | pointer-events: none; 52 | position: absolute; 53 | inset: 0px; 54 | display: flex; 55 | align-items: center; 56 | justify-content: center; 57 | } 58 | 59 | .otp-caret { 60 | height: 1rem; 61 | width: 1px; 62 | background-color: var(--accent-11); 63 | } 64 | -------------------------------------------------------------------------------- /packages/emails/src/emails/BlueskyAuthErrorEmail.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@react-email/components"; 2 | import React from "react"; 3 | import EmailHeading from "../components/Heading.js"; 4 | import EmailLayout from "../components/Layout.js"; 5 | import Lede from "../components/Lede.js"; 6 | 7 | interface BlueskyAuthErrorEmailProps { 8 | handle: string; 9 | settingsUrl: string; 10 | } 11 | 12 | const BlueskyAuthErrorEmail = ({ 13 | handle, 14 | settingsUrl, 15 | }: BlueskyAuthErrorEmailProps) => { 16 | return ( 17 | 18 | Reconnect your Bluesky account 19 | 20 | We're unable to access your Bluesky account (@{handle}) to fetch your 21 | timeline. This can happen for a number of reasons, including migrating 22 | your PDS, or intermittent errors in our OAuth process. We apologize for 23 | the inconvenience. 24 | 25 | 26 | To continue receiving links from Bluesky, please reconnect your account 27 | by logging into Sill. 28 | 29 | 40 | 41 | ); 42 | }; 43 | 44 | export default BlueskyAuthErrorEmail; 45 | -------------------------------------------------------------------------------- /packages/links/src/cloudflare.ts: -------------------------------------------------------------------------------- 1 | import Cloudflare from "cloudflare"; 2 | 3 | const cloudflare = new Cloudflare({ 4 | apiToken: process.env.CLOUDFLARE_API_TOKEN, 5 | }); 6 | 7 | export interface BrowserRenderOptions { 8 | url: string; 9 | timeout?: number; 10 | } 11 | 12 | export interface BrowserRenderResult { 13 | html: string; 14 | success: boolean; 15 | error?: string; 16 | } 17 | 18 | export async function renderPageContent( 19 | options: BrowserRenderOptions 20 | ): Promise { 21 | try { 22 | const { url, timeout = 30000 } = options; 23 | 24 | const response = await cloudflare.browserRendering.content.create({ 25 | account_id: process.env.CLOUDFLARE_ACCOUNT_ID!, 26 | url, 27 | viewport: { 28 | width: 1280, 29 | height: 720, 30 | }, 31 | actionTimeout: timeout, 32 | gotoOptions: { 33 | waitUntil: "networkidle2", 34 | }, 35 | rejectResourceTypes: ["stylesheet", "image", "font", "media"], 36 | }); 37 | 38 | return { 39 | html: response, 40 | success: true, 41 | }; 42 | } catch (error) { 43 | console.error( 44 | "[BROWSER RENDER] Cloudflare browser rendering error:", 45 | error 46 | ); 47 | return { 48 | html: "", 49 | success: false, 50 | error: error instanceof Error ? error.message : "Unknown error occurred", 51 | }; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/emails/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sill/emails", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "description": "Shared email templates for Sill", 7 | "main": "./dist/index.js", 8 | "types": "./dist/index.d.ts", 9 | "scripts": { 10 | "build": "tsc", 11 | "dev": "tsc --watch", 12 | "dev:local": "tsc --watch", 13 | "typecheck": "tsc --noEmit" 14 | }, 15 | "dependencies": { 16 | "@react-email/components": "^0.0.25", 17 | "@react-email/render": "^1.0.1", 18 | "@sill/schema": "workspace:*", 19 | "form-data": "^4.0.1", 20 | "javascript-time-ago": "^2.5.11", 21 | "mailgun.js": "^10.2.3", 22 | "node-html-markdown": "^1.3.0", 23 | "object.groupby": "^1.0.3", 24 | "react": "^18.0.0", 25 | "react-dom": "^18.0.0" 26 | }, 27 | "devDependencies": { 28 | "@types/node": "^22.0.0", 29 | "@types/object.groupby": "^1.0.4", 30 | "@types/react": "^18.0.0", 31 | "@types/react-dom": "^18.0.0", 32 | "typescript": "^5.0.0" 33 | }, 34 | "exports": { 35 | ".": { 36 | "development": { 37 | "types": "./src/index.ts", 38 | "import": "./src/index.ts" 39 | }, 40 | "production": { 41 | "types": "./dist/index.d.ts", 42 | "import": "./dist/index.js" 43 | }, 44 | "default": { 45 | "types": "./dist/index.d.ts", 46 | "import": "./dist/index.js" 47 | } 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /apps/web/app/components/linkPosts/CopyLink.tsx: -------------------------------------------------------------------------------- 1 | import { Box, IconButton, Text } from "@radix-ui/themes"; 2 | import { Check, Copy } from "lucide-react"; 3 | import { useEffect, useState } from "react"; 4 | import CopyToClipboard from "react-copy-to-clipboard"; 5 | 6 | const CopyLink = ({ 7 | url, 8 | textPositioning, 9 | layout, 10 | }: { url: string; textPositioning: object; layout: "default" | "dense" }) => { 11 | const [copied, setCopied] = useState(false); 12 | 13 | useEffect(() => { 14 | if (copied) { 15 | const timeout = setTimeout(() => { 16 | setCopied(false); 17 | }, 2000); 18 | return () => clearTimeout(timeout); 19 | } 20 | }, [copied]); 21 | 22 | return ( 23 | 24 | setCopied(true)}> 25 | 33 | {copied ? ( 34 | 38 | ) : ( 39 | 43 | )} 44 | 45 | 46 | {copied && Copied!} 47 | 48 | ); 49 | }; 50 | 51 | export default CopyLink; 52 | -------------------------------------------------------------------------------- /apps/web/app/components/marketing/WhySill.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Grid, Heading, Text } from "@radix-ui/themes"; 2 | import { Brain, Clock, Newspaper } from "lucide-react"; 3 | import styles from "./WhySill.module.css"; 4 | 5 | const benefits = [ 6 | { 7 | icon: , 8 | title: "Save Time", 9 | description: 10 | "Stop endlessly scrolling. Sill watches your timeline and surfaces what matters most.", 11 | }, 12 | { 13 | icon: , 14 | title: "Personalized News", 15 | description: 16 | "Get a curated feed of links shared by people you actually trust, not algorithmic recommendations.", 17 | }, 18 | { 19 | icon: , 20 | title: "Stop doomscrolling", 21 | description: 22 | "Stay informed without getting addicted. Focus on meaningful content, not endless feeds.", 23 | }, 24 | ]; 25 | 26 | const WhySill = () => { 27 | return ( 28 | 29 | 30 | Why Choose Sill? 31 | 32 | 33 | 34 | {benefits.map((benefit) => ( 35 | 36 | {benefit.icon} 37 | 38 | {benefit.title} 39 | 40 | 41 | {benefit.description} 42 | 43 | 44 | ))} 45 | 46 | 47 | ); 48 | }; 49 | 50 | export default WhySill; 51 | -------------------------------------------------------------------------------- /apps/web/app/components/forms/ListSwitch.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Spinner, Switch, Text } from "@radix-ui/themes"; 2 | import { useState } from "react"; 3 | import type { useFetcher } from "react-router"; 4 | import type { blueskyAccount, mastodonAccount } from "@sill/schema"; 5 | 6 | export interface ListOption { 7 | name: string; 8 | uri: string; 9 | type: "bluesky" | "mastodon"; 10 | subscribed: boolean; 11 | } 12 | 13 | interface ListSwitchProps { 14 | item: ListOption; 15 | account: 16 | | typeof blueskyAccount.$inferSelect 17 | | typeof mastodonAccount.$inferSelect; 18 | fetcher: ReturnType; 19 | } 20 | 21 | const ListSwitch = ({ item, account, fetcher }: ListSwitchProps) => { 22 | const [checked, setChecked] = useState(item.subscribed); 23 | 24 | const onCheckedChange = (e: boolean) => { 25 | fetcher.submit( 26 | { 27 | uri: item.uri, 28 | name: item.name, 29 | subscribe: e, 30 | accountId: account?.id, 31 | type: item.type, 32 | }, 33 | { 34 | method: "post", 35 | action: "/api/list/subscribe", 36 | }, 37 | ); 38 | setChecked(e); 39 | }; 40 | 41 | return ( 42 | 43 | 44 | {fetcher.state === "submitting" ? ( 45 | 46 | ) : ( 47 | 52 | )} 53 | {item.name} 54 | 55 | 56 | ); 57 | }; 58 | 59 | export default ListSwitch; 60 | -------------------------------------------------------------------------------- /apps/web/app/components/linkPosts/link/DisplayHost.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Text } from "@radix-ui/themes"; 2 | import type { MostRecentLinkPosts } from "@sill/schema"; 3 | 4 | interface DisplayHostProps { 5 | link: MostRecentLinkPosts["link"]; 6 | host: string; 7 | theme: string | undefined; 8 | image: boolean; 9 | } 10 | 11 | const DisplayHost = ({ link, host, theme, image }: DisplayHostProps) => { 12 | if (!link) return null; 13 | 14 | return ( 15 | 29 | 42 | 43 | {/* */} 48 | {link.siteName || host} 49 | {/* */} 50 | 51 | 52 | ); 53 | }; 54 | 55 | export default DisplayHost; 56 | -------------------------------------------------------------------------------- /apps/web/app/routes/mastodon/auth.callback.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "react-router"; 2 | import { apiMastodonAuthCallback } from "~/utils/api-client.server"; 3 | import { getInstanceCookie } from "~/utils/session.server"; 4 | import type { Route } from "./+types/auth.callback"; 5 | 6 | export const loader = async ({ request }: Route.LoaderArgs) => { 7 | const url = new URL(request.url); 8 | const instance = await getInstanceCookie(request); 9 | const code = url.searchParams.get("code"); 10 | 11 | if (!instance || !code) { 12 | return redirect("/settings?tabs=connect&error=instance"); 13 | } 14 | 15 | try { 16 | const response = await apiMastodonAuthCallback(request, { code, instance }); 17 | const result = await response.json(); 18 | 19 | if ("error" in result) { 20 | throw new Error(result.error); 21 | } 22 | 23 | if (result.success) { 24 | return redirect("/download?service=Mastodon"); 25 | } 26 | 27 | // Handle errors from API 28 | return redirect("/settings?tabs=connect&error=oauth"); 29 | } catch (error) { 30 | console.error("Mastodon callback error:", error); 31 | 32 | // Handle specific error codes from API 33 | if (error instanceof Error) { 34 | if (error.message.includes("Not authenticated")) { 35 | return redirect("/accounts/login?redirectTo=/settings"); 36 | } 37 | if (error.message.includes("instance")) { 38 | return redirect("/settings?tabs=connect&error=instance"); 39 | } 40 | } 41 | 42 | // Fallback error 43 | return redirect("/settings?tabs=connect&error=oauth"); 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /apps/web/app/utils/context.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect, type unstable_RouterContextProvider } from "react-router"; 2 | import { userContext, type UserProfile } from "~/context/user-context"; 3 | 4 | /** 5 | * Get user profile from context (set by middleware) 6 | */ 7 | export async function getUserFromContext( 8 | context: Readonly 9 | ): Promise { 10 | return context.get(userContext); 11 | } 12 | 13 | /** 14 | * Require user to be authenticated - throws redirect if not 15 | */ 16 | export async function requireUserFromContext( 17 | context: Readonly, 18 | redirectTo?: string 19 | ): Promise { 20 | const user = await getUserFromContext(context); 21 | 22 | if (!user) { 23 | const finalRedirectTo = redirectTo || "/accounts/login"; 24 | throw redirect(finalRedirectTo); 25 | } 26 | 27 | return user; 28 | } 29 | 30 | /** 31 | * Get user ID from context 32 | */ 33 | export async function getUserIdFromContext( 34 | context: Readonly 35 | ): Promise { 36 | const user = await getUserFromContext(context); 37 | return user?.id || null; 38 | } 39 | 40 | /** 41 | * Require user to be anonymous - throws redirect if authenticated 42 | */ 43 | export async function requireAnonymousFromContext( 44 | context: Readonly 45 | ): Promise { 46 | const user = await getUserFromContext(context); 47 | 48 | if (user) { 49 | throw redirect("/links"); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres:latest 4 | volumes: 5 | - sill_postgres_db:/var/lib/postgresql/data 6 | environment: 7 | - POSTGRES_USER=${POSTGRES_USER} 8 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} 9 | - POSTGRES_DB=${POSTGRES_DB} 10 | - POSTGRES_HOST_AUTH_METHOD=trust 11 | networks: 12 | - net 13 | env_file: 14 | - .env 15 | healthcheck: 16 | test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] 17 | interval: 10s 18 | timeout: 5s 19 | retries: 5 20 | logging: 21 | driver: json-file 22 | options: 23 | max-size: "50m" 24 | max-file: "6" 25 | 26 | pgbouncer: 27 | container_name: pgbouncer 28 | image: edoburu/pgbouncer:latest 29 | environment: 30 | - DB_USER=postgres 31 | - DB_PASSWORD=postgres 32 | - DB_HOST=db 33 | - DB_NAME=sill 34 | - AUTH_TYPE=scram-sha-256 35 | - POOL_MODE=transaction 36 | - ADMIN_USERS=postgres 37 | - MAX_CLIENT_CONN=200 38 | - MIN_POOL_SIZE=10 39 | - RESERVE_POOL_SIZE=10 40 | - MAX_DB_CONNECTIONS=50 41 | ports: 42 | - "5432:5432" 43 | depends_on: 44 | - db 45 | networks: 46 | - net 47 | logging: 48 | driver: json-file 49 | options: 50 | max-size: "50m" 51 | max-file: "6" 52 | healthcheck: 53 | test: ['CMD', 'pg_isready', '-h', 'localhost'] 54 | 55 | volumes: 56 | sill_postgres_db: 57 | 58 | networks: 59 | net: 60 | driver: bridge -------------------------------------------------------------------------------- /apps/web/app/routes/links/topic.tsx: -------------------------------------------------------------------------------- 1 | import { invariantResponse } from "@epic-web/invariant"; 2 | import LinksList from "~/components/linkPosts/LinksList"; 3 | import Layout from "~/components/nav/Layout"; 4 | import PageHeading from "~/components/nav/PageHeading"; 5 | import type { Route } from "./+types/topic"; 6 | import { requireUserFromContext } from "~/utils/context.server"; 7 | import { apiFindLinksByTopic } from "~/utils/api-client.server"; 8 | 9 | export const loader = async ({ params, context, request }: Route.LoaderArgs) => { 10 | const existingUser = await requireUserFromContext(context); 11 | const subscribed = existingUser.subscriptionStatus; 12 | 13 | invariantResponse(existingUser, "Not found", { status: 404 }); 14 | 15 | const topic = params.topic; 16 | 17 | const links = await apiFindLinksByTopic(request, { topic }); 18 | 19 | return { 20 | links, 21 | instance: existingUser.mastodonAccounts[0].mastodonInstance.instance, 22 | bsky: existingUser.blueskyAccounts[0].handle, 23 | bookmarks: existingUser.bookmarks, 24 | subscribed, 25 | topic, 26 | }; 27 | }; 28 | 29 | const LinksByTopic = ({ loaderData }: Route.ComponentProps) => { 30 | const { links, instance, bsky, bookmarks, subscribed, topic } = loaderData; 31 | 32 | return ( 33 | 34 | 35 | 42 | 43 | ); 44 | }; 45 | 46 | export default LinksByTopic; 47 | -------------------------------------------------------------------------------- /apps/web/app/components/subscription/FeatureCard.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex, Heading, Text } from "@radix-ui/themes"; 2 | import type { ReactElement } from "react"; 3 | import styles from "./FeatureCard.module.css"; 4 | 5 | interface FeatureCardProps { 6 | icon: ReactElement; 7 | title: string; 8 | description: string; 9 | benefit: string; 10 | url?: string; 11 | } 12 | 13 | export default function FeatureCard({ 14 | icon, 15 | title, 16 | description, 17 | benefit, 18 | url, 19 | }: FeatureCardProps) { 20 | const content = ( 21 | 31 | 32 | 33 | 34 | {icon} 35 | 36 | {title} 37 | 38 | 39 | 40 | {description} 41 | 42 | 43 | 44 | {benefit} 45 | 46 | 47 | 48 | ); 49 | 50 | if (url) { 51 | return ( 52 | 58 | {content} 59 | 60 | ); 61 | } 62 | 63 | return content; 64 | } 65 | -------------------------------------------------------------------------------- /apps/web/app/routes/links/author.tsx: -------------------------------------------------------------------------------- 1 | import { invariantResponse } from "@epic-web/invariant"; 2 | import LinksList from "~/components/linkPosts/LinksList"; 3 | import Layout from "~/components/nav/Layout"; 4 | import PageHeading from "~/components/nav/PageHeading"; 5 | import type { Route } from "./+types/author"; 6 | import { requireUserFromContext } from "~/utils/context.server"; 7 | import { apiFindLinksByAuthor } from "~/utils/api-client.server"; 8 | 9 | export const loader = async ({ params, context, request }: Route.LoaderArgs) => { 10 | const existingUser = await requireUserFromContext(context); 11 | const subscribed = existingUser.subscriptionStatus; 12 | 13 | invariantResponse(existingUser, "Not found", { status: 404 }); 14 | 15 | const author = params.author; 16 | const links = await apiFindLinksByAuthor(request, { author }); 17 | 18 | return { 19 | links, 20 | instance: existingUser.mastodonAccounts[0].mastodonInstance.instance, 21 | bsky: existingUser.blueskyAccounts[0].handle, 22 | bookmarks: existingUser.bookmarks, 23 | subscribed, 24 | author, 25 | }; 26 | }; 27 | 28 | const LinksByAuthor = ({ loaderData }: Route.ComponentProps) => { 29 | const { links, instance, bsky, bookmarks, subscribed, author } = loaderData; 30 | 31 | return ( 32 | 33 | 34 | 41 | 42 | ); 43 | }; 44 | 45 | export default LinksByAuthor; 46 | -------------------------------------------------------------------------------- /apps/web/app/routes/api/link.update-metadata.ts: -------------------------------------------------------------------------------- 1 | import { parseWithZod } from "@conform-to/zod"; 2 | import { data } from "react-router"; 3 | import { z } from "zod"; 4 | import { requireUserFromContext } from "~/utils/context.server"; 5 | import { apiUpdateLinkMetadata } from "~/utils/api-client.server"; 6 | import type { Route } from "./+types/link.update-metadata"; 7 | 8 | const UpdateLinkMetadataSchema = z.object({ 9 | url: z.string().url(), 10 | metadata: z.string().transform((val) => JSON.parse(val)), 11 | }); 12 | 13 | export const action = async ({ request, context }: Route.ActionArgs) => { 14 | await requireUserFromContext(context); 15 | 16 | const formData = await request.formData(); 17 | const submission = parseWithZod(formData, { 18 | schema: UpdateLinkMetadataSchema, 19 | }); 20 | 21 | if (submission.status !== "success") { 22 | return data( 23 | { 24 | result: submission.reply(), 25 | }, 26 | { 27 | status: submission.status === "error" ? 400 : 200, 28 | }, 29 | ); 30 | } 31 | 32 | try { 33 | const result = await apiUpdateLinkMetadata(request, { 34 | url: submission.value.url, 35 | metadata: submission.value.metadata, 36 | }); 37 | 38 | return data({ 39 | result: submission.reply(), 40 | data: result, 41 | }); 42 | } catch (error) { 43 | console.error("Update link metadata error:", error); 44 | 45 | return data( 46 | { 47 | result: submission.reply({ 48 | formErrors: ["Failed to update link metadata. Please try again."], 49 | }), 50 | }, 51 | { status: 500 }, 52 | ); 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /apps/web/app/routes/links/domain.tsx: -------------------------------------------------------------------------------- 1 | import { invariantResponse } from "@epic-web/invariant"; 2 | import LinksList from "~/components/linkPosts/LinksList"; 3 | import Layout from "~/components/nav/Layout"; 4 | import PageHeading from "~/components/nav/PageHeading"; 5 | import type { Route } from "./+types/domain"; 6 | import { requireUserFromContext } from "~/utils/context.server"; 7 | import { apiFindLinksByDomain } from "~/utils/api-client.server"; 8 | 9 | export const loader = async ({ params, context, request }: Route.LoaderArgs) => { 10 | const existingUser = await requireUserFromContext(context); 11 | const subscribed = existingUser.subscriptionStatus; 12 | 13 | invariantResponse(existingUser, "Not found", { status: 404 }); 14 | 15 | const domain = params.domain; 16 | 17 | const links = await apiFindLinksByDomain(request, { domain }); 18 | 19 | return { 20 | links, 21 | instance: existingUser.mastodonAccounts[0].mastodonInstance.instance, 22 | bsky: existingUser.blueskyAccounts[0].handle, 23 | bookmarks: existingUser.bookmarks, 24 | subscribed, 25 | domain, 26 | }; 27 | }; 28 | 29 | const LinksByDomain = ({ loaderData }: Route.ComponentProps) => { 30 | const { links, instance, bsky, bookmarks, subscribed, domain } = loaderData; 31 | 32 | return ( 33 | 34 | 35 | 42 | 43 | ); 44 | }; 45 | 46 | export default LinksByDomain; 47 | -------------------------------------------------------------------------------- /apps/web/app/components/linkPosts/BookmarkLink.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton, Spinner } from "@radix-ui/themes"; 2 | import { Bookmark } from "lucide-react"; 3 | import { useState } from "react"; 4 | import { useFetcher } from "react-router"; 5 | import AddBookmarkDialog from "~/components/forms/AddBookmarkDialog"; 6 | 7 | const BookmarkLink = ({ 8 | url, 9 | isBookmarked, 10 | hasBlueskyAccount = false, 11 | }: { url: string; isBookmarked: boolean; hasBlueskyAccount?: boolean }) => { 12 | const fetcher = useFetcher(); 13 | const [open, setOpen] = useState(false); 14 | 15 | const handleIconClick = () => { 16 | if (isBookmarked) { 17 | fetcher.submit( 18 | { url }, 19 | { method: "DELETE", action: "/bookmarks/delete" }, 20 | ); 21 | } else { 22 | setOpen(true); 23 | } 24 | }; 25 | 26 | return ( 27 | <> 28 | 36 | {fetcher.state === "submitting" || fetcher.state === "loading" ? ( 37 | 38 | ) : ( 39 | 44 | )} 45 | 46 | 47 | 53 | 54 | ); 55 | }; 56 | 57 | export default BookmarkLink; 58 | -------------------------------------------------------------------------------- /apps/web/app/utils/client-hints.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This file contains utilities for using client hints for user preference which 3 | * are needed by the server, but are only known by the browser. 4 | */ 5 | import { getHintUtils } from "@epic-web/client-hints"; 6 | import { 7 | clientHint as colorSchemeHint, 8 | subscribeToSchemeChange, 9 | } from "@epic-web/client-hints/color-scheme"; 10 | import { clientHint as timeZoneHint } from "@epic-web/client-hints/time-zone"; 11 | import * as React from "react"; 12 | import { useRevalidator } from "react-router"; 13 | import { useRequestInfo } from "./request-info"; 14 | 15 | const hintsUtils = getHintUtils({ 16 | theme: colorSchemeHint, 17 | timeZone: timeZoneHint, 18 | // add other hints here 19 | }); 20 | 21 | export const { getHints } = hintsUtils; 22 | 23 | /** 24 | * @returns an object with the client hints and their values 25 | */ 26 | export function useHints() { 27 | const requestInfo = useRequestInfo(); 28 | return requestInfo.hints; 29 | } 30 | 31 | /** 32 | * @returns inline script element that checks for client hints and sets cookies 33 | * if they are not set then reloads the page if any cookie was set to an 34 | * inaccurate value. 35 | */ 36 | export function ClientHintCheck({ nonce }: { nonce: string }) { 37 | const { revalidate } = useRevalidator(); 38 | React.useEffect( 39 | () => subscribeToSchemeChange(() => revalidate()), 40 | [revalidate], 41 | ); 42 | 43 | return ( 44 |