Repository: Basedash/dockhunt
Branch: main
Commit: ec885c65225f
Files: 54
Total size: 74.6 KB
Directory structure:
gitextract_llewg0mk/
├── .eslintrc.json
├── .github/
│ ├── FUNDING.yml
│ └── ISSUE_TEMPLATE/
│ ├── bug_report.md
│ ├── feature_request.md
│ └── incorrect-app-icon.md
├── .gitignore
├── .vscode/
│ └── settings.json
├── README.md
├── next.config.mjs
├── package.json
├── postcss.config.cjs
├── prettier.config.cjs
├── prisma/
│ ├── migrations/
│ │ ├── 20230126170225_init/
│ │ │ └── migration.sql
│ │ ├── 20230126181934_user_email_fields/
│ │ │ └── migration.sql
│ │ ├── 20230126215401_remove_user_stuff/
│ │ │ └── migration.sql
│ │ ├── 20230126233521_update_user_id_to_username/
│ │ │ └── migration.sql
│ │ ├── 20230127051217_add_user_description/
│ │ │ └── migration.sql
│ │ ├── 20230129204228_add_index_on_dock_featured/
│ │ │ └── migration.sql
│ │ ├── 20230130042222_add_user_url/
│ │ │ └── migration.sql
│ │ └── migration_lock.toml
│ └── schema.prisma
├── src/
│ ├── components/
│ │ ├── AddDockCard.tsx
│ │ ├── BouncingLoader.tsx
│ │ ├── Dock.tsx
│ │ ├── DockCard.tsx
│ │ └── MenuBar.tsx
│ ├── env/
│ │ ├── client.mjs
│ │ ├── schema.mjs
│ │ └── server.mjs
│ ├── pages/
│ │ ├── _app.tsx
│ │ ├── add-dock.tsx
│ │ ├── api/
│ │ │ ├── auth/
│ │ │ │ └── [...nextauth].ts
│ │ │ ├── cli/
│ │ │ │ ├── check-apps.ts
│ │ │ │ └── icon-upload.ts
│ │ │ ├── og.tsx
│ │ │ └── trpc/
│ │ │ └── [trpc].ts
│ │ ├── apps/
│ │ │ └── [appName].tsx
│ │ ├── apps.tsx
│ │ ├── index.tsx
│ │ ├── new-dock.tsx
│ │ └── users/
│ │ └── [username].tsx
│ ├── server/
│ │ ├── api/
│ │ │ ├── root.ts
│ │ │ ├── routers/
│ │ │ │ ├── apps.ts
│ │ │ │ ├── docks.ts
│ │ │ │ └── users.ts
│ │ │ └── trpc.ts
│ │ ├── auth.ts
│ │ └── db.ts
│ ├── styles/
│ │ └── globals.css
│ ├── types/
│ │ └── next-auth.d.ts
│ └── utils/
│ ├── api.ts
│ └── constants.ts
├── tailwind.config.cjs
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .eslintrc.json
================================================
{
"overrides": [
{
"extends": [
"plugin:@typescript-eslint/recommended-requiring-type-checking"
],
"files": ["*.ts", "*.tsx"],
"parserOptions": {
"project": "tsconfig.json"
}
}
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json"
},
"plugins": ["@typescript-eslint"],
"extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"],
"rules": {
"@typescript-eslint/consistent-type-imports": "warn"
}
}
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: ['https://www.basedash.com']
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
================================================
FILE: .github/ISSUE_TEMPLATE/incorrect-app-icon.md
================================================
---
name: Incorrect app icon
about: An app isn't using the correct icon on the website
title: Incorrect app icon for <app name>
labels: incorrect app icon
assignees: ''
---
App name:
Link to app on Dockhunt:
I've included the correct `.icns` file below, found in my Applications directory by right-clicking the app, selecting "Show Package Contents", and navigating to Contents > Resources.
================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# database
/prisma/db.sqlite
/prisma/db.sqlite-journal
# next.js
/.next/
/out/
next-env.d.ts
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
.idea
================================================
FILE: .vscode/settings.json
================================================
{
"editor.formatOnSave": true
}
================================================
FILE: README.md
================================================
# Dockhunt
[](https://www.dockhunt.com)
[Website](https://www.dockhunt.com) ⋅ [Twitter](https://twitter.com/dockhuntapp) ⋅ [npm](https://www.npmjs.com/package/dockhunt)
[CLI tool code](https://github.com/Basedash/dockhunt-cli)
================================================
FILE: next.config.mjs
================================================
// @ts-check
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
* This is especially useful for Docker builds.
*/
!process.env.SKIP_ENV_VALIDATION && (await import("./src/env/server.mjs"));
/** @type {import("next").NextConfig} */
const config = {
reactStrictMode: true,
/* If trying out the experimental appDir, comment the i18n config out
* @see https://github.com/vercel/next.js/issues/41980 */
i18n: {
locales: ["en"],
defaultLocale: "en",
},
images: {
remotePatterns: [
// Twitter profile images
{
protocol: "https",
hostname: "pbs.twimg.com",
pathname: "/profile_images/**",
},
// Twitter default profile image
{
protocol: "https",
hostname: "abs.twimg.com",
pathname: "/sticky/**",
},
// App icons in DigitalOcean bucket
{
protocol: "https",
hostname: "dockhunt-images.nyc3.cdn.digitaloceanspaces.com",
pathname: "/*",
},
],
},
eslint: {
ignoreDuringBuilds: true,
},
};
export default config;
================================================
FILE: package.json
================================================
{
"name": "dockhunt",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev",
"postinstall": "prisma generate",
"generate": "prisma generate",
"lint": "next lint",
"start": "next start",
"migrate": "prisma migrate dev"
},
"dependencies": {
"@aws-sdk/abort-controller": "^3.257.0",
"@next-auth/prisma-adapter": "^1.0.5",
"@prisma/client": "4.9.0",
"@radix-ui/react-scroll-area": "^1.0.2",
"@radix-ui/react-tooltip": "^1.0.3",
"@tanstack/react-query": "^4.20.0",
"@trpc/client": "^10.8.1",
"@trpc/next": "^10.8.1",
"@trpc/react-query": "^10.8.1",
"@trpc/server": "^10.8.1",
"@vercel/og": "^0.0.27",
"aws-sdk": "^2.1303.0",
"date-fns": "^2.29.3",
"formidable": "^2.1.1",
"framer-motion": "^8.5.3",
"next": "13.1.2",
"next-auth": "^4.23.1",
"next-connect": "^0.13.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"superjson": "1.9.1",
"uuid": "^9.0.0",
"zod": "^3.20.2"
},
"devDependencies": {
"@types/formidable": "^2.0.5",
"@types/multer": "^1.4.7",
"@types/multer-s3": "^3.0.0",
"@types/node": "^18.11.18",
"@types/prettier": "^2.7.2",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/uuid": "^9.0.0",
"@typescript-eslint/eslint-plugin": "^5.47.1",
"@typescript-eslint/parser": "^5.47.1",
"autoprefixer": "^10.4.7",
"eslint": "^8.30.0",
"eslint-config-next": "13.1.2",
"postcss": "^8.4.14",
"prettier": "^2.8.1",
"prettier-plugin-tailwindcss": "^0.2.1",
"prisma": "4.9.0",
"tailwindcss": "^3.2.0",
"typescript": "^4.9.4"
},
"ct3aMetadata": {
"initVersion": "7.3.2"
}
}
================================================
FILE: postcss.config.cjs
================================================
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
================================================
FILE: prettier.config.cjs
================================================
/** @type {import("prettier").Config} */
module.exports = {
plugins: [require.resolve("prettier-plugin-tailwindcss")],
};
================================================
FILE: prisma/migrations/20230126170225_init/migration.sql
================================================
-- CreateTable
CREATE TABLE "App" (
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"iconUrl" TEXT,
"description" TEXT,
"websiteUrl" TEXT,
"twitterUrl" TEXT,
CONSTRAINT "App_pkey" PRIMARY KEY ("name")
);
-- CreateTable
CREATE TABLE "Dock" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"featured" BOOLEAN NOT NULL DEFAULT false,
"userId" TEXT NOT NULL,
CONSTRAINT "Dock_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DockItem" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"appId" TEXT NOT NULL,
"position" INTEGER NOT NULL,
"dockId" TEXT NOT NULL,
CONSTRAINT "DockItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT,
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"avatarUrl" TEXT,
"twitterHandle" TEXT,
"twitterFollowerCount" INTEGER,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "Dock_userId_key" ON "Dock"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- AddForeignKey
ALTER TABLE "Dock" ADD CONSTRAINT "Dock_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DockItem" ADD CONSTRAINT "DockItem_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App"("name") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DockItem" ADD CONSTRAINT "DockItem_dockId_fkey" FOREIGN KEY ("dockId") REFERENCES "Dock"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
================================================
FILE: prisma/migrations/20230126181934_user_email_fields/migration.sql
================================================
/*
Warnings:
- A unique constraint covering the columns `[email]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "User" ADD COLUMN "email" TEXT,
ADD COLUMN "emailVerified" TIMESTAMP(3);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
================================================
FILE: prisma/migrations/20230126215401_remove_user_stuff/migration.sql
================================================
/*
Warnings:
- You are about to drop the column `image` on the `User` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "User" DROP COLUMN "image";
================================================
FILE: prisma/migrations/20230126233521_update_user_id_to_username/migration.sql
================================================
BEGIN;
-- Default values for updatedAt
ALTER TABLE "App" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE "Dock" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE "DockItem" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE "User" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP;
-- Update User columns
ALTER TABLE "User" RENAME COLUMN "twitterHandle" TO "username";
ALTER TABLE "User" ALTER COLUMN "name" SET NOT NULL;
COMMIT;
================================================
FILE: prisma/migrations/20230127051217_add_user_description/migration.sql
================================================
/*
Warnings:
- A unique constraint covering the columns `[username]` on the table `User` will be added. If there are existing duplicate values, this will fail.
- Made the column `username` on table `User` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "User" ADD COLUMN "description" TEXT,
ALTER COLUMN "username" SET NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
================================================
FILE: prisma/migrations/20230129204228_add_index_on_dock_featured/migration.sql
================================================
-- CreateIndex
CREATE INDEX "Dock_featured_idx" ON "Dock"("featured");
================================================
FILE: prisma/migrations/20230130042222_add_user_url/migration.sql
================================================
-- AlterTable
ALTER TABLE "User" ADD COLUMN "url" TEXT;
================================================
FILE: prisma/migrations/migration_lock.toml
================================================
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
================================================
FILE: prisma/schema.prisma
================================================
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model App {
name String @id
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
iconUrl String? // png of the app icon
description String?
websiteUrl String?
twitterUrl String?
dockItems DockItem[]
}
model Dock {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
featured Boolean @default(false)
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
dockItems DockItem[]
@@index([featured])
}
model DockItem {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
appId String
app App @relation(fields: [appId], references: [name], onDelete: Cascade)
position Int
dockId String
dock Dock @relation(fields: [dockId], references: [id], onDelete: Cascade)
}
// Necessary for Next auth
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model User {
id String @id @default(cuid())
username String @unique // From Twitter
name String // From Twitter
description String? // From Twitter
url String? // From Twitter
accounts Account[]
sessions Session[]
email String? @unique
emailVerified DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
avatarUrl String? // From Twitter
twitterFollowerCount Int? // From Twitter
dock Dock?
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
================================================
FILE: src/components/AddDockCard.tsx
================================================
import { useSession } from "next-auth/react";
import Link from "next/link";
import { api } from "utils/api";
import { desktopAppDownloadLink } from "utils/constants";
export function AddDockCard() {
const { data: sessionData } = useSession();
const user = api.users.getOne.useQuery({ id: sessionData?.user?.id ?? "" });
if (user.data?.dock) {
return null;
}
return (
<div className="relative">
<div className="h-64 w-full rounded-[36px] border border-solid border-gray-600/60 bg-monterey bg-cover bg-center opacity-60" />
<div className="absolute top-0 left-0 flex h-full w-full flex-col items-center justify-center p-10 text-xl">
<p className="hidden text-center sm:block">
Want to add your own dock? Run this command in your terminal:
</p>
<p className="block text-center sm:hidden">
Want to add your own dock? Run this command on a macOS device:
</p>
<code className="my-4 rounded border border-gray-100/60 bg-gray-900/70 p-4 backdrop-blur">
npx dockhunt
</code>
<p className="hidden text-md text-center sm:block mb-3">
Or install the <a className={'text-blue-400 hover:underline'} href={desktopAppDownloadLink}>desktop app</a>.
</p>
<Link href="/add-dock" className="text-blue-400 hover:underline">
More details →
</Link>
</div>
</div>
);
}
================================================
FILE: src/components/BouncingLoader.tsx
================================================
import Image from "next/image";
import { motion } from "framer-motion";
const NextImage = motion(Image);
export const BouncingLoader = () => {
return (
<div className={'flex justify-center items-center flex-col'}>
<NextImage
src={"/dockhunt-icon.png"}
width={100}
height={100}
alt={"Dockhunt logo bouncing"}
animate={{ y: [0, -40] }}
transition={{
y: {
duration: 0.4,
repeat: Infinity,
repeatType: "reverse",
ease: "easeOut"
},}
}
/>
</div>
);
}
================================================
FILE: src/components/Dock.tsx
================================================
import { motion } from "framer-motion";
import type { App } from "@prisma/client";
import Link from "next/link";
import * as Tooltip from "@radix-ui/react-tooltip";
import { placeholderImageUrl } from "utils/constants";
import Image from "next/image";
import * as ScrollArea from '@radix-ui/react-scroll-area';
const DockImage = motion(Image);
const DockItem = ({ app }: { app: App }) => {
const variants = {
hover: {
width: 92,
height: 80,
},
initial: {
width: 80,
height: 80,
},
};
return (
<Tooltip.Root>
<Tooltip.Trigger>
<Link href={`/apps/${app.name}`}>
<motion.div
variants={variants}
whileHover="hover"
initial="initial"
className="dockItem h-[60px]"
transition={{
type: "spring",
damping: 60,
stiffness: 500,
mass: 1,
}}
>
<DockImage
height={92}
width={92}
variants={{
hover: {
width: 92,
height: 92,
y: -12,
},
initial: {
width: 80,
height: 80,
},
}}
transition={{
type: "spring",
damping: 60,
stiffness: 500,
mass: 1,
}}
alt={`${app.name} app icon`}
whileHover="hover"
initial="initial"
className={"absolute"}
src={app.iconUrl ?? placeholderImageUrl}
/>
</motion.div>
</Link>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content sideOffset={10} className="z-20">
<div
className={
"rounded-[4px] border border-[#49494B] bg-[#272728] py-[4px] px-[10px] text-xs text-white"
}
>
{app.name}
</div>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
);
};
export function Dock({ apps }: { apps: App[] }) {
return (
<div className="relative">
{/* Dock background */}
<div className="absolute bottom-0 left-0 right-0 h-[80px] max-w-full rounded-[22px] border border-gray-600/60 bg-gray-800/60" />
{/* Scrollable container */}
<ScrollArea.Root>
<ScrollArea.Viewport>
<div className="fade-lr relative flex max-w-full flex-1 overflow-x-auto pt-4">
{apps.map((app) => (
<DockItem key={app.name} app={app} />
))}
</div>
</ScrollArea.Viewport>
{/* TODO: Style the scrollbar: https://www.radix-ui.com/docs/primitives/components/scroll-area */}
<ScrollArea.Scrollbar orientation="vertical">
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
<ScrollArea.Scrollbar orientation="horizontal">
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
<ScrollArea.Corner />
</ScrollArea.Root>
</div>
);
}
================================================
FILE: src/components/DockCard.tsx
================================================
import * as Tooltip from "@radix-ui/react-tooltip";
import type { inferRouterOutputs } from "@trpc/server";
import Image from "next/image";
import Link from "next/link";
import type { AppRouter } from "server/api/root";
import { Dock as DockComponent } from "./Dock";
export function DockCard({
dock,
}: {
dock: inferRouterOutputs<AppRouter>["docks"]["getFeatured"][0];
}) {
return (
<div className="flex flex-col">
<p className="mb-2 text-sm text-gray-500">
<Link href={`/users/${dock.user.username}`} className="hover:underline">
{dock.user.name}
</Link>
<span className="mx-2">⋅</span>
<a
className="hover:underline"
href={`https://twitter.com/${dock.user.username}`}
target="_blank"
rel="noreferrer"
>
@{dock.user.username}
</a>
</p>
<div className="relative flex justify-center gap-12">
<Tooltip.Root>
<Tooltip.Trigger className="absolute top-4 z-10 text-gray-600 md:top-1/2 md:left-0 md:-translate-x-1/2 md:-translate-y-1/2">
<Link href={`/users/${dock.user.username}`}>
{/* TODO: Use placeholder image for null values */}
<Image
src={dock.user.avatarUrl ?? ""}
alt={`${dock.user.name}'s avatar`}
width={80}
height={80}
className="rounded-full border border-solid border-gray-600/60"
/>
</Link>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content sideOffset={10} className="z-20">
<div className="max-w-xs rounded-[4px] border border-[#49494B] bg-[#272728] py-[4px] px-[10px] text-xs text-white">
<p className="font-bold">{dock.user.name}</p>
{dock.user.description && (
<p className="mt-1">{dock.user.description}</p>
)}
</div>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
<Link
className={`h-52 w-full rounded-[36px] border border-solid border-gray-600/60 bg-monterey bg-cover bg-center opacity-60 transition-opacity duration-300 ease-in-out hover:opacity-100 md:h-64`}
href={`/users/${dock.user.username}`}
/>
<div className="absolute bottom-4 max-w-full px-4 md:px-12">
<DockComponent
apps={dock.dockItems.map((dockItem) => dockItem.app)}
/>
</div>
</div>
</div>
);
}
================================================
FILE: src/components/MenuBar.tsx
================================================
import { format } from "date-fns";
import basedash from "images/basedash.svg";
import dockhunt from "images/dockhunt.svg";
import github from "images/github.svg";
import npm from "images/npm.svg";
import twitter from "images/twitter.svg";
import { signIn, signOut, useSession } from "next-auth/react";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
import { api } from "../utils/api";
export const MenuBar = () => {
const { data: sessionData } = useSession();
const user = api.users.getOne.useQuery({ id: sessionData?.user?.id ?? "" });
const [date, setDate] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => {
setDate(new Date());
}, 5000); // Update every 5 seconds so we don't get too out of sync
return () => clearInterval(timer);
}, []);
return (
<div className="fixed z-20 flex w-full flex-col items-center justify-between gap-2 bg-gray-800/30 px-4 py-1 text-sm backdrop-blur-3xl min-[900px]:flex-row min-[900px]:gap-4">
<div className="flex items-center gap-4">
<Link className="flex gap-4 font-bold" href="/">
<Image src={dockhunt} alt="Dockhunt" height="16" />
Dockhunt
</Link>
<Link className="hidden sm:block" href="/apps">
Top apps
</Link>
<Link className="hidden sm:block" href="/add-dock">
{user.data?.dock ? "Update your dock" : "Add your dock"}
</Link>
<Link className="hidden sm:block" href="/apps/Basedash">
Made by Basedash
</Link>
</div>
<div className="flex items-center gap-4">
<a href="https://www.basedash.com" target="_blank" rel="noreferrer">
<Image src={basedash} alt="Basedash" height="16" />
</a>
<a
href="https://twitter.com/dockhuntapp"
target="_blank"
rel="noreferrer"
>
<Image src={twitter} alt="Twitter" height="18" />
</a>
<a
href="https://github.com/Basedash/dockhunt"
target="_blank"
rel="noreferrer"
>
<Image src={github} alt="GitHub" height="20" />
</a>
<a
className="fill-white"
href="https://www.npmjs.com/package/dockhunt"
target="_blank"
rel="noreferrer"
>
<Image src={npm} alt="npm" height="20" />
</a>
<div className="hidden tabular-nums sm:block">
{format(date, "eee MMM d p")}
</div>
{sessionData && sessionData.user && (
<Link href={`/users/${sessionData.user.username}`}>
{sessionData.user.name}
</Link>
)}
<button
onClick={
sessionData
? () => void signOut({ redirect: false })
: () => void signIn("twitter")
}
>
{sessionData ? "Log out" : "Log in"}
</button>
</div>
</div>
);
};
================================================
FILE: src/env/client.mjs
================================================
// @ts-check
import { clientEnv, clientSchema } from "./schema.mjs";
const _clientEnv = clientSchema.safeParse(clientEnv);
export const formatErrors = (
/** @type {import('zod').ZodFormattedError<Map<string,string>,string>} */
errors,
) =>
Object.entries(errors)
.map(([name, value]) => {
if (value && "_errors" in value)
return `${name}: ${value._errors.join(", ")}\n`;
})
.filter(Boolean);
if (!_clientEnv.success) {
console.error(
"❌ Invalid environment variables:\n",
...formatErrors(_clientEnv.error.format()),
);
throw new Error("Invalid environment variables");
}
for (let key of Object.keys(_clientEnv.data)) {
if (!key.startsWith("NEXT_PUBLIC_")) {
console.warn(
`❌ Invalid public environment variable name: ${key}. It must begin with 'NEXT_PUBLIC_'`,
);
throw new Error("Invalid public environment variable name");
}
}
export const env = _clientEnv.data;
================================================
FILE: src/env/schema.mjs
================================================
// @ts-check
import { z } from "zod";
/**
* Specify your server-side environment variables schema here.
* This way you can ensure the app isn't built with invalid env vars.
*/
export const serverSchema = z.object({
DATABASE_URL: z.string().url(),
NODE_ENV: z.enum(["development", "test", "production"]),
NEXTAUTH_SECRET:
process.env.NODE_ENV === "production"
? z.string().min(1)
: z.string().min(1).optional(),
NEXTAUTH_URL: z.preprocess(
// This makes Vercel deployments not fail if you don't set NEXTAUTH_URL
// Since NextAuth.js automatically uses the VERCEL_URL if present.
(str) => process.env.VERCEL_URL ?? str,
// VERCEL_URL doesn't include `https` so it cant be validated as a URL
process.env.VERCEL ? z.string() : z.string().url(),
),
TWITTER_CLIENT_ID: z.string(),
TWITTER_CLIENT_SECRET: z.string(),
BUCKET_ENDPOINT: z.string().url(),
S3_ACCESS_KEY_ID: z.string(),
S3_SECRET_ACCESS_KEY: z.string(),
});
/**
* You can't destruct `process.env` as a regular object in the Next.js
* middleware, so you have to do it manually here.
* @type {{ [k in keyof z.infer<typeof serverSchema>]: z.infer<typeof serverSchema>[k] | undefined }}
*/
export const serverEnv = {
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
TWITTER_CLIENT_ID: process.env.TWITTER_CLIENT_ID,
TWITTER_CLIENT_SECRET: process.env.TWITTER_CLIENT_SECRET,
BUCKET_ENDPOINT: process.env.BUCKET_ENDPOINT,
S3_ACCESS_KEY_ID: process.env.S3_ACCESS_KEY_ID,
S3_SECRET_ACCESS_KEY: process.env.S3_SECRET_ACCESS_KEY,
};
/**
* Specify your client-side environment variables schema here.
* This way you can ensure the app isn't built with invalid env vars.
* To expose them to the client, prefix them with `NEXT_PUBLIC_`.
*/
export const clientSchema = z.object({
NEXT_PUBLIC_URL: z.string(),
});
/**
* You can't destruct `process.env` as a regular object, so you have to do
* it manually here. This is because Next.js evaluates this at build time,
* and only used environment variables are included in the build.
* @type {{ [k in keyof z.infer<typeof clientSchema>]: z.infer<typeof clientSchema>[k] | undefined }}
*/
export const clientEnv = {
NEXT_PUBLIC_URL: process.env.NEXT_PUBLIC_URL,
};
================================================
FILE: src/env/server.mjs
================================================
// @ts-check
/**
* This file is included in `/next.config.mjs` which ensures the app isn't built with invalid env vars.
* It has to be a `.mjs`-file to be imported there.
*/
import { serverSchema, serverEnv } from "./schema.mjs";
import { env as clientEnv, formatErrors } from "./client.mjs";
const _serverEnv = serverSchema.safeParse(serverEnv);
if (!_serverEnv.success) {
console.error(
"❌ Invalid environment variables:\n",
...formatErrors(_serverEnv.error.format()),
);
throw new Error("Invalid environment variables");
}
for (let key of Object.keys(_serverEnv.data)) {
if (key.startsWith("NEXT_PUBLIC_")) {
console.warn("❌ You are exposing a server-side env-variable:", key);
throw new Error("You are exposing a server-side env-variable");
}
}
export const env = { ..._serverEnv.data, ...clientEnv };
================================================
FILE: src/pages/_app.tsx
================================================
import Head from "next/head";
import { type AppType } from "next/app";
import { type Session } from "next-auth";
import * as Tooltip from "@radix-ui/react-tooltip";
import { SessionProvider } from "next-auth/react";
import Script from "next/script";
import { api } from "../utils/api";
import "../styles/globals.css";
import { MenuBar } from "components/MenuBar";
const MyApp: AppType<{ session: Session | null }> = ({
Component,
pageProps: { session, ...pageProps },
}) => {
return (
<SessionProvider session={session}>
<Tooltip.Provider delayDuration={0}>
<Script
src={"https://titans-cheeky.dockhunt.com/script.js"}
data-site="ZBAJAOGI"
/>
<Head>
<link rel="icon" href="/favicon.png" />
<meta
name={"og:image"}
content={`/opengraph.png`}
key={"opengraph-image"}
/>
<meta name="twitter:card" content="summary_large_image" />
<meta
name="twitter:image"
content={`/opengraph.png`}
key={"twitter-image"}
/>
<meta
name="twitter:description"
content={`Discover the apps everyone is docking about`}
key={"twitter-description"}
/>
<meta
name="twitter:site"
content={'dockhuntapp'}
/>
</Head>
<main className="flex min-h-screen flex-col items-center main-bg text-white">
<MenuBar />
<Component {...pageProps} />
</main>
</Tooltip.Provider>
</SessionProvider>
);
};
export default api.withTRPC(MyApp);
================================================
FILE: src/pages/add-dock.tsx
================================================
import Head from "next/head";
import pinnedDocks from "images/pinned.jpg";
import Image from "next/image";
import Link from "next/link";
import { desktopAppDownloadLink } from "utils/constants";
import { useSession } from "next-auth/react";
import { api } from "../utils/api";
const AddDock = () => {
const { data: sessionData } = useSession();
const user = api.users.getOne.useQuery({ id: sessionData?.user?.id ?? "" });
return (
<>
<Head>
<title>Dockhunt | Add your dock</title>
<meta name="description" content="Add your dock" />
<link rel="icon" href="/favicon.png" />
</Head>
<div className="flex min-h-screen max-w-[800px] flex-col items-start justify-center px-8 py-24">
<h1 className="mb-8 text-3xl font-semibold">
{!user.data?.dock ? "Add your own dock" : "Update your dock"}
</h1>
<h2 className={"mb-4 text-2xl"}>Desktop app method (preferred)</h2>
<h3 className="mb-2 text-xl">Prerequisites</h3>
<ul className="mb-8 list-disc pl-8 text-xl">
<li>macOS 11+ (Big Sur and above)</li>
</ul>
<p className="mb-4 text-xl">
Download the desktop app, unzip it, and move the app into your
applications directory. Once in your applications directory you will
be able to run the app.
</p>
<a
className="mb-8 rounded-full bg-blue-700 px-4 py-2 hover:bg-blue-600"
download
href={desktopAppDownloadLink}
>
Download desktop app
</a>
<h2 className={"mb-4 text-2xl"}>CLI method</h2>
<h3 className="mb-2 text-xl">Prerequisites</h3>
<ul className="mb-8 list-disc pl-8 text-xl">
<li>macOS 11+ (Big Sur and above)</li>
<li>
You must have{" "}
<a
href="https://nodejs.org"
target="_blank"
rel="noreferrer"
className="text-blue-400 hover:underline"
>
Node.js
</a>{" "}
installed on your computer
</li>
</ul>
<p className="mb-8 text-xl">
To add your own dock, run the following command in your{" "}
<Link href="/apps/Terminal">terminal</Link>:
</p>
<code className="mb-8 w-full rounded border bg-black p-4">
npx dockhunt
</code>
<p className="mb-2 text-xl">The command will:</p>
<ol className="mb-8 list-decimal pl-8 text-xl">
<li>Find the apps in your dock</li>
<li>Upload any icons not yet in our database</li>
<li>Create a dock on this website</li>
</ol>
<div className="mb-4 flex flex-col items-start text-xl">
<a
href="https://github.com/Basedash/dockhunt-cli"
target="_blank"
rel="noreferrer"
className="text-blue-400 hover:underline"
>
View source code on GitHub
</a>
<a
href="https://www.npmjs.com/package/dockhunt"
target="_blank"
rel="noreferrer"
className="text-blue-400 hover:underline"
>
View package on npm
</a>
</div>
<hr className={"my-8 w-full"} />
<p className="mb-8 text-xl">
Dockhunt will only use the apps that are pinned to your dock. Apps can
be pinned by right-clicking and selecting Options > Keep in Dock.
Apps that are not pinned will be ignored.
</p>
<Image
className="mb-8"
src={pinnedDocks}
alt="Pinned vs recent dock items"
/>
</div>
</>
);
};
export default AddDock;
================================================
FILE: src/pages/api/auth/[...nextauth].ts
================================================
import NextAuth, { type NextAuthOptions } from "next-auth";
import type { TwitterProfile } from "next-auth/providers/twitter";
import TwitterProvider from "next-auth/providers/twitter";
// Prisma adapter for NextAuth, optional and can be removed
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { env } from "../../../env/server.mjs";
import { prisma } from "../../../server/db";
export const authOptions: NextAuthOptions = {
// Include user.id on session
callbacks: {
session({ session, user }) {
if (session.user) {
session.user.id = user.id;
session.user.image = user.avatarUrl;
session.user.username = user.username;
}
return session;
},
},
// Configure one or more authentication providers
adapter: PrismaAdapter(prisma),
providers: [
TwitterProvider({
clientId: env.TWITTER_CLIENT_ID,
clientSecret: env.TWITTER_CLIENT_SECRET,
version: "2.0",
userinfo: {
url: "https://api.twitter.com/2/users/me",
params: {
"user.fields":
"description,url,entities,profile_image_url,public_metrics",
},
},
profile(profile: {
data: TwitterProfile["data"] & {
profile_image_url: string;
public_metrics: { followers_count: number };
};
}) {
return {
id: profile.data.id,
name: profile.data.name,
description: profile.data.description,
url:
profile.data.entities?.url?.urls[0]?.expanded_url ??
profile.data.url,
twitterFollowerCount: profile.data.public_metrics.followers_count,
username: profile.data.username,
avatarUrl: profile.data.profile_image_url.replace(
/_normal\.(jpg|png|gif)$/,
".$1"
),
};
},
}),
/**
* ...add more providers here
*
* Most other providers require a bit more work than the Discord provider.
* For example, the GitHub provider requires you to add the
* `refresh_token_expires_in` field to the Account model. Refer to the
* NextAuth.js docs for the provider you want to use. Example:
* @see https://next-auth.js.org/providers/github
*/
],
};
export default NextAuth(authOptions);
================================================
FILE: src/pages/api/cli/check-apps.ts
================================================
import type { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "../../../server/db";
// Endpoint that checks if the provided apps have images already specified in the database
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") {
const { app } = req.query;
if (!app) {
return res.status(400).json({ message: "Missing apps" });
}
const arrayOfAppNames = Array.isArray(app) ? app : [app];
const correspondingAppsFromDatabase = await prisma.app.findMany({
where: {
name: {
in: arrayOfAppNames
},
}
});
const appToAppname = new Map(correspondingAppsFromDatabase.map(app => [app.name, app]));
const missingAppsInformation: {name: string; foundInDb: boolean; missingAppIcon: boolean;}[] = [];
for (const appName of arrayOfAppNames) {
const appFromDatabase = appToAppname.get(appName);
if (appFromDatabase && !appFromDatabase.iconUrl) {
missingAppsInformation.push({
name: appName,
foundInDb: true,
missingAppIcon: true,
});
} else if (!appFromDatabase) {
missingAppsInformation.push({
name: appName,
foundInDb: false,
missingAppIcon: true,
});
}
}
return res.json({missingAppsInformation})
} else {
return res.status(405).json({ message: "Method not allowed" });
}
}
================================================
FILE: src/pages/api/cli/icon-upload.ts
================================================
/* Route to upload app icons (if the app doesn't already have an icon in our database)
* If an icon is found in the DB for the given app name, we will ignore the image in the request.
*
* Note that this route also works for just saving an app with no icon in the db. We should probably rename it.
**/
import type { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "../../../server/db";
import aws from "aws-sdk";
import { v4 as uuid } from "uuid";
import fs from "fs";
import formidable from "formidable";
import { env } from "env/server.mjs";
const s3 = new aws.S3({
endpoint: env.BUCKET_ENDPOINT,
credentials: {
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
},
});
export const config = {
api: {
bodyParser: false, // Disallow body parsing, consume as stream
},
};
export const uploadFile = async ({
file,
bucketName,
}: {
file: formidable.File;
bucketName: string;
}) => {
const fileStream = fs.readFileSync(file.filepath);
const key = uuid();
const uploadParams = {
Bucket: bucketName,
Body: fileStream,
Key: key,
ACL: "public-read",
};
return s3.upload(uploadParams).promise();
};
export default function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method === "POST") {
const form = formidable();
// eslint-disable-next-line @typescript-eslint/no-misused-promises
form.parse(req, async function (err, fields, files) {
if (err) {
console.log('err', err);
return res.status(400).json({ message: "Error parsing form" });
} else {
const appName = fields.app;
if (!appName) {
return res.status(400).json({ error: "App name not provided" });
}
if (Array.isArray(appName)) {
return res.status(400).json({ error: "Cannot provide multipe app names" });
}
const existingApp = await prisma.app.findUnique({
where: {
name: appName,
},
});
if (existingApp && existingApp.iconUrl) {
return res.status(400).json({ message: "App already has an icon" });
}
const file = files.icon;
if (!file) {
console.warn("No icon file found for app: ", appName);
}
let uploadedFile: aws.S3.ManagedUpload.SendData | undefined;
if (file) {
if (Array.isArray(file)) {
return res.status(400).json({ error: "Only 1 file permitted" });
}
if (file.size > 1 * 1024 * 1024) {
// Make sure file is less than 1MB
return res.status(400).json({ error: "File is too large" });
}
uploadedFile = await uploadFile({
file,
bucketName: "dockhunt-images",
});
}
const app = await prisma.app.upsert({
where: {
name: appName,
},
create: {
name: appName,
iconUrl: uploadedFile ? `https://dockhunt-images.nyc3.cdn.digitaloceanspaces.com/${uploadedFile.Key}` : null,
},
update: {
// Important to use `undefined` and not `null` here to avoid overwriting existing icon if the user calls this endpoint with no icon,
// but there is already an icon in the DB
iconUrl: uploadedFile ? `https://dockhunt-images.nyc3.cdn.digitaloceanspaces.com/${uploadedFile.Key}` : undefined,
},
});
return res.status(201).json({ app });
}
});
} else {
return res.status(405).json({ message: "Method not allowed" });
}
}
================================================
FILE: src/pages/api/og.tsx
================================================
import { ImageResponse } from "@vercel/og";
import type { NextRequest } from "next/server";
import type { NextApiResponse } from "next";
export const config = {
runtime: "experimental-edge",
};
const getIconSize = (numberOfIcons: number): number => {
if (numberOfIcons <= 5) {
return 120;
} else if (numberOfIcons <= 10) {
return 90;
} else if (numberOfIcons <= 15) {
return 70;
} else if (numberOfIcons <= 20) {
return 60;
} else if (numberOfIcons <= 25) {
return 50;
} else if (numberOfIcons <= 35) {
return 40;
} else {
return 30;
}
};
export default function handler(req: NextRequest, res: NextApiResponse) {
try {
const { searchParams } = new URL(req.url);
const hasUsername = searchParams.has("username");
const username = hasUsername
? searchParams.get("username")?.slice(0, 100)
: "Dockhunt";
const appIcons = searchParams.getAll("icon");
const avatarUrl = searchParams.get("avatar");
const iconSize = getIconSize(appIcons.length);
return new ImageResponse(
(
<div
style={{
backgroundImage: `url("${
process.env.NEXTAUTH_URL as string
}/og-wallpaper-monterey.jpg")`,
backgroundSize: "cover",
width: "1200px",
height: "630px",
display: "flex",
textAlign: "center",
alignItems: "center",
justifyContent: "center",
flexDirection: "column",
flexWrap: "nowrap",
position: "relative",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
justifyItems: "center",
marginTop: "160px",
marginBottom: "16px",
}}
>
<img
src={avatarUrl ?? ""}
height={180}
width={180}
style={{
borderRadius: "180px",
}}
/>
</div>
<div
style={{
fontSize: 30,
fontStyle: "normal",
color: "white",
}}
>
{`@${username}`}
</div>
<div
style={{
display: "flex",
border: "1px solid rgba(75,85,99,.6)",
backgroundColor: "rgba(31,41,55,.6)",
borderRadius: "15px",
marginTop: "auto",
marginBottom: "30px",
}}
>
{appIcons.map((icon) => (
<img key={icon} src={icon} height={iconSize} width={iconSize} />
))}
</div>
</div>
),
{
width: 1200,
height: 630,
}
);
} catch (e) {
if (e instanceof Error) {
console.log(`${e.message}`);
}
return new Response(`Failed to generate the image`, {
status: 500,
});
}
}
================================================
FILE: src/pages/api/trpc/[trpc].ts
================================================
import { createNextApiHandler } from "@trpc/server/adapters/next";
import { env } from "../../../env/server.mjs";
import { createTRPCContext } from "../../../server/api/trpc";
import { appRouter } from "../../../server/api/root";
// export API handler
export default createNextApiHandler({
router: appRouter,
createContext: createTRPCContext,
onError:
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
);
}
: undefined,
});
================================================
FILE: src/pages/apps/[appName].tsx
================================================
import { DockCard } from "components/DockCard";
import Head from "next/head";
import Image from "next/image";
import { useRouter } from "next/router";
import { api } from "../../utils/api";
import { BouncingLoader } from "components/BouncingLoader";
export default function AppPage() {
const router = useRouter();
const appName = router.query.appName as string | null;
if (!appName) return null;
const app = api.apps.getOne.useQuery({ name: appName });
if (!app.data) {
return (
<>
<Head>
<title>Dockhunt | {appName}</title>
</Head>
<div className="flex h-screen flex-col items-center justify-center">
<BouncingLoader />
</div>
</>
);
}
if (!app.data.app) {
return (
<>
<Head>
<title>Dockhunt | App not found</title>
</Head>
<div className="flex h-screen flex-col items-center justify-center">
<h1 className="text-3xl font-black">App not found</h1>
</div>
</>
);
}
return (
<>
<Head>
<title>Dockhunt | {app.data.app.name}</title>
</Head>
<div className="w-screen max-w-[80rem] px-6 md:px-20">
<div className="flex flex-col items-center">
{app.data.app.iconUrl && (
<Image
src={app.data.app.iconUrl}
alt={`${app.data.app.name} app icon`}
className="mt-20"
width="150"
height="150"
/>
)}
<h1 className="mt-2 text-3xl font-semibold">{app.data.app.name}</h1>
{app.data.app.description && (
<div className="mt-3 flex max-w-2xl flex-col gap-1 text-center leading-normal text-gray-300">
{app.data.app.description.split("\n").map((paragraph, index) => (
<p key={index}>{paragraph}</p>
))}
</div>
)}
<div className="mt-4 flex gap-4">
{app.data.app.websiteUrl && (
<a
className="text-blue-400 hover:underline"
href={app.data.app.websiteUrl}
target="_blank"
rel="noreferrer"
>
{app.data.app.websiteUrl.split("//").at(-1)?.split("/").at(0)}
</a>
)}
{app.data.app.twitterUrl && (
<a
className="text-blue-400 hover:underline"
href={app.data.app.twitterUrl}
target="_blank"
rel="noreferrer"
>
@{app.data.app.twitterUrl.split("/").at(-1)}
</a>
)}
</div>
</div>
<div className="w-full py-24">
<h3 className="mb-8 text-3xl font-semibold">
Docked by {app.data.dockCount}{" "}
{app.data.dockCount === 1 ? "person" : "people"}
</h3>
<div className="flex flex-col gap-10 md:gap-16">
{app.data.docks.map((dock) => (
<DockCard key={dock.id} dock={dock} />
))}
</div>
</div>
</div>
</>
);
}
================================================
FILE: src/pages/apps.tsx
================================================
import { useState, useMemo } from "react";
import Head from "next/head";
import Image from "next/image";
import Link from "next/link";
import { api } from "utils/api";
import { placeholderImageUrl } from "utils/constants";
export default function Apps() {
const topApps = api.apps.getTop.useQuery();
const dockCount = api.docks.getCount.useQuery();
const [query, setQuery] = useState("");
const filteredApps = useMemo(() => {
return topApps.data
?.map((app, index) => ({
...app,
position: index + 1,
}))
.filter((app) =>
app.name?.toLocaleLowerCase().includes(query.toLocaleLowerCase())
);
}, [topApps, query]);
return (
<>
<Head>
<title>Dockhunt | Top apps</title>
<meta name="description" content="Top apps" />
<link rel="icon" href="/favicon.png" />
</Head>
<div className="flex min-h-screen w-screen max-w-[80rem] flex-col items-start px-6 py-24 md:px-20">
<h1 className="mb-6 text-3xl font-semibold">Top 100 apps</h1>
<div className="relative mb-8 flex w-full max-w-xs items-center">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search apps"
className="w-full rounded-md border border-gray-600/60 bg-gray-900/60 p-2 ring-blue-500/50 hover:bg-gray-800/60 focus:outline-none focus:ring-4"
/>
{query.length > 0 && (
<button
onClick={() => setQuery("")}
className="absolute right-2 rounded-full bg-gray-800 p-1 px-2 text-sm text-gray-400 hover:bg-gray-700 hover:text-gray-200"
>
Clear
</button>
)}
</div>
<div className="flex w-full flex-col divide-y divide-gray-600/60">
{filteredApps?.map((app) => (
<Link
key={app.name}
className="flex items-center gap-4 hover:bg-gray-600/60 sm:p-2"
href={`/apps/${app.name}`}
>
<Image
src={app.iconUrl ?? placeholderImageUrl}
width={50}
height={50}
alt={`${app.name} app icon`}
/>
<h2>
{app.position}. {app.name}
</h2>
<div className="flex-grow" />
<p>{app._count.dockItems} docks</p>
{dockCount.data && (
<p>
{Math.round((app._count.dockItems / dockCount.data) * 100)}%
</p>
)}
</Link>
))}
</div>
</div>
</>
);
}
================================================
FILE: src/pages/index.tsx
================================================
import type { GetServerSidePropsContext } from "next";
import { type NextPage } from "next";
import Head from "next/head";
import { api } from "../utils/api";
import { unstable_getServerSession } from "next-auth";
import { authOptions } from "./api/auth/[...nextauth]";
import { DockCard } from "components/DockCard";
import { AddDockCard } from "components/AddDockCard";
import Link from "next/link";
import { useSession } from "next-auth/react";
import { BouncingLoader } from "components/BouncingLoader";
const Home: NextPage = () => {
const { data: sessionData } = useSession();
const user = api.users.getOne.useQuery({ id: sessionData?.user?.id ?? "" });
const featuredDocks = api.docks.getFeatured.useQuery();
const latestDocks = api.docks.getLatest.useQuery();
const dockCount = api.docks.getCount.useQuery();
return (
<>
<Head>
<title>Dockhunt</title>
<meta name={"og:title"} content={"Dockhunt"} key={"opengraph-title"} />
<meta
name={"twitter:title"}
content={"Dockhunt"}
key={"twitter-title"}
/>
<meta
name="description"
content="Discover the apps everyone is docking about"
/>
</Head>
<div className="flex w-screen max-w-[80rem] flex-col px-6 py-24 md:py-32 md:px-20">
<div className="mb-16 flex flex-col items-center gap-4">
<h1 className="text-center text-4xl font-bold">
Discover the apps everyone is docking about
</h1>
<h2 className="text-center text-xl">
Share your dock and see who else has docked the apps you use
</h2>
<Link
href="/add-dock"
className="rounded-full bg-blue-700 px-4 py-2 hover:bg-blue-600"
>
{!user.data?.dock ? "Add your dock" : "Update your dock"}
</Link>
{dockCount.data && dockCount.data > 0 && (
<span className="self-center text-xs uppercase text-gray-400">
{dockCount.data} docks on Dockhunt
</span>
)}
</div>
{!featuredDocks.data || !latestDocks.data ? (
<div
className={"mt-40 flex h-full w-full items-center justify-center"}
>
<BouncingLoader />
</div>
) : (
<>
<h3 className="mb-8 text-3xl font-semibold">Featured docks</h3>
<div className="mb-24 flex flex-col gap-10 md:gap-16">
{featuredDocks.data.map((dock) => (
<DockCard key={dock.id} dock={dock} />
))}
</div>
<h3 className="mb-8 text-3xl font-semibold">Latest docks</h3>
<div className="flex flex-col gap-10 md:gap-16">
<AddDockCard />
{latestDocks.data.map((dock) => (
<DockCard key={dock.id} dock={dock} />
))}
</div>
</>
)}
</div>
</>
);
};
export default Home;
export async function getServerSideProps(context: GetServerSidePropsContext) {
const session = await unstable_getServerSession(
context.req,
context.res,
authOptions
);
// Hack to convert undefined values to null for user.image
// TODO: Fix why user.image is undefined. Probably need to map it to "avatarUrl" in DB
if (session && session.user) {
session.user.image = session.user.image ?? null;
}
return {
props: {
session,
},
};
}
================================================
FILE: src/pages/new-dock.tsx
================================================
import type { App } from "@prisma/client";
import { useRouter } from "next/router";
import { api } from "../utils/api";
import { Dock } from "../components/Dock";
import Head from "next/head";
import { signIn, useSession } from "next-auth/react";
import { useEffect, useRef } from "react";
import { authOptions } from "./api/auth/[...nextauth]";
import { unstable_getServerSession } from "next-auth";
import type { GetServerSidePropsContext } from "next";
const NewDock = () => {
const router = useRouter();
const { data: session } = useSession();
const queryParams = router.query;
const appNames = Array.isArray(queryParams.app)
? queryParams.app
: [queryParams.app ?? ""];
const creatingDockRef = useRef(false);
const createDockMutation = api.docks.createDock.useMutation({
onSuccess: async () => {
await router.replace(`/users/${session?.user?.username ?? ""}`);
},
});
useEffect(() => {
const handleSigninIfNotSignedIn = async () => {
if (!session) {
await signIn("twitter");
}
};
void handleSigninIfNotSignedIn();
}, [session]);
useEffect(() => {
const handleGenerateDock = () => {
if (session && creatingDockRef.current === false) {
creatingDockRef.current = true;
createDockMutation.mutate({
apps: appNames,
});
}
};
handleGenerateDock();
}, [appNames, createDockMutation, session]);
return (
<>
<Head>
<title>Dockhunt | New dock</title>
<meta name="description" content="Save your dock" />
<link rel="icon" href="/favicon.png" />
</Head>
{session && (
<div className="flex min-h-screen flex-col items-center justify-center">
<h1 className={"mb-4 text-xl"}>Generating your dock...</h1>
</div>
)}
</>
);
};
export default NewDock;
export async function getServerSideProps(context: GetServerSidePropsContext) {
const session = await unstable_getServerSession(
context.req,
context.res,
authOptions
);
// Hack to convert undefined values to null for user.image
// TODO: Fix why user.image is undefined. Probably need to map it to "avatarUrl" in DB
if (session && session.user) {
session.user.image = session.user.image ?? null;
}
return {
props: {
session,
},
};
}
================================================
FILE: src/pages/users/[username].tsx
================================================
import { Dock } from "components/Dock";
import { env } from "env/client.mjs";
import twitter from "images/twitter.svg";
import Head from "next/head";
import Image from "next/image";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
import { api } from "../../utils/api";
import superjson from "superjson";
import { createInnerTRPCContext } from "server/api/trpc";
import { appRouter } from "server/api/root";
import { createProxySSGHelpers } from "@trpc/react-query/ssg";
import { GetServerSidePropsContext, InferGetServerSidePropsType } from "next";
export default function UserPage({
username,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
const router = useRouter();
const { data: sessionData } = useSession();
if (!username) return null;
const user = api.users.getOne.useQuery({ username: username });
if (!user.data) {
return null;
}
const appIconUrls = user.data.dock?.dockItems
.map((dockItem) => dockItem.app.iconUrl)
.filter((url): url is string => url !== null);
const title = user.data ? `Dockhunt | ${user.data.username}` : "Dockhunt";
const ogImageLink = `${
env.NEXT_PUBLIC_URL
}/api/og?username=${username}&avatar=${encodeURIComponent(
user.data?.avatarUrl ?? ""
)}&${appIconUrls
?.map((url) => `icon=${encodeURIComponent(url)}`)
.join("&")}&v1.0.0`;
const description = `${user.data.name}'s dock on Dockhunt`;
return (
<>
<Head>
<title>{title}</title>
<meta name={"og:image"} content={ogImageLink} key={"opengraph-image"} />
<meta name={"og:image:height"} content={"630"} />
<meta name={"og:image:width"} content={"1200"} />
<meta name={"twitter:title"} content={title} key={"twitter-title"} />
<meta
name={"twitter:description"}
content={description}
key={"twitter-description"}
/>
<meta
name="twitter:image"
content={ogImageLink}
key={"twitter-image"}
/>
<meta name="twitter:card" content="summary_large_image" />
</Head>
<div className="relative flex h-screen w-screen flex-col items-center justify-center bg-monterey bg-cover">
{/* Only show the share button for the dock of the currently logged-in user */}
{sessionData?.user?.username === username && (
<a
className={
"absolute top-[70px] right-[15px] flex items-center gap-2 rounded-full bg-[#4999E9] px-4 py-2 hover:bg-[#428AD2] min-[900px]:top-[45px]"
}
href={`https://twitter.com/intent/tweet?text=Check%20out%20my%20dock%20on%20%40dockhuntapp%3A%0A%0A${encodeURIComponent(
env.NEXT_PUBLIC_URL
)}${encodeURIComponent(router.asPath)}`}
target={"_blank"}
rel="noreferrer"
>
<Image src={twitter} alt="Twitter" height="18" />
<span className="hidden sm:block">Share</span>
</a>
)}
<div className="flex flex-col items-center px-6 pb-20 md:px-20">
{user.data.avatarUrl && (
<Image
src={user.data.avatarUrl}
alt={`${user.data.name} avatar`}
className="rounded-full"
width="150"
height="150"
/>
)}
<h1 className="mt-2 text-2xl">{user.data.name}</h1>
{user.data.description && (
<div className="mt-3 flex max-w-2xl flex-col gap-1 text-center leading-normal text-gray-300">
{user.data.description.split("\n").map((paragraph, index) => (
<p key={index}>{paragraph}</p>
))}
</div>
)}
<div className="mt-4 flex gap-4">
{user.data.url && (
<a
className="text-blue-400 hover:underline"
href={user.data.url}
target="_blank"
rel="noreferrer"
>
{user.data.url.split("//").at(-1)?.split("/").at(0)}
</a>
)}
<a
className="text-blue-400 hover:underline"
href={`https://twitter.com/${username}`}
target="_blank"
rel="noreferrer"
>
@{username}
</a>
</div>
</div>
<div className="absolute bottom-10 max-w-full px-4">
{user.data.dock && (
<Dock
apps={user.data.dock.dockItems.map((dockItem) => dockItem.app)}
/>
)}
</div>
</div>
</>
);
}
export async function getServerSideProps(
context: GetServerSidePropsContext<{ username: string }>,
) {
const ssg = createProxySSGHelpers({
router: appRouter,
ctx: createInnerTRPCContext(),
transformer: superjson,
});
const username = context.params?.username as string;
/*
* `prefetch` does not return the result and never throws - if you need that behavior, use `fetch` instead.
*/
await ssg.users.getOne.prefetch({ username });
return {
props: {
trpcState: ssg.dehydrate(),
username,
},
};
}
================================================
FILE: src/server/api/root.ts
================================================
import { createTRPCRouter } from "./trpc";
import { appsRouter } from "./routers/apps";
import { docksRouter } from "./routers/docks";
import { usersRouter } from "./routers/users";
export const appRouter = createTRPCRouter({
apps: appsRouter,
docks: docksRouter,
users: usersRouter,
});
export type AppRouter = typeof appRouter;
================================================
FILE: src/server/api/routers/apps.ts
================================================
import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "../trpc";
export const appsRouter = createTRPCRouter({
getOne: publicProcedure
.input(z.object({ name: z.string() }))
.query(async ({ ctx, input }) => {
const app = await ctx.prisma.app.findUnique({
where: { name: input.name },
});
const docks = await ctx.prisma.dock.findMany({
where: { dockItems: { some: { app: { name: input.name } } } },
include: {
user: true,
dockItems: {
include: {
app: true,
},
orderBy: { position: "asc" },
},
},
orderBy: [{ featured: "desc" }, { createdAt: "desc" }],
take: 20,
});
const dockCount = await ctx.prisma.dock.count({
where: { dockItems: { some: { app: { name: input.name } } } },
});
return { app, docks, dockCount };
}),
getAll: publicProcedure.query(async ({ ctx }) => {
const apps = await ctx.prisma.app.findMany();
return apps;
}),
getTop: publicProcedure.query(async ({ ctx }) => {
const apps = await ctx.prisma.app.findMany({
include: {
_count: {
select: { dockItems: true },
},
},
orderBy: {
dockItems: {
_count: "desc",
},
},
take: 100,
});
return apps;
}),
getManyFromNames: publicProcedure
.input(z.object({ names: z.array(z.string()) }))
.query(async ({ ctx, input }) => {
const names = input.names;
const app = await ctx.prisma.app.findMany({
where: {
name: {
in: names,
},
},
});
return app;
}),
});
================================================
FILE: src/server/api/routers/docks.ts
================================================
import { z } from "zod";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
export const docksRouter = createTRPCRouter({
getOne: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ ctx, input }) => {
return ctx.prisma.dock.findUnique({ where: { id: input.id } });
}),
getFeatured: publicProcedure.query(({ ctx }) => {
return ctx.prisma.dock.findMany({
where: { featured: true },
include: {
user: true,
dockItems: {
include: {
app: true,
},
orderBy: { position: "asc" },
},
},
orderBy: [
{ user: { twitterFollowerCount: "desc" } },
{ createdAt: "desc" },
],
});
}),
getLatest: publicProcedure.query(({ ctx }) => {
return ctx.prisma.dock.findMany({
where: { featured: false },
include: {
user: true,
dockItems: {
include: {
app: true,
},
orderBy: { position: "asc" },
},
},
orderBy: [{ createdAt: "desc" }],
take: 20,
});
}),
getCount: publicProcedure.query(({ ctx }) => {
return ctx.prisma.dock.count();
}),
createDock: protectedProcedure
.input(
z.object({
apps: z.array(z.string()),
})
)
.mutation(async ({ ctx, input }) => {
const usersDockIsCurrentlyFeatured =
(await ctx.prisma.dock.count({
where: {
featured: true,
userId: ctx.session.user.id,
},
})) > 0;
await ctx.prisma.dock.deleteMany({
where: {
userId: ctx.session.user.id,
},
});
return ctx.prisma.dock.create({
data: {
featured: usersDockIsCurrentlyFeatured,
user: {
connect: {
username: ctx.session.user.username,
},
},
dockItems: {
createMany: {
data: input.apps.map((app, index) => ({
appId: app,
position: index,
})),
},
},
},
});
}),
});
================================================
FILE: src/server/api/routers/users.ts
================================================
import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "../trpc";
export const usersRouter = createTRPCRouter({
getOne: publicProcedure
.input(
// Take either an id or a username
z.union([
z.object({ id: z.string() }),
z.object({ username: z.string() }),
])
)
.query(async ({ ctx, input }) => {
const user = await ctx.prisma.user.findUnique({
where: { ...input },
select: {
username: true,
name: true,
description: true,
url: true,
avatarUrl: true,
dock: {
include: {
dockItems: {
include: {
app: true,
},
orderBy: { position: "asc" },
},
},
},
},
});
return user;
}),
});
================================================
FILE: src/server/api/trpc.ts
================================================
/**
* YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:
* 1. You want to modify request context (see Part 1)
* 2. You want to create a new middleware or type of procedure (see Part 3)
*
* tl;dr - this is where all the tRPC server stuff is created and plugged in.
* The pieces you will need to use are documented accordingly near the end
*/
/**
* 1. CONTEXT
*
* This section defines the "contexts" that are available in the backend API
*
* These allow you to access things like the database, the session, etc, when
* processing a request
*
*/
import { type CreateNextContextOptions } from "@trpc/server/adapters/next";
import { type Session } from "next-auth";
import { getServerAuthSession } from "../auth";
import { prisma } from "../db";
type CreateContextOptions = {
session: Session | null;
};
/**
* This helper generates the "internals" for a tRPC context. If you need to use
* it, you can export it from here
*
* Examples of things you may need it for:
* - testing, so we dont have to mock Next.js' req/res
* - trpc's `createSSGHelpers` where we don't have req/res
* @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts
*/
export const createInnerTRPCContext = (opts?: CreateContextOptions) => {
return {
session: opts?.session ?? null,
prisma,
};
};
/**
* This is the actual context you'll use in your router. It will be used to
* process every request that goes through your tRPC endpoint
* @link https://trpc.io/docs/context
*/
export const createTRPCContext = async (opts: CreateNextContextOptions) => {
const { req, res } = opts;
// Get the session from the server using the unstable_getServerSession wrapper function
const session = await getServerAuthSession({ req, res });
return createInnerTRPCContext({
session,
});
};
/**
* 2. INITIALIZATION
*
* This is where the trpc api is initialized, connecting the context and
* transformer
*/
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape }) {
return shape;
},
});
/**
* 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
*
* These are the pieces you use to build your tRPC API. You should import these
* a lot in the /src/server/api/routers folder
*/
/**
* This is how you create new routers and subrouters in your tRPC API
* @see https://trpc.io/docs/router
*/
export const createTRPCRouter = t.router;
/**
* Public (unauthed) procedure
*
* This is the base piece you use to build new queries and mutations on your
* tRPC API. It does not guarantee that a user querying is authorized, but you
* can still access user session data if they are logged in
*/
export const publicProcedure = t.procedure;
/**
* Reusable middleware that enforces users are logged in before running the
* procedure
*/
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session || !ctx.session.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
});
/**
* Protected (authed) procedure
*
* If you want a query or mutation to ONLY be accessible to logged in users, use
* this. It verifies the session is valid and guarantees ctx.session.user is not
* null
*
* @see https://trpc.io/docs/procedures
*/
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
================================================
FILE: src/server/auth.ts
================================================
import { type GetServerSidePropsContext } from "next";
import { unstable_getServerSession } from "next-auth";
import { authOptions } from "../pages/api/auth/[...nextauth]";
/**
* Wrapper for unstable_getServerSession, used in trpc createContext and the
* restricted API route
*
* Don't worry too much about the "unstable", it's safe to use but the syntax
* may change in future versions
*
* @see https://next-auth.js.org/configuration/nextjs
*/
export const getServerAuthSession = async (ctx: {
req: GetServerSidePropsContext["req"];
res: GetServerSidePropsContext["res"];
}) => {
return await unstable_getServerSession(ctx.req, ctx.res, authOptions);
};
================================================
FILE: src/server/db.ts
================================================
import { PrismaClient } from "@prisma/client";
import { env } from "../env/server.mjs";
declare global {
// eslint-disable-next-line no-var
var prisma: PrismaClient | undefined;
}
export const prisma =
global.prisma ||
new PrismaClient({
log: ["error"],
});
if (env.NODE_ENV !== "production") {
global.prisma = prisma;
}
================================================
FILE: src/styles/globals.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.fade-lr {
mask-image: radial-gradient(circle at center, black calc(100% - 10px), transparent calc(100% - 7px), transparent);
}
}
.main-bg {
background: rgb(28,12,32);
background: linear-gradient(129deg, rgba(28,12,32,1) 45%, rgba(0,0,0,1) 100%);
}
================================================
FILE: src/types/next-auth.d.ts
================================================
import { type DefaultSession } from "next-auth";
import type { DefaultUser } from "next-auth/core/types";
declare module "next-auth" {
/**
* Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
*/
interface Session {
user?: {
id: string;
username: string; // Twitter handle
name: string;
} & DefaultSession["user"];
}
interface User extends DefaultUser {
avatarUrl: string | null;
username: string;
name: string;
}
}
================================================
FILE: src/utils/api.ts
================================================
/**
* This is the client-side entrypoint for your tRPC API.
* It's used to create the `api` object which contains the Next.js App-wrapper
* as well as your typesafe react-query hooks.
*
* We also create a few inference helpers for input and output types
*/
import { httpBatchLink, loggerLink } from "@trpc/client";
import { createTRPCNext } from "@trpc/next";
import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
import superjson from "superjson";
import { type AppRouter } from "../server/api/root";
const getBaseUrl = () => {
if (typeof window !== "undefined") return ""; // browser should use relative url
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url
return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost
};
/**
* A set of typesafe react-query hooks for your tRPC API
*/
export const api = createTRPCNext<AppRouter>({
config() {
return {
/**
* Transformer used for data de-serialization from the server
* @see https://trpc.io/docs/data-transformers
**/
transformer: superjson,
/**
* Links used to determine request flow from client to server
* @see https://trpc.io/docs/links
* */
links: [
loggerLink({
enabled: (opts) =>
process.env.NODE_ENV === "development" ||
(opts.direction === "down" && opts.result instanceof Error),
}),
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
}),
],
};
},
/**
* Whether tRPC should await queries when server rendering pages
* @see https://trpc.io/docs/nextjs#ssr-boolean-default-false
*/
ssr: false,
});
/**
* Inference helper for inputs
* @example type HelloInput = RouterInputs['example']['hello']
**/
export type RouterInputs = inferRouterInputs<AppRouter>;
/**
* Inference helper for outputs
* @example type HelloOutput = RouterOutputs['example']['hello']
**/
export type RouterOutputs = inferRouterOutputs<AppRouter>;
================================================
FILE: src/utils/constants.ts
================================================
export const placeholderImageUrl = "https://dockhunt-images.nyc3.cdn.digitaloceanspaces.com/placeholder.png";
export const desktopAppDownloadLink = 'https://dockhunt-app.nyc3.cdn.digitaloceanspaces.com/Dockhunt.zip';
================================================
FILE: tailwind.config.cjs
================================================
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
backgroundImage: {
monterey:
"url(https://dockhunt-images.nyc3.cdn.digitaloceanspaces.com/monterey.jpg)",
},
},
},
plugins: [],
};
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "es2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"noUncheckedIndexedAccess": true,
"baseUrl": "./src"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs", "**/*.mjs"],
"exclude": ["node_modules"]
}
gitextract_llewg0mk/ ├── .eslintrc.json ├── .github/ │ ├── FUNDING.yml │ └── ISSUE_TEMPLATE/ │ ├── bug_report.md │ ├── feature_request.md │ └── incorrect-app-icon.md ├── .gitignore ├── .vscode/ │ └── settings.json ├── README.md ├── next.config.mjs ├── package.json ├── postcss.config.cjs ├── prettier.config.cjs ├── prisma/ │ ├── migrations/ │ │ ├── 20230126170225_init/ │ │ │ └── migration.sql │ │ ├── 20230126181934_user_email_fields/ │ │ │ └── migration.sql │ │ ├── 20230126215401_remove_user_stuff/ │ │ │ └── migration.sql │ │ ├── 20230126233521_update_user_id_to_username/ │ │ │ └── migration.sql │ │ ├── 20230127051217_add_user_description/ │ │ │ └── migration.sql │ │ ├── 20230129204228_add_index_on_dock_featured/ │ │ │ └── migration.sql │ │ ├── 20230130042222_add_user_url/ │ │ │ └── migration.sql │ │ └── migration_lock.toml │ └── schema.prisma ├── src/ │ ├── components/ │ │ ├── AddDockCard.tsx │ │ ├── BouncingLoader.tsx │ │ ├── Dock.tsx │ │ ├── DockCard.tsx │ │ └── MenuBar.tsx │ ├── env/ │ │ ├── client.mjs │ │ ├── schema.mjs │ │ └── server.mjs │ ├── pages/ │ │ ├── _app.tsx │ │ ├── add-dock.tsx │ │ ├── api/ │ │ │ ├── auth/ │ │ │ │ └── [...nextauth].ts │ │ │ ├── cli/ │ │ │ │ ├── check-apps.ts │ │ │ │ └── icon-upload.ts │ │ │ ├── og.tsx │ │ │ └── trpc/ │ │ │ └── [trpc].ts │ │ ├── apps/ │ │ │ └── [appName].tsx │ │ ├── apps.tsx │ │ ├── index.tsx │ │ ├── new-dock.tsx │ │ └── users/ │ │ └── [username].tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts │ │ │ ├── routers/ │ │ │ │ ├── apps.ts │ │ │ │ ├── docks.ts │ │ │ │ └── users.ts │ │ │ └── trpc.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── styles/ │ │ └── globals.css │ ├── types/ │ │ └── next-auth.d.ts │ └── utils/ │ ├── api.ts │ └── constants.ts ├── tailwind.config.cjs └── tsconfig.json
SYMBOL INDEX (37 symbols across 20 files)
FILE: prisma/migrations/20230126170225_init/migration.sql
type "App" (line 2) | CREATE TABLE "App" (
type "Dock" (line 15) | CREATE TABLE "Dock" (
type "DockItem" (line 26) | CREATE TABLE "DockItem" (
type "Account" (line 38) | CREATE TABLE "Account" (
type "Session" (line 56) | CREATE TABLE "Session" (
type "User" (line 66) | CREATE TABLE "User" (
type "VerificationToken" (line 80) | CREATE TABLE "VerificationToken" (
type "Dock" (line 87) | CREATE UNIQUE INDEX "Dock_userId_key" ON "Dock"("userId")
type "Account" (line 90) | CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account...
type "Session" (line 93) | CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken")
type "VerificationToken" (line 96) | CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"...
type "VerificationToken" (line 99) | CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "Verific...
FILE: prisma/migrations/20230126181934_user_email_fields/migration.sql
type "User" (line 12) | CREATE UNIQUE INDEX "User_email_key" ON "User"("email")
FILE: prisma/migrations/20230127051217_add_user_description/migration.sql
type "User" (line 13) | CREATE UNIQUE INDEX "User_username_key" ON "User"("username")
FILE: prisma/migrations/20230129204228_add_index_on_dock_featured/migration.sql
type "Dock" (line 2) | CREATE INDEX "Dock_featured_idx" ON "Dock"("featured")
FILE: src/components/AddDockCard.tsx
function AddDockCard (line 6) | function AddDockCard() {
FILE: src/components/Dock.tsx
function Dock (line 83) | function Dock({ apps }: { apps: App[] }) {
FILE: src/components/DockCard.tsx
function DockCard (line 9) | function DockCard({
FILE: src/pages/api/auth/[...nextauth].ts
method session (line 13) | session({ session, user }) {
method profile (line 36) | profile(profile: {
FILE: src/pages/api/cli/check-apps.ts
function handler (line 5) | async function handler(req: NextApiRequest, res: NextApiResponse) {
FILE: src/pages/api/cli/icon-upload.ts
function handler (line 51) | function handler(
FILE: src/pages/api/og.tsx
function handler (line 27) | function handler(req: NextRequest, res: NextApiResponse) {
FILE: src/pages/apps.tsx
function Apps (line 8) | function Apps() {
FILE: src/pages/apps/[appName].tsx
function AppPage (line 8) | function AppPage() {
FILE: src/pages/index.tsx
function getServerSideProps (line 90) | async function getServerSideProps(context: GetServerSidePropsContext) {
FILE: src/pages/new-dock.tsx
function getServerSideProps (line 65) | async function getServerSideProps(context: GetServerSidePropsContext) {
FILE: src/pages/users/[username].tsx
function UserPage (line 15) | function UserPage({
function getServerSideProps (line 134) | async function getServerSideProps(
FILE: src/server/api/root.ts
type AppRouter (line 12) | type AppRouter = typeof appRouter;
FILE: src/server/api/trpc.ts
type CreateContextOptions (line 25) | type CreateContextOptions = {
method errorFormatter (line 72) | errorFormatter({ shape }) {
FILE: src/types/next-auth.d.ts
type Session (line 8) | interface Session {
type User (line 15) | interface User extends DefaultUser {
FILE: src/utils/api.ts
method config (line 25) | config() {
type RouterInputs (line 60) | type RouterInputs = inferRouterInputs<AppRouter>;
type RouterOutputs (line 65) | type RouterOutputs = inferRouterOutputs<AppRouter>;
Condensed preview — 54 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (84K chars).
[
{
"path": ".eslintrc.json",
"chars": 531,
"preview": "{\n \"overrides\": [\n {\n \"extends\": [\n \"plugin:@typescript-eslint/recommended-requiring-type-checking\"\n "
},
{
"path": ".github/FUNDING.yml",
"chars": 760,
"preview": "# These are supported funding model platforms\n\ngithub: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [u"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.md",
"chars": 536,
"preview": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: bug\nassignees: ''\n\n---\n\n**Describe the "
},
{
"path": ".github/ISSUE_TEMPLATE/feature_request.md",
"chars": 604,
"preview": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: enhancement\nassignees: ''\n\n---\n\n**Is"
},
{
"path": ".github/ISSUE_TEMPLATE/incorrect-app-icon.md",
"chars": 396,
"preview": "---\nname: Incorrect app icon\nabout: An app isn't using the correct icon on the website\ntitle: Incorrect app icon for <ap"
},
{
"path": ".gitignore",
"chars": 598,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
},
{
"path": ".vscode/settings.json",
"chars": 33,
"preview": "{\n\t\"editor.formatOnSave\": true\n}\n"
},
{
"path": "README.md",
"chars": 390,
"preview": "# Dockhunt\n\n[.Config} */\nmodule.exports = {\n plugins: [require.resolve(\"prettier-plugin-tailwindcss\")],"
},
{
"path": "prisma/migrations/20230126170225_init/migration.sql",
"chars": 3334,
"preview": "-- CreateTable\nCREATE TABLE \"App\" (\n \"name\" TEXT NOT NULL,\n \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIME"
},
{
"path": "prisma/migrations/20230126181934_user_email_fields/migration.sql",
"chars": 346,
"preview": "/*\n Warnings:\n\n - A unique constraint covering the columns `[email]` on the table `User` will be added. If there are e"
},
{
"path": "prisma/migrations/20230126215401_remove_user_stuff/migration.sql",
"chars": 181,
"preview": "/*\n Warnings:\n\n - You are about to drop the column `image` on the `User` table. All the data in the column will be los"
},
{
"path": "prisma/migrations/20230126233521_update_user_id_to_username/migration.sql",
"chars": 493,
"preview": "BEGIN;\n\n-- Default values for updatedAt\nALTER TABLE \"App\" ALTER COLUMN \"updatedAt\" SET DEFAULT CURRENT_TIMESTAMP;\nALTER "
},
{
"path": "prisma/migrations/20230127051217_add_user_description/migration.sql",
"chars": 477,
"preview": "/*\n Warnings:\n\n - A unique constraint covering the columns `[username]` on the table `User` will be added. If there ar"
},
{
"path": "prisma/migrations/20230129204228_add_index_on_dock_featured/migration.sql",
"chars": 71,
"preview": "-- CreateIndex\nCREATE INDEX \"Dock_featured_idx\" ON \"Dock\"(\"featured\");\n"
},
{
"path": "prisma/migrations/20230130042222_add_user_url/migration.sql",
"chars": 60,
"preview": "-- AlterTable\nALTER TABLE \"User\" ADD COLUMN \"url\" TEXT;\n"
},
{
"path": "prisma/migrations/migration_lock.toml",
"chars": 126,
"preview": "# Please do not edit this file manually\n# It should be added in your version-control system (i.e. Git)\nprovider = \"postg"
},
{
"path": "prisma/schema.prisma",
"chars": 2819,
"preview": "generator client {\n provider = \"prisma-client-js\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n url = env(\""
},
{
"path": "src/components/AddDockCard.tsx",
"chars": 1425,
"preview": "import { useSession } from \"next-auth/react\";\nimport Link from \"next/link\";\nimport { api } from \"utils/api\";\nimport { de"
},
{
"path": "src/components/BouncingLoader.tsx",
"chars": 596,
"preview": "import Image from \"next/image\";\nimport { motion } from \"framer-motion\";\n\nconst NextImage = motion(Image);\n\nexport const "
},
{
"path": "src/components/Dock.tsx",
"chars": 3113,
"preview": "import { motion } from \"framer-motion\";\nimport type { App } from \"@prisma/client\";\nimport Link from \"next/link\";\nimport "
},
{
"path": "src/components/DockCard.tsx",
"chars": 2549,
"preview": "import * as Tooltip from \"@radix-ui/react-tooltip\";\nimport type { inferRouterOutputs } from \"@trpc/server\";\nimport Image"
},
{
"path": "src/components/MenuBar.tsx",
"chars": 2994,
"preview": "import { format } from \"date-fns\";\nimport basedash from \"images/basedash.svg\";\nimport dockhunt from \"images/dockhunt.svg"
},
{
"path": "src/env/client.mjs",
"chars": 939,
"preview": "// @ts-check\nimport { clientEnv, clientSchema } from \"./schema.mjs\";\n\nconst _clientEnv = clientSchema.safeParse(clientEn"
},
{
"path": "src/env/schema.mjs",
"chars": 2357,
"preview": "// @ts-check\nimport { z } from \"zod\";\n\n/**\n * Specify your server-side environment variables schema here.\n * This way yo"
},
{
"path": "src/env/server.mjs",
"chars": 840,
"preview": "// @ts-check\n/**\n * This file is included in `/next.config.mjs` which ensures the app isn't built with invalid env vars."
},
{
"path": "src/pages/_app.tsx",
"chars": 1647,
"preview": "import Head from \"next/head\";\nimport { type AppType } from \"next/app\";\nimport { type Session } from \"next-auth\";\nimport "
},
{
"path": "src/pages/add-dock.tsx",
"chars": 3742,
"preview": "import Head from \"next/head\";\nimport pinnedDocks from \"images/pinned.jpg\";\nimport Image from \"next/image\";\nimport Link f"
},
{
"path": "src/pages/api/auth/[...nextauth].ts",
"chars": 2300,
"preview": "import NextAuth, { type NextAuthOptions } from \"next-auth\";\nimport type { TwitterProfile } from \"next-auth/providers/twi"
},
{
"path": "src/pages/api/cli/check-apps.ts",
"chars": 1459,
"preview": "import type { NextApiRequest, NextApiResponse } from \"next\";\nimport { prisma } from \"../../../server/db\";\n\n// Endpoint t"
},
{
"path": "src/pages/api/cli/icon-upload.ts",
"chars": 3626,
"preview": "/* Route to upload app icons (if the app doesn't already have an icon in our database)\n * If an icon is found in the DB "
},
{
"path": "src/pages/api/og.tsx",
"chars": 3004,
"preview": "import { ImageResponse } from \"@vercel/og\";\nimport type { NextRequest } from \"next/server\";\nimport type { NextApiRespons"
},
{
"path": "src/pages/api/trpc/[trpc].ts",
"chars": 570,
"preview": "import { createNextApiHandler } from \"@trpc/server/adapters/next\";\n\nimport { env } from \"../../../env/server.mjs\";\nimpor"
},
{
"path": "src/pages/apps/[appName].tsx",
"chars": 3135,
"preview": "import { DockCard } from \"components/DockCard\";\nimport Head from \"next/head\";\nimport Image from \"next/image\";\nimport { u"
},
{
"path": "src/pages/apps.tsx",
"chars": 2659,
"preview": "import { useState, useMemo } from \"react\";\nimport Head from \"next/head\";\nimport Image from \"next/image\";\nimport Link fro"
},
{
"path": "src/pages/index.tsx",
"chars": 3478,
"preview": "import type { GetServerSidePropsContext } from \"next\";\nimport { type NextPage } from \"next\";\nimport Head from \"next/head"
},
{
"path": "src/pages/new-dock.tsx",
"chars": 2341,
"preview": "import type { App } from \"@prisma/client\";\nimport { useRouter } from \"next/router\";\nimport { api } from \"../utils/api\";\n"
},
{
"path": "src/pages/users/[username].tsx",
"chars": 5183,
"preview": "import { Dock } from \"components/Dock\";\nimport { env } from \"env/client.mjs\";\nimport twitter from \"images/twitter.svg\";\n"
},
{
"path": "src/server/api/root.ts",
"chars": 338,
"preview": "import { createTRPCRouter } from \"./trpc\";\nimport { appsRouter } from \"./routers/apps\";\nimport { docksRouter } from \"./r"
},
{
"path": "src/server/api/routers/apps.ts",
"chars": 1719,
"preview": "import { z } from \"zod\";\nimport { createTRPCRouter, publicProcedure } from \"../trpc\";\n\nexport const appsRouter = createT"
},
{
"path": "src/server/api/routers/docks.ts",
"chars": 2150,
"preview": "import { z } from \"zod\";\nimport { createTRPCRouter, protectedProcedure, publicProcedure } from \"../trpc\";\n\nexport const "
},
{
"path": "src/server/api/routers/users.ts",
"chars": 876,
"preview": "import { z } from \"zod\";\nimport { createTRPCRouter, publicProcedure } from \"../trpc\";\n\nexport const usersRouter = create"
},
{
"path": "src/server/api/trpc.ts",
"chars": 3542,
"preview": "/**\n * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:\n * 1. You want to modify request context (see Part 1)\n * 2. Yo"
},
{
"path": "src/server/auth.ts",
"chars": 672,
"preview": "import { type GetServerSidePropsContext } from \"next\";\nimport { unstable_getServerSession } from \"next-auth\";\n\nimport { "
},
{
"path": "src/server/db.ts",
"chars": 341,
"preview": "import { PrismaClient } from \"@prisma/client\";\n\nimport { env } from \"../env/server.mjs\";\n\ndeclare global {\n // eslint-d"
},
{
"path": "src/styles/globals.css",
"chars": 335,
"preview": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n@layer utilities {\n\t.fade-lr {\n\t\tmask-image: radial-gradient"
},
{
"path": "src/types/next-auth.d.ts",
"chars": 520,
"preview": "import { type DefaultSession } from \"next-auth\";\nimport type { DefaultUser } from \"next-auth/core/types\";\n\ndeclare modul"
},
{
"path": "src/utils/api.ts",
"chars": 2075,
"preview": "/**\n * This is the client-side entrypoint for your tRPC API.\n * It's used to create the `api` object which contains the "
},
{
"path": "src/utils/constants.ts",
"chars": 217,
"preview": "export const placeholderImageUrl = \"https://dockhunt-images.nyc3.cdn.digitaloceanspaces.com/placeholder.png\";\nexport con"
},
{
"path": "tailwind.config.cjs",
"chars": 300,
"preview": "/** @type {import('tailwindcss').Config} */\nmodule.exports = {\n content: [\"./src/**/*.{js,ts,jsx,tsx}\"],\n theme: {\n "
},
{
"path": "tsconfig.json",
"chars": 598,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"es2017\",\n \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n \"allowJs\": true,\n "
}
]
About this extraction
This page contains the full source code of the Basedash/dockhunt GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 54 files (74.6 KB), approximately 20.7k tokens, and a symbol index with 37 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.