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