├── public
├── robots.txt
├── 404-text.png
├── favicon.png
├── octostar.png
├── spaceship.png
├── guirlande1.png
├── guirlande2.png
├── guirlande3.png
├── octoshadow.png
├── favicon-dark.png
├── spaceship-shadow.png
├── light_theme_preview.svg
├── dark_theme_preview.svg
├── favicon-dark.svg
├── favicon.svg
└── next.svg
├── .eslintrc.json
├── drizzle
├── 0074_dear_prowler.sql
├── 0001_living_morlocks.sql
├── 0002_clever_glorian.sql
├── 0007_certain_skrulls.sql
├── 0020_swift_exodus.sql
├── 0033_naive_landau.sql
├── 0032_cold_tattoo.sql
├── 0040_awesome_leech.sql
├── 0066_hot_quasimodo.sql
├── 0034_rainy_ezekiel.sql
├── 0039_marvelous_mercury.sql
├── 0009_lean_hammerhead.sql
├── 0022_early_rick_jones.sql
├── 0042_gorgeous_doctor_spectrum.sql
├── 0044_brainy_jazinda.sql
├── 0065_sad_dagger.sql
├── 0069_petite_shriek.sql
├── 0008_free_ghost_rider.sql
├── 0021_curly_captain_universe.sql
├── 0057_goofy_mephisto.sql
├── 0059_cuddly_grim_reaper.sql
├── 0062_naive_jubilee.sql
├── 0043_blue_tusk.sql
├── 0052_goofy_prism.sql
├── 0026_first_whizzer.sql
├── 0018_gifted_kate_bishop.sql
├── 0004_gifted_exodus.sql
├── 0003_zippy_black_tarantula.sql
├── 0005_pretty_crusher_hogan.sql
├── 0016_glorious_mariko_yashida.sql
├── 0031_ambiguous_wasp.sql
├── 0017_burly_nitro.sql
├── 0075_volatile_harry_osborn.sql
├── 0041_damp_jack_murdock.sql
├── 0050_special_blue_blade.sql
├── 0049_flowery_ikaris.sql
├── 0067_rapid_thunderbolt_ross.sql
├── 0070_even_princess_powerful.sql
├── 0068_wakeful_skaar.sql
├── 0028_needy_katie_power.sql
├── 0036_careful_freak.sql
├── 0047_same_stark_industries.sql
├── 0019_real_william_stryker.sql
├── 0024_reflective_zzzax.sql
├── 0045_bent_roulette.sql
├── 0014_tricky_scalphunter.sql
├── 0063_brief_colossus.sql
├── 0071_swift_captain_cross.sql
├── 0015_lucky_sentinels.sql
├── 0013_rainy_princess_powerful.sql
├── 0029_nasty_triathlon.sql
├── 0037_cynical_peter_parker.sql
├── 0035_deep_mephisto.sql
├── 0058_flimsy_ender_wiggin.sql
├── 0006_confused_the_watchers.sql
├── 0051_tricky_tempest.sql
├── 0023_bored_texas_twister.sql
├── 0012_brainy_invisible_woman.sql
├── 0027_spicy_meltdown.sql
├── 0056_kind_whirlwind.sql
├── 0073_sad_crusher_hogan.sql
├── 0061_striped_monster_badoon.sql
├── 0053_familiar_dragon_man.sql
├── 0054_marvelous_living_lightning.sql
├── 0055_nasty_toad_men.sql
├── 0060_wealthy_chimera.sql
├── 0010_nosy_toad.sql
├── 0025_mute_wilson_fisk.sql
├── 0064_faithful_ben_parker.sql
├── 0072_oval_fallen_one.sql
├── 0048_dusty_leper_queen.sql
├── 0046_square_ikaris.sql
├── 0038_common_hiroim.sql
├── 0030_yielding_blonde_phantom.sql
└── 0011_mixed_falcon.sql
├── docker
├── Dockerfile.redis
├── docker-stack.prod.yaml
├── webdis.json
├── docker-stack.local.yaml
└── Dockerfile.prod
├── pr-preview-workflow
├── bun.lockb
├── package.json
├── README.md
├── tsconfig.json
├── index.ts
├── add-caddyfile.ts
└── add-docker-app.ts
├── src
├── app
│ ├── (app)
│ │ ├── @page_title
│ │ │ ├── default.tsx
│ │ │ ├── [user]
│ │ │ │ ├── [repository]
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ └── [...pages]
│ │ │ │ │ │ └── page.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── page.tsx
│ │ │ ├── settings
│ │ │ │ ├── page.tsx
│ │ │ │ └── [...pages]
│ │ │ │ │ └── page.tsx
│ │ │ └── notifications
│ │ │ │ └── page.tsx
│ │ ├── @header_subnav
│ │ │ ├── [user]
│ │ │ │ ├── page.tsx
│ │ │ │ └── [repository]
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ └── [...pages]
│ │ │ │ │ └── page.tsx
│ │ │ ├── default.tsx
│ │ │ ├── settings
│ │ │ │ ├── page.tsx
│ │ │ │ └── [...pages]
│ │ │ │ │ └── page.tsx
│ │ │ └── notifications
│ │ │ │ └── page.tsx
│ │ ├── notifications
│ │ │ └── page.tsx
│ │ ├── settings
│ │ │ ├── page.tsx
│ │ │ ├── appearance
│ │ │ │ └── page.tsx
│ │ │ ├── layout.tsx
│ │ │ └── account
│ │ │ │ └── page.tsx
│ │ ├── [user]
│ │ │ ├── page.tsx
│ │ │ └── [repository]
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── actions
│ │ │ │ └── page.tsx
│ │ │ │ └── issues
│ │ │ │ └── new
│ │ │ │ └── page.tsx
│ │ └── layout.tsx
│ ├── client-providers.tsx
│ ├── global-error.tsx
│ ├── layout.tsx
│ └── not-found.tsx
├── lib
│ ├── server
│ │ ├── kv
│ │ │ ├── sqlite
│ │ │ │ ├── config.ts
│ │ │ │ ├── kv-entry.sql.ts
│ │ │ │ └── index.ts
│ │ │ ├── index.server.ts
│ │ │ ├── cloudfare.ts
│ │ │ └── http.ts
│ │ ├── db
│ │ │ ├── schema
│ │ │ │ ├── index.sql.ts
│ │ │ │ ├── repository.sql.ts
│ │ │ │ ├── mention.sql.ts
│ │ │ │ ├── user.sql.ts
│ │ │ │ └── label.sql.ts
│ │ │ └── index.server.ts
│ │ ├── rsc-utils.server.ts
│ │ └── utils.server.ts
│ ├── client
│ │ ├── hooks
│ │ │ ├── use-typed-params.ts
│ │ │ ├── use-active-link.ts
│ │ │ └── use-media-query.ts
│ │ └── pauseable-timeout.ts
│ └── shared
│ │ ├── cache-keys.shared.ts
│ │ └── constants.ts
├── models
│ ├── dto
│ │ ├── public-user-output-validator.ts
│ │ ├── repository-output-validator.ts
│ │ ├── update-profile-info-input-validator.ts
│ │ └── issue-search-output-validator.ts
│ ├── label.ts
│ └── repository.ts
├── actions
│ ├── markdown.action.tsx
│ ├── middlewares.ts
│ ├── user.action.ts
│ └── theme.action.ts
├── components
│ ├── card.tsx
│ ├── badge.tsx
│ ├── custom-rsc-renderer
│ │ ├── rsc-manifest.ts
│ │ ├── render-rsc-to-string.ts
│ │ ├── load-client-references.ts
│ │ └── rsc-client-renderer.tsx
│ ├── issues
│ │ ├── use-issue-author-list-query.ts
│ │ ├── use-issue-author-list-by-name-query.ts
│ │ ├── use-issue-label-list-query.ts
│ │ ├── use-issue-assignee-list-query.ts
│ │ ├── issue-row-skeleton.tsx
│ │ ├── clear-search-button.tsx
│ │ ├── issue-row-avatar-stack.tsx
│ │ └── issue-search-link.tsx
│ ├── user-dropdown
│ │ ├── user-dropdown.server.tsx
│ │ └── user-dropdown.client.tsx
│ ├── markdown
│ │ ├── markdown-error-boundary.tsx
│ │ ├── markdown-title.tsx
│ │ ├── markdown-skeleton.tsx
│ │ ├── markdown-h.tsx
│ │ └── markdown-code-block.tsx
│ ├── skip-to-main-button.tsx
│ ├── counter-badge.tsx
│ ├── segmented-layout.tsx
│ ├── skeleton.tsx
│ ├── toast
│ │ ├── toaster.server.tsx
│ │ └── toaster.client.tsx
│ ├── loading-indicator.tsx
│ ├── avatar.tsx
│ ├── react-query-provider.tsx
│ ├── submit-button.tsx
│ ├── tailwind-indicator.tsx
│ ├── theme-form.tsx
│ ├── x-mas-decorations.tsx
│ ├── label-badge.tsx
│ ├── icon-switcher.tsx
│ ├── settings-vertical-navlist.tsx
│ ├── cache.tsx
│ ├── top-loader.tsx
│ ├── footer.tsx
│ ├── nav-link.tsx
│ ├── markdown-editor
│ │ └── markdown-editor-preview.tsx
│ ├── vertical-nav-link.tsx
│ ├── hovercard
│ │ ├── user-hovercard-contents.tsx
│ │ ├── hovercard.tsx
│ │ └── issue-hovercard-link.tsx
│ ├── tooltip.tsx
│ ├── theme-card.tsx
│ ├── header
│ │ └── header-navlinks.tsx
│ └── update-user-infos-form.tsx
├── env.ts
└── env-config.mjs
├── .dockerignore
├── postcss.config.cjs
├── prettier.config.cjs
├── dc-build-local.sh
├── drizzle.config.ts
├── migrate.mjs
├── .env.example
├── .vscode
└── settings.json
├── searching.md
├── ecosystem.config.cjs
├── biome.json
├── .github
├── ISSUE_TEMPLATE
│ ├── feature_request.md
│ ├── refactor.md
│ └── bug_report.md
└── pull_request_template.md
├── docker-compose.yaml
├── next.config.mjs
├── .gitignore
├── globals.d.ts
├── tsconfig.script.json
├── tsconfig.json
├── LICENSE
├── latency.ts
├── scripts
└── fetchUsers.ts
├── rsdw.d.ts
└── CONTRIBUTING.md
/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow: /
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/drizzle/0074_dear_prowler.sql:
--------------------------------------------------------------------------------
1 | DROP INDEX IF EXISTS "repo_name_idx";
--------------------------------------------------------------------------------
/public/404-text.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Fredkiss3/gh-next/HEAD/public/404-text.png
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Fredkiss3/gh-next/HEAD/public/favicon.png
--------------------------------------------------------------------------------
/public/octostar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Fredkiss3/gh-next/HEAD/public/octostar.png
--------------------------------------------------------------------------------
/public/spaceship.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Fredkiss3/gh-next/HEAD/public/spaceship.png
--------------------------------------------------------------------------------
/drizzle/0001_living_morlocks.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_users" ADD COLUMN "company" varchar(255);
--------------------------------------------------------------------------------
/drizzle/0002_clever_glorian.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issues" RENAME COLUMN "description" TO "body";
--------------------------------------------------------------------------------
/drizzle/0007_certain_skrulls.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issues" ALTER COLUMN "number" SET NOT NULL;
--------------------------------------------------------------------------------
/drizzle/0020_swift_exodus.sql:
--------------------------------------------------------------------------------
1 | CREATE INDEX IF NOT EXISTS "title_idx" ON "gh_next_issues" ("title");
--------------------------------------------------------------------------------
/drizzle/0033_naive_landau.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issues" ALTER COLUMN "title" SET DATA TYPE text;
--------------------------------------------------------------------------------
/public/guirlande1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Fredkiss3/gh-next/HEAD/public/guirlande1.png
--------------------------------------------------------------------------------
/public/guirlande2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Fredkiss3/gh-next/HEAD/public/guirlande2.png
--------------------------------------------------------------------------------
/public/guirlande3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Fredkiss3/gh-next/HEAD/public/guirlande3.png
--------------------------------------------------------------------------------
/public/octoshadow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Fredkiss3/gh-next/HEAD/public/octoshadow.png
--------------------------------------------------------------------------------
/drizzle/0032_cold_tattoo.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issues" ALTER COLUMN "repository_id" SET NOT NULL;
--------------------------------------------------------------------------------
/public/favicon-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Fredkiss3/gh-next/HEAD/public/favicon-dark.png
--------------------------------------------------------------------------------
/docker/Dockerfile.redis:
--------------------------------------------------------------------------------
1 | FROM nicolas/webdis:0.1.22
2 |
3 | COPY ./docker/webdis.json /etc/webdis.prod.json
--------------------------------------------------------------------------------
/drizzle/0040_awesome_leech.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issues" ALTER COLUMN "title" SET DATA TYPE varchar(500);
--------------------------------------------------------------------------------
/drizzle/0066_hot_quasimodo.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issues" ADD COLUMN "author_username_cs" varchar(255);
--------------------------------------------------------------------------------
/public/spaceship-shadow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Fredkiss3/gh-next/HEAD/public/spaceship-shadow.png
--------------------------------------------------------------------------------
/drizzle/0034_rainy_ezekiel.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_repositories" ADD COLUMN "is_public" boolean DEFAULT true;
--------------------------------------------------------------------------------
/drizzle/0039_marvelous_mercury.sql:
--------------------------------------------------------------------------------
1 | CREATE INDEX IF NOT EXISTS "iss_status_idx" ON "gh_next_issues" ("status");
--------------------------------------------------------------------------------
/pr-preview-workflow/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Fredkiss3/gh-next/HEAD/pr-preview-workflow/bun.lockb
--------------------------------------------------------------------------------
/src/app/(app)/@page_title/default.tsx:
--------------------------------------------------------------------------------
1 | export default function DefaultPageTitle() {
2 | return null;
3 | }
4 |
--------------------------------------------------------------------------------
/src/app/(app)/@header_subnav/[user]/page.tsx:
--------------------------------------------------------------------------------
1 | export default function UserHeaderSubNav() {
2 | return null;
3 | }
4 |
--------------------------------------------------------------------------------
/src/app/(app)/@header_subnav/default.tsx:
--------------------------------------------------------------------------------
1 | export default function DefaultHeaderSubNav() {
2 | return null;
3 | }
4 |
--------------------------------------------------------------------------------
/src/app/(app)/notifications/page.tsx:
--------------------------------------------------------------------------------
1 | export default async function NotificationsPage() {
2 | return <>>;
3 | }
4 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .env
2 | .env.local
3 | Dockerfile
4 | .dockerignore
5 | node_modules
6 | npm-debug.log
7 | README.md
8 | .git
--------------------------------------------------------------------------------
/drizzle/0009_lean_hammerhead.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_labels" ADD CONSTRAINT "gh_next_labels_name_unique" UNIQUE("name");
--------------------------------------------------------------------------------
/drizzle/0022_early_rick_jones.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issues" ADD COLUMN "updated_at" timestamp DEFAULT now() NOT NULL;
--------------------------------------------------------------------------------
/drizzle/0042_gorgeous_doctor_spectrum.sql:
--------------------------------------------------------------------------------
1 | CREATE INDEX IF NOT EXISTS "rakt_type_idx" ON "gh_next_reactions" ("author_id");
--------------------------------------------------------------------------------
/drizzle/0044_brainy_jazinda.sql:
--------------------------------------------------------------------------------
1 | CREATE INDEX IF NOT EXISTS "ment_issue_idx" ON "gh_next_issue_user_mentions" ("issue_id");
--------------------------------------------------------------------------------
/drizzle/0065_sad_dagger.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issues_to_assignees" ALTER COLUMN "assignee_username_cs" DROP NOT NULL;
--------------------------------------------------------------------------------
/drizzle/0069_petite_shriek.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issues_to_assignees" ADD COLUMN "assignee_username_cs" varchar(255);
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {}
5 | }
6 | };
7 |
--------------------------------------------------------------------------------
/drizzle/0008_free_ghost_rider.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issues" ADD CONSTRAINT "gh_next_issues_number_unique" UNIQUE("number");
--------------------------------------------------------------------------------
/src/app/(app)/@header_subnav/settings/page.tsx:
--------------------------------------------------------------------------------
1 | export default function SettingsHeaderSubNav() {
2 | return null;
3 | }
4 |
--------------------------------------------------------------------------------
/drizzle/0021_curly_captain_universe.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issues" ADD COLUMN "status_updated_at" timestamp DEFAULT now() NOT NULL;
--------------------------------------------------------------------------------
/drizzle/0057_goofy_mephisto.sql:
--------------------------------------------------------------------------------
1 | CREATE INDEX IF NOT EXISTS "ev_initiator_uname_idx" ON "gh_next_issue_events" ("initiator_username");
--------------------------------------------------------------------------------
/src/app/(app)/@header_subnav/notifications/page.tsx:
--------------------------------------------------------------------------------
1 | export default function SettingsHeaderSubNav() {
2 | return null;
3 | }
4 |
--------------------------------------------------------------------------------
/drizzle/0059_cuddly_grim_reaper.sql:
--------------------------------------------------------------------------------
1 | CREATE INDEX IF NOT EXISTS "ev_assignee_uname_idx" ON "gh_next_issue_events" ("assignee_username");
--------------------------------------------------------------------------------
/src/app/(app)/@header_subnav/settings/[...pages]/page.tsx:
--------------------------------------------------------------------------------
1 | export default function SettingsHeaderSubNav() {
2 | return null;
3 | }
4 |
--------------------------------------------------------------------------------
/drizzle/0062_naive_jubilee.sql:
--------------------------------------------------------------------------------
1 | CREATE INDEX IF NOT EXISTS "is_2_ass_assignee_uname_idx" ON "gh_next_issues_to_assignees" ("assignee_username");
--------------------------------------------------------------------------------
/drizzle/0043_blue_tusk.sql:
--------------------------------------------------------------------------------
1 | DROP INDEX IF EXISTS "rakt_type_idx";--> statement-breakpoint
2 | CREATE INDEX IF NOT EXISTS "rakt_type_idx" ON "gh_next_reactions" ("type");
--------------------------------------------------------------------------------
/drizzle/0052_goofy_prism.sql:
--------------------------------------------------------------------------------
1 | -- Custom SQL migration file, put you code below! --
2 | CREATE COLLATION IF NOT EXISTS ci (provider = 'icu', locale = 'en-US-u-ks-level2', deterministic = false);
--------------------------------------------------------------------------------
/drizzle/0026_first_whizzer.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issue_user_mentions" ADD CONSTRAINT "gh_next_issue_user_mentions_username_issue_id_comment_id_unique" UNIQUE("username","issue_id","comment_id");
--------------------------------------------------------------------------------
/drizzle/0018_gifted_kate_bishop.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issues" ADD COLUMN "lock_reason" "issue_lock_reason";--> statement-breakpoint
2 | ALTER TABLE "gh_next_issue_events" DROP COLUMN IF EXISTS "lock_reason";
--------------------------------------------------------------------------------
/drizzle/0004_gifted_exodus.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issues" ADD COLUMN "author_username" varchar(255) NOT NULL;--> statement-breakpoint
2 | ALTER TABLE "gh_next_issues" ADD COLUMN "author_avatar_url" varchar(255) NOT NULL;
--------------------------------------------------------------------------------
/drizzle/0003_zippy_black_tarantula.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issues" DROP CONSTRAINT "gh_next_issues_assignee_id_gh_next_users_id_fk";
2 | --> statement-breakpoint
3 | ALTER TABLE "gh_next_issues" DROP COLUMN IF EXISTS "assignee_id";
--------------------------------------------------------------------------------
/drizzle/0005_pretty_crusher_hogan.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_comments" ADD COLUMN "author_username" varchar(255) NOT NULL;--> statement-breakpoint
2 | ALTER TABLE "gh_next_comments" ADD COLUMN "author_avatar_url" varchar(255) NOT NULL;
--------------------------------------------------------------------------------
/drizzle/0016_glorious_mariko_yashida.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issue_events" ADD COLUMN "assignee_username" varchar(255);--> statement-breakpoint
2 | ALTER TABLE "gh_next_issue_events" ADD COLUMN "assignee_avatar_url" varchar(255);
--------------------------------------------------------------------------------
/drizzle/0031_ambiguous_wasp.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issues" DROP CONSTRAINT "gh_next_issues_number_unique";--> statement-breakpoint
2 | ALTER TABLE "gh_next_issues" ADD CONSTRAINT "uniq_number_idx" UNIQUE("repository_id","number");
--------------------------------------------------------------------------------
/drizzle/0017_burly_nitro.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issue_events" DROP CONSTRAINT "gh_next_issue_events_assignee_id_gh_next_users_id_fk";
2 | --> statement-breakpoint
3 | ALTER TABLE "gh_next_issue_events" DROP COLUMN IF EXISTS "assignee_id";
--------------------------------------------------------------------------------
/drizzle/0075_volatile_harry_osborn.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_repositories" ADD COLUMN "description" text DEFAULT '' NOT NULL;
2 | UPDATE "gh_next_repositories" SET "description" = 'A minimal Github clone built on nextjs app router.' WHERE 1=1;
--------------------------------------------------------------------------------
/prettier.config.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import("prettier").Config} */
2 | module.exports = {
3 | tabWidth: 2,
4 | semi: true,
5 | arrowParens: "always",
6 | // plugins: ["prettier-plugin-tailwindcss"],
7 | trailingComma: "none"
8 | };
9 |
--------------------------------------------------------------------------------
/dc-build-local.sh:
--------------------------------------------------------------------------------
1 | while read -r line; do
2 | build_args="$build_args --build-arg $line"
3 | done < .env.docker.local
4 | echo args="'$build_args'"
5 | docker buildx build -t dcr.fredkiss.dev/gh-next:dev -f docker/Dockerfile.dev $build_args .
--------------------------------------------------------------------------------
/drizzle/0041_damp_jack_murdock.sql:
--------------------------------------------------------------------------------
1 | CREATE INDEX IF NOT EXISTS "com_author_uname_idx" ON "gh_next_comments" ("author_username");--> statement-breakpoint
2 | CREATE INDEX IF NOT EXISTS "iss_author_uname_idx" ON "gh_next_issues" ("author_username");
--------------------------------------------------------------------------------
/src/app/(app)/@page_title/[user]/[repository]/page.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | // types
3 | import { RepositoryPageTitle } from "~/app/(app)/@page_title/[user]/[repository]/[...pages]/page";
4 |
5 | export default RepositoryPageTitle;
6 |
--------------------------------------------------------------------------------
/drizzle/0050_special_blue_blade.sql:
--------------------------------------------------------------------------------
1 | CREATE INDEX IF NOT EXISTS "lbl_2_iss_issue_fk_index" ON "gh_next_labels_to_issues" ("issue_id");--> statement-breakpoint
2 | CREATE INDEX IF NOT EXISTS "lbl_2_iss_assignee_fk_index" ON "gh_next_labels_to_issues" ("label_id");
--------------------------------------------------------------------------------
/drizzle/0049_flowery_ikaris.sql:
--------------------------------------------------------------------------------
1 | CREATE INDEX IF NOT EXISTS "is_2_ass_issue_fk_index" ON "gh_next_issues_to_assignees" ("issue_id");--> statement-breakpoint
2 | CREATE INDEX IF NOT EXISTS "is_2_ass_assignee_fk_index" ON "gh_next_issues_to_assignees" ("assignee_id");
--------------------------------------------------------------------------------
/drizzle/0067_rapid_thunderbolt_ross.sql:
--------------------------------------------------------------------------------
1 |
2 | -- copy data from the ci column to the cs column
3 | UPDATE "gh_next_issues" SET "author_username_cs" = "author_username";
4 |
5 | ALTER TABLE "gh_next_issues" ALTER COLUMN "author_username_cs" SET NOT NULL; -- add the not-null constraint
--------------------------------------------------------------------------------
/drizzle/0070_even_princess_powerful.sql:
--------------------------------------------------------------------------------
1 | -- ALTER TABLE "gh_next_issues_to_assignees" ADD COLUMN "assignee_username_cs" varchar(255);
2 |
3 | -- copy data from the ci column to the cs column
4 | UPDATE "gh_next_issues_to_assignees" SET "assignee_username_cs" = "assignee_username";
5 |
--------------------------------------------------------------------------------
/src/app/(app)/settings/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 | import { getUserOrRedirect } from "~/actions/auth.action";
3 |
4 | export default async function Page() {
5 | await getUserOrRedirect("/settings/account");
6 | redirect("/settings/account");
7 | }
8 |
--------------------------------------------------------------------------------
/drizzle/0068_wakeful_skaar.sql:
--------------------------------------------------------------------------------
1 | DROP TRIGGER IF EXISTS "username_update_trigger" ON "gh_next_issues_to_assignees";
2 | DROP TRIGGER IF EXISTS "username_insert_trigger" ON "gh_next_issues_to_assignees";
3 | ALTER TABLE "gh_next_issues_to_assignees" DROP COLUMN IF EXISTS "assignee_username_cs";
--------------------------------------------------------------------------------
/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "drizzle-kit";
2 |
3 | export default {
4 | schema: "./src/lib/server/db/schema/*.sql.ts",
5 | out: "./drizzle",
6 | driver: "pg",
7 | dbCredentials: {
8 | connectionString: process.env.DATABASE_URL!
9 | }
10 | } satisfies Config;
11 |
--------------------------------------------------------------------------------
/drizzle/0028_needy_katie_power.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_comments" ADD COLUMN "content_search_vector" tsvector generated always as (to_tsvector('english',content)) stored;--> statement-breakpoint
2 | CREATE INDEX IF NOT EXISTS "content_search_vector_idex" ON "gh_next_comments" using gin("content_search_vector");
--------------------------------------------------------------------------------
/drizzle/0036_careful_freak.sql:
--------------------------------------------------------------------------------
1 | CREATE INDEX IF NOT EXISTS "author_fk_idx" ON "gh_next_reactions" ("author_id");--> statement-breakpoint
2 | CREATE INDEX IF NOT EXISTS "issue_fk_idx" ON "gh_next_reactions" ("issue_id");--> statement-breakpoint
3 | CREATE INDEX IF NOT EXISTS "comment_fk_idx" ON "gh_next_reactions" ("comment_id");
--------------------------------------------------------------------------------
/drizzle/0047_same_stark_industries.sql:
--------------------------------------------------------------------------------
1 | -- Custom SQL migration file, put you code below! --
2 | CREATE INDEX IF NOT EXISTS "comment_count_issue_id_idx" ON "comment_count_per_issue" ("issue_id");--> statement-breakpoint
3 | CREATE INDEX IF NOT EXISTS "reaction_count_issue_id_idx" ON "reaction_count_per_issue" ("issue_id");
4 |
--------------------------------------------------------------------------------
/drizzle/0019_real_william_stryker.sql:
--------------------------------------------------------------------------------
1 | ALTER TYPE event_type RENAME TO event_type_old;
2 | CREATE TYPE event_type AS ENUM('CHANGE_TITLE', 'TOGGLE_STATUS', 'ISSUE_MENTION', 'ASSIGN_USER', 'ADD_LABEL', 'REMOVE_LABEL', 'ADD_COMMENT');
3 | ALTER TABLE gh_next_issue_events ALTER COLUMN type TYPE event_type USING type::text::event_type;
--------------------------------------------------------------------------------
/pr-preview-workflow/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "preview-workflow",
3 | "module": "index.ts",
4 | "type": "module",
5 | "devDependencies": {
6 | "@types/bun": "latest"
7 | },
8 | "peerDependencies": {
9 | "typescript": "^5.0.0"
10 | },
11 | "dependencies": {
12 | "zod": "^3.22.4"
13 | }
14 | }
--------------------------------------------------------------------------------
/pr-preview-workflow/README.md:
--------------------------------------------------------------------------------
1 | # preview-workflow
2 |
3 | To install dependencies:
4 |
5 | ```bash
6 | bun install
7 | ```
8 |
9 | To run:
10 |
11 | ```bash
12 | bun run index.ts
13 | ```
14 |
15 | This project was created using `bun init` in bun v1.0.25. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
16 |
--------------------------------------------------------------------------------
/src/app/(app)/[user]/page.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import type { PageProps } from "~/lib/types";
3 |
4 | export default async function UserPage({
5 | params: { username }
6 | }: PageProps<{ username: string }>) {
7 | return (
8 | <>
9 |
User {username}
10 | >
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/src/lib/server/kv/sqlite/config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "drizzle-kit";
2 |
3 | export default ({
4 | schema: "./src/lib/kv/sqlite/kv-entry.sql.ts",
5 | driver: "turso",
6 | dbCredentials: {
7 | url: process.env.TURSO_DB_URL!,
8 | authToken: process.env.TURSO_DB_TOKEN!
9 | }
10 | } satisfies Config);
11 |
--------------------------------------------------------------------------------
/drizzle/0024_reflective_zzzax.sql:
--------------------------------------------------------------------------------
1 | DROP INDEX IF EXISTS "content_search_vector_idex";--> statement-breakpoint
2 | DROP INDEX IF EXISTS "body_search_vector_idex";--> statement-breakpoint
3 | ALTER TABLE "gh_next_comments" DROP COLUMN IF EXISTS "content_search_vector";--> statement-breakpoint
4 | ALTER TABLE "gh_next_issues" DROP COLUMN IF EXISTS "body_search_vector";
--------------------------------------------------------------------------------
/src/app/client-providers.tsx:
--------------------------------------------------------------------------------
1 | // app/provider.tsx
2 | "use client";
3 | import * as React from "react";
4 | // components
5 | import { ReactQueryProvider } from "~/components/react-query-provider";
6 |
7 | export function ClientProviders({ children }: { children: React.ReactNode }) {
8 | return {children} ;
9 | }
10 |
--------------------------------------------------------------------------------
/drizzle/0045_bent_roulette.sql:
--------------------------------------------------------------------------------
1 | DROP INDEX IF EXISTS "title_idx";--> statement-breakpoint
2 | CREATE INDEX IF NOT EXISTS "iss_title_idx" ON "gh_next_issues" ("title");--> statement-breakpoint
3 | CREATE INDEX IF NOT EXISTS "iss_created_idx" ON "gh_next_issues" ("created_at");--> statement-breakpoint
4 | CREATE INDEX IF NOT EXISTS "iss_updated_idx" ON "gh_next_issues" ("updated_at");
--------------------------------------------------------------------------------
/drizzle/0014_tricky_scalphunter.sql:
--------------------------------------------------------------------------------
1 | DO $$ BEGIN
2 | CREATE TYPE "issue_lock_reason" AS ENUM('OFF_TOPIC', 'TOO_HEATED', 'RESOLVED', 'SPAM');
3 | EXCEPTION
4 | WHEN duplicate_object THEN null;
5 | END $$;
6 | --> statement-breakpoint
7 | ALTER TYPE "event_type" ADD VALUE 'ISSUE_LOCK';--> statement-breakpoint
8 | ALTER TABLE "gh_next_issue_events" ADD COLUMN "lock_reason" "issue_lock_reason";
--------------------------------------------------------------------------------
/drizzle/0063_brief_colossus.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issues_to_assignees" ADD COLUMN "assignee_username_cs" varchar(255);
2 |
3 | -- copy data from the ci column to the cs column
4 | UPDATE "gh_next_issues_to_assignees" SET "assignee_username_cs" = "assignee_username";
5 |
6 | ALTER TABLE "gh_next_issues_to_assignees" ALTER COLUMN "assignee_username_cs" SET NOT NULL; -- add the not-null constraint
--------------------------------------------------------------------------------
/migrate.mjs:
--------------------------------------------------------------------------------
1 | import { migrate } from "drizzle-orm/postgres-js/migrator";
2 | import postgres from "postgres";
3 | import { drizzle } from "drizzle-orm/postgres-js";
4 |
5 | const db = drizzle(
6 | postgres(process.env.REMOTE_DATABASE_URL ?? process.env.DATABASE_URL)
7 | );
8 |
9 | async function main() {
10 | await migrate(db, { migrationsFolder: "drizzle" });
11 | process.exit(0);
12 | }
13 | main();
14 |
--------------------------------------------------------------------------------
/drizzle/0071_swift_captain_cross.sql:
--------------------------------------------------------------------------------
1 | -- Custom SQL migration file, put you code below! --
2 | CREATE TRIGGER iss_2_ass_assignee_username_update_trigger
3 | BEFORE UPDATE ON gh_next_issues_to_assignees
4 | FOR EACH ROW EXECUTE FUNCTION sync_username_update();
5 |
6 | CREATE TRIGGER iss_2_ass_assignee_username_insert_trigger
7 | BEFORE INSERT ON gh_next_issues_to_assignees
8 | FOR EACH ROW EXECUTE FUNCTION sync_username_insert();
--------------------------------------------------------------------------------
/src/app/(app)/@header_subnav/[user]/[repository]/page.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { HeaderNavLinks } from "~/components/header/header-navlinks";
3 |
4 | import type { PageProps } from "~/lib/types";
5 |
6 | export default function RepositoryHeaderSubnav({
7 | params
8 | }: PageProps<{
9 | user: string;
10 | repository: string;
11 | }>) {
12 | return ;
13 | }
14 |
--------------------------------------------------------------------------------
/drizzle/0015_lucky_sentinels.sql:
--------------------------------------------------------------------------------
1 | DO $$ BEGIN
2 | CREATE TYPE "comment_hide_reason" AS ENUM('ABUSE', 'OFF_TOPIC', 'OUTDATED', 'RESOLVED', 'DUPLICATE', 'SPAM');
3 | EXCEPTION
4 | WHEN duplicate_object THEN null;
5 | END $$;
6 | --> statement-breakpoint
7 | ALTER TABLE "gh_next_comments" ADD COLUMN "hidden" boolean DEFAULT false NOT NULL;--> statement-breakpoint
8 | ALTER TABLE "gh_next_comments" ADD COLUMN "hidden_reason" "comment_hide_reason";
--------------------------------------------------------------------------------
/src/app/(app)/@header_subnav/[user]/[repository]/[...pages]/page.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { HeaderNavLinks } from "~/components/header/header-navlinks";
3 |
4 | import type { PageProps } from "~/lib/types";
5 |
6 | export default function RepositoryHeaderSubnav({
7 | params
8 | }: PageProps<{
9 | user: string;
10 | repository: string;
11 | }>) {
12 | return ;
13 | }
14 |
--------------------------------------------------------------------------------
/src/models/dto/public-user-output-validator.ts:
--------------------------------------------------------------------------------
1 | import "server-only";
2 | import { z } from "zod";
3 |
4 | export const publicUserOutputValidator = z.object({
5 | id: z.number().nullable(),
6 | username: z.string(),
7 | avatar_url: z.string(),
8 | bio: z.string().nullable(),
9 | location: z.string().nullable(),
10 | name: z.string().nullable()
11 | });
12 |
13 | export type PublicUser = z.TypeOf;
14 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | SESSION_SECRET=""
2 | GITHUB_CLIENT_ID=
3 | GITHUB_REDIRECT_URI="http://localhost:3000/api/auth/callback"
4 | GITHUB_SECRET=
5 | GITHUB_PERSONAL_ACCESS_TOKEN=""
6 | REDIS_HTTP_URL="http://127.0.0.1:6380"
7 | REDIS_HTTP_USERNAME="user"
8 | REDIS_HTTP_PASSWORD="password"
9 | NEXT_PUBLIC_VERCEL_URL="localhost:3000"
10 | DATABASE_URL="postgresql://postgres:password@localhost:5433/gh_next"
11 | KV_PREFIX=
--------------------------------------------------------------------------------
/src/actions/markdown.action.tsx:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { Markdown } from "~/components/markdown/markdown";
4 | import { renderRSCtoString } from "~/components/custom-rsc-renderer/render-rsc-to-string";
5 |
6 | export async function getMarkdownPreview(
7 | content: string,
8 | repositoryPath: `${string}/${string}`
9 | ) {
10 | return await renderRSCtoString(
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/drizzle/0013_rainy_princess_powerful.sql:
--------------------------------------------------------------------------------
1 | ALTER TYPE "event_type" ADD VALUE 'ADD_COMMENT';--> statement-breakpoint
2 | ALTER TABLE "gh_next_issue_events" ADD COLUMN "comment_id" integer;--> statement-breakpoint
3 | DO $$ BEGIN
4 | ALTER TABLE "gh_next_issue_events" ADD CONSTRAINT "gh_next_issue_events_comment_id_gh_next_comments_id_fk" FOREIGN KEY ("comment_id") REFERENCES "gh_next_comments"("id") ON DELETE cascade ON UPDATE no action;
5 | EXCEPTION
6 | WHEN duplicate_object THEN null;
7 | END $$;
8 |
--------------------------------------------------------------------------------
/src/models/dto/repository-output-validator.ts:
--------------------------------------------------------------------------------
1 | import "server-only";
2 | import { z } from "zod";
3 | import { publicUserOutputValidator } from "~/models/dto/public-user-output-validator";
4 |
5 | export const repositoryOutputValidator = z.object({
6 | id: z.number(),
7 | name: z.string(),
8 | description: z.string(),
9 | is_public: z.boolean(),
10 | owner: publicUserOutputValidator
11 | });
12 |
13 | export type RepositoryOutput = z.TypeOf;
14 |
--------------------------------------------------------------------------------
/src/components/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { clsx } from "~/lib/shared/utils.shared";
3 |
4 | export type CardProps = {
5 | className?: string;
6 | children?: React.ReactNode;
7 | };
8 |
9 | export function Card({ className, children }: CardProps) {
10 | return (
11 |
17 | {children}
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/lib/server/db/schema/index.sql.ts:
--------------------------------------------------------------------------------
1 | import { customType, pgTableCreator } from "drizzle-orm/pg-core";
2 |
3 | export const pgTable = pgTableCreator((name) => `gh_next_${name}`);
4 |
5 | export const tsVector = customType<{
6 | data: string;
7 | config: { generated: string };
8 | }>({
9 | dataType(config) {
10 | return (
11 | `tsvector` +
12 | (config
13 | ? ` generated always as (${config.generated}) stored`
14 | : ""
15 | ).toString()
16 | );
17 | }
18 | });
19 |
--------------------------------------------------------------------------------
/src/lib/server/kv/sqlite/kv-entry.sql.ts:
--------------------------------------------------------------------------------
1 | import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
2 | import { sql } from "drizzle-orm";
3 | import type { InferModel } from "drizzle-orm";
4 |
5 | export const kvEntry = sqliteTable("kv_entries", {
6 | key: text("key").primaryKey(),
7 | value: text("value").notNull(),
8 | expiry: integer("expiry", { mode: "timestamp" }).default(
9 | sql`(strftime('%s', 'now'))`
10 | )
11 | });
12 |
13 | export type KVEntry = InferModel;
14 |
--------------------------------------------------------------------------------
/drizzle/0029_nasty_triathlon.sql:
--------------------------------------------------------------------------------
1 | DROP INDEX IF EXISTS "content_search_vector_idex";--> statement-breakpoint
2 | DROP INDEX IF EXISTS "body_search_vector_idx";--> statement-breakpoint
3 | DROP INDEX IF EXISTS "title_search_vector_idx";--> statement-breakpoint
4 | ALTER TABLE "gh_next_comments" DROP COLUMN IF EXISTS "content_search_vector";--> statement-breakpoint
5 | ALTER TABLE "gh_next_issues" DROP COLUMN IF EXISTS "body_search_vector";--> statement-breakpoint
6 | ALTER TABLE "gh_next_issues" DROP COLUMN IF EXISTS "title_search_vector";
--------------------------------------------------------------------------------
/src/components/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { clsx } from "~/lib/shared/utils.shared";
3 |
4 | export type BadgeProps = {
5 | label: string;
6 | className?: string;
7 | };
8 |
9 | export function Badge({ label, className }: BadgeProps) {
10 | return (
11 |
17 | {label}
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/drizzle/0037_cynical_peter_parker.sql:
--------------------------------------------------------------------------------
1 | DROP INDEX IF EXISTS "author_fk_idx";--> statement-breakpoint
2 | DROP INDEX IF EXISTS "issue_fk_idx";--> statement-breakpoint
3 | DROP INDEX IF EXISTS "comment_fk_idx";--> statement-breakpoint
4 | CREATE INDEX IF NOT EXISTS "rakt_author_fk_idx" ON "gh_next_reactions" ("author_id");--> statement-breakpoint
5 | CREATE INDEX IF NOT EXISTS "rakt_issue_fk_idx" ON "gh_next_reactions" ("issue_id");--> statement-breakpoint
6 | CREATE INDEX IF NOT EXISTS "rakt_comment_fk_idx" ON "gh_next_reactions" ("comment_id");
--------------------------------------------------------------------------------
/src/app/(app)/@page_title/page.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Link from "next/link";
3 | import { clsx } from "~/lib/shared/utils.shared";
4 |
5 | export default function ExplorePageTitle() {
6 | return (
7 |
16 | Explore
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/drizzle/0035_deep_mephisto.sql:
--------------------------------------------------------------------------------
1 | CREATE INDEX IF NOT EXISTS "author_fk_idx" ON "gh_next_comments" ("author_id");--> statement-breakpoint
2 | CREATE INDEX IF NOT EXISTS "issue_fk_idx" ON "gh_next_comments" ("issue_id");--> statement-breakpoint
3 | CREATE INDEX IF NOT EXISTS "repository_fk_idx" ON "gh_next_issues" ("repository_id");--> statement-breakpoint
4 | CREATE INDEX IF NOT EXISTS "author_fk_idx" ON "gh_next_issues" ("author_id");--> statement-breakpoint
5 | CREATE INDEX IF NOT EXISTS "creator_fk_idx" ON "gh_next_repositories" ("creator_id");
--------------------------------------------------------------------------------
/src/components/custom-rsc-renderer/rsc-manifest.ts:
--------------------------------------------------------------------------------
1 | export async function getClientManifest() {
2 | let clientManifest: ClientManifest = {};
3 |
4 | // we concatennate all the manifest for all pages
5 | if (globalThis.__RSC_MANIFEST) {
6 | const allManifests = Object.values(globalThis.__RSC_MANIFEST);
7 | for (const rscManifest of allManifests) {
8 | clientManifest = {
9 | ...clientManifest,
10 | ...rscManifest.clientModules
11 | };
12 | }
13 | }
14 | return clientManifest;
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/issues/use-issue-author-list-query.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { filterIssueAuthorsByUsername } from "~/actions/issue.action";
3 |
4 | export function useIssueAuthorListQuery({
5 | name = "",
6 | enabled
7 | }: {
8 | name?: string;
9 | enabled: boolean;
10 | }) {
11 | return useQuery({
12 | queryKey: ["ISSUE_AUTHOR_LIST", name],
13 | queryFn: () =>
14 | filterIssueAuthorsByUsername(name).then((result) => result.promise),
15 | enabled
16 | });
17 | }
18 |
--------------------------------------------------------------------------------
/src/lib/client/hooks/use-typed-params.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { useParams } from "next/navigation";
3 |
4 | export function useTypedParams(
5 | schema: TSchema,
6 | errorMessage = "Error parsing params, you are maybe in the wrong route"
7 | ): z.output {
8 | const _params = useParams();
9 | const res = schema.safeParse(_params);
10 | if (!res.success) {
11 | throw new Error(errorMessage, { cause: res.error.flatten().fieldErrors });
12 | }
13 | return res.data;
14 | }
15 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "[javascript]": {
3 | "editor.defaultFormatter": "biomejs.biome"
4 | },
5 | "[typescript]": {
6 | "editor.defaultFormatter": "biomejs.biome"
7 | },
8 | "[typescriptreact]": {
9 | "editor.defaultFormatter": "biomejs.biome"
10 | },
11 | "editor.defaultFormatter": "biomejs.biome",
12 | "typescript.preferences.importModuleSpecifier": "non-relative",
13 | "typescript.tsdk": "node_modules/typescript/lib",
14 | "[json]": {
15 | "editor.defaultFormatter": "biomejs.biome"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/docker/docker-stack.prod.yaml:
--------------------------------------------------------------------------------
1 | version: "3.4"
2 |
3 | services:
4 | gh-next-prod:
5 | image: dcr.fredkiss.dev/gh-next:latest
6 | deploy:
7 | replicas: 2
8 | update_config:
9 | parallelism: 1
10 | delay: 5s
11 | order: start-first
12 | failure_action: rollback
13 | restart_policy:
14 | condition: on-failure
15 | delay: 5s
16 | max_attempts: 3
17 | window: 120s
18 | networks:
19 | - gh-next
20 | networks:
21 | gh-next:
22 | external: true
23 |
--------------------------------------------------------------------------------
/searching.md:
--------------------------------------------------------------------------------
1 | ## SEARCH
2 |
3 | - https://remarkjs.github.io/react-markdown/
4 | - https://github.com/remarkjs/remark-rehype
5 | - https://github.com/remarkjs/remark-github
6 | - https://unifiedjs.com/explore/package/rehype-raw/
7 | - https://github.com/remarkjs/react-markdown/pull/682
8 | - https://github.com/remarkjs/react-markdown/blob/main/lib/index.js
9 |
10 |
11 | ### Debugging
12 |
13 | - https://github.com/vercel/next.js/issues/48748
14 |
15 | ### Learning
16 |
17 | - https://github.github.com/gfm/#disallowed-raw-html-extension-
18 |
--------------------------------------------------------------------------------
/src/components/issues/use-issue-author-list-by-name-query.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { filterIssueAuthorsByName } from "~/actions/issue.action";
3 |
4 | export function useIssueAuthorListByNameQuery({
5 | name = ""
6 | }: {
7 | name?: string;
8 | }) {
9 | return useQuery({
10 | queryKey: ["ISSUE_AUTHOR_LIST_BY_NAME", name],
11 | queryFn: () =>
12 | filterIssueAuthorsByName(name).then((result) => result.promise),
13 | placeholderData: (previousData) => previousData
14 | });
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/user-dropdown/user-dropdown.server.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { getAuthedUser } from "~/actions/auth.action";
3 | import { UserDropdown as UserDropdownClient } from "./user-dropdown.client";
4 |
5 | export async function UserDropdown() {
6 | const user = await getAuthedUser();
7 | if (!user) return null;
8 |
9 | return (
10 |
11 | );
12 | }
13 |
14 | export function preloadAuthedUser() {
15 | getAuthedUser();
16 | }
17 |
--------------------------------------------------------------------------------
/ecosystem.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | apps: [
3 | {
4 | name: "gh-clone",
5 | script: ".next/standalone/server.js",
6 | time: true,
7 | instances: 2,
8 | autorestart: true,
9 | max_restarts: 5,
10 | exec_mode: "cluster_mode",
11 | watch: false,
12 | max_memory_restart: "1G",
13 | wait_ready: true,
14 | listen_timeout: 10_000,
15 | increment_var: "PORT",
16 | env: {
17 | PORT: 8892,
18 | HOSTNAME: "0.0.0.0"
19 | }
20 | }
21 | ]
22 | };
23 |
--------------------------------------------------------------------------------
/drizzle/0058_flimsy_ender_wiggin.sql:
--------------------------------------------------------------------------------
1 | -- ADD A case insensitive COLUMN for the username
2 | ALTER TABLE "gh_next_issue_events" ADD COLUMN "assignee_username_ci" varchar(255) COLLATE "ci";
3 | -- migrate data from the old column to the new column
4 | UPDATE "gh_next_issue_events" SET "assignee_username_ci" = "assignee_username";
5 |
6 | ALTER TABLE "gh_next_issue_events" DROP COLUMN "assignee_username"; -- remove the old column
7 | ALTER TABLE "gh_next_issue_events" RENAME COLUMN "assignee_username_ci" TO "assignee_username"; -- rename the new column to the old column
8 |
--------------------------------------------------------------------------------
/src/components/markdown/markdown-error-boundary.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ErrorBoundary } from "react-error-boundary";
4 |
5 | type MarkdownErrorBoundaryProps = {
6 | children: React.ReactNode;
7 | };
8 |
9 | // TODO : render a better error UI
10 | export async function MarkdownErrorBoundary({
11 | children
12 | }: MarkdownErrorBoundaryProps) {
13 | return (
14 | <>Error rendering markdown : {error}>}
16 | >
17 | {children}
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/drizzle/0006_confused_the_watchers.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issue_revisions" DROP CONSTRAINT "gh_next_issue_revisions_revised_by_id_gh_next_users_id_fk";
2 | --> statement-breakpoint
3 | ALTER TABLE "gh_next_issue_revisions" ADD COLUMN "revised_by_username" varchar(255) NOT NULL;--> statement-breakpoint
4 | ALTER TABLE "gh_next_issue_revisions" ADD COLUMN "revised_by_avatar_url" varchar(255) NOT NULL;--> statement-breakpoint
5 | ALTER TABLE "gh_next_issues" ADD COLUMN "number" integer;--> statement-breakpoint
6 | ALTER TABLE "gh_next_issue_revisions" DROP COLUMN IF EXISTS "revised_by_id";
--------------------------------------------------------------------------------
/src/components/issues/use-issue-label-list-query.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { filterLabelsByName } from "~/actions/issue.action";
3 |
4 | export function useIssueLabelListByNameQuery({
5 | name = "",
6 | enabled = true
7 | }: {
8 | name?: string;
9 | enabled?: boolean;
10 | }) {
11 | return useQuery({
12 | queryKey: ["ISSUE_LABEL_LIST_BY_NAME", name],
13 | queryFn: () => filterLabelsByName(name).then((result) => result.promise),
14 | enabled,
15 | placeholderData: (previousData) => previousData
16 | });
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/skip-to-main-button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { clsx } from "~/lib/shared/utils.shared";
3 |
4 | export function SkipToMainButton() {
5 | return (
6 |
15 | Skip to content
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "linter": {
3 | "enabled": false
4 | },
5 | "formatter": {
6 | "enabled": true,
7 | "formatWithErrors": true,
8 | "indentStyle": "tab",
9 | "indentWidth": 2,
10 | "lineWidth": 80,
11 | "ignore": []
12 | },
13 | "javascript": {
14 | "formatter": {
15 | "arrowParentheses": "always",
16 | "indentStyle": "space",
17 | "trailingComma": "none",
18 | "indentWidth": 2
19 | }
20 | },
21 | "json": {
22 | "formatter": {
23 | "indentWidth": 2,
24 | "indentStyle": "space"
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/pr-preview-workflow/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["ESNext"],
4 | "target": "ESNext",
5 | "module": "ESNext",
6 | "moduleDetection": "force",
7 | "jsx": "react-jsx",
8 | "allowJs": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "verbatimModuleSyntax": true,
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "skipLibCheck": true,
18 | "strict": true,
19 | "noFallthroughCasesInSwitch": true,
20 | "forceConsistentCasingInFileNames": true
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/app/(app)/@page_title/settings/page.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Link from "next/link";
3 | import { clsx } from "~/lib/shared/utils.shared";
4 |
5 | export default function SettingPageTitle() {
6 | return (
7 |
16 | Settings
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/app/(app)/@page_title/settings/[...pages]/page.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Link from "next/link";
3 | import { clsx } from "~/lib/shared/utils.shared";
4 |
5 | export default function SettingPageTitle() {
6 | return (
7 |
16 | Settings
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/docker/webdis.json:
--------------------------------------------------------------------------------
1 | {
2 | "http_host": "0.0.0.0",
3 | "http_port": 7379,
4 | "redis_host": "127.0.0.1",
5 | "redis_port": 6379,
6 |
7 | "acl": [
8 | {
9 | "disabled": ["*"]
10 | },
11 |
12 | {
13 | "http_basic_auth": "user:password",
14 | "enabled": [
15 | "GET",
16 | "SET",
17 | "DEL",
18 | "SETEX",
19 | "HSET",
20 | "HGETALL",
21 | "HGET",
22 | "SREM",
23 | "PING",
24 | "SADD",
25 | "SMEMBERS"
26 | ]
27 | }
28 | ],
29 |
30 | "verbosity": 4,
31 | "logfile": "/dev/stderr"
32 | }
33 |
--------------------------------------------------------------------------------
/src/app/(app)/@page_title/notifications/page.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Link from "next/link";
3 | import { clsx } from "~/lib/shared/utils.shared";
4 |
5 | export default function SettingPageTitle() {
6 | return (
7 |
16 | Notifications
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/lib/server/kv/index.server.ts:
--------------------------------------------------------------------------------
1 | import "server-only";
2 | import { WebdisKV } from "./webdis.server.mjs";
3 |
4 | export interface KVStore {
5 | set = {}>(
6 | key: string,
7 | value: T,
8 | ttl_in_seconds?: number,
9 | key_prefix?: string
10 | ): Promise;
11 | get = {}>(
12 | key: string,
13 | key_prefix?: string
14 | ): Promise;
15 | delete(key: string, key_prefix?: string): Promise;
16 | }
17 |
18 | function getKV(): KVStore {
19 | return new WebdisKV();
20 | }
21 |
22 | export const kv = getKV();
23 |
--------------------------------------------------------------------------------
/drizzle/0051_tricky_tempest.sql:
--------------------------------------------------------------------------------
1 | -- Custom SQL migration file, put you code below! --
2 | -- Custom SQL migration file, put you code below! --
3 | CREATE OR REPLACE FUNCTION refresh_comment_count_per_issue()
4 | RETURNS TRIGGER AS $$
5 | BEGIN
6 | REFRESH MATERIALIZED VIEW comment_count_per_issue;
7 | RETURN NEW;
8 | END;
9 | $$ LANGUAGE plpgsql;--> statement-breakpoint
10 |
11 | CREATE OR REPLACE FUNCTION refresh_reaction_count_per_issue()
12 | RETURNS TRIGGER AS $$
13 | BEGIN
14 | REFRESH MATERIALIZED VIEW reaction_count_per_issue;
15 | RETURN NEW;
16 | END;
17 | $$ LANGUAGE plpgsql;--> statement-breakpoint
18 |
--------------------------------------------------------------------------------
/src/components/markdown/markdown-title.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { clsx } from "~/lib/shared/utils.shared";
3 |
4 | // types
5 | export type MarkdownTitleProps = {
6 | title: string;
7 | className?: string;
8 | };
9 |
10 | export async function MarkdownTitle({ title, className }: MarkdownTitleProps) {
11 | const parsed = title.replace(
12 | /`(.*?)`/g,
13 | '$1'
14 | );
15 |
16 | return (
17 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/drizzle/0023_bored_texas_twister.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_comments" ADD COLUMN "content_search_vector" "tsvector";--> statement-breakpoint
2 | ALTER TABLE "gh_next_issues" ADD COLUMN "body_search_vector" "tsvector";--> statement-breakpoint
3 |
4 | UPDATE gh_next_issues SET body_search_vector = to_tsvector('english', body);
5 | UPDATE gh_next_comments SET content_search_vector = to_tsvector('english', content);
6 |
7 | CREATE INDEX IF NOT EXISTS "content_search_vector_idex" ON "gh_next_comments" using gin("content_search_vector");--> statement-breakpoint
8 | CREATE INDEX IF NOT EXISTS "body_search_vector_idex" ON "gh_next_issues" using gin("body_search_vector");
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: "[✨ feature] i want this"
5 | labels: feature, need-triage
6 | assignees: ""
7 | ---
8 |
9 | ## Is your feature request related to a problem? Please describe.
10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
11 |
12 | ## Describe the solution you'd like
13 | A clear and concise description of what you want to happen.
14 |
15 | ## Additional context
16 | Add any other context or screenshots about the feature request here.
17 |
18 | - [ ] Are you willing to make a PR for this ?
19 |
--------------------------------------------------------------------------------
/drizzle/0012_brainy_invisible_woman.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issues_to_assignees" DROP CONSTRAINT "gh_next_issues_to_assignees_issue_id";--> statement-breakpoint
2 | ALTER TABLE "gh_next_issues_to_assignees" ADD COLUMN "id" serial NOT NULL;--> statement-breakpoint
3 | ALTER TABLE "gh_next_issues_to_assignees" ADD COLUMN "assignee_id" integer;--> statement-breakpoint
4 | DO $$ BEGIN
5 | ALTER TABLE "gh_next_issues_to_assignees" ADD CONSTRAINT "gh_next_issues_to_assignees_assignee_id_gh_next_users_id_fk" FOREIGN KEY ("assignee_id") REFERENCES "gh_next_users"("id") ON DELETE cascade ON UPDATE no action;
6 | EXCEPTION
7 | WHEN duplicate_object THEN null;
8 | END $$;
9 |
--------------------------------------------------------------------------------
/drizzle/0027_spicy_meltdown.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issues" ADD COLUMN "body_search_vector" tsvector generated always as (to_tsvector('english',body)) stored;--> statement-breakpoint
2 | ALTER TABLE "gh_next_issues" ADD COLUMN "title_search_vector" tsvector generated always as (
3 | setweight(to_tsvector('simple',title), 'A') || ' ' ||
4 | setweight(to_tsvector('english',title), 'B')) stored;--> statement-breakpoint
5 | CREATE INDEX IF NOT EXISTS "body_search_vector_idx" ON "gh_next_issues" using gin("body_search_vector");--> statement-breakpoint
6 | CREATE INDEX IF NOT EXISTS "title_search_vector_idx" ON "gh_next_issues" using gin("title_search_vector");
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/refactor.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Refactor
3 | about: Suggest a new refactor task to the project
4 | title: "[♻️ refactor] We need to do..."
5 | labels: refactor, need-triage
6 | assignees: ''
7 |
8 | ---
9 |
10 | ## What is the reason to do this refactor ?.
11 | A clear and concise description of why this refactor is needed and/or what advantages does it provide.
12 |
13 | ## Describe the work that needs to be done
14 | A clear and concise description of the work that needs to be done.
15 |
16 | ## Additional context
17 | Add any other context or screenshots about the issue here.
18 |
19 | - [ ] Are you willing to make a PR for this ?
20 |
--------------------------------------------------------------------------------
/src/lib/client/hooks/use-active-link.ts:
--------------------------------------------------------------------------------
1 | import { usePathname } from "next/navigation";
2 | import { linkWithSlash } from "~/lib/shared/utils.shared";
3 |
4 | export function useActiveLink(href: string, isRoot?: boolean) {
5 | const path = usePathname();
6 |
7 | // treat `/` as special or else it would always be considered active
8 | // as every path starts with `/`
9 | const url = new URL(href, "http://localhost");
10 | if (
11 | (url.pathname === "/" || isRoot) &&
12 | linkWithSlash(url.pathname) !== linkWithSlash(path)
13 | ) {
14 | return false;
15 | }
16 |
17 | return linkWithSlash(path).startsWith(linkWithSlash(href));
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/counter-badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export type CounterBadgeProps = {
4 | count?: number;
5 | };
6 |
7 | export function CounterBadge({ count }: CounterBadgeProps) {
8 | return (
9 | <>
10 |
14 | {count !== undefined
15 | ? new Intl.NumberFormat("en-US", { notation: "compact" }).format(
16 | count
17 | )
18 | : "-"}
19 |
20 | ({count ?? "undefined"})
21 | >
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/issues/use-issue-assignee-list-query.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { filterIssueAssignees } from "~/actions/issue.action";
3 |
4 | export function useIssueAssigneeListQuery({
5 | name = "",
6 | enabled,
7 | checkFullName
8 | }: {
9 | name?: string;
10 | enabled: boolean;
11 | checkFullName?: boolean;
12 | }) {
13 | return useQuery({
14 | queryKey: ["ISSUE_ASSIGNEE_LIST", name],
15 | queryFn: () =>
16 | filterIssueAssignees(name, checkFullName).then(
17 | (result) => result.promise
18 | ),
19 | enabled,
20 | placeholderData: (previousData) => previousData
21 | });
22 | }
23 |
--------------------------------------------------------------------------------
/drizzle/0056_kind_whirlwind.sql:
--------------------------------------------------------------------------------
1 | -- ADD A case insensitive COLUMN for the username
2 | ALTER TABLE "gh_next_issue_events" ADD COLUMN "initiator_username_ci" varchar(255) COLLATE "ci";
3 | -- migrate data from the old column to the new column
4 | UPDATE "gh_next_issue_events" SET "initiator_username_ci" = "initiator_username";
5 |
6 | ALTER TABLE "gh_next_issue_events" DROP COLUMN "initiator_username"; -- remove the old column
7 | ALTER TABLE "gh_next_issue_events" RENAME COLUMN "initiator_username_ci" TO "initiator_username"; -- rename the new column to the old column
8 | ALTER TABLE "gh_next_issue_events" ALTER COLUMN "initiator_username" SET NOT NULL; -- add the not-null constraint
9 |
--------------------------------------------------------------------------------
/src/components/segmented-layout.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { clsx } from "~/lib/shared/utils.shared";
3 |
4 | export type SegmentedLayoutProps = {
5 | children: React.ReactNode;
6 | className?: string;
7 | };
8 |
9 | export function SegmentedLayout({ children, className }: SegmentedLayoutProps) {
10 | return (
11 | li:not(:last-child)>*]:!border-r-0",
16 | "[&>li:not(:first-child)>*]:!rounded-l-none",
17 | "[&>li:not(:last-child)>*]:!rounded-r-none"
18 | )}
19 | >
20 | {children}
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | version: "3.1"
2 |
3 | services:
4 | webdis:
5 | image: nicolas/webdis:latest
6 | volumes: # mount volume containing the config file
7 | - ./docker/webdis.json:/etc/webdis.prod.json
8 | ports:
9 | - "6380:7379"
10 | db:
11 | image: postgres:12-alpine
12 | restart: always
13 | volumes:
14 | - db-data:/var/lib/postgresql/data
15 | environment:
16 | POSTGRES_USER: postgres
17 | POSTGRES_PASSWORD: password
18 | POSTGRES_DB: gh_next
19 | ports:
20 | - "5433:5432"
21 | adminer:
22 | image: adminer
23 | restart: always
24 | ports:
25 | - 8081:8080
26 |
27 | volumes:
28 | db-data:
29 |
--------------------------------------------------------------------------------
/drizzle/0073_sad_crusher_hogan.sql:
--------------------------------------------------------------------------------
1 | -- ADD A case insensitive COLUMN for the username
2 | ALTER TABLE "gh_next_repositories" ADD COLUMN "name_ci" varchar(255) COLLATE "ci";
3 | -- migrate data from the old column to the new column
4 | UPDATE "gh_next_repositories" SET "name_ci" = "name";
5 |
6 | ALTER TABLE "gh_next_repositories" DROP COLUMN "name"; -- remove the old column
7 | ALTER TABLE "gh_next_repositories" RENAME COLUMN "name_ci" TO "name"; -- rename the new column to the old column
8 | ALTER TABLE "gh_next_repositories" ALTER COLUMN "name" SET NOT NULL; -- add the not-null constraint
9 |
10 | CREATE UNIQUE INDEX "repo_name_uniq_idx" ON "gh_next_repositories" ("name"); -- readd the unique constraint--
--------------------------------------------------------------------------------
/drizzle/0061_striped_monster_badoon.sql:
--------------------------------------------------------------------------------
1 | -- ADD A case insensitive COLUMN for the username
2 | ALTER TABLE "gh_next_issues_to_assignees" ADD COLUMN "assignee_username_ci" varchar(255) COLLATE "ci";
3 | -- migrate data from the old column to the new column
4 | UPDATE "gh_next_issues_to_assignees" SET "assignee_username_ci" = "assignee_username";
5 |
6 | ALTER TABLE "gh_next_issues_to_assignees" DROP COLUMN "assignee_username"; -- remove the old column
7 | ALTER TABLE "gh_next_issues_to_assignees" RENAME COLUMN "assignee_username_ci" TO "assignee_username"; -- rename the new column to the old column
8 | ALTER TABLE "gh_next_issues_to_assignees" ALTER COLUMN "assignee_username" SET NOT NULL; -- add the not-null constraint
9 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | // @ts-check
2 | import "./src/env-config.mjs";
3 |
4 | /** @type {import('next').NextConfig} */
5 | const nextConfig = {
6 | reactStrictMode: true,
7 | output: "standalone",
8 | cacheHandler:
9 | process.env.NODE_ENV === "production"
10 | ? "./custom-incremental-cache-handler.mjs"
11 | : undefined,
12 | cacheMaxMemorySize: 0,
13 | experimental: {
14 | taint: true
15 | },
16 | logging: {
17 | fetches: {
18 | fullUrl: true
19 | }
20 | },
21 | images: {
22 | remotePatterns: [
23 | {
24 | protocol: "https",
25 | hostname: "avatars.githubusercontent.com"
26 | }
27 | ]
28 | }
29 | };
30 |
31 | export default nextConfig;
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env*.local
29 |
30 | # vercel
31 | .vercel
32 |
33 | # typescript
34 | *.tsbuildinfo
35 | next-env.d.ts
36 |
37 |
38 | # cloudfare pages
39 | .wrangler/
40 | /cache/
41 | .idea/
42 |
43 | # Docker
44 | docker-stack.pr.yaml
45 | caddyfile.pr
46 | pr.caddyfile
47 | *.bak
48 | *.log
49 | notes.md
50 | certificates
--------------------------------------------------------------------------------
/drizzle/0053_familiar_dragon_man.sql:
--------------------------------------------------------------------------------
1 | -- ADD A case insensitive COLUMN for the username
2 | ALTER TABLE "gh_next_issues" ADD COLUMN "author_username_ci" varchar(255) COLLATE "ci";
3 | -- migrate data from the old column to the new column
4 | UPDATE "gh_next_issues" SET "author_username_ci" = "author_username";
5 |
6 | ALTER TABLE "gh_next_issues" DROP COLUMN "author_username"; -- remove the old column
7 | ALTER TABLE "gh_next_issues" RENAME COLUMN "author_username_ci" TO "author_username"; -- rename the new column to the old column
8 | ALTER TABLE "gh_next_issues" ALTER COLUMN "author_username" SET NOT NULL; -- add the not-null constraint
9 |
10 | CREATE INDEX "iss_author_uname_idx" ON "gh_next_issues" ("author_username"); -- readd the index
--------------------------------------------------------------------------------
/drizzle/0054_marvelous_living_lightning.sql:
--------------------------------------------------------------------------------
1 | -- ADD A case insensitive COLUMN for the username
2 | ALTER TABLE "gh_next_users" ADD COLUMN "username_ci" varchar(255) COLLATE "ci";
3 | -- migrate data from the old column to the new column
4 | UPDATE "gh_next_users" SET "username_ci" = "username";
5 |
6 | ALTER TABLE "gh_next_users" DROP COLUMN "username"; -- remove the old column
7 | ALTER TABLE "gh_next_users" RENAME COLUMN "username_ci" TO "username"; -- rename the new column to the old column
8 | ALTER TABLE "gh_next_users" ALTER COLUMN "username" SET NOT NULL; -- add the not-null constraint
9 |
10 | CREATE UNIQUE INDEX "uname_uniq_idx" ON "gh_next_users" ("username"); -- readd the index-- Custom SQL migration file, put you code below! --
--------------------------------------------------------------------------------
/drizzle/0055_nasty_toad_men.sql:
--------------------------------------------------------------------------------
1 | -- ADD A case insensitive COLUMN for the username
2 | ALTER TABLE "gh_next_comments" ADD COLUMN "author_username_ci" varchar(255) COLLATE "ci";
3 | -- migrate data from the old column to the new column
4 | UPDATE "gh_next_comments" SET "author_username_ci" = "author_username";
5 |
6 | ALTER TABLE "gh_next_comments" DROP COLUMN "author_username"; -- remove the old column
7 | ALTER TABLE "gh_next_comments" RENAME COLUMN "author_username_ci" TO "author_username"; -- rename the new column to the old column
8 | ALTER TABLE "gh_next_comments" ALTER COLUMN "author_username" SET NOT NULL; -- add the not-null constraint
9 |
10 | CREATE INDEX "com_author_uname_idx" ON "gh_next_comments" ("author_username"); -- readd the index
--------------------------------------------------------------------------------
/src/app/(app)/layout.tsx:
--------------------------------------------------------------------------------
1 | // components
2 | import { Footer } from "~/components/footer";
3 | import { Header } from "~/components/header/header";
4 | import { clsx } from "~/lib/shared/utils.shared";
5 |
6 | export default async function AppLayout({
7 | children,
8 | header_subnav,
9 | page_title
10 | }: {
11 | children: React.ReactNode;
12 | header_subnav: React.ReactNode;
13 | page_title: React.ReactNode;
14 | }) {
15 | return (
16 | <>
17 |
18 |
22 | {children}
23 |
24 |
25 | >
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { clsx } from "~/lib/shared/utils.shared";
3 |
4 | export type SkeletonProps = {
5 | className?: string;
6 | shape?: "rectangle" | "circle";
7 | "aria-label"?: string;
8 | };
9 |
10 | export function Skeleton({
11 | className,
12 | shape = "rectangle",
13 | "aria-label": ariaLabel
14 | }: SkeletonProps) {
15 | return (
16 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/drizzle/0060_wealthy_chimera.sql:
--------------------------------------------------------------------------------
1 | -- ADD A case insensitive COLUMN for the username
2 | ALTER TABLE "gh_next_issue_user_mentions" ADD COLUMN "username_ci" varchar(255) COLLATE "ci";
3 | -- migrate data from the old column to the new column
4 | UPDATE "gh_next_issue_user_mentions" SET "username_ci" = "username";
5 |
6 | ALTER TABLE "gh_next_issue_user_mentions" DROP COLUMN "username"; -- remove the old column
7 | ALTER TABLE "gh_next_issue_user_mentions" RENAME COLUMN "username_ci" TO "username"; -- rename the new column to the old column
8 | ALTER TABLE "gh_next_issue_user_mentions" ALTER COLUMN "username" SET NOT NULL; -- add the not-null constraint
9 |
10 | CREATE INDEX "ment_username_idx" ON "gh_next_issue_user_mentions" ("username"); -- readd the index
--------------------------------------------------------------------------------
/drizzle/0010_nosy_toad.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issues_to_assignees" DROP CONSTRAINT "gh_next_issues_to_assignees_issue_id_assignee_id";--> statement-breakpoint
2 | ALTER TABLE "gh_next_issues_to_assignees" DROP CONSTRAINT "gh_next_issues_to_assignees_assignee_id_gh_next_users_id_fk";
3 | --> statement-breakpoint
4 | ALTER TABLE "gh_next_issues_to_assignees" ADD COLUMN "assignee_username" varchar(255) NOT NULL;--> statement-breakpoint
5 | ALTER TABLE "gh_next_issues_to_assignees" ADD COLUMN "assignee_avatar_url" varchar(255) NOT NULL;--> statement-breakpoint
6 | ALTER TABLE "gh_next_issues_to_assignees" DROP COLUMN IF EXISTS "assignee_id";--> statement-breakpoint
7 | ALTER TABLE "gh_next_issues_to_assignees" ADD CONSTRAINT "gh_next_issues_to_assignees_issue_id" PRIMARY KEY("issue_id");
--------------------------------------------------------------------------------
/src/components/toast/toaster.server.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | // utils
4 | import { getSession } from "~/actions/session.action";
5 | import { ToasterClient } from "./toaster.client";
6 | import { headers } from "next/headers";
7 |
8 | export async function Toaster() {
9 | // ignore HEAD requests because
10 | // next use them for redirects and rerender the page twice
11 | if (headers().get("x-method") === "HEAD") return null;
12 |
13 | const flashes = await getSession().then((session) => session.getFlash());
14 |
15 | return (
16 | ({
18 | ...flash,
19 | id: Math.random().toString(),
20 | delay: index * 500
21 | }))}
22 | />
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/models/dto/update-profile-info-input-validator.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const updateUserProfileInfosInputValidator = z.object({
4 | name: z
5 | .string({
6 | required_error: "This field is required"
7 | })
8 | .trim()
9 | .nullable(),
10 | bio: z
11 | .string({
12 | required_error: "This field is required"
13 | })
14 | .trim()
15 | .nullable(),
16 | location: z
17 | .string({
18 | required_error: "This field is required"
19 | })
20 | .trim()
21 | .nullable(),
22 | company: z
23 | .string({
24 | required_error: "This field is required"
25 | })
26 | .trim()
27 | .nullable()
28 | });
29 |
30 | export type UpdateUserProfileInfosInput = z.TypeOf<
31 | typeof updateUserProfileInfosInputValidator
32 | >;
33 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: "[\U0001F41B bug] your-issue"
5 | labels: bug, need-triage
6 | assignees: ""
7 | ---
8 |
9 | ## Describe the bug
10 | A clear and concise description of what the bug is.
11 |
12 | ## To Reproduce
13 | Steps to reproduce the behavior:
14 |
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | ## Expected behavior
21 | A clear and concise description of what you expected to happen.
22 |
23 | ## Screenshots or Video
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | ## Additional context
27 | Add any other context about the problem here for example if the issue only appears in one browser or OS.
28 |
29 | - [ ] Are you willing to make a PR for this ?
30 |
--------------------------------------------------------------------------------
/globals.d.ts:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from "react-dom";
2 | import * as React from "react";
3 |
4 | declare global {
5 | namespace NodeJS {
6 | interface Global {
7 | crypto: Crypto;
8 | }
9 | }
10 |
11 | export type ClientReferenceManifestEntry = {
12 | id: string;
13 | // chunks is a double indexed array of chunkId / chunkFilename pairs
14 | chunks: Array;
15 | name: string;
16 | };
17 |
18 | export type ClientManifest = {
19 | [id: string]: ClientReferenceManifestEntry;
20 | };
21 |
22 | type RSCManifest = {
23 | clientModules?: ClientManifest;
24 | moduleLoading?: Record;
25 | ssrModuleMapping?: Record;
26 | };
27 |
28 | var __RSC_MANIFEST: Record | null;
29 | }
30 |
31 | declare module "react" {
32 | export function unstable_postpone(reason?: string): never;
33 | }
34 |
--------------------------------------------------------------------------------
/drizzle/0025_mute_wilson_fisk.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "gh_next_issue_user_mentions" (
2 | "id" serial PRIMARY KEY NOT NULL,
3 | "username" varchar(255) NOT NULL,
4 | "issue_id" integer NOT NULL,
5 | "comment_id" integer
6 | );
7 | --> statement-breakpoint
8 | DO $$ BEGIN
9 | ALTER TABLE "gh_next_issue_user_mentions" ADD CONSTRAINT "gh_next_issue_user_mentions_issue_id_gh_next_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "gh_next_issues"("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 "gh_next_issue_user_mentions" ADD CONSTRAINT "gh_next_issue_user_mentions_comment_id_gh_next_comments_id_fk" FOREIGN KEY ("comment_id") REFERENCES "gh_next_comments"("id") ON DELETE cascade ON UPDATE no action;
16 | EXCEPTION
17 | WHEN duplicate_object THEN null;
18 | END $$;
19 |
--------------------------------------------------------------------------------
/drizzle/0064_faithful_ben_parker.sql:
--------------------------------------------------------------------------------
1 | CREATE OR REPLACE FUNCTION sync_username_insert()
2 | RETURNS TRIGGER AS $$
3 | BEGIN
4 | NEW.assignee_username_cs := NEW.assignee_username;
5 | RETURN NEW;
6 | END;
7 | $$ LANGUAGE plpgsql;
8 |
9 | CREATE TRIGGER username_insert_trigger
10 | BEFORE INSERT ON gh_next_issues_to_assignees
11 | FOR EACH ROW EXECUTE FUNCTION sync_username_insert();
12 |
13 | CREATE OR REPLACE FUNCTION sync_username_update()
14 | RETURNS TRIGGER AS $$
15 | BEGIN
16 | -- Sync from username to username_cs
17 | IF NEW.assignee_username IS DISTINCT FROM OLD.assignee_username THEN
18 | NEW.assignee_username_cs := NEW.assignee_username;
19 | END IF;
20 |
21 | RETURN NEW;
22 | END;
23 | $$ LANGUAGE plpgsql;
24 |
25 | CREATE TRIGGER username_update_trigger
26 | BEFORE UPDATE ON gh_next_issues_to_assignees
27 | FOR EACH ROW EXECUTE FUNCTION sync_username_update();
28 |
--------------------------------------------------------------------------------
/drizzle/0072_oval_fallen_one.sql:
--------------------------------------------------------------------------------
1 | CREATE OR REPLACE FUNCTION sync_author_username_insert()
2 | RETURNS TRIGGER AS $$
3 | BEGIN
4 | NEW.author_username_cs := NEW.author_username;
5 | RETURN NEW;
6 | END;
7 | $$ LANGUAGE plpgsql;
8 |
9 | CREATE TRIGGER iss_author_username_insert_trigger
10 | BEFORE INSERT ON gh_next_issues
11 | FOR EACH ROW EXECUTE FUNCTION sync_author_username_insert();
12 |
13 | CREATE OR REPLACE FUNCTION sync_author_username_update()
14 | RETURNS TRIGGER AS $$
15 | BEGIN
16 | -- Sync from username to username_cs
17 | IF NEW.author_username IS DISTINCT FROM OLD.author_username THEN
18 | NEW.author_username_cs := NEW.author_username;
19 | END IF;
20 |
21 | RETURN NEW;
22 | END;
23 | $$ LANGUAGE plpgsql;
24 |
25 | CREATE TRIGGER iss_author_username_update_trigger
26 | BEFORE UPDATE ON gh_next_issues
27 | FOR EACH ROW EXECUTE FUNCTION sync_author_username_update();
28 |
--------------------------------------------------------------------------------
/public/light_theme_preview.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/public/dark_theme_preview.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/public/favicon-dark.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/src/components/loading-indicator.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { clsx } from "~/lib/shared/utils.shared";
3 |
4 | export function LoadingIndicator({
5 | className,
6 | "aria-label": ariaLabel
7 | }: {
8 | className?: string;
9 | "aria-label"?: string;
10 | }) {
11 | return (
12 |
19 |
27 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/drizzle/0048_dusty_leper_queen.sql:
--------------------------------------------------------------------------------
1 | -- Custom SQL migration file, put you code below! --
2 | CREATE OR REPLACE FUNCTION refresh_comment_count_per_issue()
3 | RETURNS TRIGGER AS $$
4 | BEGIN
5 | REFRESH MATERIALIZED VIEW CONCURRENTLY comment_count_per_issue;
6 | RETURN NEW;
7 | END;
8 | $$ LANGUAGE plpgsql;--> statement-breakpoint
9 |
10 | CREATE OR REPLACE FUNCTION refresh_reaction_count_per_issue()
11 | RETURNS TRIGGER AS $$
12 | BEGIN
13 | REFRESH MATERIALIZED VIEW CONCURRENTLY reaction_count_per_issue;
14 | RETURN NEW;
15 | END;
16 | $$ LANGUAGE plpgsql;--> statement-breakpoint
17 |
18 |
19 | CREATE TRIGGER trigger_refresh_comment_count
20 | AFTER INSERT OR DELETE
21 | ON gh_next_comments
22 | FOR EACH ROW
23 | EXECUTE FUNCTION refresh_comment_count_per_issue();--> statement-breakpoint
24 |
25 | CREATE TRIGGER trigger_refresh_reaction_count
26 | AFTER INSERT OR DELETE
27 | ON gh_next_reactions
28 | FOR EACH ROW
29 | EXECUTE FUNCTION refresh_reaction_count_per_issue();
30 |
--------------------------------------------------------------------------------
/src/app/(app)/@page_title/[user]/page.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | // components
3 | import Link from "next/link";
4 |
5 | // utils
6 | import { clsx } from "~/lib/shared/utils.shared";
7 | import { getUserByUsername } from "~/models/user";
8 | import { notFound } from "next/navigation";
9 |
10 | // types
11 | import type { PageProps } from "~/lib/types";
12 |
13 | export default async function UserPageTitle({
14 | params
15 | }: PageProps<{
16 | user: string;
17 | }>) {
18 | const user = await getUserByUsername(params.user);
19 |
20 | if (user === null) {
21 | notFound();
22 | }
23 | return (
24 |
34 | {user.username}
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/avatar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { clsx } from "~/lib/shared/utils.shared";
3 |
4 | export type AvatarProps = {
5 | username: string;
6 | src: string;
7 | className?: string;
8 | size?: "x-small" | "small" | "medium" | "large";
9 | };
10 |
11 | export function Avatar({
12 | username,
13 | className,
14 | src,
15 | size = "medium"
16 | }: AvatarProps) {
17 | return (
18 | // eslint-disable-next-line @next/next/no-img-element
19 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/markdown/markdown-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Skeleton } from "~/components/skeleton";
3 | import { clsx } from "~/lib/shared/utils.shared";
4 |
5 | export type MarkdownSkeletonProps = {
6 | className?: string;
7 | size?: "short" | "long";
8 | };
9 |
10 | // TODO : comment fallback
11 | export function MarkdownSkeleton({
12 | className,
13 | size = "long"
14 | }: MarkdownSkeletonProps) {
15 | return (
16 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/react-query-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 | // components
4 | import { QueryClientProvider } from "@tanstack/react-query";
5 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
6 |
7 | // utils
8 | import { QueryClient } from "@tanstack/react-query";
9 |
10 | export type ReactQueryProviderProps = {
11 | children: React.ReactNode;
12 | };
13 |
14 | export function ReactQueryProvider({ children }: ReactQueryProviderProps) {
15 | const [queryClient] = React.useState(
16 | () =>
17 | new QueryClient({
18 | defaultOptions: {
19 | queries: {
20 | refetchOnWindowFocus: false,
21 | retry: 2, // retry twice by default
22 | staleTime: 5 * 60 * 1_000 // 5 minutes
23 | }
24 | }
25 | })
26 | );
27 | return (
28 |
29 | {children}
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/tsconfig.script.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "verbatimModuleSyntax": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "noEmit": true,
11 | "esModuleInterop": true,
12 | "module": "esnext",
13 | "moduleResolution": "node",
14 | "types": ["@cloudflare/workers-types"],
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "noErrorTruncation": true,
18 | "jsx": "preserve",
19 | "incremental": true,
20 | "plugins": [
21 | {
22 | "name": "next"
23 | }
24 | ],
25 | "paths": {
26 | "~/*": ["./src/*"]
27 | }
28 | },
29 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
30 | "exclude": [
31 | "src/env.ts",
32 | "node_modules",
33 | "scripts/github-query-builder-element.ts",
34 | "scripts/github-action-list-element.ts"
35 | ]
36 | }
37 |
--------------------------------------------------------------------------------
/src/app/(app)/[user]/[repository]/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import * as React from "react";
3 | import { getRepositoryByOwnerAndName } from "~/models/repository";
4 | import type { LayoutProps, PageProps } from "~/lib/types";
5 |
6 | export async function generateMetadata(
7 | props: LayoutProps<{
8 | user: string;
9 | repository: string;
10 | }>
11 | ): Promise {
12 | const repository = await getRepositoryByOwnerAndName(
13 | props.params.user,
14 | props.params.repository
15 | );
16 |
17 | if (!repository) {
18 | return {
19 | title: "Not-Found"
20 | };
21 | }
22 |
23 | return {
24 | title: {
25 | template: `%s · ${repository.owner.username}/${repository.name}`,
26 | default: `${repository.owner.username}/${repository.name} · ${repository.description}`
27 | }
28 | };
29 | }
30 |
31 | export default function RepositoryLayout({
32 | children
33 | }: LayoutProps<{
34 | user: string;
35 | repository: string;
36 | }>) {
37 | return children;
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/submit-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 |
4 | // components
5 | import { Button } from "./button";
6 |
7 | // utils
8 | import { useFormStatus } from "react-dom";
9 | // types
10 | import type { BaseButtonProps, CommonButtonProps } from "./button";
11 | import type { EventFor } from "~/lib/types";
12 | export type SubmitButtonProps = CommonButtonProps &
13 | Omit & {
14 | loadingMessage: React.ReactNode;
15 | };
16 |
17 | export function SubmitButton(props: SubmitButtonProps) {
18 | const status = useFormStatus();
19 |
20 | const { loadingMessage, ...restProps } = props;
21 | return (
22 | ) => {
27 | if (status.pending) e.preventDefault();
28 | }}
29 | >
30 | {status.pending && loadingMessage ? loadingMessage : props.children}
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/tailwind-indicator.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 |
4 | export function TailwindIndicator() {
5 | const [mediaSize, setMediaSize] = React.useState(0);
6 | React.useEffect(() => {
7 | const listener = () => setMediaSize(window.innerWidth);
8 | window.addEventListener("resize", listener);
9 |
10 | listener();
11 | return () => {
12 | window.removeEventListener("resize", listener);
13 | };
14 | }, []);
15 |
16 | return (
17 |
18 |
xs
19 |
sm
20 |
md
21 |
lg
22 |
xl
23 |
2xl
| {mediaSize}px
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/lib/client/pauseable-timeout.ts:
--------------------------------------------------------------------------------
1 | export class PauseableTimeout {
2 | #callback: () => void;
3 | #delay: number;
4 | #remaining: number;
5 | #startTime?: number;
6 | #timerId?: NodeJS.Timeout;
7 |
8 | constructor(callback: () => void, delay: number) {
9 | this.#callback = callback;
10 | this.#delay = delay;
11 | this.#remaining = this.#delay;
12 | }
13 |
14 | public start(): void {
15 | this.#startTime = Date.now();
16 | this.#timerId = setTimeout(this.#callback, this.#remaining);
17 | }
18 |
19 | public pause(): void {
20 | if (this.#timerId) {
21 | clearTimeout(this.#timerId);
22 | this.#remaining -= Date.now() - (this.#startTime || 0);
23 | }
24 | }
25 |
26 | public resume(): void {
27 | this.#startTime = Date.now();
28 | this.#timerId = setTimeout(this.#callback, this.#remaining);
29 | }
30 |
31 | public stop(): void {
32 | if (this.#timerId) {
33 | clearTimeout(this.#timerId);
34 | this.#timerId = undefined;
35 | this.#remaining = this.#delay;
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/lib/shared/cache-keys.shared.ts:
--------------------------------------------------------------------------------
1 | export const CacheKeys = {
2 | github: () => ["GITHUB_REPO_DATA"],
3 | labelCount: () => ["LABEL_COUNT"],
4 | openIssuesCount: () => ["OPEN_ISSUES_COUNT"],
5 | readme: (user: string, repo: string, updatedAt: number | string | Date) => [
6 | user,
7 | repo,
8 | `readme`,
9 | new Date(updatedAt).getTime()
10 | ],
11 | issues: (props: {
12 | user: string;
13 | repo: string;
14 | number: number;
15 | updatedAt: number | string | Date;
16 | }) => [
17 | props.user,
18 | props.repo,
19 | "issues",
20 | props.number,
21 | new Date(props.updatedAt).getTime()
22 | ],
23 | issueHovercard: (props: { user: string; repo: string; number: number }) => [
24 | "ISSUE_HOVERCARD",
25 | props.user,
26 | props.repo,
27 | props.number
28 | ],
29 | geo: (ip: string) => ["GEO", ip],
30 | markdownPreview: (props: {
31 | repositoryPath: `${string}/${string}`;
32 | content: string;
33 | }) => ["MARKDOWN_PREVIEW", props.repositoryPath, props.content]
34 | } as const;
35 |
--------------------------------------------------------------------------------
/src/components/theme-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 |
4 | // components
5 | import { ThemeCard } from "./theme-card";
6 |
7 | // utils
8 | import { updateTheme } from "~/actions/theme.action";
9 |
10 | // types
11 | import type { Theme } from "~/actions/theme.action";
12 | import { SubmitButton } from "./submit-button";
13 |
14 | export type ThemeFormProps = {
15 | theme?: Theme;
16 | };
17 |
18 | export function ThemeForm({ theme }: ThemeFormProps) {
19 | return (
20 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/models/label.ts:
--------------------------------------------------------------------------------
1 | import "server-only";
2 | import { asc, ilike, sql } from "drizzle-orm";
3 | import { CacheKeys } from "~/lib/shared/cache-keys.shared";
4 | import { db } from "~/lib/server/db/index.server";
5 | import { labels } from "~/lib/server/db/schema/label.sql";
6 | import { nextCache } from "~/lib/server/rsc-utils.server";
7 |
8 | const labelsByNamePrepared = db
9 | .select()
10 | .from(labels)
11 | .where(ilike(labels.name, sql.placeholder("name")))
12 | .orderBy(asc(labels.name))
13 | .prepare("label_by_name");
14 |
15 | export async function getLabelsByName(name: string) {
16 | return await labelsByNamePrepared.execute({
17 | name: "%" + name + "%"
18 | });
19 | }
20 |
21 | export async function getLabelsCount() {
22 | const fn = nextCache(
23 | async () => {
24 | const [count] = await db
25 | .select({
26 | count: sql`count(*)`.mapWith(Number)
27 | })
28 | .from(labels);
29 |
30 | return count.count;
31 | },
32 | {
33 | tags: CacheKeys.labelCount()
34 | }
35 | );
36 |
37 | return fn();
38 | }
39 |
--------------------------------------------------------------------------------
/docker/docker-stack.local.yaml:
--------------------------------------------------------------------------------
1 | version: "3.4"
2 |
3 | services:
4 | app:
5 | image: dcr.fredkiss.dev/gh-next:latest
6 | ports:
7 | - "3000:3000"
8 | deploy:
9 | replicas: 1
10 | update_config:
11 | parallelism: 1
12 | delay: 5s
13 | order: start-first
14 | failure_action: rollback
15 | restart_policy:
16 | condition: on-failure
17 | delay: 5s
18 | max_attempts: 3
19 | window: 120s
20 | depends_on:
21 | - db
22 | - webdis
23 | webdis:
24 | image: nicolas/webdis:latest
25 | volumes: # mount volume containing the config file
26 | - ./webdis.json:/etc/webdis.prod.json
27 | ports:
28 | - "6380:7379"
29 | adminer:
30 | image: adminer
31 | ports:
32 | - 8081:8080
33 | db:
34 | image: postgres:12-alpine
35 | volumes:
36 | - db-data:/var/lib/postgresql/data
37 | environment:
38 | POSTGRES_USER: postgres
39 | POSTGRES_PASSWORD: password
40 | POSTGRES_DB: gh_next
41 | ports:
42 | - "5433:5432"
43 | volumes:
44 | db-data:
45 |
--------------------------------------------------------------------------------
/src/components/issues/issue-row-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { IssueOpenedIcon } from "@primer/octicons-react";
2 | import * as React from "react";
3 | import { clsx } from "~/lib/shared/utils.shared";
4 |
5 | export type IssueRowSkeletonProps = {};
6 |
7 | export function IssueRowSkeleton({}: IssueRowSkeletonProps) {
8 | return (
9 |
10 |
11 |
12 |
Loading issue...
13 |
17 |
18 |
19 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/x-mas-decorations.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export type XMasDecorationsProps = {};
4 |
5 | export function XMasDecorations({}: XMasDecorationsProps) {
6 | return (
7 | <>
8 |
17 |
26 |
35 | >
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/label-badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | // utils
3 | import { clsx, hexToRGBHSL } from "~/lib/shared/utils.shared";
4 |
5 | // types
6 | import type { RGBHSLColor } from "~/lib/shared/utils.shared";
7 | export type LabelBadgeProps = {
8 | title: string;
9 | color: string;
10 | className?: string;
11 | };
12 |
13 | export function LabelBadge({ title, color, className }: LabelBadgeProps) {
14 | const { r, g, b, h, s, l }: RGBHSLColor = hexToRGBHSL(color) ?? {
15 | r: 255,
16 | g: 255,
17 | b: 255,
18 | h: 0,
19 | s: 0,
20 | l: 0
21 | };
22 | return (
23 |
38 | {title}
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "verbatimModuleSyntax": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "noEmit": true,
11 | "esModuleInterop": true,
12 | "module": "esnext",
13 | "moduleResolution": "node",
14 | "types": ["@cloudflare/workers-types"],
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "noErrorTruncation": true,
18 | "jsx": "preserve",
19 | "incremental": true,
20 | "plugins": [
21 | {
22 | "name": "next"
23 | }
24 | ],
25 | "paths": {
26 | "~/*": ["./src/*"]
27 | }
28 | },
29 | "include": [
30 | "next-env.d.ts",
31 | "**/*.ts",
32 | "**/*.mjs",
33 | "**/*.tsx",
34 | ".next/types/**/*.ts"
35 | ],
36 | "exclude": [
37 | "node_modules",
38 | "pr-preview-workflow/**/*.ts",
39 | "scripts/github-query-builder-element.ts",
40 | "scripts/github-action-list-element.ts"
41 | ]
42 | }
43 |
--------------------------------------------------------------------------------
/src/app/(app)/settings/appearance/page.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 | import * as React from "react";
3 |
4 | // components
5 | import { ThemeForm } from "~/components/theme-form";
6 |
7 | // utils
8 | import { getUserOrRedirect } from "~/actions/auth.action";
9 | import { getTheme } from "~/actions/theme.action";
10 |
11 | // types
12 | import type { Metadata } from "next";
13 | export const metadata: Metadata = {
14 | title: "Appearance"
15 | };
16 |
17 | export default async function Page() {
18 | await getUserOrRedirect("/settings/appearance");
19 | const theme = await getTheme();
20 | return (
21 |
22 |
23 |
24 | Theme preferences
25 |
26 |
27 |
28 | Choose how GitHub looks to you. Select a single theme, or sync with
29 | your system and automatically switch between day and night themes.
30 |
31 |
32 |
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Adrien KISSIE
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/drizzle/0046_square_ikaris.sql:
--------------------------------------------------------------------------------
1 | -- Custom SQL migration file, put you code below! --
2 | CREATE MATERIALIZED VIEW comment_count_per_issue AS
3 | SELECT
4 | issue_id,
5 | COUNT(id) AS comment_count
6 | FROM
7 | gh_next_comments
8 | GROUP BY
9 | issue_id; --> statement-breakpoint
10 |
11 | CREATE MATERIALIZED VIEW reaction_count_per_issue AS
12 | SELECT
13 | issue_id,
14 | COALESCE(SUM(CASE WHEN type = 'PLUS_ONE' THEN 1 ELSE 0 END), 0) AS plus_one_count,
15 | COALESCE(SUM(CASE WHEN type = 'MINUS_ONE' THEN 1 ELSE 0 END), 0) AS minus_one_count,
16 | COALESCE(SUM(CASE WHEN type = 'CONFUSED' THEN 1 ELSE 0 END), 0) AS confused_count,
17 | COALESCE(SUM(CASE WHEN type = 'EYES' THEN 1 ELSE 0 END), 0) AS eyes_count,
18 | COALESCE(SUM(CASE WHEN type = 'HEART' THEN 1 ELSE 0 END), 0) AS heart_count,
19 | COALESCE(SUM(CASE WHEN type = 'HOORAY' THEN 1 ELSE 0 END), 0) AS hooray_count,
20 | COALESCE(SUM(CASE WHEN type = 'LAUGH' THEN 1 ELSE 0 END), 0) AS laugh_count,
21 | COALESCE(SUM(CASE WHEN type = 'ROCKET' THEN 1 ELSE 0 END), 0) AS rocket_count
22 | FROM
23 | gh_next_reactions
24 | GROUP BY
25 | issue_id;
26 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ## Description
2 |
3 | Please provide a brief summary of the changes. If it fixes a bug or resolves a feature request, be sure to link to that issue.
4 |
5 | fixes #
6 |
7 | ## Type of Change
8 |
9 | - [ ] Bug fix (non-breaking change which fixes an issue)
10 | - [ ] New feature (non-breaking change which adds functionality)
11 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
12 |
13 |
--------------------------------------------------------------------------------
/src/components/icon-switcher.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import type { Theme } from "~/actions/theme.action";
5 |
6 | function updateIcons(theme: Theme) {
7 | const userDefinedTheme = document.documentElement.dataset.theme !== "system";
8 | if (!userDefinedTheme) {
9 | const icons = document.querySelectorAll(
10 | "[data-favicon]"
11 | ) as NodeListOf;
12 | icons.forEach((icon) => {
13 | const ext = icon.type === "image/svg+xml" ? "svg" : "png";
14 | icon.href = theme === "dark" ? `/favicon-dark.${ext}` : `/favicon.${ext}`;
15 | });
16 | }
17 | }
18 |
19 | export function IconSwitcher() {
20 | React.useEffect(() => {
21 | const darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
22 |
23 | updateIcons(darkQuery.matches ? "dark" : "light");
24 | const listenToDarkQuery = (e: MediaQueryListEvent) =>
25 | updateIcons(e.matches ? "dark" : "light");
26 |
27 | darkQuery.addEventListener("change", listenToDarkQuery);
28 | return () => darkQuery.removeEventListener("change", listenToDarkQuery);
29 | }, []);
30 | return null;
31 | }
32 |
--------------------------------------------------------------------------------
/latency.ts:
--------------------------------------------------------------------------------
1 | const numberOfRequests = 50; // Number of requests to send
2 |
3 | async function measureLatency(url: string, label: string) {
4 | let totalLatency = 0;
5 |
6 | for (let i = 0; i < numberOfRequests; i++) {
7 | const startTime = performance.now();
8 | await fetch(url);
9 | const endTime = performance.now();
10 | totalLatency += endTime - startTime;
11 | }
12 |
13 | const averageLatency = totalLatency / numberOfRequests;
14 | console.log(
15 | `\x1b[33m[${label}]\x1b[37m Average latency for ${url} (${numberOfRequests} requests) : ${averageLatency} ms`
16 | );
17 | }
18 |
19 | Promise.all([
20 | measureLatency(
21 | "https://gh.fredkiss.dev/Fredkiss3/gh-next/issues/58248",
22 | "production"
23 | ),
24 | measureLatency(
25 | "https://gh-dev.fredkiss.dev/Fredkiss3/gh-next/issues/58248",
26 | "staging"
27 | )
28 | // measureLatency("http://localhost:3001/Fredkiss3/gh-next/issues", "docker"),
29 | // measureLatency(
30 | // "http://localhost:3000/Fredkiss3/gh-next/issues",
31 | // "docker swarm"
32 | // ),
33 | // measureLatency(
34 | // "http://localhost:3002/Fredkiss3/gh-next/issues",
35 | // "npm run start"
36 | // )
37 | ]).catch(console.error);
38 |
--------------------------------------------------------------------------------
/src/app/(app)/[user]/[repository]/actions/page.tsx:
--------------------------------------------------------------------------------
1 | // components
2 | import { HomeIcon } from "@primer/octicons-react";
3 | import { Button } from "~/components/button";
4 |
5 | // utils
6 | import { clsx } from "~/lib/shared/utils.shared";
7 |
8 | // types
9 | import type { Metadata } from "next";
10 | import type { PageProps } from "~/lib/types";
11 |
12 | export const metadata: Metadata = {
13 | title: "Actions"
14 | };
15 |
16 | export default function ActionPage(
17 | props: PageProps<{ user: string; repository: string }>
18 | ) {
19 | return (
20 |
27 |
28 | This page has not been implemented yet
29 |
30 |
31 | Come back later when we implement this.
32 |
33 |
38 |
39 | Go home
40 |
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/scripts/fetchUsers.ts:
--------------------------------------------------------------------------------
1 | import { users } from "~/lib/server/db/schema/user.sql";
2 | import { fetchFromGithubAPI } from "~/lib/server/utils.server";
3 | import { drizzle } from "drizzle-orm/postgres-js";
4 | import postgres from "postgres";
5 | import { eq } from "drizzle-orm";
6 |
7 | const db = drizzle(postgres(process.env.DATABASE_URL!));
8 |
9 | const dbUsers = await db.select().from(users);
10 |
11 | for (const user of dbUsers) {
12 | const userFromGithub = await fetchFromGithubAPI<{
13 | user: {
14 | name: string;
15 | bio: string;
16 | location: string;
17 | company: string;
18 | };
19 | }>(
20 | /* GraphQL */ `
21 | query ($login: String!) {
22 | user(login: $login) {
23 | name
24 | bio
25 | location
26 | company
27 | }
28 | }
29 | `,
30 | {
31 | login: user.username
32 | }
33 | ).catch((error) => null);
34 |
35 | if (userFromGithub) {
36 | await db
37 | .update(users)
38 | .set(userFromGithub.user)
39 | .where(eq(users.username, user.username));
40 | console.log(`updated user [${user.username}] with infos : `);
41 | console.dir(userFromGithub.user, { depth: null });
42 | }
43 | }
44 |
45 | process.exit();
46 |
--------------------------------------------------------------------------------
/src/components/settings-vertical-navlist.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 | // components
4 | import { VerticalNavLink } from "./vertical-nav-link";
5 | import {
6 | BroadcastIcon,
7 | GearIcon,
8 | PaintbrushIcon
9 | } from "@primer/octicons-react";
10 | import { clsx } from "~/lib/shared/utils.shared";
11 |
12 | export type SettingsVerticalNavlistProps = {
13 | className?: string;
14 | };
15 |
16 | export function SettingsVerticalNavlist({
17 | className
18 | }: SettingsVerticalNavlistProps) {
19 | return (
20 |
21 |
22 |
23 |
24 |
25 | Account
26 |
27 |
28 |
29 |
30 | Appearance
31 |
32 |
33 |
34 |
35 | Sessions
36 |
37 |
38 |
39 |
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/drizzle/0038_common_hiroim.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "gh_next_issues" DROP CONSTRAINT "uniq_number_idx";--> statement-breakpoint
2 | DROP INDEX IF EXISTS "author_fk_idx";--> statement-breakpoint
3 | DROP INDEX IF EXISTS "issue_fk_idx";--> statement-breakpoint
4 | DROP INDEX IF EXISTS "repository_fk_idx";--> statement-breakpoint
5 | DROP INDEX IF EXISTS "name_idx";--> statement-breakpoint
6 | DROP INDEX IF EXISTS "creator_fk_idx";--> statement-breakpoint
7 | CREATE INDEX IF NOT EXISTS "com_author_fk_idx" ON "gh_next_comments" ("author_id");--> statement-breakpoint
8 | CREATE INDEX IF NOT EXISTS "com_issue_fk_idx" ON "gh_next_comments" ("issue_id");--> statement-breakpoint
9 | CREATE INDEX IF NOT EXISTS "iss_repo_fk_idx" ON "gh_next_issues" ("repository_id");--> statement-breakpoint
10 | CREATE INDEX IF NOT EXISTS "iss_author_fk_idx" ON "gh_next_issues" ("author_id");--> statement-breakpoint
11 | CREATE INDEX IF NOT EXISTS "ment_username_idx" ON "gh_next_issue_user_mentions" ("username");--> statement-breakpoint
12 | CREATE INDEX IF NOT EXISTS "repo_name_idx" ON "gh_next_repositories" ("name");--> statement-breakpoint
13 | CREATE INDEX IF NOT EXISTS "repo_creator_fk_idx" ON "gh_next_repositories" ("creator_id");--> statement-breakpoint
14 | ALTER TABLE "gh_next_issues" ADD CONSTRAINT "iss_uniq_number_idx" UNIQUE("repository_id","number");
--------------------------------------------------------------------------------
/src/components/cache.tsx:
--------------------------------------------------------------------------------
1 | import { createCacheComponent } from "@rsc-cache/next";
2 | import {
3 | evaluateClientReferences,
4 | getBuildId
5 | } from "~/components/custom-rsc-renderer/load-client-references";
6 | import { kv } from "~/lib/server/kv/index.server";
7 | import { DEFAULT_CACHE_TTL } from "~/lib/shared/constants";
8 |
9 | const NextRscCacheComponent = createCacheComponent({
10 | async cacheFn(generatePayload, cacheKey, ttl) {
11 | let cachedPayload = await kv.get<{ rsc: string }>(cacheKey);
12 | const cacheHit = !!cachedPayload;
13 |
14 | if (!cachedPayload) {
15 | cachedPayload = { rsc: await generatePayload() };
16 | await kv.set(cacheKey, cachedPayload, ttl ?? DEFAULT_CACHE_TTL);
17 | }
18 |
19 | if (cacheHit) {
20 | console.log(
21 | `\x1b[32mCACHE HIT \x1b[37mFOR key \x1b[90m"\x1b[33m${cacheKey}\x1b[90m"\x1b[37m`
22 | );
23 | } else {
24 | console.log(
25 | `\x1b[31mCACHE MISS \x1b[37mFOR key \x1b[90m"\x1b[33m${cacheKey}\x1b[90m"\x1b[37m`
26 | );
27 | }
28 | return cachedPayload.rsc;
29 | },
30 | getBuildId
31 | });
32 |
33 | export async function Cache(
34 | props: React.ComponentProps
35 | ) {
36 | await evaluateClientReferences();
37 | return ;
38 | }
39 |
--------------------------------------------------------------------------------
/docker/Dockerfile.prod:
--------------------------------------------------------------------------------
1 | ##### DEPENDENCIES
2 |
3 | FROM node:20-alpine3.19 AS deps
4 | RUN apk add --no-cache libc6-compat
5 | RUN apk update && apk upgrade openssl
6 | WORKDIR /app
7 |
8 | # Install dependencies based on the preferred package manager
9 |
10 | COPY package.json pnpm-lock.yaml ./
11 | COPY ./patches ./patches
12 |
13 | RUN yarn global add pnpm@8 && pnpm install --shamefully-hoist --strict-peer-dependencies=false --frozen-lockfile
14 |
15 | ##### RUNNER
16 |
17 | FROM node:20-alpine3.19 AS runner
18 | WORKDIR /app
19 |
20 | ENV REDIS_HTTP_USERNAME=user
21 | ENV REDIS_HTTP_PASSWORD=password
22 |
23 |
24 | ENV NODE_ENV=production
25 |
26 | ENV NEXT_TELEMETRY_DISABLED=1
27 |
28 | RUN addgroup --system --gid 1001 nodejs
29 | RUN adduser --system --uid 1001 nextjs
30 |
31 | COPY --from=deps /app/node_modules ./node_modules
32 |
33 | COPY ./public/ ./public/
34 | COPY ./drizzle/ ./drizzle/
35 |
36 | COPY ./migrate.mjs .
37 | COPY ./next.config.mjs .
38 | COPY ./package.json .
39 | COPY ./custom-incremental-cache-handler.mjs .
40 |
41 | COPY --chown=nextjs:nodejs .next/standalone ./
42 | COPY --chown=nextjs:nodejs .next/static ./.next/static
43 |
44 |
45 | USER nextjs
46 | EXPOSE 80
47 | ENV PORT=80
48 | ENV NODE_ENV=production
49 | ENV HOSTNAME=0.0.0.0
50 |
51 | CMD ["sh", "-c", "node migrate.mjs && node server.js"]
52 |
--------------------------------------------------------------------------------
/rsdw.d.ts:
--------------------------------------------------------------------------------
1 | declare module "react-server-dom-webpack/server.edge" {
2 | export type ReactClientValue = any;
3 | export type ClientReferenceManifestEntry = {
4 | id: string;
5 | // chunks is a double indexed array of chunkId / chunkFilename pairs
6 | chunks: Array;
7 | name: string;
8 | };
9 |
10 | export type ClientManifest = {
11 | [id: string]: ClientReferenceManifestEntry;
12 | };
13 |
14 | export type Options = {
15 | identifierPrefix?: string;
16 | signal?: AbortSignal;
17 | onError?: (error: unknown) => void;
18 | onPostpone?: (reason: string) => void;
19 | };
20 |
21 | export function renderToReadableStream(
22 | model: ReactClientValue,
23 | webpackMap: ClientManifest,
24 | options?: Options
25 | ): ReadableStream;
26 | }
27 |
28 | declare module "react-server-dom-webpack/client.edge" {
29 | export function createFromReadableStream(
30 | stream: ReadableStream,
31 | options: {
32 | ssrManifest: {
33 | moduleLoading: any;
34 | moduleMap: any;
35 | };
36 | }
37 | ): Promise;
38 | }
39 |
40 | declare module "react-server-dom-webpack/client" {
41 | export function createFromReadableStream(
42 | stream: ReadableStream,
43 | options?: Record
44 | ): Promise;
45 | }
46 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/lib/server/kv/cloudfare.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { env } from "~/env";
3 | import { DEFAULT_CACHE_TTL } from "~/lib/shared/constants";
4 |
5 | import type { KVStore } from "./index.server";
6 |
7 | const CloudfarekvNamespaceSchema = z.object({
8 | KV: z.object({
9 | get: z.function(),
10 | put: z.function(),
11 | delete: z.function()
12 | })
13 | });
14 |
15 | export class CloudfareKV implements KVStore {
16 | #client: KVNamespace;
17 |
18 | constructor() {
19 | CloudfarekvNamespaceSchema.parse(env);
20 | // @ts-expect-error
21 | this.#client = env.KV as KVNamespace;
22 | }
23 |
24 | public async set = {}>(
25 | key: string,
26 | value: T,
27 | ttl_in_seconds: number = DEFAULT_CACHE_TTL
28 | ) {
29 | await this.#client.put(
30 | key,
31 | JSON.stringify(value),
32 | ttl_in_seconds
33 | ? {
34 | expirationTtl: ttl_in_seconds
35 | }
36 | : undefined
37 | );
38 | }
39 |
40 | public async get>(key: string) {
41 | return await this.#client.get(key).then((str) => {
42 | if (str === null) return null;
43 | return JSON.parse(str) as T;
44 | });
45 | }
46 |
47 | public async delete(key: string) {
48 | await this.#client.delete(key);
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/components/custom-rsc-renderer/render-rsc-to-string.ts:
--------------------------------------------------------------------------------
1 | import "server-only";
2 | import * as RSDW from "react-server-dom-webpack/server.edge";
3 | import * as React from "react";
4 | import { getClientManifest } from "./rsc-manifest";
5 | import { evaluateClientReferences } from "./load-client-references";
6 |
7 | export async function renderRSCtoString(component: React.ReactNode) {
8 | await evaluateClientReferences();
9 | const rscPayload = RSDW.renderToReadableStream(
10 | component,
11 | // the client manifest is required for react to resolve
12 | // all the clients components and where to import them
13 | // they will be inlined into the RSC payload as references
14 | // React will use those references during SSR to resolve
15 | // the client components
16 | await getClientManifest()
17 | );
18 | return await transformStreamToString(rscPayload);
19 | }
20 |
21 | async function transformStreamToString(stream: ReadableStream) {
22 | const reader = stream.getReader();
23 | const textDecoder = new TextDecoder();
24 | let result = "";
25 |
26 | async function read() {
27 | const { done, value } = await reader.read();
28 |
29 | if (done) {
30 | return result;
31 | }
32 |
33 | result += textDecoder.decode(value, { stream: true });
34 | return read();
35 | }
36 |
37 | return read();
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/top-loader.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 | import * as NProgress from "nprogress";
4 | import NextTopLoader from "nextjs-toploader";
5 | import { usePathname, useRouter, useSearchParams } from "next/navigation";
6 |
7 | export function TopLoader() {
8 | return (
9 | <>
10 |
11 |
12 | >
13 | );
14 | }
15 |
16 | function FinishingLoader() {
17 | const pathname = usePathname();
18 | const router = useRouter();
19 | const searchParams = useSearchParams();
20 | React.useEffect(() => {
21 | NProgress.done();
22 | }, [pathname, router, searchParams]);
23 | React.useEffect(() => {
24 | const linkClickListener = (ev: MouseEvent) => {
25 | const element = ev.target as HTMLElement;
26 | const closestlink = element.closest("a");
27 | const isOpenToNewTabClick =
28 | ev.ctrlKey ||
29 | ev.shiftKey ||
30 | ev.metaKey || // apple
31 | (ev.button && ev.button == 1); // middle click, >IE9 + everyone else
32 |
33 | if (closestlink && isOpenToNewTabClick) {
34 | NProgress.done();
35 | }
36 | };
37 | window.addEventListener("click", linkClickListener);
38 | return () => window.removeEventListener("click", linkClickListener);
39 | }, []);
40 | return null;
41 | }
42 |
--------------------------------------------------------------------------------
/src/env.ts:
--------------------------------------------------------------------------------
1 | import { experimental_taintUniqueValue as taintUniqueValue } from "react";
2 | import { _envObject } from "./env-config.mjs";
3 |
4 | /**
5 | * taintUniqueValue is undefined outside of server components :
6 | * - in the client & in other call sites
7 | */
8 | if (process.env.SKIP_ENV_VALIDATION?.toString() !== "1" && taintUniqueValue) {
9 | taintUniqueValue(
10 | "Do not pass the DB URL to the client.",
11 | _envObject,
12 | _envObject.DATABASE_URL
13 | );
14 | taintUniqueValue(
15 | "Do not pass the session SECRET to the client.",
16 | _envObject,
17 | _envObject.SESSION_SECRET
18 | );
19 |
20 | taintUniqueValue(
21 | "Do not pass the Github client ID to the client.",
22 | _envObject,
23 | _envObject.GITHUB_CLIENT_ID
24 | );
25 | taintUniqueValue(
26 | "Do not pass the Github client secret to the client.",
27 | _envObject,
28 | _envObject.GITHUB_SECRET
29 | );
30 | taintUniqueValue(
31 | "Do not pass the Github personnal access token to the client.",
32 | _envObject,
33 | _envObject.GITHUB_PERSONAL_ACCESS_TOKEN
34 | );
35 |
36 | if (_envObject.REDIS_HTTP_PASSWORD) {
37 | taintUniqueValue(
38 | "Do not pass REDIS credentials to the client.",
39 | _envObject,
40 | _envObject.REDIS_HTTP_PASSWORD
41 | );
42 | }
43 | }
44 | export { _envObject as env };
45 |
--------------------------------------------------------------------------------
/src/components/footer.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | // components
4 | import { MarkGithubIcon } from "@primer/octicons-react";
5 | import Link from "next/link";
6 |
7 | // utils
8 | import { clsx } from "~/lib/shared/utils.shared";
9 |
10 | export type FooterProps = {};
11 |
12 | export async function Footer({}: FooterProps) {
13 | return (
14 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/issues/clear-search-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 | // components
4 | import { XIcon } from "@primer/octicons-react";
5 | import Link from "next/link";
6 |
7 | // utils
8 | import { DEFAULT_ISSUE_SEARCH_QUERY } from "~/lib/shared/constants";
9 | import { useParams, useSearchParams } from "next/navigation";
10 |
11 | export function ClearSearchButtonSection() {
12 | const searchParams = useSearchParams();
13 | const params = useParams() as {
14 | user: string;
15 | repository: string;
16 | };
17 | const searchQuery = searchParams.get("q");
18 |
19 | return (
20 | <>
21 | {searchQuery !== null &&
22 | DEFAULT_ISSUE_SEARCH_QUERY !== searchQuery.trim() && (
23 |
24 |
29 |
30 |
31 |
32 | Clear current search query, filters, and sorts
33 |
34 |
35 | )}
36 | >
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/nav-link.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 |
4 | // components
5 | import Link from "next/link";
6 |
7 | // utils
8 | import { useActiveLink } from "~/lib/client/hooks/use-active-link";
9 | import { clsx } from "~/lib/shared/utils.shared";
10 |
11 | // types
12 | import type { LinkProps } from "next/link";
13 |
14 | type NavLinkProps = LinkProps & {
15 | children: React.ReactNode;
16 | icon: React.ReactNode;
17 | isRootLink?: boolean;
18 | };
19 |
20 | export function NavLink({
21 | href,
22 | children,
23 | icon,
24 | isRootLink,
25 | ...props
26 | }: NavLinkProps) {
27 | const isActive = useActiveLink(href.toString(), isRootLink);
28 |
29 | return (
30 |
39 |
46 | {icon}
47 | {children}
48 |
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/pr-preview-workflow/index.ts:
--------------------------------------------------------------------------------
1 | import { parseArgs } from "node:util";
2 | import { z } from "zod";
3 | import { addDockerApp } from "./add-docker-app";
4 | import { addCaddyfile } from "./add-caddyfile";
5 |
6 | const argSchema = z.object({
7 | "pr-id": z.coerce.number(),
8 | "pr-branch": z.string(),
9 | "caddy-config-path": z.string(),
10 | "reload-caddy": z.boolean(),
11 | "reload-docker": z.boolean()
12 | });
13 |
14 | const { values } = parseArgs({
15 | args: Bun.argv,
16 | options: {
17 | "pr-id": {
18 | type: "string"
19 | },
20 | "pr-branch": {
21 | type: "string"
22 | },
23 | "caddy-config-path": {
24 | type: "string",
25 | default: "./caddy-test" // for testing locally
26 | },
27 | "reload-caddy": {
28 | type: "boolean",
29 | default: false
30 | },
31 | "reload-docker": {
32 | type: "boolean",
33 | default: false
34 | }
35 | },
36 | strict: true,
37 | allowPositionals: true
38 | });
39 |
40 | const {
41 | "pr-id": PR_ID,
42 | "pr-branch": PR_BRANCH,
43 | "caddy-config-path": CADDY_CONFIG_FOLDER_PATH,
44 | "reload-caddy": shouldReloadCaddy,
45 | "reload-docker": shouldReloadDockerStack
46 | } = argSchema.parse(values);
47 |
48 | await addDockerApp(PR_ID, shouldReloadDockerStack);
49 | await addCaddyfile(
50 | PR_ID,
51 | PR_BRANCH,
52 | CADDY_CONFIG_FOLDER_PATH,
53 | shouldReloadCaddy
54 | );
55 |
--------------------------------------------------------------------------------
/src/lib/server/db/schema/repository.sql.ts:
--------------------------------------------------------------------------------
1 | import {
2 | boolean,
3 | index,
4 | integer,
5 | serial,
6 | text,
7 | timestamp,
8 | varchar
9 | } from "drizzle-orm/pg-core";
10 | import { relations, type InferSelectModel } from "drizzle-orm";
11 | import { pgTable } from "./index.sql";
12 | import { users } from "./user.sql";
13 | import { issues } from "./issue.sql";
14 |
15 | export const repositories = pgTable(
16 | "repositories",
17 | {
18 | id: serial("id").primaryKey(),
19 | name: varchar("name", { length: 255 }).notNull().unique(),
20 | description: text("description").notNull().default(""),
21 | created_at: timestamp("created_at").defaultNow().notNull(),
22 | creator_id: integer("creator_id").references(() => users.id, {
23 | onDelete: "cascade"
24 | }),
25 | is_archived: boolean("is_archived").default(false),
26 | is_public: boolean("is_public").default(true)
27 | },
28 | (table) => ({
29 | creatorFkIdx: index("repo_creator_fk_idx").on(table.creator_id)
30 | })
31 | );
32 |
33 | export const repositoryRelations = relations(repositories, ({ one, many }) => ({
34 | creator: one(users, {
35 | fields: [repositories.creator_id],
36 | references: [users.id],
37 | relationName: "creator"
38 | }),
39 | issues: many(issues, {
40 | relationName: "issues"
41 | })
42 | }));
43 |
44 | export type Repository = InferSelectModel;
45 |
--------------------------------------------------------------------------------
/src/components/toast/toaster.client.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 |
4 | // components
5 | import { Toast } from "./toast";
6 |
7 | // types
8 | import type { SessionFlash } from "~/lib/server/session.server";
9 |
10 | export type ToasterClientProps = {
11 | flashes: (SessionFlash & { id: string; delay: number })[];
12 | };
13 |
14 | export function ToasterClient({ flashes }: ToasterClientProps) {
15 | const [allFlashes, setFlashes] = React.useState(flashes);
16 |
17 | React.useEffect(() => {
18 | // We can safely add the flashes to the state as when
19 | // flashes will change, it means new flashes are added
20 | // thoses flashes are guarranted to be new ones
21 | // the reference stays the same
22 | setFlashes((oldFlashes) => {
23 | if (oldFlashes === flashes) return oldFlashes;
24 | return [...oldFlashes, ...flashes];
25 | });
26 | }, [flashes]);
27 |
28 | return (
29 | <>
30 | {allFlashes.length > 0 && (
31 |
32 | {allFlashes.map((flash, index) => (
33 |
39 | {flash.message}
40 |
41 | ))}
42 |
43 | )}
44 | >
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/src/lib/client/hooks/use-media-query.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export function useMediaQuery(query: string): boolean {
4 | const getMatches = (query: string): boolean => {
5 | // Prevents SSR issues
6 | if (typeof window !== "undefined") {
7 | return window.matchMedia(query).matches;
8 | }
9 | return false;
10 | };
11 |
12 | const [matches, setMatches] = React.useState(getMatches(query));
13 |
14 | React.useEffect(() => {
15 | const matchMedia = window.matchMedia(query);
16 |
17 | function handleChange() {
18 | setMatches(getMatches(query));
19 | }
20 |
21 | // Triggered at the first client-side load and if query changes
22 | handleChange();
23 |
24 | // Listen matchMedia
25 | matchMedia.addEventListener("change", handleChange);
26 |
27 | return () => {
28 | matchMedia.removeEventListener("change", handleChange);
29 | };
30 | }, [query]);
31 |
32 | /**
33 | * This is a hack to not get matches instantly and avoid hydration issues,
34 | * since `useMediaQuery` does not return anything on the server and will return
35 | * a value on the client, we do not want to have hydration errors and let first render
36 | * finish, then we can show the modal.
37 | */
38 | const [isFirstRender, setIsFirstRender] = React.useState(true);
39 |
40 | React.useEffect(() => {
41 | setIsFirstRender(false);
42 | }, []);
43 |
44 | return !isFirstRender && matches;
45 | }
46 |
--------------------------------------------------------------------------------
/src/actions/middlewares.ts:
--------------------------------------------------------------------------------
1 | import "server-only";
2 | import { revalidatePath } from "next/cache";
3 | import { getAuthedUser } from "./auth.action";
4 | import { getSession } from "./session.action";
5 | import type { FunctionWithoutLastArg } from "~/lib/types";
6 | import type { Session } from "~/lib/server/session.server";
7 | import type { User } from "~/lib/server/db/schema/user.sql";
8 |
9 | export type AuthState = {
10 | currentUser: User;
11 | session: Session;
12 | };
13 |
14 | export type AuthError = {
15 | type: "AUTH_ERROR";
16 | };
17 |
18 | export type AuthedServerAction<
19 | Action extends (...args: [...any[], auth: AuthState]) => Promise
20 | > = (
21 | ...args: Parameters>
22 | ) => Promise> | AuthError>;
23 |
24 | export function withAuth Promise>(
25 | action: Action
26 | ) {
27 | return (async (...args: Parameters>) => {
28 | const session = await getSession();
29 | const currentUser = await getAuthedUser();
30 |
31 | if (!currentUser) {
32 | await session.addFlash({
33 | type: "warning",
34 | message: "You must be authenticated to do this action."
35 | });
36 |
37 | revalidatePath("/");
38 | return {
39 | type: "AUTH_ERROR" as const
40 | } satisfies AuthError;
41 | }
42 |
43 | return action(...args, { currentUser, session });
44 | }) as AuthedServerAction;
45 | }
46 |
--------------------------------------------------------------------------------
/src/app/(app)/[user]/[repository]/issues/new/page.tsx:
--------------------------------------------------------------------------------
1 | // components
2 | import { NewIssueForm } from "~/components/issues/new-issue-form";
3 | import { Markdown } from "~/components/markdown/markdown";
4 |
5 | // utils
6 | import { notFound } from "next/navigation";
7 | import { getUserOrRedirect } from "~/actions/auth.action";
8 | import { getRepositoryByOwnerAndName } from "~/models/repository";
9 |
10 | // types
11 | import type { Metadata } from "next";
12 | import type { PageProps } from "~/lib/types";
13 |
14 | type NewIssuePageProps = PageProps<{
15 | user: string;
16 | repository: string;
17 | }>;
18 |
19 | export const metadata: Metadata = {
20 | title: "New Issue"
21 | };
22 |
23 | export default async function NewIssuePage(props: NewIssuePageProps) {
24 | const [repository, currentUser] = await Promise.all([
25 | getRepositoryByOwnerAndName(props.params.user, props.params.repository),
26 | getUserOrRedirect(
27 | `/${props.params.user}/${props.params.repository}/issues/new`
28 | )
29 | ]);
30 |
31 | if (!repository) {
32 | notFound();
33 | }
34 |
35 | return (
36 | {
43 | "use server";
44 | return ;
45 | }}
46 | />
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/src/components/markdown/markdown-h.tsx:
--------------------------------------------------------------------------------
1 | import "server-only";
2 | import { LinkIcon } from "@primer/octicons-react";
3 | import { clsx } from "~/lib/shared/utils.shared";
4 |
5 | export type MarkdownHProps = {
6 | as: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
7 | showLink: boolean;
8 | } & React.ComponentProps<"h1">;
9 |
10 | export function MarkdownH({ as, showLink, ...props }: MarkdownHProps) {
11 | const Tag = as;
12 | return (
13 |
29 | {showLink && (
30 |
39 |
40 |
41 | )}
42 |
43 | {props.children}
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/src/actions/user.action.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { revalidatePath } from "next/cache";
4 | import { withAuth, type AuthError, type AuthState } from "./middlewares";
5 | import { updateUserInfos } from "~/models/user";
6 | import {
7 | updateUserProfileInfosInputValidator,
8 | type UpdateUserProfileInfosInput
9 | } from "~/models/dto/update-profile-info-input-validator";
10 |
11 | import type { FormState } from "~/lib/types";
12 |
13 | export const updateUserProfile = withAuth(async (
14 | _previousState: FormState | AuthError,
15 | formData: FormData,
16 | { session, currentUser }: AuthState
17 | ): Promise> => {
18 | const result = updateUserProfileInfosInputValidator.safeParse(
19 | Object.fromEntries(formData)
20 | );
21 |
22 | if (!result.success) {
23 | return {
24 | type: "error" as const,
25 | fieldErrors: result.error.flatten().fieldErrors,
26 | formData: {
27 | name: formData.get("name")?.toString() ?? null,
28 | bio: formData.get("bio")?.toString() ?? null,
29 | company: formData.get("company")?.toString() ?? null,
30 | location: formData.get("company")?.toString() ?? null
31 | }
32 | };
33 | }
34 |
35 | await updateUserInfos(result.data, currentUser.id);
36 |
37 | await session.addFlash({
38 | type: "success",
39 | message: "Profile updated successfully"
40 | });
41 |
42 | revalidatePath(`/`);
43 | return {
44 | type: "success" as const,
45 | message: "Success"
46 | };
47 | });
48 |
--------------------------------------------------------------------------------
/src/components/markdown-editor/markdown-editor-preview.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 | // components
4 | import { ErrorBoundary } from "react-error-boundary";
5 | import { Skeleton } from "~/components/skeleton";
6 |
7 | export type MarkdownEditorPreviewProps = {
8 | renderedMarkdown: Promise;
9 | };
10 |
11 | export function MarkdownEditorPreview(props: MarkdownEditorPreviewProps) {
12 | return (
13 | <>
14 | (
16 |
17 |
18 | Error rendering preview :
19 |
20 |
21 | {error.toString()}
22 |
23 |
24 | )}
25 | >
26 |
29 | loading preview...
30 |
31 |
32 |
33 |
34 |
35 |
36 | }
37 | >
38 | {React.use(props.renderedMarkdown)}
39 |
40 |
41 | >
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/vertical-nav-link.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 |
4 | // components
5 | import Link from "next/link";
6 |
7 | // utils
8 | import { useActiveLink } from "~/lib/client/hooks/use-active-link";
9 | import { clsx } from "~/lib/shared/utils.shared";
10 |
11 | // types
12 | import type { LinkProps } from "next/link";
13 |
14 | type VerticalNavLinkProps = LinkProps & {
15 | children: React.ReactNode;
16 | icon: React.ComponentType<{ className?: string }>;
17 | };
18 |
19 | export function VerticalNavLink({
20 | href,
21 | children,
22 | icon: Icon,
23 | ...props
24 | }: VerticalNavLinkProps) {
25 | const isActive = useActiveLink(href.toString());
26 |
27 | return (
28 |
39 |
48 |
49 | {children}
50 |
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/src/app/(app)/settings/layout.tsx:
--------------------------------------------------------------------------------
1 | // components
2 | import { Avatar } from "~/components/avatar";
3 |
4 | import { SettingsVerticalNavlist } from "~/components/settings-vertical-navlist";
5 |
6 | // utils
7 | import { getUserOrRedirect } from "~/actions/auth.action";
8 | import { clsx } from "~/lib/shared/utils.shared";
9 |
10 | export default async function SettingsLayout({
11 | children
12 | }: {
13 | children: React.ReactNode;
14 | }) {
15 | const user = await getUserOrRedirect("/settings/account");
16 | return (
17 | <>
18 |
25 |
26 |
27 |
28 |
29 |
30 | {user.name ?? user.username}
31 | {user.name && (
32 | ({user.username})
33 | )}
34 |
35 |
36 |
Your personnal account
37 |
38 |
39 |
40 |
41 |
42 |
{children}
43 |
44 |
45 | >
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/markdown/markdown-code-block.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 | import { Button } from "~/components/button";
4 | import { CheckIcon, CopyIcon } from "@primer/octicons-react";
5 | import { clsx, wait } from "~/lib/shared/utils.shared";
6 | import { Tooltip } from "~/components/tooltip";
7 |
8 | export type MarkdownCodeBlockProps = {
9 | codeStr: string;
10 | children: React.ReactNode;
11 | className?: string;
12 | };
13 |
14 | export function MarkdownCodeBlock({
15 | codeStr,
16 | className,
17 | children
18 | }: MarkdownCodeBlockProps) {
19 | const [isPending, startTransition] = React.useTransition();
20 |
21 | return (
22 |
23 | {children}
24 |
25 |
26 | {
31 | navigator.clipboard.writeText(codeStr).then(() => {
32 | // show pending state (which is success state), until the user has stopped clicking the button
33 | startTransition(() => wait(1000));
34 | });
35 | }}
36 | isSquared
37 | renderLeadingIcon={(cls) =>
38 | isPending ? (
39 |
40 | ) : (
41 |
42 | )
43 | }
44 | />
45 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/src/lib/shared/constants.ts:
--------------------------------------------------------------------------------
1 | export const FLASH_COOKIE_KEY = "__flash";
2 | export const SESSION_COOKIE_KEY = "__id";
3 | export const LOGGED_OUT_SESSION_TTL = 1 * 24 * 60 * 60; // 1 day in seconds
4 | export const LOGGED_IN_SESSION_TTL = 2 * 24 * 60 * 60; // 2 days in seconds
5 |
6 | export const DEFAULT_CACHE_TTL = 7 * 24 * 60 * 60; // 7 days in seconds
7 | export const GITHUB_REPOSITORY_CACHE_KEY = "github:repository";
8 |
9 | export const GITHUB_AUTHOR_USERNAME = "Fredkiss3";
10 | export const PRODUCTION_DOMAIN = "gh.fredkiss.dev";
11 | export const GITHUB_REPOSITORY_NAME = "gh-next";
12 | export const AUTHOR_AVATAR_URL =
13 | "https://avatars.githubusercontent.com/u/38298743?v=4";
14 |
15 | export const SORT_FILTERS = [
16 | "created-desc",
17 | "created-asc",
18 | "comments-asc",
19 | "comments-desc",
20 | "updated-asc",
21 | "updated-desc",
22 | "reactions-+1-desc",
23 | "reactions--1-desc",
24 | "reactions-smile-desc",
25 | "reactions-tada-desc",
26 | "reactions-thinking_face-desc",
27 | "reactions-heart-desc",
28 | "reactions-rocket-desc",
29 | "reactions-eyes-desc"
30 | ] as const;
31 |
32 | export const IN_FILTERS = ["title", "body", "comments"] as const;
33 | export const STATUS_FILTERS = ["open", "closed"] as const;
34 | export const REASON_FILTERS = ["completed", "not-planned"] as const;
35 | export const NO_METADATA_FILTERS = ["label", "assignee"] as const;
36 | export const DEFAULT_ISSUE_SEARCH_QUERY = "is:open";
37 |
38 | export const MAX_ITEMS_PER_PAGE = 25;
39 | export const UN_MATCHABLE_USERNAME = "<>";
40 | export const SHARED_KEY_PREFIX = "__gh_next__cache__shared_";
41 |
--------------------------------------------------------------------------------
/drizzle/0030_yielding_blonde_phantom.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "gh_next_repositories" (
2 | "id" serial PRIMARY KEY NOT NULL,
3 | "name" varchar(255) NOT NULL,
4 | "created_at" timestamp DEFAULT now() NOT NULL,
5 | "creator_id" integer,
6 | "is_archived" boolean DEFAULT false,
7 | CONSTRAINT "gh_next_repositories_name_unique" UNIQUE("name")
8 | );
9 | --> statement-breakpoint
10 | ALTER TABLE "gh_next_issues" ADD COLUMN "repository_id" integer;--> statement-breakpoint
11 |
12 | --> make it so that there is no issue without a repo attached
13 | WITH inserted AS (
14 | INSERT INTO gh_next_repositories (name, creator_id)
15 | VALUES (
16 | 'gh-next',
17 | (
18 | SELECT id FROM gh_next_users WHERE username = 'Fredkiss3' LIMIT 1
19 | )
20 | )
21 | ON CONFLICT (name) DO NOTHING
22 | RETURNING id
23 | )
24 | UPDATE gh_next_issues SET repository_id=(SELECT id FROM inserted) WHERE 1=1;--> statement-breakpoint
25 |
26 | CREATE INDEX IF NOT EXISTS "name_idx" ON "gh_next_repositories" ("name");--> statement-breakpoint
27 | DO $$ BEGIN
28 | ALTER TABLE "gh_next_issues" ADD CONSTRAINT "gh_next_issues_repository_id_gh_next_repositories_id_fk" FOREIGN KEY ("repository_id") REFERENCES "gh_next_repositories"("id") ON DELETE cascade ON UPDATE no action;
29 | EXCEPTION
30 | WHEN duplicate_object THEN null;
31 | END $$;
32 | --> statement-breakpoint
33 | DO $$ BEGIN
34 | ALTER TABLE "gh_next_repositories" ADD CONSTRAINT "gh_next_repositories_creator_id_gh_next_users_id_fk" FOREIGN KEY ("creator_id") REFERENCES "gh_next_users"("id") ON DELETE cascade ON UPDATE no action;
35 | EXCEPTION
36 | WHEN duplicate_object THEN null;
37 | END $$;
38 |
--------------------------------------------------------------------------------
/src/lib/server/kv/http.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import type { KVStore } from "./index.server";
3 | import { env } from "~/env";
4 | import { jsonFetch } from "~/lib/shared/utils.shared";
5 | import { DEFAULT_CACHE_TTL } from "~/lib/shared/constants";
6 |
7 | const kvRESTSchema = z.object({
8 | KV_REST_URL: z.string().url()
9 | });
10 |
11 | export class HttpKV implements KVStore {
12 | #rest_url: string;
13 |
14 | constructor() {
15 | const { KV_REST_URL } = kvRESTSchema.parse(env);
16 | this.#rest_url = KV_REST_URL;
17 | }
18 | public async set = {}>(
19 | key: string,
20 | value: T,
21 | ttl_in_seconds?: number | undefined
22 | ): Promise {
23 | await jsonFetch(`${this.#rest_url}/set`, {
24 | method: "POST",
25 | cache: "no-store",
26 | body: JSON.stringify({
27 | key,
28 | value,
29 | TTL: ttl_in_seconds ?? DEFAULT_CACHE_TTL
30 | })
31 | });
32 | }
33 | public async get = {}>(
34 | key: string
35 | ): Promise {
36 | const result = await jsonFetch<
37 | | {
38 | data: T;
39 | }
40 | | {
41 | errors: "not found";
42 | }
43 | >(`${this.#rest_url}/get/${key}`, {
44 | method: "GET",
45 | cache: "no-store"
46 | });
47 |
48 | if ("errors" in result) {
49 | return null;
50 | }
51 |
52 | return result.data;
53 | }
54 | public async delete(key: string): Promise {
55 | await jsonFetch(`${this.#rest_url}/delete/${key}`, {
56 | method: "DELETE",
57 | cache: "no-store"
58 | });
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/components/hovercard/user-hovercard-contents.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | // components
3 | import { Avatar } from "~/components/avatar";
4 | import { LocationIcon } from "@primer/octicons-react";
5 | import Link from "next/link";
6 |
7 | // utils
8 | import { clsx, getExcerpt } from "~/lib/shared/utils.shared";
9 |
10 | // types
11 | import type { PublicUser } from "~/models/dto/public-user-output-validator";
12 | export type UserHoverCardContentsProps = {
13 | className?: string;
14 | } & Omit;
15 |
16 | export function UserHoverCardContents({
17 | name,
18 | username,
19 | bio,
20 | location,
21 | avatar_url,
22 | className
23 | }: UserHoverCardContentsProps) {
24 | return (
25 |
31 |
32 |
33 |
34 | {username}
35 |
36 | {name && (
37 | <>
38 |
39 |
40 | {name}
41 |
42 | >
43 | )}
44 |
45 | {bio &&
{getExcerpt(bio, 88)}
}
46 | {location && (
47 |
48 |
49 | {location}
50 |
51 | )}
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/src/lib/server/db/schema/mention.sql.ts:
--------------------------------------------------------------------------------
1 | import { index, integer, serial, unique, varchar } from "drizzle-orm/pg-core";
2 | import { pgTable } from "./index.sql";
3 | import { issues } from "./issue.sql";
4 | import { comments } from "./comment.sql";
5 |
6 | import {
7 | relations,
8 | type InferInsertModel,
9 | type InferSelectModel
10 | } from "drizzle-orm";
11 |
12 | export const issueUserMentions = pgTable(
13 | "issue_user_mentions",
14 | {
15 | id: serial("id").primaryKey(),
16 | username: varchar("username", { length: 255 }).notNull(),
17 | issue_id: integer("issue_id")
18 | .references(() => issues.id, {
19 | onDelete: "cascade"
20 | })
21 | .notNull(),
22 | comment_id: integer("comment_id").references(() => comments.id, {
23 | onDelete: "cascade"
24 | })
25 | },
26 | (table) => ({
27 | username_idx: index("ment_username_idx").on(table.username),
28 | issue_idx: index("ment_issue_idx").on(table.issue_id),
29 | unique_idx: unique().on(table.username, table.issue_id, table.comment_id)
30 | })
31 | );
32 |
33 | export const issueMentionsRelations = relations(
34 | issueUserMentions,
35 | ({ one }) => ({
36 | comment: one(comments, {
37 | fields: [issueUserMentions.comment_id],
38 | references: [comments.id],
39 | relationName: "mentionning_comment"
40 | }),
41 | issue: one(issues, {
42 | fields: [issueUserMentions.issue_id],
43 | references: [issues.id],
44 | relationName: "mentionning_issue"
45 | })
46 | })
47 | );
48 |
49 | export type IssueUserMention = InferSelectModel;
50 | export type IssueUserMentionInsert = InferInsertModel;
51 |
--------------------------------------------------------------------------------
/src/actions/theme.action.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { z } from "zod";
4 | import { getSession } from "./session.action";
5 | import { cache } from "react";
6 | import { updateUserTheme } from "~/models/user";
7 | import { revalidatePath } from "next/cache";
8 |
9 | import { users } from "~/lib/server/db/schema/user.sql";
10 | import { createSelectSchema } from "drizzle-zod";
11 | import { redirect } from "next/navigation";
12 | import { withAuth, type AuthState } from "./middlewares";
13 |
14 | const userThemeSchema = createSelectSchema(users).pick({
15 | preferred_theme: true
16 | });
17 | const themeSchema = userThemeSchema.shape.preferred_theme;
18 |
19 | export type Theme = z.TypeOf;
20 |
21 | export const getTheme = cache(async function getTheme() {
22 | const session = await getSession();
23 |
24 | return session.user?.preferred_theme ?? "system";
25 | });
26 |
27 | export const updateTheme = withAuth(async function updateTheme(
28 | formData: FormData,
29 | { session, currentUser }: AuthState
30 | ) {
31 | const themeResult = themeSchema.safeParse(formData.get("theme")?.toString());
32 |
33 | if (!themeResult.success) {
34 | revalidatePath("/settings/appearance");
35 | await session.addFlash({
36 | type: "warning",
37 | message: "Invalid theme provided, please retry"
38 | });
39 | return;
40 | }
41 |
42 | const theme = themeResult.data;
43 |
44 | await updateUserTheme(theme, currentUser.id);
45 | await session.setUserTheme(theme);
46 |
47 | await session.addFlash({
48 | type: "success",
49 | message: `Theme changed to ${theme}`
50 | });
51 |
52 | revalidatePath("/settings/appearance");
53 | redirect("/settings/appearance");
54 | });
55 |
--------------------------------------------------------------------------------
/src/lib/server/db/index.server.ts:
--------------------------------------------------------------------------------
1 | import {
2 | issueUserMentions,
3 | issueMentionsRelations
4 | } from "./schema/mention.sql";
5 | import { users, usersRelations } from "./schema/user.sql";
6 | import {
7 | issues,
8 | issuesRelations,
9 | issueRevisions,
10 | issueRevisionsRelations,
11 | issueUserSubscriptionRelations,
12 | issueUserSubscriptions
13 | } from "./schema/issue.sql";
14 | import {
15 | labels,
16 | labelToIssues,
17 | labelRelations,
18 | labelToIssuesRelations
19 | } from "./schema/label.sql";
20 | import {
21 | comments,
22 | commentsRelations,
23 | commentRevisions,
24 | commentRevisionsRelations
25 | } from "./schema/comment.sql";
26 | import { issueEvents, issueEventsRelations } from "./schema/event.sql";
27 | import { reactions, reactionsRelations } from "./schema/reaction.sql";
28 | import { env } from "~/env";
29 | import { drizzle } from "drizzle-orm/node-postgres";
30 | import { Pool } from "pg";
31 |
32 | const pool = new Pool({
33 | connectionString: env.DATABASE_URL,
34 | min: 1,
35 | max: 5
36 | });
37 |
38 | export const db = drizzle(pool, {
39 | logger: true,
40 | schema: {
41 | users,
42 | issues,
43 | labels,
44 | labelToIssues,
45 | comments,
46 | reactions,
47 | issueRevisions,
48 | commentRevisions,
49 | issueEvents,
50 | issueUserSubscriptions,
51 | issueMentions: issueUserMentions,
52 | // relations
53 | issueUserSubscriptionRelations,
54 | issueEventsRelations,
55 | issueRevisionsRelations,
56 | commentRevisionsRelations,
57 | usersRelations,
58 | issuesRelations,
59 | labelRelations,
60 | labelToIssuesRelations,
61 | commentsRelations,
62 | reactionsRelations,
63 | issueMentionsRelations
64 | }
65 | });
66 |
--------------------------------------------------------------------------------
/src/components/issues/issue-row-avatar-stack.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | // components
4 | import { Avatar } from "~/components/avatar";
5 | import { Tooltip } from "~/components/tooltip";
6 |
7 | // utils
8 | import { clsx } from "~/lib/shared/utils.shared";
9 | import { IssueSearchLink } from "./issue-search-link";
10 |
11 | // types
12 | export type IssueRowAvatarStackProps = {
13 | users: Array<{
14 | username: string;
15 | avatar_url: string;
16 | }>;
17 | className?: string;
18 | size?: "small" | "medium" | "large";
19 | };
20 |
21 | export function IssueRowAvatarStack({
22 | users,
23 | className,
24 | size = "small"
25 | }: IssueRowAvatarStackProps) {
26 | return (
27 |
30 | assigned to {users.map((u) => u.username).join(" and ")}
31 |
32 | }
33 | delayInMs={500}
34 | side="bottom"
35 | align="end"
36 | >
37 |
38 | {users.map((u, index) => (
39 |
45 |
51 |
57 |
58 |
59 | ))}
60 |
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/lib/server/db/schema/user.sql.ts:
--------------------------------------------------------------------------------
1 | import { repositories } from "./repository.sql";
2 | import { relations, type InferSelectModel } from "drizzle-orm";
3 | import { issueToAssignees, issueUserSubscriptions, issues } from "./issue.sql";
4 | import { reactions } from "./reaction.sql";
5 | import { comments } from "./comment.sql";
6 | import { pgTable } from "./index.sql";
7 | import { pgEnum, serial, text, varchar } from "drizzle-orm/pg-core";
8 |
9 | export const THEMES = ["dark", "light", "system"] as const;
10 | export const themesEnum = pgEnum("themes", THEMES);
11 |
12 | export const users = pgTable("users", {
13 | id: serial("id").primaryKey(),
14 | username: varchar("username", { length: 255 }).notNull().unique(),
15 | github_id: varchar("github_id", { length: 255 }).notNull().unique(),
16 | avatar_url: varchar("avatar_url", { length: 255 }).notNull(),
17 | name: varchar("name", { length: 255 }),
18 | bio: text("bio"),
19 | location: varchar("location", { length: 255 }),
20 | company: varchar("company", { length: 255 }),
21 | preferred_theme: themesEnum("preferred_theme").default("system").notNull()
22 | });
23 |
24 | export const usersRelations = relations(users, ({ many }) => ({
25 | createdIssues: many(issues, {
26 | relationName: "author"
27 | }),
28 | repositories: many(repositories, {
29 | relationName: "repositories"
30 | }),
31 | assignedIssues: many(issueToAssignees, {
32 | relationName: "assignee"
33 | }),
34 | reactions: many(reactions, {
35 | relationName: "reactions"
36 | }),
37 | comments: many(comments, {
38 | relationName: "comments"
39 | }),
40 | subcriptions: many(issueUserSubscriptions, {
41 | relationName: "user"
42 | })
43 | }));
44 |
45 | export type User = InferSelectModel;
46 | export type Theme = (typeof THEMES)[number];
47 |
--------------------------------------------------------------------------------
/src/app/global-error.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 |
4 | // components
5 | import { HomeIcon } from "@primer/octicons-react";
6 | import { Button } from "~/components/button";
7 |
8 | // utils
9 | import { Inter } from "next/font/google";
10 | import { clsx } from "~/lib/shared/utils.shared";
11 |
12 | const inter = Inter({ subsets: ["latin"] });
13 |
14 | export default function GlobalError({
15 | error,
16 | reset
17 | }: {
18 | error: Error & { digest?: string };
19 | reset: () => void;
20 | }) {
21 | React.useEffect(() => {
22 | // Log the error to an error reporting service
23 | console.error(error);
24 | }, [error]);
25 |
26 | return (
27 |
28 |
29 |
30 |
31 | Something went wrong !
32 |
33 |
37 |
38 | OOPS ! An error occured
39 |
40 |
41 | Please reset the page, if that does not work, reload the page
42 | instead.
43 |
44 |
45 | reset()}
47 | className="inline-flex items-center gap-2 !border-foreground !text-foreground"
48 | variant="invisible"
49 | >
50 |
51 | Reset the page
52 |
53 |
54 |
55 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/src/models/repository.ts:
--------------------------------------------------------------------------------
1 | import "server-only";
2 | import { and, eq, sql } from "drizzle-orm";
3 | import { db } from "~/lib/server/db/index.server";
4 | import { repositories } from "~/lib/server/db/schema/repository.sql";
5 | import { users } from "~/lib/server/db/schema/user.sql";
6 | import { z } from "zod";
7 | import { repositoryOutputValidator } from "~/models/dto/repository-output-validator";
8 | import { cache } from "react";
9 |
10 | const repositoryByNamePrepared = db
11 | .select({
12 | id: repositories.id,
13 | name: repositories.name,
14 | description: repositories.description,
15 | is_public: repositories.is_public,
16 | owner: {
17 | id: users.id,
18 | username: users.username,
19 | name: users.name,
20 | avatar_url: users.avatar_url,
21 | bio: users.bio,
22 | location: users.location
23 | }
24 | })
25 | .from(repositories)
26 | .innerJoin(
27 | users,
28 | and(
29 | eq(repositories.creator_id, users.id),
30 | eq(users.username, sql.placeholder("owner_username"))
31 | )
32 | )
33 | .where(
34 | and(
35 | eq(repositories.name, sql.placeholder("repository_name")),
36 | eq(repositories.is_public, true) // TODO : remove this condition when visibility is correctly implemented
37 | )
38 | )
39 | .prepare("repositories_by_name_and_owner");
40 |
41 | export const getRepositoryByOwnerAndName = cache(
42 | async function getRepositoryByOwnerAndName(
43 | owner_username: string,
44 | repository_name: string
45 | ) {
46 | const [repository] = await repositoryByNamePrepared.execute({
47 | repository_name,
48 | owner_username
49 | });
50 |
51 | const outputSchema = z.union([repositoryOutputValidator, z.null()]);
52 |
53 | return outputSchema.parse(repository ?? null);
54 | }
55 | );
56 |
--------------------------------------------------------------------------------
/src/components/issues/issue-search-link.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 | // components
4 | import Link from "next/link";
5 |
6 | // utils
7 | import {
8 | formatSearchFiltersToString,
9 | parseIssueFilterTokens
10 | } from "~/lib/shared/utils.shared";
11 | import { useParams, useSearchParams } from "next/navigation";
12 | import { DEFAULT_ISSUE_SEARCH_QUERY } from "~/lib/shared/constants";
13 |
14 | // types
15 | import type { LinkProps } from "next/link";
16 | import type { IssueSearchFilters } from "~/lib/shared/utils.shared";
17 |
18 | export type IssueSearchLinkProps = Omit & {
19 | filters: IssueSearchFilters;
20 | children?: React.ReactNode;
21 | className?: string;
22 | conserveCurrentFilters?: boolean;
23 | };
24 |
25 | export const IssueSearchLink = React.forwardRef<
26 | React.ElementRef,
27 | IssueSearchLinkProps
28 | >(function IssueSearchLink(
29 | { filters, conserveCurrentFilters = false, ...props },
30 | ref
31 | ) {
32 | const params = useParams() as {
33 | user: string;
34 | repository: string;
35 | };
36 | const searchParams = useSearchParams();
37 | const allFilters = parseIssueFilterTokens(
38 | searchParams.get("q") ?? DEFAULT_ISSUE_SEARCH_QUERY
39 | );
40 |
41 | let computedFilters: IssueSearchFilters = {
42 | is: "open"
43 | };
44 |
45 | if (conserveCurrentFilters) {
46 | computedFilters = { ...computedFilters, ...allFilters };
47 | }
48 |
49 | const searchStr = formatSearchFiltersToString({
50 | ...computedFilters,
51 | ...filters
52 | });
53 |
54 | const sp = new URLSearchParams();
55 | sp.append("q", searchStr);
56 | const href = `/${params.user}/${params.repository}/issues?` + sp.toString();
57 |
58 | return ;
59 | });
60 |
--------------------------------------------------------------------------------
/src/components/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 |
4 | // components
5 | import * as RadixTooltip from "@radix-ui/react-tooltip";
6 |
7 | // utils
8 | import { clsx } from "~/lib/shared/utils.shared";
9 |
10 | // types
11 | export type TooltipProps = {
12 | className?: string;
13 | children?: React.ReactNode;
14 | content: React.ReactNode;
15 | delayInMs?: number;
16 | isOpen?: boolean;
17 | } & Pick;
18 |
19 | export const Tooltip = React.forwardRef<
20 | React.ElementRef,
21 | TooltipProps
22 | >(function Tooltip(
23 | { className, children, content, delayInMs = 150, isOpen, ...contentProps },
24 | ref
25 | ) {
26 | return (
27 |
28 |
29 | {children}
30 |
31 |
45 | {content}
46 |
47 |
48 |
49 |
50 |
51 | );
52 | });
53 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contribution Guidelines
2 |
3 | Thank you for your interest in contributing to our project! We welcome and appreciate all contributions. This document provides guidelines and steps for contributing.
4 |
5 | ## Code of Conduct
6 |
7 | All contributors are expected to follow our [Code of Conduct](./CODE_OF_CONDUCT.md). Please ensure you are welcoming and friendly in all of our spaces.
8 |
9 | ## Getting Started
10 |
11 | 1. Fork the repository on GitHub.
12 | 2. Clone the forked repository to your machine.
13 | 3. Create a new branch for your work.
14 | 4. Make your changes.
15 | 5. Commit your changes.
16 | 6. Push your changes to your fork on GitHub.
17 | 7. Create a pull request against the main branch of the original repository.
18 |
19 | ## Pull Request Guidelines
20 |
21 | - Ensure any install or build dependencies are removed before the end of the layer when doing a build.
22 | - Update the README.md with details of changes to the interface, this includes new environment variables, exposed ports, useful file locations, and container parameters.
23 | - Ensure your PR has a meaningful title.
24 |
25 | ## Reporting Issues
26 |
27 | If you believe you found a bug, please report it using the issue tracker. When filing an issue:
28 |
29 | - Provide a quick summary.
30 | - Include reproduction steps, environment details, and any other relevant information.
31 | - Attach logs or screenshots if possible.
32 |
33 | ## Suggesting Enhancements
34 |
35 | We love feedback. If you have a suggestion for improving the project, please open an issue for discussion.
36 |
37 | ## Discussions
38 |
39 | If you have questions, suggestions, or want to discuss topics related to the project, please open a discussion in the [Discussions tab](LINK_TO_GITHUB_DISCUSSIONS).
40 |
41 | ## License
42 |
43 | By contributing, you agree that your contributions will be licensed under the same license as the original project.
44 |
45 | Thank you for your contribution!
46 |
--------------------------------------------------------------------------------
/src/env-config.mjs:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | import { createEnv } from "@t3-oss/env-nextjs";
4 | import { preprocess, z } from "zod";
5 |
6 | /**
7 | * BEWARE !!!
8 | * This is only intended to be imported in `next.config.mjs`
9 | * You should import from `src/env`.
10 | * Modify this file if you want to add more env variables
11 | */
12 | export const _envObject = createEnv({
13 | server: {
14 | SESSION_SECRET: z.string().min(32).max(32),
15 | GITHUB_SECRET: z.string(),
16 | GITHUB_CLIENT_ID: z.string(),
17 | GITHUB_REDIRECT_URI: z.string().url(),
18 | GITHUB_PERSONAL_ACCESS_TOKEN: z.string(),
19 | REDIS_HTTP_URL: z.string().url().optional(),
20 | REDIS_HTTP_USERNAME: z.string().optional(),
21 | REDIS_HTTP_PASSWORD: z.string().optional(),
22 | DATABASE_URL: z.string().url(),
23 | KV_PREFIX: z
24 | .string()
25 | .regex(/^[a-zA-Z_][a-zA-Z0-9_]+$/)
26 | .catch("")
27 | .optional()
28 | .default("")
29 | },
30 | client: {
31 | NEXT_PUBLIC_VERCEL_URL: preprocess((arg) => {
32 | if (!arg || typeof arg !== "string") return arg;
33 | const protocol = arg.startsWith("localhost") ? "http" : "https";
34 | return `${protocol}://${arg}`;
35 | }, z.string().url())
36 | },
37 | runtimeEnv: {
38 | DATABASE_URL: process.env.DATABASE_URL,
39 | KV_PREFIX: process.env.KV_PREFIX,
40 | REDIS_HTTP_URL: process.env.REDIS_HTTP_URL,
41 | REDIS_HTTP_USERNAME: process.env.REDIS_HTTP_USERNAME,
42 | REDIS_HTTP_PASSWORD: process.env.REDIS_HTTP_PASSWORD,
43 | SESSION_SECRET: process.env.SESSION_SECRET,
44 | NEXT_PUBLIC_VERCEL_URL: process.env.NEXT_PUBLIC_VERCEL_URL,
45 | GITHUB_SECRET: process.env.GITHUB_SECRET,
46 | GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID,
47 | GITHUB_REDIRECT_URI: process.env.GITHUB_REDIRECT_URI,
48 | GITHUB_PERSONAL_ACCESS_TOKEN: process.env.GITHUB_PERSONAL_ACCESS_TOKEN
49 | },
50 | skipValidation: process.env.SKIP_ENV_VALIDATION?.toString() === "1"
51 | });
52 |
--------------------------------------------------------------------------------
/src/lib/server/db/schema/label.sql.ts:
--------------------------------------------------------------------------------
1 | import {
2 | serial,
3 | varchar,
4 | integer,
5 | primaryKey,
6 | index
7 | } from "drizzle-orm/pg-core";
8 |
9 | import {
10 | relations,
11 | type InferSelectModel,
12 | type InferInsertModel
13 | } from "drizzle-orm";
14 | import { issues } from "./issue.sql";
15 | import { pgTable } from "./index.sql";
16 |
17 | export const labels = pgTable("labels", {
18 | id: serial("id").primaryKey(),
19 | name: varchar("name", { length: 255 }).notNull().unique(),
20 | description: varchar("description", { length: 255 }).default("").notNull(),
21 | // 10 chars for a generous length, in practice it is 7 chars at most
22 | // ex: #FF10C0
23 | color: varchar("color", { length: 10 }).notNull()
24 | });
25 |
26 | export const labelToIssues = pgTable(
27 | "labels_to_issues",
28 | {
29 | issue_id: integer("issue_id")
30 | .notNull()
31 | .references(() => issues.id, {
32 | onDelete: "cascade"
33 | }),
34 | label_id: integer("label_id")
35 | .notNull()
36 | .references(() => labels.id, {
37 | onDelete: "cascade"
38 | })
39 | },
40 | (table) => ({
41 | pk: primaryKey(table.issue_id, table.label_id),
42 | issueFkIndex: index("lbl_2_iss_issue_fk_index").on(table.issue_id),
43 | assigneeFkIndex: index("lbl_2_iss_assignee_fk_index").on(table.label_id)
44 | })
45 | );
46 |
47 | export const labelToIssuesRelations = relations(labelToIssues, ({ one }) => ({
48 | issue: one(issues, {
49 | fields: [labelToIssues.issue_id],
50 | references: [issues.id]
51 | }),
52 | label: one(labels, {
53 | fields: [labelToIssues.label_id],
54 | references: [labels.id]
55 | })
56 | }));
57 |
58 | export const labelRelations = relations(labels, ({ many }) => ({
59 | labelToIssues: many(labelToIssues)
60 | }));
61 |
62 | export type Label = InferSelectModel;
63 | export type LabelInsert = InferInsertModel;
64 |
65 | export type LabelToIssueInsert = InferInsertModel;
66 |
--------------------------------------------------------------------------------
/src/lib/server/rsc-utils.server.ts:
--------------------------------------------------------------------------------
1 | import "server-only";
2 | import { unstable_cache } from "next/cache";
3 | import { cache } from "react";
4 | import { kv } from "~/lib/server/kv/index.server";
5 | import { env } from "~/env";
6 | import { DEFAULT_CACHE_TTL } from "~/lib/shared/constants";
7 |
8 | type Callback = (...args: any[]) => Promise;
9 | export function nextCache(
10 | cb: T,
11 | options: {
12 | tags: string[];
13 | revalidate?: number;
14 | }
15 | ) {
16 | if (process.env.NODE_ENV === "development") {
17 | return cache(
18 | ttlCache(cb, {
19 | id: options.tags,
20 | ttl: options.revalidate
21 | })
22 | );
23 | }
24 | return cache(unstable_cache(cb, options.tags, options));
25 | }
26 |
27 | export type CacheId = string | number | (string | number)[];
28 | export function ttlCache(
29 | cb: T,
30 | options: {
31 | id: CacheId;
32 | ttl?: number;
33 | forceDev?: boolean;
34 | }
35 | ) {
36 | return async (...args: Parameters): Promise>> => {
37 | if (process.env.NODE_ENV === "development" && !options.forceDev) {
38 | return await cb(...args);
39 | }
40 |
41 | const { id, ttl = DEFAULT_CACHE_TTL } = options;
42 | const key =
43 | env.KV_PREFIX + (Array.isArray(id) ? id.join("-") : id.toString());
44 | let cachedValue = await kv.get<{
45 | cached: Awaited>;
46 | }>(key);
47 |
48 | const cacheHit = !!cachedValue?.cached;
49 |
50 | if (!cachedValue?.cached) {
51 | cachedValue = {
52 | cached: await cb(...args)
53 | };
54 | await kv.set(key, cachedValue, ttl);
55 | }
56 |
57 | if (cacheHit) {
58 | console.log(
59 | `\x1b[32mCACHE HIT \x1b[37mFOR key \x1b[90m"\x1b[33m${key}\x1b[90m"\x1b[37m`
60 | );
61 | } else {
62 | console.log(
63 | `\x1b[31mCACHE MISS \x1b[37mFOR key \x1b[90m"\x1b[33m${key}\x1b[90m"\x1b[37m`
64 | );
65 | }
66 | return cachedValue.cached;
67 | };
68 | }
69 |
--------------------------------------------------------------------------------
/src/models/dto/issue-search-output-validator.ts:
--------------------------------------------------------------------------------
1 | import "server-only";
2 |
3 | import { z } from "zod";
4 | import { createSelectSchema } from "drizzle-zod";
5 | import { issues } from "~/lib/server/db/schema/issue.sql";
6 | import { publicUserOutputValidator } from "./public-user-output-validator";
7 |
8 | export const issueSearchListOutputValidator = z.object({
9 | noOfIssuesOpen: z.number(),
10 | noOfIssuesClosed: z.number(),
11 | totalCount: z.number(),
12 | issues: z.array(
13 | z
14 | .object({
15 | plus_one_count: z.number().catch(0),
16 | minus_one_count: z.number().catch(0),
17 | confused_count: z.number().catch(0),
18 | eyes_count: z.number().catch(0),
19 | heart_count: z.number().catch(0),
20 | hooray_count: z.number().catch(0),
21 | laugh_count: z.number().catch(0),
22 | rocket_count: z.number().catch(0),
23 | number: z.number(),
24 | title: z.string(),
25 | excerpt: z.string().optional(),
26 | assigned_to: z.array(
27 | z.object({
28 | username: z.string(),
29 | avatar_url: z.string().url()
30 | })
31 | ),
32 | author: publicUserOutputValidator,
33 | no_of_comments: z.number(),
34 | created_at: z.date(),
35 | status_updated_at: z.date(),
36 | labels: z.array(
37 | z.object({
38 | id: z.number(),
39 | color: z.string(),
40 | name: z.string(),
41 | description: z.string().optional()
42 | })
43 | ),
44 | mentioned_user: z.string().nullable(),
45 | commented_user: z.string().nullable(),
46 | repository_name: z.string(),
47 | repository_owner: z.string()
48 | })
49 | .merge(
50 | createSelectSchema(issues).pick({
51 | status: true
52 | })
53 | )
54 | )
55 | });
56 |
57 | export type IssueSearchListResult = z.TypeOf<
58 | typeof issueSearchListOutputValidator
59 | >;
60 |
61 | export type IssueSearchItem = IssueSearchListResult["issues"][number];
62 |
--------------------------------------------------------------------------------
/pr-preview-workflow/add-caddyfile.ts:
--------------------------------------------------------------------------------
1 | import { $, file, write } from "bun";
2 |
3 | export async function addCaddyfile(
4 | PR_ID: number,
5 | PR_BRANCH: string,
6 | CADDY_CONFIG_FOLDER_PATH: string,
7 | shouldReloadCaddy: boolean
8 | ) {
9 | await $`echo '[🔄 Caddy] adding preview environment config...'`;
10 | const caddyfileToAdd = file(
11 | `${pathWithoutSlash(CADDY_CONFIG_FOLDER_PATH)}/pull-request-${PR_ID}.caddy`
12 | );
13 |
14 | if (await caddyfileToAdd.exists()) {
15 | await $`echo '[ℹ️ Caddy] Configuration for preview branch pull request #${PR_ID} already exists, skipping work.'`;
16 | return;
17 | }
18 |
19 | const CADDY_TEMPLATE_CONTENT = `gh-${PR_ID}.gh.fredkiss.dev, gh-${PR_BRANCH}.gh.fredkiss.dev {
20 | route {
21 | sablier {
22 | group gh-next-${PR_ID}
23 | session_duration 30m
24 | dynamic {
25 | theme ghost
26 | display_name preview environment ${PR_BRANCH} (pull request ID: ${PR_ID})
27 | refresh_frequency 5s
28 | }
29 | }
30 | reverse_proxy http://gh-next-${PR_ID}:3000 {
31 | header_up Host {http.request.host}
32 | # disables buffering
33 | flush_interval -1
34 | }
35 | }
36 | log
37 | }`;
38 |
39 | await write(caddyfileToAdd, CADDY_TEMPLATE_CONTENT);
40 | await $`echo '[✅ caddy] config for pull request #${PR_ID} added successfully'`;
41 | if (shouldReloadCaddy) {
42 | // reload caddy service in docker
43 | await $`echo '[🔄 Caddy] reloading caddy server...'`;
44 | const { exitCode, stderr } =
45 | await $`docker exec $(docker ps -q -f name=caddy-stack_proxy) caddy reload -c /etc/caddy/Caddyfile`;
46 | if (exitCode !== 0) {
47 | await $`echo '[❌ Caddy] caddy service encountered an unexpected error : ${stderr.toString()}'`;
48 | process.exit(1);
49 | }
50 | await $`echo '[✅ Caddy] caddy server reloaded succesfully'`;
51 | }
52 | }
53 |
54 | function pathWithoutSlash(path: string) {
55 | if (path.endsWith("/")) {
56 | return path.substring(0, path.length - 1);
57 | }
58 | return path;
59 | }
60 |
--------------------------------------------------------------------------------
/src/components/theme-card.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 | "use client";
3 | import * as React from "react";
4 |
5 | // utils
6 | import { clsx } from "~/lib/shared/utils.shared";
7 |
8 | export type ThemeCardProps = {
9 | value: "light" | "dark" | "system";
10 | defaultSelected: boolean;
11 | };
12 |
13 | export function ThemeCard({ value, defaultSelected }: ThemeCardProps) {
14 | return (
15 |
16 | {value !== "system" ? (
17 |
25 | ) : (
26 |
27 |
32 |
37 |
42 |
43 | )}
44 |
45 |
46 |
e.currentTarget.form?.requestSubmit()}
53 | />
54 |
55 |
61 |
62 |
{value}
63 |
64 |
65 | );
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/custom-rsc-renderer/load-client-references.ts:
--------------------------------------------------------------------------------
1 | import "server-only";
2 | import fs from "fs/promises";
3 | import path from "path";
4 | import { unstable_cache } from "next/cache";
5 | import { cache } from "react";
6 | import { lifetimeCache } from "~/lib/shared/lifetime-cache";
7 |
8 | export const getBuildId = lifetimeCache(async () => {
9 | return process.env.NODE_ENV === "development"
10 | ? new Date().getTime().toString()
11 | : await fs.readFile(".next/BUILD_ID", "utf-8");
12 | });
13 |
14 | // Async function to recursively list files
15 | async function listFilesRecursively(dir = ".next"): Promise {
16 | let fileList: string[] = [];
17 | const files = await fs.readdir(dir);
18 |
19 | for (const file of files) {
20 | const filePath = path.join(dir, file);
21 | const fileStats = await fs.stat(filePath);
22 |
23 | if (fileStats.isDirectory()) {
24 | fileList = fileList.concat(await listFilesRecursively(filePath));
25 | } else if (file.endsWith("client-reference-manifest.js")) {
26 | fileList.push(filePath);
27 | }
28 | }
29 | return fileList;
30 | }
31 |
32 | async function evaluateFile(filePath: string) {
33 | try {
34 | const fileContent = await fs.readFile(filePath, "utf8");
35 | eval(fileContent);
36 | } catch (err) {
37 | console.error(`Error evaluating client reference ${filePath}:`, err);
38 | }
39 | }
40 |
41 | export const evaluateClientReferences = cache(
42 | async function evaluateClientReferences() {
43 | const loadClientRefs = async () => {
44 | await listFilesRecursively().then(
45 | async (files) => await Promise.allSettled(files.map(evaluateFile))
46 | );
47 | return globalThis.__RSC_MANIFEST;
48 | };
49 |
50 | if (process.env.NODE_ENV === "development") {
51 | globalThis.__RSC_MANIFEST ??= await loadClientRefs();
52 | } else {
53 | const buildId = await getBuildId();
54 | const tags = [`__rsc_cache__client_manifest_evaluation_${buildId}`];
55 | const fn = unstable_cache(loadClientRefs, tags, {
56 | tags
57 | });
58 | globalThis.__RSC_MANIFEST ??= await fn();
59 | }
60 | }
61 | );
62 |
--------------------------------------------------------------------------------
/src/components/header/header-navlinks.tsx:
--------------------------------------------------------------------------------
1 | import "server-only";
2 | import * as React from "react";
3 |
4 | // components
5 | import { NavLink } from "~/components/nav-link";
6 | import { CodeIcon, IssueOpenedIcon, PlayIcon } from "@primer/octicons-react";
7 | import { CounterBadge } from "~/components/counter-badge";
8 |
9 | // utils
10 | import { clsx } from "~/lib/shared/utils.shared";
11 | import { getOpenIssuesCount } from "~/models/issues";
12 |
13 | // types
14 | export type UnderlineNavbarProps = {
15 | className?: string;
16 | params: {
17 | user: string;
18 | repository: string;
19 | };
20 | };
21 |
22 | export async function HeaderNavLinks({
23 | className,
24 | params
25 | }: UnderlineNavbarProps) {
26 | const noOfOpennedIssues = await getOpenIssuesCount();
27 | return (
28 |
36 |
42 |
43 | }
47 | >
48 | Code
49 |
50 |
51 |
52 | }
55 | >
56 | Issues
57 | {noOfOpennedIssues > 0 && (
58 |
59 | )}
60 |
61 |
62 |
63 | }
66 | >
67 | Actions
68 |
69 |
70 |
71 |
72 | );
73 | }
74 |
--------------------------------------------------------------------------------
/src/components/hovercard/hovercard.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 | import * as RadixHoverCard from "@radix-ui/react-hover-card";
4 | import { clsx } from "~/lib/shared/utils.shared";
5 | import { useMediaQuery } from "~/lib/client/hooks/use-media-query";
6 |
7 | export type HoverCardProps = {
8 | children: React.ReactNode;
9 | content: React.ReactNode;
10 | delayInMs?: number;
11 | onOpenChange?: (isOpen: boolean) => void;
12 | className?: string;
13 | isOpen?: boolean;
14 | closeDelayInMs?: number;
15 | } & Pick;
16 |
17 | export const HoverCard = React.forwardRef<
18 | React.ElementRef,
19 | HoverCardProps
20 | >(function HoverCard(
21 | {
22 | className,
23 | children,
24 | content,
25 | isOpen,
26 | onOpenChange,
27 | delayInMs = 150,
28 | closeDelayInMs = 150,
29 | ...contentProps
30 | },
31 | ref
32 | ) {
33 | const isHoverCardEnabled = useMediaQuery(`(min-width: 768px)`);
34 | return (
35 |
41 | {children}
42 |
43 |
59 | {content}
60 |
61 |
62 |
63 |
64 | );
65 | });
66 |
--------------------------------------------------------------------------------
/src/components/hovercard/issue-hovercard-link.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | // components
5 | import Link from "next/link";
6 | import { HoverCard } from "~/components/hovercard/hovercard";
7 | import { IssueHoverCardSkeleton } from "~/components/hovercard/issue-hovercard-contents";
8 | import { ErrorBoundary } from "react-error-boundary";
9 |
10 | // utils
11 | import { getIssueHoverCard } from "~/actions/issue.action";
12 |
13 | export type IssueHoverCardLinkProps = {
14 | user: string;
15 | repository: string;
16 | no: number;
17 | children: React.ReactNode;
18 | className?: string;
19 | href: string;
20 | };
21 |
22 | export function IssueHoverCardLink({
23 | children,
24 | className,
25 | href,
26 | ...props
27 | }: IssueHoverCardLinkProps) {
28 | const [canLoadIssueContent, setCanLoadIssueContent] = React.useState(false);
29 |
30 | return (
31 | (
36 |
37 | Error loading hovercard
38 |
39 | {error.toString()}
40 |
41 |
42 | )}
43 | >
44 | }>
45 |
46 |
47 |
48 | ) : (
49 |
50 | )
51 | }
52 | onOpenChange={(open) => {
53 | // only once
54 | if (open && !canLoadIssueContent) {
55 | setCanLoadIssueContent(true);
56 | }
57 | }}
58 | >
59 |
60 | {children}
61 |
62 |
63 | );
64 | }
65 |
66 | const loadHoverCardContents = React.cache(getIssueHoverCard);
67 |
68 | function IssueContents({
69 | user,
70 | repository,
71 | no
72 | }: Pick) {
73 | return React.use(loadHoverCardContents(user, repository, no));
74 | }
75 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import "./globals.css";
2 | import * as React from "react";
3 |
4 | // components
5 | import { TailwindIndicator } from "~/components/tailwind-indicator";
6 | import { Toaster } from "~/components/toast/toaster.server";
7 | import { IconSwitcher } from "~/components/icon-switcher";
8 | import { TopLoader } from "~/components/top-loader";
9 | import { SkipToMainButton } from "~/components/skip-to-main-button";
10 | import { XMasDecorations } from "~/components/x-mas-decorations";
11 | import { ClientProviders } from "./client-providers";
12 |
13 | // utils
14 | import { GeistSans } from "geist/font/sans";
15 | import { getTheme } from "~/actions/theme.action";
16 | import { clsx } from "~/lib/shared/utils.shared";
17 |
18 | // types
19 | import type { Metadata } from "next";
20 |
21 | export const metadata: Metadata = {
22 | title: {
23 | template: "%s · gh-next",
24 | default: "gh-next · A minimal Github clone built on nextjs app router"
25 | },
26 | description: "A clone of github"
27 | };
28 |
29 | export const revalidate = 0;
30 | export const fetchCache = "default-cache";
31 |
32 | export default async function RootLayout({
33 | children
34 | }: {
35 | children: React.ReactNode;
36 | }) {
37 | const theme = await getTheme();
38 |
39 | return (
40 |
41 |
42 |
48 |
54 |
55 |
59 |
60 | {/* only on december */}
61 | {new Date().getMonth() === 11 && }
62 |
63 |
64 |
65 | {children}
66 | {process.env.NODE_ENV !== "production" && }
67 |
68 |
69 |
70 |
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/src/app/(app)/@page_title/[user]/[repository]/[...pages]/page.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | // components
3 | import Link from "next/link";
4 | import { HoverCard } from "~/components/hovercard/hovercard";
5 | import { UserHoverCardContents } from "~/components/hovercard/user-hovercard-contents";
6 |
7 | // utils
8 | import { clsx } from "~/lib/shared/utils.shared";
9 | import { getRepositoryByOwnerAndName } from "~/models/repository";
10 | import { notFound } from "next/navigation";
11 |
12 | // types
13 | import type { PageProps } from "~/lib/types";
14 |
15 | export async function RepositoryPageTitle({
16 | params
17 | }: PageProps<{
18 | user: string;
19 | repository: string;
20 | }>) {
21 | const repository = await getRepositoryByOwnerAndName(
22 | params.user,
23 | params.repository
24 | );
25 |
26 | if (repository === null) {
27 | notFound();
28 | }
29 |
30 | return (
31 |
32 |
44 | }
45 | >
46 |
55 | {repository.owner.username}
56 |
57 |
58 | /
59 |
66 |
67 | {repository.name}
68 |
69 |
70 |
71 | );
72 | }
73 |
74 | export default RepositoryPageTitle;
75 |
--------------------------------------------------------------------------------
/src/lib/server/kv/sqlite/index.ts:
--------------------------------------------------------------------------------
1 | import { eq, sql } from "drizzle-orm";
2 | import { kvEntry } from "./kv-entry.sql";
3 | import { db } from "~/lib/server/db/index.server";
4 |
5 | import type { KVStore } from "~/lib/server/kv/index.server";
6 |
7 | // export class SqliteKV implements KVStore {
8 | // public async get = {}>(
9 | // key: string
10 | // ): Promise {
11 | // const time = Date.now();
12 | // console.time(`[KV sqlite] GET ${key} - @${time}`);
13 | // const entry = await db
14 | // .select()
15 | // .from(kvEntry)
16 | // .where(
17 | // sql`${kvEntry.key} = ${key} AND (${kvEntry.expiry} > strftime('%s', 'now') OR ${kvEntry.expiry} IS NULL)`
18 | // )
19 | // .all();
20 |
21 | // console.timeEnd(`[KV sqlite] GET ${key} - @${time}`);
22 | // if (entry.length === 0) return null;
23 |
24 | // return JSON.parse(entry[0].value) as T;
25 | // }
26 |
27 | // public async set = {}>(
28 | // key: string,
29 | // value: T,
30 | // ttl_in_seconds?: number | undefined
31 | // ): Promise {
32 | // const time = Date.now();
33 | // console.time(`[KV sqlite] SET ${key} - @${time}`);
34 | // const newData = {
35 | // expiry: ttl_in_seconds
36 | // ? new Date(Date.now() + ttl_in_seconds * 1000)
37 | // : null,
38 | // value: JSON.stringify(value),
39 | // };
40 | // await db
41 | // .insert(kvEntry)
42 | // .values({
43 | // key,
44 | // ...newData,
45 | // })
46 | // .onConflictDoUpdate({
47 | // target: kvEntry.key,
48 | // set: {
49 | // ...newData,
50 | // },
51 | // })
52 | // .run();
53 | // console.timeEnd(`[KV sqlite] SET ${key} - @${time}`);
54 | // }
55 |
56 | // public async delete(key: string): Promise {
57 | // const time = Date.now();
58 | // console.timeEnd(`[KV sqlite] DELETE ${key} - @${time}`);
59 | // await db.delete(kvEntry).where(eq(kvEntry.key, key)).run();
60 | // console.timeEnd(`[KV sqlite] DELETE ${key} - @${time}`);
61 | // }
62 |
63 | // public async deleteExpiredCacheEntries() {
64 | // await db
65 | // .delete(kvEntry)
66 | // .where(sql`${kvEntry.expiry} < strftime('%s', 'now')`)
67 | // .run();
68 | // }
69 | // }
70 |
--------------------------------------------------------------------------------
/src/app/(app)/settings/account/page.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | // components
4 | import { UpdateUserInfosForm } from "~/components/update-user-infos-form";
5 | import { Button } from "~/components/button";
6 |
7 | // utils
8 | import { getUserOrRedirect } from "~/actions/auth.action";
9 | import { updateUserProfileInfosInputValidator } from "~/models/dto/update-profile-info-input-validator";
10 |
11 | // types
12 | import type { Metadata } from "next";
13 |
14 | export const metadata: Metadata = {
15 | title: "Account settings"
16 | };
17 |
18 | export default async function AccountSettingsPage() {
19 | const user = await getUserOrRedirect("/settings/account");
20 |
21 | return (
22 |
23 |
24 |
25 | Update your public profile
26 |
27 |
28 |
29 | Changing informations on this website won’t affect your real
30 | informations on github. Every data is stored in our database. You can
31 | request to delete your informations in the form below.
32 |
33 |
34 |
37 |
38 |
39 |
40 |
41 | Danger Zone
42 |
43 |
44 |
45 |
46 |
47 |
Delete your account
48 |
49 | This will delete your mentions, comments and issues references
50 | in this website. It won’t have any effect on your real
51 | github account
52 |
53 |
54 |
55 |
60 |
61 |
62 |
63 |
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/src/components/user-dropdown/user-dropdown.client.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 | import * as ReactDOM from "react-dom";
4 | // components
5 | import { Avatar } from "~/components/avatar";
6 | import {
7 | DropdownContent,
8 | DropdownItem,
9 | DropdownRoot,
10 | DropdownSeparator,
11 | DropdownTrigger
12 | } from "~/components/dropdown";
13 | import {
14 | PaintbrushIcon,
15 | PersonIcon,
16 | SignOutIcon
17 | } from "@primer/octicons-react";
18 |
19 | // utils
20 | import { logoutUser } from "~/actions/auth.action";
21 |
22 | // types
23 | export type UserDropdownProps = {
24 | avatar_url: string;
25 | username: string;
26 | };
27 |
28 | export function UserDropdown({ avatar_url, username }: UserDropdownProps) {
29 | const [, startTransition] = React.useTransition();
30 | const [canInjectMentionStyles, setCanInjectMentionStyles] =
31 | React.useState(false);
32 |
33 | React.useEffect(() => {
34 | setCanInjectMentionStyles(true);
35 | }, []);
36 | return (
37 | <>
38 | {canInjectMentionStyles &&
39 | ReactDOM.createPortal(
40 | ,
48 | document.body
49 | )}
50 |
51 |
52 |
53 |
57 |
58 |
59 |
60 |
61 |
66 |
71 |
72 | startTransition(() => void logoutUser())}
76 | />
77 |
78 |
79 | >
80 | );
81 | }
82 |
--------------------------------------------------------------------------------
/pr-preview-workflow/add-docker-app.ts:
--------------------------------------------------------------------------------
1 | import { $, file, write } from "bun";
2 |
3 | export async function addDockerApp(
4 | PR_ID: number,
5 | shouldReloadDockerStack: boolean
6 | ) {
7 | await $`echo '[🔄 Docker] adding docker stack config...'`;
8 | const COMPOSE_FILE_PATH = `./docker/docker-stack.pr-${PR_ID}.yaml`;
9 |
10 | const composeFile = file(COMPOSE_FILE_PATH);
11 |
12 | if (await composeFile.exists()) {
13 | await $`echo '[ℹ️ Docker] docker stack config for pull request #${PR_ID} already exists, skipping work.'`;
14 | if (shouldReloadDockerStack) {
15 | await $`echo '[🔄 Docker] updating docker services...'`;
16 | const { exitCode, stderr } =
17 | await $`docker stack deploy --with-registry-auth --compose-file ${COMPOSE_FILE_PATH} gh-stack-pr-${PR_ID}`;
18 |
19 | if (exitCode !== 0) {
20 | await $`echo '[❌ Docker] docker services encountered an unexpected error : ${stderr.toString()}'`;
21 | process.exit(1);
22 | }
23 | await $`echo '[✅ Docker] docker services updated succesfully'`;
24 | }
25 | return;
26 | }
27 |
28 | // Placeholder text in the Docker Compose file
29 | const DOCKER_STACK_TEMPLATE = `# service configuration for pull request #132
30 | version: "3.4"
31 |
32 | services:
33 | gh-next-${PR_ID}:
34 | image: dcr.fredkiss.dev/gh-next:pr-${PR_ID}
35 | deploy:
36 | replicas: 0
37 | update_config:
38 | parallelism: 1
39 | delay: 5s
40 | order: start-first
41 | failure_action: rollback
42 | restart_policy:
43 | condition: on-failure
44 | delay: 5s
45 | max_attempts: 3
46 | window: 120s
47 | labels:
48 | - sablier.enable=true
49 | - sablier.group=gh-next-${PR_ID}
50 | networks:
51 | - gh-next
52 | networks:
53 | gh-next:
54 | external: true
55 | `;
56 | await write(composeFile, DOCKER_STACK_TEMPLATE);
57 | await $`echo '[✅ Docker] Added docker stack config file for pull request #${PR_ID}.'`;
58 | if (shouldReloadDockerStack) {
59 | await $`echo '[🔄 Docker] updating docker services...'`;
60 | const { exitCode, stderr } =
61 | await $`docker stack deploy --with-registry-auth --compose-file ${COMPOSE_FILE_PATH} gh-stack-pr-${PR_ID}`;
62 |
63 | if (exitCode !== 0) {
64 | await $`echo '[❌ Docker] docker services encountered an unexpected error : ${stderr.toString()}'`;
65 | process.exit(1);
66 | }
67 | await $`echo '[✅ Docker] docker services updated succesfully'`;
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 | import { HomeIcon } from "@primer/octicons-react";
3 | import { Button } from "~/components/button";
4 |
5 | import type { Metadata } from "next";
6 | export const metadata: Metadata = {
7 | title: "Page not found"
8 | };
9 |
10 | export default function RootNotFound() {
11 | return (
12 | <>
13 | {/*
14 | FIXME: this is a fix because nextjs considers the metadata on the children layouts
15 | over the metadata set here, even though this is on top of the layout
16 | So this way we manually overwrites the title above with a title inside
17 | */}
18 | Page not found · gh-next
19 |
20 |
21 |
28 |
29 |
30 |
37 |
38 |
45 |
46 |
47 |
48 |
55 |
62 |
63 |
64 |
65 |
70 |
71 | Go home
72 |
73 |
74 | >
75 | );
76 | }
77 |
--------------------------------------------------------------------------------
/src/lib/server/utils.server.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { _envObject as env } from "~/env-config.mjs";
3 | import { GITHUB_AUTHOR_USERNAME } from "~/lib/shared/constants";
4 |
5 | const githubGraphQLAPIResponseSchema = z.union([
6 | z.object({
7 | message: z.string(),
8 | documentation_url: z.string().url()
9 | }),
10 | z.object({
11 | message: z.undefined(),
12 | data: z.record(z.string(), z.any())
13 | }),
14 | z.object({
15 | data: z.undefined(),
16 | message: z.undefined(),
17 | errors: z.array(z.record(z.string(), z.any()))
18 | })
19 | ]);
20 |
21 | /**
22 | * @param graphqlQuery
23 | * @param variables
24 | *
25 | * To explore and see the available graphQL queries, see : https://docs.github.com/fr/graphql/overview/explorer
26 | * @returns
27 | */
28 | export async function fetchFromGithubAPI>(
29 | graphqlQuery: string,
30 | variables: Record = {}
31 | ) {
32 | return fetch(`https://api.github.com/graphql`, {
33 | method: "POST",
34 | body: JSON.stringify({
35 | query: graphqlQuery,
36 | variables
37 | }),
38 | headers: {
39 | "content-type": "application/json",
40 | Authorization: `Bearer ${env.GITHUB_PERSONAL_ACCESS_TOKEN}`,
41 | // this header is required per the documentation : https://docs.github.com/en/rest/overview/resources-in-the-rest-api?apiVersion=2022-11-28#user-agent-required
42 | "User-Agent": GITHUB_AUTHOR_USERNAME
43 | }
44 | // cache: "no-store",
45 | })
46 | .then(async (r) => {
47 | const text = await r.text();
48 | if (!r.ok) {
49 | console.error({
50 | text,
51 | status: r.status,
52 | statusText: r.statusText
53 | });
54 | throw new Error(text);
55 | }
56 | return JSON.parse(text);
57 | })
58 | .then((json) => {
59 | const parsed = githubGraphQLAPIResponseSchema.parse(json);
60 |
61 | if (parsed.message !== undefined) {
62 | if (parsed.message.toLowerCase() === "bad credentials") {
63 | throw new Error(
64 | "Invalid credentials, please update the credentials in the app settings"
65 | );
66 | } else {
67 | throw new Error(`Unknown error ${parsed.message}`);
68 | }
69 | } else if (parsed.data) {
70 | return parsed.data as T;
71 | } else {
72 | console.dir(
73 | {
74 | errors: parsed.errors
75 | },
76 | {
77 | depth: null
78 | }
79 | );
80 | throw new Error(`GraphQL error : check the terminal for errors.`);
81 | }
82 | });
83 | }
84 |
--------------------------------------------------------------------------------
/src/components/update-user-infos-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 | import { useFormState } from "react-dom";
4 |
5 | // components
6 | import { Input } from "./input";
7 | import { Textarea } from "./textarea";
8 | import { SubmitButton } from "./submit-button";
9 |
10 | // utils
11 | import { updateUserProfile } from "~/actions/user.action";
12 |
13 | // types
14 | import type { UpdateUserProfileInfosInput } from "~/models/dto/update-profile-info-input-validator";
15 |
16 | export type UpdateUserInfosProps = {
17 | defaultValues: UpdateUserProfileInfosInput;
18 | };
19 |
20 | export function UpdateUserInfosForm({ defaultValues }: UpdateUserInfosProps) {
21 | const [state, formAction] = useFormState(updateUserProfile, {
22 | message: null,
23 | type: undefined
24 | });
25 |
26 | defaultValues = state?.type === "error" ? state.formData : defaultValues;
27 |
28 | const errors = state.type !== "error" ? null : state.fieldErrors;
29 |
30 | return (
31 |
80 | );
81 | }
82 |
--------------------------------------------------------------------------------
/src/components/custom-rsc-renderer/rsc-client-renderer.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 | import * as RSDW from "react-server-dom-webpack/client";
4 | import { unstable_postpone as postpone } from "react";
5 |
6 | export type RscClientRendererProps = {
7 | payloadOrPromise: string | Promise;
8 | };
9 |
10 | export function RscClientRenderer({
11 | payloadOrPromise
12 | }: RscClientRendererProps) {
13 | if (typeof window === "undefined") {
14 | postpone("This component can only be used on the client");
15 | }
16 | const renderPromise = React.useMemo(() => {
17 | /**
18 | * This is to fix a bug that happens sometimes in the SSR phase,
19 | * calling `use` seems to suspend indefinitely resulting in
20 | * your app not responding.
21 | *
22 | * We override the promise result and add `status` & `value`,
23 | * these fields are used internally by `use` and are what's
24 | * prevent `use` from suspending indefinitely.
25 | */
26 | const pendingPromise = resolveElement(payloadOrPromise)
27 | .then((value) => {
28 | // @ts-expect-error
29 | if (pendingPromise.status === "pending") {
30 | const fulfilledThenable = pendingPromise as any;
31 | fulfilledThenable.status = "fulfilled";
32 | fulfilledThenable.value = value;
33 | }
34 | return value;
35 | })
36 | .catch((error) => {
37 | // @ts-expect-error
38 | if (pendingPromise.status === "pending") {
39 | const rejectedThenable = pendingPromise as any;
40 | rejectedThenable.status = "rejected";
41 | rejectedThenable.reason = error;
42 | }
43 | throw error;
44 | });
45 | // @ts-expect-error
46 | pendingPromise.status = "pending";
47 | return pendingPromise;
48 | }, [payloadOrPromise]);
49 |
50 | return ;
51 | }
52 |
53 | function RscClientRendererUse(props: {
54 | promise: Promise;
55 | }) {
56 | return React.use(props.promise);
57 | }
58 |
59 | async function resolveElement(payloadOrPromise: string | Promise) {
60 | const payload =
61 | typeof payloadOrPromise === "string"
62 | ? payloadOrPromise
63 | : await payloadOrPromise;
64 | const rscStream = transformStringToReadableStream(payload);
65 | return await RSDW.createFromReadableStream(rscStream, {});
66 | }
67 |
68 | export function transformStringToReadableStream(input: string) {
69 | // Using Flight to deserialize the args from the string.
70 | return new ReadableStream({
71 | start(controller) {
72 | controller.enqueue(new TextEncoder().encode(input));
73 | controller.close();
74 | }
75 | });
76 | }
77 |
--------------------------------------------------------------------------------
/drizzle/0011_mixed_falcon.sql:
--------------------------------------------------------------------------------
1 | DO $$ BEGIN
2 | CREATE TYPE "event_type" AS ENUM('CHANGE_TITLE', 'TOGGLE_STATUS', 'ISSUE_MENTION', 'ASSIGN_USER', 'ADD_LABEL', 'REMOVE_LABEL');
3 | EXCEPTION
4 | WHEN duplicate_object THEN null;
5 | END $$;
6 | --> statement-breakpoint
7 | CREATE TABLE IF NOT EXISTS "gh_next_issue_events" (
8 | "id" serial PRIMARY KEY NOT NULL,
9 | "created_at" timestamp DEFAULT now() NOT NULL,
10 | "initiator_id" integer,
11 | "initiator_username" varchar(255) NOT NULL,
12 | "initiator_avatar_url" varchar(255) NOT NULL,
13 | "issue_id" integer NOT NULL,
14 | "type" "event_type" NOT NULL,
15 | "old_title" varchar(255),
16 | "new_title" varchar(255),
17 | "status" "issue_status",
18 | "mentionned_issue_id" integer,
19 | "assignee_id" integer,
20 | "label_id" integer
21 | );
22 | --> statement-breakpoint
23 | DROP TABLE "gh_next_assign_activities";--> statement-breakpoint
24 | DROP TABLE "gh_next_change_title_activities";--> statement-breakpoint
25 | DROP TABLE "gh_next_edit_activity_to_labels";--> statement-breakpoint
26 | DROP TABLE "gh_next_edit_labels_activities";--> statement-breakpoint
27 | DROP TABLE "gh_next_issue_mention_activities";--> statement-breakpoint
28 | DROP TABLE "gh_next_issue_toggle_activities";--> statement-breakpoint
29 | DO $$ BEGIN
30 | ALTER TABLE "gh_next_issue_events" ADD CONSTRAINT "gh_next_issue_events_initiator_id_gh_next_users_id_fk" FOREIGN KEY ("initiator_id") REFERENCES "gh_next_users"("id") ON DELETE set null ON UPDATE no action;
31 | EXCEPTION
32 | WHEN duplicate_object THEN null;
33 | END $$;
34 | --> statement-breakpoint
35 | DO $$ BEGIN
36 | ALTER TABLE "gh_next_issue_events" ADD CONSTRAINT "gh_next_issue_events_issue_id_gh_next_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "gh_next_issues"("id") ON DELETE cascade ON UPDATE no action;
37 | EXCEPTION
38 | WHEN duplicate_object THEN null;
39 | END $$;
40 | --> statement-breakpoint
41 | DO $$ BEGIN
42 | ALTER TABLE "gh_next_issue_events" ADD CONSTRAINT "gh_next_issue_events_mentionned_issue_id_gh_next_issues_id_fk" FOREIGN KEY ("mentionned_issue_id") REFERENCES "gh_next_issues"("id") ON DELETE cascade ON UPDATE no action;
43 | EXCEPTION
44 | WHEN duplicate_object THEN null;
45 | END $$;
46 | --> statement-breakpoint
47 | DO $$ BEGIN
48 | ALTER TABLE "gh_next_issue_events" ADD CONSTRAINT "gh_next_issue_events_assignee_id_gh_next_users_id_fk" FOREIGN KEY ("assignee_id") REFERENCES "gh_next_users"("id") ON DELETE cascade ON UPDATE no action;
49 | EXCEPTION
50 | WHEN duplicate_object THEN null;
51 | END $$;
52 | --> statement-breakpoint
53 | DO $$ BEGIN
54 | ALTER TABLE "gh_next_issue_events" ADD CONSTRAINT "gh_next_issue_events_label_id_gh_next_labels_id_fk" FOREIGN KEY ("label_id") REFERENCES "gh_next_labels"("id") ON DELETE cascade ON UPDATE no action;
55 | EXCEPTION
56 | WHEN duplicate_object THEN null;
57 | END $$;
58 |
--------------------------------------------------------------------------------