Massive refactor

This commit is contained in:
Fergal Moran
2025-07-04 19:37:30 +01:00
parent ffe6d25fad
commit 395aec4793
139 changed files with 3229 additions and 18309 deletions

View File

@@ -1,54 +0,0 @@
/** @type {import("eslint").Linter.Config} */
const config = {
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
plugins: ["@typescript-eslint", "drizzle"],
extends: [
"next/core-web-vitals",
"plugin:@typescript-eslint/recommended-type-checked",
"plugin:@typescript-eslint/stylistic-type-checked",
],
rules: {
"@typescript-eslint/no-empty-object-type": "off",
"@typescript-eslint/prefer-nullish-coalescing": "off",
"@typescript-eslint/array-type": "off",
"@typescript-eslint/consistent-type-definitions": "off",
"@typescript-eslint/consistent-type-imports": [
"warn",
{
prefer: "type-imports",
fixStyle: "inline-type-imports",
},
],
"@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
},
],
"@typescript-eslint/require-await": "off",
"@typescript-eslint/no-misused-promises": [
"error",
{
checksVoidReturn: {
attributes: false,
},
},
],
"drizzle/enforce-delete-with-where": [
"error",
{
drizzleObjectName: ["db", "ctx.db"],
},
],
"drizzle/enforce-update-with-where": [
"error",
{
drizzleObjectName: ["db", "ctx.db"],
},
],
},
};
module.exports = config;

11
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,11 @@
# Copilot Instructions
1. Always use bun install to install new packages.
2. Use bun run to execute scripts defined in package.json.
3. For managing dependencies, prefer bun add <package> over npm install <package>.
4. Components should be CamelCase and files should be kebab-case.
5. Answer all questions in the style of a friendly colleague, using informal language.
6. Don't go overboard with explanations; keep it concise and to the point.
7. Use comments in code to clarify complex logic, but keep them brief.
8. When writing tests, use the same naming conventions as the components.
9. Always check for existing issues or discussions before creating new ones.
10. When generating new components, make sure to follow the project's existing tailwind/shadcn styles and include light & dark mode support.

27
.gitignore vendored
View File

@@ -3,20 +3,19 @@
# dependencies
/node_modules
/.pnp
.pnp.js
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# database
/prisma/db.sqlite
/prisma/db.sqlite-journal
db.sqlite
# next.js
/.next/
/out/
next-env.d.ts
# production
/build
@@ -31,19 +30,13 @@ 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 files (can opt-in for committing if needed)
.env*
.notenv*
# vercel
.vercel
# typescript
*.tsbuildinfo
# idea files
.idea.env
.idea/
.vscode
public/uploads/*
next-env.d.ts
INSTRUCTIONS.md

View File

@@ -1,10 +0,0 @@
{
"reject": [
"eslint",
"typescript",
"@types/eslint",
"@typescript-eslint/eslint-plugin",
"@typescript-eslint/parser",
"eslint-config-next"
]
}

182
README.md
View File

@@ -1,3 +1,181 @@
# Warning, may contain traces of webp
# OpenGifame - Image Sharing Platform
[![Netlify Status](https://api.netlify.com/api/v1/badges/f59bfe3b-8dde-4505-b3e1-8541857b6410/deploy-status)](https://app.netlify.com/sites/opengifame/deploys)
An open-source image sharing platform similar to Imgur, built with Next.js, Drizzle ORM, PostgreSQL, NextAuth, and shadcn/ui.
## Features
- 🖼️ **Image Upload & Sharing** - Upload and share images with the community
- 👍 **Voting System** - Upvote and downvote images
- 💬 **Comments** - Comment on images with threaded replies
- 🏷️ **Tagging System** - Tag images and create new tags
- 📈 **Trending Gallery** - View trending images based on community votes
- 🔐 **Authentication** - Sign in with GitHub or Google
- 🌙 **Dark/Light Mode** - Responsive design with theme support
- 📱 **Mobile Responsive** - Works great on all devices
## Tech Stack
- **Frontend**: Next.js 15, React 19, TypeScript
- **Styling**: Tailwind CSS, shadcn/ui components
- **Database**: PostgreSQL with Drizzle ORM
- **Authentication**: NextAuth.js
- **Icons**: Lucide React
- **Package Manager**: Bun
## Getting Started
### Prerequisites
- Node.js 18+ or Bun
- PostgreSQL database
- Git
### Installation
1. **Clone the repository**
```bash
git clone <your-repo-url>
cd opengifame
```
2. **Install dependencies**
```bash
bun install
```
3. **Set up environment variables**
```bash
cp .env.example .env.local
```
Edit `.env.local` and configure:
```env
# Database
DATABASE_URL="postgres://postgres:hackme@localhost:5432/opengifame"
# NextAuth
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="your-secret-key-here"
# OAuth providers (optional)
GITHUB_CLIENT_ID="your-github-client-id"
GITHUB_CLIENT_SECRET="your-github-client-secret"
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
```
4. **Set up the database**
Create the PostgreSQL database:
```sql
CREATE DATABASE opengifame;
```
Run the database migration:
```bash
bun run db:push
```
5. **Start the development server**
```bash
bun run dev
```
Open [http://localhost:3000](http://localhost:3000) in your browser.
## Database Schema
The application uses the following main tables:
- **users** - User accounts (NextAuth)
- **images** - Uploaded images with metadata
- **votes** - User votes on images (upvote/downvote)
- **comments** - Comments on images with threading support
- **tags** - Image tags
- **image_tags** - Many-to-many relationship between images and tags
## API Endpoints
- `POST /api/images/upload` - Upload a new image
- `POST /api/images/vote` - Vote on an image
- `GET/POST /api/auth/[...nextauth]` - NextAuth endpoints
## Scripts
- `bun run dev` - Start development server
- `bun run build` - Build for production
- `bun run start` - Start production server
- `bun run lint` - Run ESLint
- `bun run db:generate` - Generate database migrations
- `bun run db:push` - Push schema changes to database
- `bun run db:studio` - Open Drizzle Studio
## OAuth Setup (Optional)
### GitHub OAuth
1. Go to GitHub Settings > Developer settings > OAuth Apps
2. Create a new OAuth App with:
- Homepage URL: `http://localhost:3000`
- Authorization callback URL: `http://localhost:3000/api/auth/callback/github`
3. Add the Client ID and Secret to your `.env.local`
### Google OAuth
1. Go to the Google Cloud Console
2. Create a new project or select an existing one
3. Enable the Google+ API
4. Create OAuth 2.0 credentials with:
- Authorized JavaScript origins: `http://localhost:3000`
- Authorized redirect URIs: `http://localhost:3000/api/auth/callback/google`
5. Add the Client ID and Secret to your `.env.local`
## Project Structure
```
src/
├── app/ # Next.js app directory
│ ├── api/ # API routes
│ ├── upload/ # Upload page
│ ├── trending/ # Trending page
│ └── page.tsx # Home page
├── components/ # React components
│ ├── ui/ # shadcn/ui components
│ ├── header.tsx # Navigation header
│ └── image-card.tsx # Image display component
├── lib/ # Utilities and configuration
│ ├── db/ # Database configuration and schema
│ ├── auth.ts # NextAuth configuration
│ └── utils.ts # Utility functions
└── types/ # TypeScript type definitions
```
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Run tests and linting
5. Submit a pull request
## License
This project is open source and available under the [MIT License](LICENSE).
## Deployment
The application can be deployed to any platform that supports Next.js:
- **Vercel** (recommended)
- **Netlify**
- **Railway**
- **Docker**
Make sure to:
1. Set up environment variables in your deployment platform
2. Configure a PostgreSQL database
3. Set up OAuth providers for production URLs
## Support
If you have any questions or issues, please open an issue on GitHub.

BIN
bun.lockb

Binary file not shown.

View File

@@ -4,15 +4,18 @@
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/globals.css",
"baseColor": "zinc",
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui"
}
}
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -1,12 +1,10 @@
import { type Config } from "drizzle-kit";
import { defineConfig } from 'drizzle-kit';
import { env } from "@/env";
export default {
schema: "./src/server/db/schema.ts",
dialect: "postgresql",
export default defineConfig({
schema: './src/lib/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: env.DATABASE_URL,
url: process.env.DATABASE_URL || 'postgres://postgres:hackme@localhost:5432/opengifame',
},
tablesFilter: ["*"],
} satisfies Config;
});

View File

@@ -1,72 +0,0 @@
CREATE TABLE IF NOT EXISTS "accounts" (
"user_id" varchar(255) NOT NULL,
"type" varchar(255) NOT NULL,
"provider" varchar(255) NOT NULL,
"provider_account_id" varchar(255) NOT NULL,
"refresh_token" text,
"access_token" text,
"expires_at" integer,
"token_type" varchar(255),
"scope" varchar(255),
"id_token" text,
"session_state" varchar(255),
CONSTRAINT "accounts_provider_provider_account_id_pk" PRIMARY KEY("provider","provider_account_id")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "posts" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"slug" varchar(255) NOT NULL,
"title" varchar(256),
"description" varchar,
"tags" text[],
"filepath" varchar(256),
"created_by" varchar(255) NOT NULL,
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updated_at" timestamp with time zone,
CONSTRAINT "posts_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "sessions" (
"session_token" varchar(255) PRIMARY KEY NOT NULL,
"user_id" varchar(255) NOT NULL,
"expires" timestamp with time zone NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "users" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"name" varchar(255),
"email" varchar(255) NOT NULL,
"email_verified" timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
"image" varchar(255),
"password" text
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "verification_tokens" (
"identifier" varchar(255) NOT NULL,
"token" varchar(255) NOT NULL,
"expires" timestamp with time zone NOT NULL,
CONSTRAINT "verification_tokens_identifier_token_pk" PRIMARY KEY("identifier","token")
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "posts" ADD CONSTRAINT "posts_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "account_user_id_idx" ON "accounts" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "image_user_id_idx" ON "posts" USING btree ("created_by");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "image_slug_idx" ON "posts" USING btree ("slug");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "session_user_id_idx" ON "sessions" USING btree ("user_id");

View File

@@ -0,0 +1,95 @@
CREATE TABLE "account" (
"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_provider_providerAccountId_pk" PRIMARY KEY("provider","providerAccountId")
);
--> statement-breakpoint
CREATE TABLE "comments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"content" text NOT NULL,
"image_id" uuid NOT NULL,
"author_id" text NOT NULL,
"parent_id" uuid,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "image_tags" (
"image_id" uuid NOT NULL,
"tag_id" uuid NOT NULL,
CONSTRAINT "image_tags_image_id_tag_id_pk" PRIMARY KEY("image_id","tag_id")
);
--> statement-breakpoint
CREATE TABLE "images" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"title" text NOT NULL,
"description" text,
"filename" text NOT NULL,
"original_name" text NOT NULL,
"mime_type" text NOT NULL,
"size" integer NOT NULL,
"url" text NOT NULL,
"uploaded_by" text NOT NULL,
"upvotes" integer DEFAULT 0 NOT NULL,
"downvotes" integer DEFAULT 0 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
"sessionToken" text PRIMARY KEY NOT NULL,
"userId" text NOT NULL,
"expires" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "tags" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"created_by" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "tags_name_unique" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text,
"email" text NOT NULL,
"emailVerified" timestamp,
"image" text
);
--> statement-breakpoint
CREATE TABLE "verificationToken" (
"identifier" text NOT NULL,
"token" text NOT NULL,
"expires" timestamp NOT NULL,
CONSTRAINT "verificationToken_identifier_token_pk" PRIMARY KEY("identifier","token")
);
--> statement-breakpoint
CREATE TABLE "votes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"image_id" uuid NOT NULL,
"user_id" text NOT NULL,
"is_upvote" boolean NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_image_id_images_id_fk" FOREIGN KEY ("image_id") REFERENCES "public"."images"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_author_id_user_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_parent_id_comments_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "image_tags" ADD CONSTRAINT "image_tags_image_id_images_id_fk" FOREIGN KEY ("image_id") REFERENCES "public"."images"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "image_tags" ADD CONSTRAINT "image_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "images" ADD CONSTRAINT "images_uploaded_by_user_id_fk" FOREIGN KEY ("uploaded_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tags" ADD CONSTRAINT "tags_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "votes" ADD CONSTRAINT "votes_image_id_images_id_fk" FOREIGN KEY ("image_id") REFERENCES "public"."images"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "votes" ADD CONSTRAINT "votes_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -1,13 +0,0 @@
CREATE TABLE IF NOT EXISTS "votes" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"up" boolean DEFAULT true NOT NULL,
"created_by" varchar(255) NOT NULL,
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updated_at" timestamp with time zone
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "votes" ADD CONSTRAINT "votes_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -1,6 +0,0 @@
ALTER TABLE "votes" ADD COLUMN "post_id" varchar(255) NOT NULL;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "votes" ADD CONSTRAINT "votes_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -1,7 +0,0 @@
DO $$ BEGIN
CREATE TYPE "public"."post-visibility" AS ENUM('public', 'private', 'link');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
ALTER TABLE "posts" ADD COLUMN "visibility" "post-visibility" DEFAULT 'public' NOT NULL;

View File

@@ -1,34 +1,34 @@
{
"id": "df0e1372-accb-4875-885d-c19970e69eae",
"id": "473ec1da-b564-4012-a1ad-e856f6e2c23d",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.accounts": {
"name": "accounts",
"public.account": {
"name": "account",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "varchar(255)",
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "varchar(255)",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "varchar(255)",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_account_id": {
"name": "provider_account_id",
"type": "varchar(255)",
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true
},
@@ -52,13 +52,13 @@
},
"token_type": {
"name": "token_type",
"type": "varchar(255)",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "varchar(255)",
"type": "text",
"primaryKey": false,
"notNull": false
},
@@ -70,267 +70,445 @@
},
"session_state": {
"name": "session_state",
"type": "varchar(255)",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"account_user_id_idx": {
"name": "account_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"indexes": {},
"foreignKeys": {
"accounts_user_id_users_id_fk": {
"name": "accounts_user_id_users_id_fk",
"tableFrom": "accounts",
"tableTo": "users",
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"accounts_provider_provider_account_id_pk": {
"name": "accounts_provider_provider_account_id_pk",
"account_provider_providerAccountId_pk": {
"name": "account_provider_providerAccountId_pk",
"columns": [
"provider",
"provider_account_id"
"providerAccountId"
]
}
},
"uniqueConstraints": {}
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.posts": {
"name": "posts",
"public.comments": {
"name": "comments",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(255)",
"type": "uuid",
"primaryKey": true,
"notNull": true
"notNull": true,
"default": "gen_random_uuid()"
},
"slug": {
"name": "slug",
"type": "varchar(255)",
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true
},
"image_id": {
"name": "image_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"author_id": {
"name": "author_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"parent_id": {
"name": "parent_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"comments_image_id_images_id_fk": {
"name": "comments_image_id_images_id_fk",
"tableFrom": "comments",
"tableTo": "images",
"columnsFrom": [
"image_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"comments_author_id_user_id_fk": {
"name": "comments_author_id_user_id_fk",
"tableFrom": "comments",
"tableTo": "user",
"columnsFrom": [
"author_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"comments_parent_id_comments_id_fk": {
"name": "comments_parent_id_comments_id_fk",
"tableFrom": "comments",
"tableTo": "comments",
"columnsFrom": [
"parent_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.image_tags": {
"name": "image_tags",
"schema": "",
"columns": {
"image_id": {
"name": "image_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"tag_id": {
"name": "tag_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"image_tags_image_id_images_id_fk": {
"name": "image_tags_image_id_images_id_fk",
"tableFrom": "image_tags",
"tableTo": "images",
"columnsFrom": [
"image_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"image_tags_tag_id_tags_id_fk": {
"name": "image_tags_tag_id_tags_id_fk",
"tableFrom": "image_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"image_tags_image_id_tag_id_pk": {
"name": "image_tags_image_id_tag_id_pk",
"columns": [
"image_id",
"tag_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.images": {
"name": "images",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"title": {
"name": "title",
"type": "varchar(256)",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": true
},
"description": {
"name": "description",
"type": "varchar",
"type": "text",
"primaryKey": false,
"notNull": false
},
"tags": {
"name": "tags",
"type": "text[]",
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": true
},
"filepath": {
"name": "filepath",
"type": "varchar(256)",
"original_name": {
"name": "original_name",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": true
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"uploaded_by": {
"name": "uploaded_by",
"type": "text",
"primaryKey": false,
"notNull": true
},
"upvotes": {
"name": "upvotes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"downvotes": {
"name": "downvotes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"images_uploaded_by_user_id_fk": {
"name": "images_uploaded_by_user_id_fk",
"tableFrom": "images",
"tableTo": "user",
"columnsFrom": [
"uploaded_by"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"sessionToken": {
"name": "sessionToken",
"type": "text",
"primaryKey": true,
"notNull": true
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"session_userId_user_id_fk": {
"name": "session_userId_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tags": {
"name": "tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_by": {
"name": "created_by",
"type": "varchar(255)",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"image_user_id_idx": {
"name": "image_user_id_idx",
"columns": [
{
"expression": "created_by",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"image_slug_idx": {
"name": "image_slug_idx",
"columns": [
{
"expression": "slug",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"posts_created_by_users_id_fk": {
"name": "posts_created_by_users_id_fk",
"tableFrom": "posts",
"tableTo": "users",
"tags_created_by_user_id_fk": {
"name": "tags_created_by_user_id_fk",
"tableFrom": "tags",
"tableTo": "user",
"columnsFrom": [
"created_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"posts_slug_unique": {
"name": "posts_slug_unique",
"tags_name_unique": {
"name": "tags_name_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
"name"
]
}
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"session_token": {
"name": "session_token",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"session_user_id_idx": {
"name": "session_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.users": {
"name": "users",
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(255)",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "CURRENT_TIMESTAMP"
},
"image": {
"name": "image",
"type": "varchar(255)",
"emailVerified": {
"name": "emailVerified",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
@@ -339,27 +517,30 @@
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification_tokens": {
"name": "verification_tokens",
"public.verificationToken": {
"name": "verificationToken",
"schema": "",
"columns": {
"identifier": {
"name": "identifier",
"type": "varchar(255)",
"type": "text",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "varchar(255)",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp with time zone",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
@@ -367,20 +548,98 @@
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"verification_tokens_identifier_token_pk": {
"name": "verification_tokens_identifier_token_pk",
"verificationToken_identifier_token_pk": {
"name": "verificationToken_identifier_token_pk",
"columns": [
"identifier",
"token"
]
}
},
"uniqueConstraints": {}
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.votes": {
"name": "votes",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"image_id": {
"name": "image_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"is_upvote": {
"name": "is_upvote",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"votes_image_id_images_id_fk": {
"name": "votes_image_id_images_id_fk",
"tableFrom": "votes",
"tableTo": "images",
"columnsFrom": [
"image_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"votes_user_id_user_id_fk": {
"name": "votes_user_id_user_id_fk",
"tableFrom": "votes",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},

View File

@@ -1,445 +0,0 @@
{
"id": "e8b4ac19-4d79-4886-ad5e-eb19f551efde",
"prevId": "df0e1372-accb-4875-885d-c19970e69eae",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.accounts": {
"name": "accounts",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"provider_account_id": {
"name": "provider_account_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"token_type": {
"name": "token_type",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"session_state": {
"name": "session_state",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"account_user_id_idx": {
"name": "account_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"accounts_user_id_users_id_fk": {
"name": "accounts_user_id_users_id_fk",
"tableFrom": "accounts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"accounts_provider_provider_account_id_pk": {
"name": "accounts_provider_provider_account_id_pk",
"columns": [
"provider",
"provider_account_id"
]
}
},
"uniqueConstraints": {}
},
"public.posts": {
"name": "posts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"slug": {
"name": "slug",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "varchar(256)",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "varchar",
"primaryKey": false,
"notNull": false
},
"tags": {
"name": "tags",
"type": "text[]",
"primaryKey": false,
"notNull": false
},
"filepath": {
"name": "filepath",
"type": "varchar(256)",
"primaryKey": false,
"notNull": false
},
"created_by": {
"name": "created_by",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"image_user_id_idx": {
"name": "image_user_id_idx",
"columns": [
{
"expression": "created_by",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"image_slug_idx": {
"name": "image_slug_idx",
"columns": [
{
"expression": "slug",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"posts_created_by_users_id_fk": {
"name": "posts_created_by_users_id_fk",
"tableFrom": "posts",
"tableTo": "users",
"columnsFrom": [
"created_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"posts_slug_unique": {
"name": "posts_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
}
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"session_token": {
"name": "session_token",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"session_user_id_idx": {
"name": "session_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "CURRENT_TIMESTAMP"
},
"image": {
"name": "image",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.verification_tokens": {
"name": "verification_tokens",
"schema": "",
"columns": {
"identifier": {
"name": "identifier",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"verification_tokens_identifier_token_pk": {
"name": "verification_tokens_identifier_token_pk",
"columns": [
"identifier",
"token"
]
}
},
"uniqueConstraints": {}
},
"public.votes": {
"name": "votes",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"up": {
"name": "up",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_by": {
"name": "created_by",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"votes_created_by_users_id_fk": {
"name": "votes_created_by_users_id_fk",
"tableFrom": "votes",
"tableTo": "users",
"columnsFrom": [
"created_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"schemas": {},
"sequences": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -1,464 +0,0 @@
{
"id": "2ce7d201-995a-4c67-9041-93767b913d60",
"prevId": "e8b4ac19-4d79-4886-ad5e-eb19f551efde",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.accounts": {
"name": "accounts",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"provider_account_id": {
"name": "provider_account_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"token_type": {
"name": "token_type",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"session_state": {
"name": "session_state",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"account_user_id_idx": {
"name": "account_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"accounts_user_id_users_id_fk": {
"name": "accounts_user_id_users_id_fk",
"tableFrom": "accounts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"accounts_provider_provider_account_id_pk": {
"name": "accounts_provider_provider_account_id_pk",
"columns": [
"provider",
"provider_account_id"
]
}
},
"uniqueConstraints": {}
},
"public.posts": {
"name": "posts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"slug": {
"name": "slug",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "varchar(256)",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "varchar",
"primaryKey": false,
"notNull": false
},
"tags": {
"name": "tags",
"type": "text[]",
"primaryKey": false,
"notNull": false
},
"filepath": {
"name": "filepath",
"type": "varchar(256)",
"primaryKey": false,
"notNull": false
},
"created_by": {
"name": "created_by",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"image_user_id_idx": {
"name": "image_user_id_idx",
"columns": [
{
"expression": "created_by",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"image_slug_idx": {
"name": "image_slug_idx",
"columns": [
{
"expression": "slug",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"posts_created_by_users_id_fk": {
"name": "posts_created_by_users_id_fk",
"tableFrom": "posts",
"tableTo": "users",
"columnsFrom": [
"created_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"posts_slug_unique": {
"name": "posts_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
}
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"session_token": {
"name": "session_token",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"session_user_id_idx": {
"name": "session_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "CURRENT_TIMESTAMP"
},
"image": {
"name": "image",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.verification_tokens": {
"name": "verification_tokens",
"schema": "",
"columns": {
"identifier": {
"name": "identifier",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"verification_tokens_identifier_token_pk": {
"name": "verification_tokens_identifier_token_pk",
"columns": [
"identifier",
"token"
]
}
},
"uniqueConstraints": {}
},
"public.votes": {
"name": "votes",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"post_id": {
"name": "post_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"up": {
"name": "up",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_by": {
"name": "created_by",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"votes_post_id_posts_id_fk": {
"name": "votes_post_id_posts_id_fk",
"tableFrom": "votes",
"tableTo": "posts",
"columnsFrom": [
"post_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"votes_created_by_users_id_fk": {
"name": "votes_created_by_users_id_fk",
"tableFrom": "votes",
"tableTo": "users",
"columnsFrom": [
"created_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"schemas": {},
"sequences": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -1,482 +0,0 @@
{
"id": "b1930da4-c6a4-4cda-ba3b-f30e01c1b174",
"prevId": "2ce7d201-995a-4c67-9041-93767b913d60",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.accounts": {
"name": "accounts",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"provider_account_id": {
"name": "provider_account_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"token_type": {
"name": "token_type",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"session_state": {
"name": "session_state",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"account_user_id_idx": {
"name": "account_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"accounts_user_id_users_id_fk": {
"name": "accounts_user_id_users_id_fk",
"tableFrom": "accounts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"accounts_provider_provider_account_id_pk": {
"name": "accounts_provider_provider_account_id_pk",
"columns": [
"provider",
"provider_account_id"
]
}
},
"uniqueConstraints": {}
},
"public.posts": {
"name": "posts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"slug": {
"name": "slug",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "varchar(256)",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "varchar",
"primaryKey": false,
"notNull": false
},
"tags": {
"name": "tags",
"type": "text[]",
"primaryKey": false,
"notNull": false
},
"filepath": {
"name": "filepath",
"type": "varchar(256)",
"primaryKey": false,
"notNull": false
},
"visibility": {
"name": "visibility",
"type": "post-visibility",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'public'"
},
"created_by": {
"name": "created_by",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"image_user_id_idx": {
"name": "image_user_id_idx",
"columns": [
{
"expression": "created_by",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"image_slug_idx": {
"name": "image_slug_idx",
"columns": [
{
"expression": "slug",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"posts_created_by_users_id_fk": {
"name": "posts_created_by_users_id_fk",
"tableFrom": "posts",
"tableTo": "users",
"columnsFrom": [
"created_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"posts_slug_unique": {
"name": "posts_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
}
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"session_token": {
"name": "session_token",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"session_user_id_idx": {
"name": "session_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "CURRENT_TIMESTAMP"
},
"image": {
"name": "image",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.verification_tokens": {
"name": "verification_tokens",
"schema": "",
"columns": {
"identifier": {
"name": "identifier",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"verification_tokens_identifier_token_pk": {
"name": "verification_tokens_identifier_token_pk",
"columns": [
"identifier",
"token"
]
}
},
"uniqueConstraints": {}
},
"public.votes": {
"name": "votes",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"post_id": {
"name": "post_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"up": {
"name": "up",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_by": {
"name": "created_by",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"votes_post_id_posts_id_fk": {
"name": "votes_post_id_posts_id_fk",
"tableFrom": "votes",
"tableTo": "posts",
"columnsFrom": [
"post_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"votes_created_by_users_id_fk": {
"name": "votes_created_by_users_id_fk",
"tableFrom": "votes",
"tableTo": "users",
"columnsFrom": [
"created_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {
"public.post-visibility": {
"name": "post-visibility",
"schema": "public",
"values": [
"public",
"private",
"link"
]
}
},
"schemas": {},
"sequences": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -5,29 +5,8 @@
{
"idx": 0,
"version": "7",
"when": 1726870110731,
"tag": "0000_windy_wrecking_crew",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1726870450874,
"tag": "0001_warm_jimmy_woo",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1726870854367,
"tag": "0002_sloppy_starfox",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1727802113532,
"tag": "0003_mean_thing",
"when": 1751648196867,
"tag": "0000_worried_blade",
"breakpoints": true
}
]

16
eslint.config.mjs Normal file
View File

@@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

View File

@@ -1,6 +0,0 @@
import type { Config } from "jest";
const config: Config = {
verbose: true,
};
export default config;

View File

@@ -1,35 +0,0 @@
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
await import("./src/env.js");
/** @type {import("next").NextConfig} */
const config = {
images: {
remotePatterns: [
{
hostname: "avatars.githubusercontent.com",
},
{
hostname: "opengifame.dev.fergl.ie",
},
{
hostname: "cloudflare-ipfs.com",
},
{
hostname: "localhost",
},
],
},
// async rewrites() {
// return [
// {
// source: "/i/:path*",
// destination: "https://your-custom-image-location.com/:path*", // Replace with your custom location
// },
// ];
// },
};
export default config;

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

12206
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,134 +2,49 @@
"name": "opengifame",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"dev:ssl": "NODE_ENV=development next dev -p 3002 --turbo & local-ssl-proxy --config ./ssl-proxy.json",
"build": "next build",
"start": "next start",
"lint": "next lint",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"dev:turbo": "NODE_ENV=development next dev -p 3002 --turbo & local-ssl-proxy --config ./ssl-proxy.json",
"dev": "NODE_ENV=development next dev -p 3002 & local-ssl-proxy --config ./ssl-proxy.json",
"dev:plain": "next dev",
"lint": "next lint",
"start": "next start"
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@auth/drizzle-adapter": "^1.5.3",
"@headlessui/react": "^2.1.8",
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-aspect-ratio": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.0",
"@radix-ui/react-context-menu": "^2.2.1",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-hover-card": "^1.1.1",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-menubar": "^1.1.1",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@sindresorhus/slugify": "^2.2.1",
"@t3-oss/env-nextjs": "^0.11.1",
"@tanstack/react-query": "^5.59.0",
"@trpc/client": "^11.0.0-rc.446",
"@trpc/react-query": "^11.0.0-rc.446",
"@trpc/server": "^11.0.0-rc.446",
"@types/bcrypt": "^5.0.2",
"@types/http-status-codes": "^1.2.0",
"@types/lodash": "^4.17.9",
"@types/loglevel": "^1.6.3",
"@types/mime-types": "^2.1.4",
"@types/react-copy-to-clipboard": "^5.0.7",
"@types/react-responsive-masonry": "^2.1.3",
"bcrypt": "^5.1.1",
"chalk": "^5.3.0",
"class-variance-authority": "^0.7.0",
"@auth/drizzle-adapter": "^1.10.0",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@types/bcryptjs": "^3.0.0",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.0",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.33.0",
"embla-carousel-react": "^8.3.0",
"framer-motion": "^11.9.0",
"geist": "^1.3.1",
"http-status-codes": "^2.3.0",
"input-otp": "^1.2.4",
"lodash": "^4.17.21",
"loglevel": "^1.9.2",
"loglevel-plugin-prefix": "^0.8.4",
"lucide-react": "^0.446.0",
"mime-types": "^2.1.35",
"next": "^14.2.13",
"next-auth": "^4.24.8",
"next-images": "^1.8.5",
"next-themes": "^0.3.0",
"postgres": "^3.4.4",
"react": "^18.3.1",
"react-copy-to-clipboard": "^5.1.0",
"react-day-picker": "9.1.3",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"react-icons": "^5.3.0",
"react-resizable-panels": "^2.1.4",
"react-responsive-masonry": "^2.3.0",
"recharts": "^2.12.7",
"server-only": "^0.0.1",
"sonner": "^1.5.0",
"superjson": "^2.2.1",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.0.0",
"zod": "^3.23.8"
"drizzle-orm": "^0.44.2",
"lucide-react": "^0.525.0",
"next": "15.3.5",
"next-auth": "^4.24.11",
"next-themes": "^0.4.6",
"postgres": "^3.4.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@types/eslint": "^8.56.10",
"@typescript-eslint/eslint-plugin": "^8.1.0",
"@typescript-eslint/parser": "^8.1.0",
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.4",
"eslint-plugin-drizzle": "^0.2.3",
"@faker-js/faker": "^9.0.3",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.1",
"@types/bun": "latest",
"@types/jest": "^29.5.13",
"@types/node": "^22.7.4",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"drizzle-kit": "^0.24.2",
"jest": "^29.7.0",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.8",
"tailwindcss": "^3.4.13",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.5.3"
},
"ct3aMetadata": {
"initVersion": "7.37.0"
},
"module": "index.ts"
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.4",
"eslint": "^9",
"eslint-config-next": "15.3.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.5",
"typescript": "^5"
}
}

View File

@@ -1,7 +0,0 @@
const config = {
plugins: {
tailwindcss: {},
},
};
module.exports = config;

5
postcss.config.mjs Normal file
View File

@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View File

@@ -1,6 +0,0 @@
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
const config = {
plugins: ["prettier-plugin-tailwindcss"],
};
export default config;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

22
public/favicon.svg Normal file
View File

@@ -0,0 +1,22 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Background circle -->
<circle cx="16" cy="16" r="15" fill="#3B82F6" stroke="#2563EB" stroke-width="2"/>
<!-- Camera/Gallery icon -->
<g transform="translate(6, 8)">
<!-- Camera body -->
<rect x="2" y="4" width="16" height="12" rx="2" fill="white" opacity="0.9"/>
<!-- Camera lens -->
<circle cx="10" cy="10" r="3.5" fill="none" stroke="#3B82F6" stroke-width="1.5"/>
<circle cx="10" cy="10" r="2" fill="#3B82F6"/>
<!-- Camera flash/viewfinder -->
<rect x="4" y="2" width="4" height="2" rx="1" fill="white" opacity="0.9"/>
<!-- Gallery indicator (small rectangles) -->
<rect x="14.5" y="5.5" width="2" height="1.5" rx="0.3" fill="#3B82F6" opacity="0.6"/>
<rect x="14.5" y="7.5" width="2" height="1.5" rx="0.3" fill="#3B82F6" opacity="0.8"/>
<rect x="14.5" y="9.5" width="2" height="1.5" rx="0.3" fill="#3B82F6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 996 B

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<!-- Background -->
<rect x="0" y="0" width="100" height="100" rx="20" ry="20" fill="#FF6B6B"/>
<!-- Abstract prism shape - significantly enlarged -->
<path d="M50,10 L90,80 L10,80 Z" fill="white"/>
<!-- Color refraction lines - adjusted for new size -->
<path d="M50,10 L62,80" stroke="#FFD166" stroke-width="4" stroke-linecap="round"/>
<path d="M50,10 L50,80" stroke="#06D6A0" stroke-width="4" stroke-linecap="round"/>
<path d="M50,10 L38,80" stroke="#118AB2" stroke-width="4" stroke-linecap="round"/>
<!-- Circular highlight - adjusted position and size -->
<circle cx="50" cy="35" r="9" fill="#FF6B6B" stroke="white" stroke-width="2.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 755 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -1,14 +0,0 @@
{
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -1,21 +0,0 @@
#!/usr/bin/env bash
export PGUSER=postgres
export PGPASSWORD=hackme
export PGHOST=localhost
echo Removing migrations
rm -rf drizzle
echo "Dropping db"
dropdb -f --if-exists opengifame
echo "Creating db"
createdb opengifame
source .env.development
bun run db:generate
bun run db:push
rm /srv/dev/opengifame/working/uploads/* -rfv
# # bun run src/db/migrate.ts
# bun run ./src/server/db/scripts/seed.ts
# bun run ./src/server/db/scripts/auth.ts

View File

@@ -1,12 +0,0 @@
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('974248ec-ae31-4c1a-900d-348432e16e90', 'Frasier "directs" Niles', 'Ham Radio', '{}', '974248ec-ae31-4c1a-900d-348432e16e90.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:26:51.350362 +00:00', '2024-09-18 15:26:51.639000 +00:00', 'frasier-directs-niles');
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('18fac61f-d2c4-4248-b74b-5d23e8717bbc', 'Frasier groaning.', 'Frasier & Niles in Nervosa', '{}', '18fac61f-d2c4-4248-b74b-5d23e8717bbc.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:27:46.438441 +00:00', '2024-09-18 15:27:46.477000 +00:00', 'frasier-groaning');
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('56e89352-aa69-4a9e-92c4-5eb3e8e94703', 'Frasier tasting caviar', 'From Roe to Perdition S10E18 ', '{}', '56e89352-aa69-4a9e-92c4-5eb3e8e94703.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:27:28.599836 +00:00', '2024-09-18 15:27:28.632000 +00:00', 'frasier-tasting-caviar');
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('38d3e400-20a3-49be-bf8f-5c4c77ab3a9f', 'Niles gets a class of champagne in the face', 'From Voyage of the Damned', '{}', '38d3e400-20a3-49be-bf8f-5c4c77ab3a9f.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:27:01.933852 +00:00', '2024-09-18 15:27:01.967000 +00:00', 'niles-gets-a-class-of-champagne-in-the-face');
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('4aa77fab-cfe4-4f5d-ba62-c674b43b09c8', 'I miss being unapproachable', 'From Good Grief', '{}', '4aa77fab-cfe4-4f5d-ba62-c674b43b09c8.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:27:18.617006 +00:00', '2024-09-18 15:27:18.651000 +00:00', 'i-miss-being-unapproachable');
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('062edc3f-2d3c-4d97-a569-f41804d6d352', 'I''m having one now', 'Niles has never had an unexpressed thought', '{niles,frasier}', '062edc3f-2d3c-4d97-a569-f41804d6d352.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:27:59.258519 +00:00', '2024-09-18 15:27:59.283000 +00:00', 'i-m-having-one-now');
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('ced6dc13-10b4-43d7-8072-0db2091840d7', 'Wounded!!!', 'Frasier is wounded', '{frasier,wounded}', 'ced6dc13-10b4-43d7-8072-0db2091840d7.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:27:37.875584 +00:00', '2024-09-18 15:27:37.914000 +00:00', 'wounded');
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('13c54e08-2fd6-4810-982f-14b701e38b5f', 'Rrrrreally.', 'Guy checks out Niles'' bottom', '{}', '13c54e08-2fd6-4810-982f-14b701e38b5f.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:27:13.414832 +00:00', '2024-09-18 15:27:13.442000 +00:00', 'rrrrreally');
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('d23df48f-2b99-4b29-90fd-2f3cff205010', 'Successful High Five', 'Niles & Frasier successfully complete a high five (including behind the back down low).', '{}', 'd23df48f-2b99-4b29-90fd-2f3cff205010.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:27:51.131650 +00:00', '2024-09-18 15:27:51.152000 +00:00', 'successful-high-five');
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('0ccfccd4-c24f-42a0-9864-2cb8d230bb16', 'Niles clapping', 'Niles is excited', '{}', '0ccfccd4-c24f-42a0-9864-2cb8d230bb16.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:27:33.156638 +00:00', '2024-09-18 15:27:33.220000 +00:00', 'niles-clapping');
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('4a208725-453d-4d0c-b620-cc82b7192ea8', 'I just want to die!!', '', '{}', '4a208725-453d-4d0c-b620-cc82b7192ea8.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:27:07.374464 +00:00', '2024-09-18 15:27:07.396000 +00:00', 'i-just-want-to-die');
INSERT INTO public.posts (id, title, description, tags, filepath, created_by, created_at, updated_at, slug) VALUES ('4a2a52a2-85eb-417c-bdb8-a1d0101df17f', 'Some boys go to college.', 'Oh, some boys go to college, but we think they''re all wussies, cuz they get all the knowledge, and we get all the...', '{}', '4a2a52a2-85eb-417c-bdb8-a1d0101df17f.gif', '6c98e4e0-873c-4bd0-a059-93177709eddd', '2024-09-18 15:27:42.138300 +00:00', '2024-09-18 15:27:42.175000 +00:00', 'some-boys-go-to-college');

View File

@@ -1,12 +0,0 @@
import PostPage from "@/components/pages/post/post-page";
import { api } from "@/trpc/server";
type PostRouteParams = {
slug: string;
};
const PostRoute = async ({ params }: { params: PostRouteParams }) => {
const post = await api.post.getBySlug({ slug: params.slug });
return <PostPage post={post} />;
};
export default PostRoute;

View File

@@ -1,50 +0,0 @@
"use client";
import * as React from "react";
import UploadPage from "@/components/pages/upload-page";
import { ClipboardContext } from "@/lib/clipboard/clipboard-context";
import { useSearchParams } from "next/navigation";
import GenericError from "@/components/errors/generic-error";
import Loading from "@/components/widgets/loading";
const Upload = () => {
const clipboardContext = React.useContext(ClipboardContext);
const [loading, setLoading] = React.useState(true);
const path = useSearchParams();
const c = path.get("c") || "";
React.useEffect(() => {
console.log("page", "c", c);
console.log("page", "f", clipboardContext?.file);
if (c === "1" && clipboardContext?.file) {
setLoading(false);
console.log("page", "set-file", clipboardContext?.file);
} else {
if (c !== "1") {
setLoading(false);
}
}
}, [c, clipboardContext?.file]);
const renderPage = () => {
if (
(c === "1" && clipboardContext?.file != null && !loading) ||
c !== "1"
) {
return <UploadPage pastedImage={clipboardContext?.file ?? undefined} />;
}
if (c === "1" && loading) {
return <Loading />;
}
return (
<GenericError
code={500}
title="Ooopsies"
text="Unable to find any image data, please try again."
/>
);
};
return renderPage();
};
export default Upload;

View File

@@ -1,27 +0,0 @@
import Link from "next/link";
import { Icons } from "@/components/icons";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
interface AuthLayoutProps {
children: React.ReactNode;
}
export default function AuthLayout({ children }: AuthLayoutProps) {
return (
<div className="container flex h-screen w-screen flex-col items-center ">
<Link
href="/"
className={cn(
buttonVariants({ variant: "ghost" }),
"absolute left-4 top-4 mt-8 md:left-8 md:top-8",
)}
>
<>
<Icons.chevronLeft className="mr-2 h-4 w-4" />
Back
</>
</Link>
<div className="mt-8">{children}</div>
</div>
);
}

View File

@@ -1,31 +0,0 @@
"use client";
import React from "react";
import RegistrationForm from "@/components/forms/auth/registration-form";
import SocialLogin from "@/components/widgets/login/social-login-button";
import Link from "next/link";
import { Icons } from "@/components/icons";
const RegisterPage: React.FC = () => {
return (
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-center">
<Icons.logo className="mx-auto h-6 w-6" />
<h1 className="text-2xl font-semibold tracking-tight">Welcome</h1>
<p className="text-sm text-muted-foreground">Register for an account</p>
</div>
<RegistrationForm />
<SocialLogin />
<p className="px-8 text-center text-sm text-muted-foreground">
<Link
href="/signin"
className="hover:text-brand underline underline-offset-4"
>
Already have an account? Login?
</Link>
</p>
</div>
);
};
export default RegisterPage;

View File

@@ -1,32 +0,0 @@
"use client";
import SocialLogin from "@/components/widgets/login/social-login-button";
import Link from "next/link";
import React from "react";
import { Icons } from "@/components/icons";
import SignInForm from "@/components/forms/auth/signin-form";
const SignInPage = () => {
return (
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-center">
<Icons.logo className="mx-auto h-6 w-6" />
<h1 className="text-2xl font-semibold tracking-tight">Welcome back</h1>
<p className="text-sm text-muted-foreground">
Enter your email to sign in to your account
</p>
</div>
<SignInForm />
<SocialLogin />
<p className="px-8 text-center text-sm text-muted-foreground">
<Link
href="/register"
className="hover:text-brand underline underline-offset-4"
>
Don&apos;t have an account? Sign Up
</Link>
</p>
</div>
);
};
export default SignInPage;

View File

@@ -1,11 +0,0 @@
"use client";
import React from "react";
import { SessionProvider } from "next-auth/react";
import { clipboardImageToFile } from "@/lib/clipboard/converters";
import { logger } from "@/lib/logger";
export default function SiteLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return <SessionProvider>{children}</SessionProvider>;
}

View File

@@ -1,7 +1,7 @@
import NextAuth from "next-auth";
import NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth';
import { authOptions } from "@/server/auth";
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
export const GET = handler;
export const POST = handler;

View File

@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from 'next/server';
import bcrypt from 'bcryptjs';
// import { db } from '@/lib/db';
// import { users } from '@/lib/db/schema';
export async function POST(request: NextRequest) {
try {
const { email, password, username } = await request.json();
// Validation
if (!email || !password || !username) {
return NextResponse.json(
{ error: 'Email, password, and username are required' },
{ status: 400 }
);
}
if (username.includes(' ')) {
return NextResponse.json(
{ error: 'Username cannot contain spaces' },
{ status: 400 }
);
}
if (username.length > 32) {
return NextResponse.json(
{ error: 'Username must be less than 32 characters' },
{ status: 400 }
);
}
if (password.length < 6) {
return NextResponse.json(
{ error: 'Password must be at least 6 characters long' },
{ status: 400 }
);
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: 'Please enter a valid email address' },
{ status: 400 }
);
}
// TODO: Check if user already exists when DB is connected
// const existingUser = await db.select().from(users).where(eq(users.email, email)).limit(1);
// const existingUsername = await db.select().from(users).where(eq(users.username, username)).limit(1);
// if (existingUser.length > 0) {
// return NextResponse.json(
// { error: 'User with this email already exists' },
// { status: 400 }
// );
// }
// if (existingUsername.length > 0) {
// return NextResponse.json(
// { error: 'Username is already taken' },
// { status: 400 }
// );
// }
// Hash password
const saltRounds = 12;
const hashedPassword = await bcrypt.hash(password, saltRounds);
// TODO: Create user in database when DB is connected
// const newUser = await db.insert(users).values({
// email,
// username,
// password: hashedPassword,
// name: username, // Use username as display name initially
// }).returning();
// For now, return success without actually creating user
return NextResponse.json(
{
message: 'Registration successful! Please sign in.',
// TODO: Remove this note when DB is connected
note: 'Note: User not actually created until database is connected'
},
{ status: 201 }
);
} catch (error) {
console.error('Registration error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { images, tags, imageTags } from '@/lib/db/schema';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { eq } from 'drizzle-orm';
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get('file') as File;
const title = formData.get('title') as string;
const description = formData.get('description') as string;
const tagsInput = formData.get('tags') as string;
if (!file || !title) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Validate file type
if (!file.type.startsWith('image/')) {
return NextResponse.json({ error: 'File must be an image' }, { status: 400 });
}
// Generate unique filename
const timestamp = Date.now();
const extension = file.name.split('.').pop();
const filename = `${timestamp}-${Math.random().toString(36).substring(2)}.${extension}`;
// Create upload directory if it doesn't exist
const uploadDir = join(process.cwd(), 'public', 'uploads');
await mkdir(uploadDir, { recursive: true });
// Save file
const filepath = join(uploadDir, filename);
const bytes = await file.arrayBuffer();
await writeFile(filepath, Buffer.from(bytes));
// Create image record
const imageUrl = `/uploads/${filename}`;
const [newImage] = await db
.insert(images)
.values({
title,
description: description || null,
filename,
originalName: file.name,
mimeType: file.type,
size: file.size,
url: imageUrl,
uploadedBy: session.user.id,
})
.returning();
// Process tags
if (tagsInput) {
const tagNames = tagsInput
.split(',')
.map(tag => tag.trim().toLowerCase())
.filter(tag => tag.length > 0);
for (const tagName of tagNames) {
// Check if tag exists, create if not
const existingTag = await db
.select()
.from(tags)
.where(eq(tags.name, tagName))
.limit(1);
let tagId;
if (existingTag.length === 0) {
const [newTag] = await db
.insert(tags)
.values({
name: tagName,
createdBy: session.user.id,
})
.returning();
tagId = newTag.id;
} else {
tagId = existingTag[0].id;
}
// Link tag to image
await db.insert(imageTags).values({
imageId: newImage.id,
tagId,
});
}
}
return NextResponse.json({
id: newImage.id,
url: imageUrl,
message: 'Image uploaded successfully',
});
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
}
}

View File

@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { votes, images } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { imageId, isUpvote } = await request.json();
if (!imageId || typeof isUpvote !== 'boolean') {
return NextResponse.json({ error: 'Invalid input' }, { status: 400 });
}
// Check if user already voted on this image
const existingVote = await db
.select()
.from(votes)
.where(and(eq(votes.imageId, imageId), eq(votes.userId, session.user.id)))
.limit(1);
if (existingVote.length > 0) {
const currentVote = existingVote[0];
if (currentVote.isUpvote === isUpvote) {
// Remove vote if clicking the same vote type
await db
.delete(votes)
.where(eq(votes.id, currentVote.id));
} else {
// Update vote if changing vote type
await db
.update(votes)
.set({ isUpvote })
.where(eq(votes.id, currentVote.id));
}
} else {
// Create new vote
await db.insert(votes).values({
imageId,
userId: session.user.id,
isUpvote,
});
}
// Get updated vote counts
const allVotes = await db
.select()
.from(votes)
.where(eq(votes.imageId, imageId));
const upvotes = allVotes.filter(v => v.isUpvote).length;
const downvotes = allVotes.filter(v => !v.isUpvote).length;
// Update image vote counts
await db
.update(images)
.set({ upvotes, downvotes })
.where(eq(images.id, imageId));
// Get user's current vote
const userVote = await db
.select()
.from(votes)
.where(and(eq(votes.imageId, imageId), eq(votes.userId, session.user.id)))
.limit(1);
return NextResponse.json({
upvotes,
downvotes,
userVote: userVote.length > 0 ? (userVote[0].isUpvote ? 'up' : 'down') : null,
});
} catch (error) {
console.error('Vote error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -1,34 +0,0 @@
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { type NextRequest } from "next/server";
import { env } from "@/env";
import { appRouter } from "@/server/api/root";
import { createTRPCContext } from "@/server/api/trpc";
/**
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
* handling a HTTP request (e.g. when you make requests from Client Components).
*/
const createContext = async (req: NextRequest) => {
return createTRPCContext({
headers: req.headers,
});
};
const handler = (req: NextRequest) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createContext(req),
onError:
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
);
}
: undefined,
});
export { handler as GET, handler as POST };

View File

@@ -1,50 +0,0 @@
import { getServerSession } from "next-auth";
import { StatusCodes } from "http-status-codes";
import { env } from "@/env";
import { type NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { db } from "@/server/db";
import { posts } from "@/server/db/schema";
import { eq } from "drizzle-orm";
export async function POST(request: NextRequest) {
const session = await getServerSession();
if (!session) {
return NextResponse.json({
error: "Unauthorized",
status: StatusCodes.UNAUTHORIZED,
});
}
const id = request.nextUrl.searchParams.get("id");
if (!id) {
return NextResponse.json({
error: "No post id in query",
status: StatusCodes.BAD_REQUEST,
});
}
const formData = await request.formData();
const body = Object.fromEntries(formData);
const file = (body.image as Blob) || null;
if (file) {
const buffer = Buffer.from(await file.arrayBuffer());
const extension = path.extname((body.image as File).name);
const filePath = `${id}${extension}`;
fs.writeFileSync(path.resolve(env.UPLOAD_PATH, filePath), buffer);
await db.update(posts).set({ filePath }).where(eq(posts.id, id));
return NextResponse.json({
success: true,
url: env.NEXT_PUBLIC_SITE_URL + `/i/${filePath}`,
});
}
return NextResponse.json({
error: "Cannot find file in form data",
status: StatusCodes.BAD_REQUEST,
});
}

View File

@@ -0,0 +1,242 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle, CheckCircle } from 'lucide-react';
export default function RegisterPage() {
const [formData, setFormData] = useState({
email: '',
username: '',
password: '',
confirmPassword: ''
});
const [errors, setErrors] = useState<string[]>([]);
const [success, setSuccess] = useState('');
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const validateForm = () => {
const newErrors: string[] = [];
if (!formData.email) {
newErrors.push('Email is required');
} else {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email)) {
newErrors.push('Please enter a valid email address');
}
}
if (!formData.username) {
newErrors.push('Username is required');
} else {
if (formData.username.includes(' ')) {
newErrors.push('Username cannot contain spaces');
}
if (formData.username.length > 32) {
newErrors.push('Username must be less than 32 characters');
}
if (formData.username.length < 3) {
newErrors.push('Username must be at least 3 characters long');
}
}
if (!formData.password) {
newErrors.push('Password is required');
} else if (formData.password.length < 6) {
newErrors.push('Password must be at least 6 characters long');
}
if (formData.password !== formData.confirmPassword) {
newErrors.push('Passwords do not match');
}
return newErrors;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors([]);
setSuccess('');
const validationErrors = validateForm();
if (validationErrors.length > 0) {
setErrors(validationErrors);
return;
}
setIsLoading(true);
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: formData.email,
username: formData.username,
password: formData.password,
}),
});
const data = await response.json();
if (response.ok) {
setSuccess(data.message);
setFormData({ email: '', username: '', password: '', confirmPassword: '' });
// Redirect to sign-in page after successful registration
setTimeout(() => {
router.push('/auth/signin');
}, 2000);
} else {
setErrors([data.error || 'Registration failed']);
}
} catch (error) {
console.error('Registration error:', error);
setErrors(['Network error. Please try again.']);
} finally {
setIsLoading(false);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
// Clear errors when user starts typing
if (errors.length > 0) {
setErrors([]);
}
if (success) {
setSuccess('');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">Create Account</CardTitle>
<p className="text-muted-foreground">
Join OpenGifame to share and discover amazing images
</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{errors.length > 0 && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<ul className="list-disc list-inside space-y-1">
{errors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{success && (
<Alert className="border-green-200 bg-green-50 text-green-800">
<CheckCircle className="h-4 w-4 text-green-600" />
<AlertDescription>{success}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">
Email
</label>
<Input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleInputChange}
placeholder="Enter your email"
disabled={isLoading}
required
/>
</div>
<div className="space-y-2">
<label htmlFor="username" className="text-sm font-medium">
Username
</label>
<Input
id="username"
name="username"
type="text"
value={formData.username}
onChange={handleInputChange}
placeholder="Choose a username"
disabled={isLoading}
maxLength={32}
required
/>
<p className="text-xs text-muted-foreground">
No spaces, max 32 characters
</p>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">
Password
</label>
<Input
id="password"
name="password"
type="password"
value={formData.password}
onChange={handleInputChange}
placeholder="Enter your password"
disabled={isLoading}
required
/>
</div>
<div className="space-y-2">
<label htmlFor="confirmPassword" className="text-sm font-medium">
Confirm Password
</label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={handleInputChange}
placeholder="Confirm your password"
disabled={isLoading}
required
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Creating Account...' : 'Create Account'}
</Button>
</form>
<div className="mt-6 text-center">
<p className="text-sm text-muted-foreground">
Already have an account?{' '}
<Link href="/auth/signin" className="text-primary hover:underline font-medium">
Sign in here
</Link>
</p>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,206 @@
'use client';
import { signIn, getProviders } from 'next-auth/react';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Github, Mail, AlertCircle } from 'lucide-react';
interface Provider {
id: string;
name: string;
type: string;
signinUrl: string;
callbackUrl: string;
}
export default function SignInPage() {
const [providers, setProviders] = useState<Record<string, Provider> | null>(null);
const [credentials, setCredentials] = useState({ email: '', password: '' });
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
(async () => {
const res = await getProviders();
setProviders(res);
})();
// Check for error from URL params
const errorParam = searchParams.get('error');
if (errorParam) {
setError('Invalid credentials. Please try again.');
}
}, [searchParams]);
const handleSignIn = (providerId: string) => {
signIn(providerId, { callbackUrl: '/' });
};
const handleCredentialsSignIn = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
const result = await signIn('credentials', {
email: credentials.email,
password: credentials.password,
redirect: false,
});
if (result?.error) {
setError('Invalid credentials. Please check your email and password.');
} else if (result?.ok) {
router.push('/');
}
} catch (error) {
console.error('Sign-in error:', error);
setError('Something went wrong. Please try again.');
} finally {
setIsLoading(false);
}
};
const getProviderIcon = (providerId: string) => {
switch (providerId) {
case 'github':
return <Github className="h-5 w-5" />;
case 'google':
return <Mail className="h-5 w-5" />;
case 'facebook':
return (
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
);
default:
return null;
}
};
const getProviderColor = (providerId: string) => {
switch (providerId) {
case 'github':
return 'bg-gray-800 hover:bg-gray-700 text-white';
case 'google':
return 'bg-red-600 hover:bg-red-700 text-white';
case 'facebook':
return 'bg-blue-600 hover:bg-blue-700 text-white';
default:
return 'bg-primary hover:bg-primary/90';
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">Welcome Back</CardTitle>
<p className="text-muted-foreground">
Sign in to your OpenGifame account
</p>
</CardHeader>
<CardContent className="space-y-6">
{/* Credentials Form */}
<form onSubmit={handleCredentialsSignIn} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">
Email
</label>
<Input
id="email"
type="email"
value={credentials.email}
onChange={(e) => setCredentials(prev => ({ ...prev, email: e.target.value }))}
placeholder="Enter your email"
disabled={isLoading}
required
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">
Password
</label>
<Input
id="password"
type="password"
value={credentials.password}
onChange={(e) => setCredentials(prev => ({ ...prev, password: e.target.value }))}
placeholder="Enter your password"
disabled={isLoading}
required
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
</form>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
{/* OAuth Providers */}
<div className="space-y-3">
{providers ? (
Object.values(providers)
.filter(provider => provider.id !== 'credentials')
.map((provider) => (
<Button
key={provider.id}
onClick={() => handleSignIn(provider.id)}
className={`w-full flex items-center justify-center space-x-2 ${getProviderColor(provider.id)}`}
variant="default"
>
{getProviderIcon(provider.id)}
<span>Continue with {provider.name}</span>
</Button>
))
) : (
<div className="space-y-4">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<p className="text-center text-muted-foreground">Loading sign-in options...</p>
</div>
)}
</div>
{/* Register Link */}
<div className="text-center">
<p className="text-sm text-muted-foreground">
Don&apos;t have an account?{' '}
<Link href="/auth/register" className="text-primary hover:underline font-medium">
Create one here
</Link>
</p>
</div>
</CardContent>
</Card>
</div>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

82
src/app/globals.css Normal file
View File

@@ -0,0 +1,82 @@
@import "tailwindcss";
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 9% 17.9%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
@theme inline {
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--radius: var(--radius);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
body {
background: hsl(var(--background));
color: hsl(var(--foreground));
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
transition: background-color 0.3s ease, color 0.3s ease;
}
* {
border-color: hsl(var(--border));
}

View File

@@ -1,8 +0,0 @@
import { useParams } from "next/navigation";
import React from "react";
const ImagePage = ({ params }: { params: { id: string } }) => {
return <div>{params.id}</div>;
};
export default ImagePage;

View File

@@ -1,74 +1,50 @@
import { Inknut_Antiqua as font } from "next/font/google";
import "@/styles/globals.css";
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { AuthProvider } from '@/components/auth-provider';
import { ThemeProvider } from '@/components/theme-provider';
import { Header } from '@/components/header';
import { type Metadata, type Viewport } from "next";
import { TRPCReactProvider } from "@/trpc/react";
import { cn } from "@/lib/utils";
import { ThemeProvider } from "next-themes";
import { TailwindIndicator } from "@/components/tailwind-indicator";
import { Toaster } from "@/components/ui/toaster";
import React from "react";
import TopNavbar from "@/components/navbar/top-navbar";
import { dashboardConfig } from "@/config/top-nav.config";
import { siteConfig } from "@/config/site.config";
import { getServerSession } from "next-auth";
import { SessionProvider } from "next-auth/react";
import { ClipboardListener } from "@/components/clipboard-listener";
import {
ClipboardContext,
ClipboardProvider,
} from "@/lib/clipboard/clipboard-context";
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "white" },
{ media: "(prefers-color-scheme: dark)", color: "black" },
],
};
const f = font({
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
weight: ["400"],
});
export const metadata: Metadata = {
title: "Open Gifame",
description: siteConfig.description,
icons: [{ rel: "icon", url: "/favicon.ico" }],
title: "OpenGifame - Share and Discover Images",
description: "An open-source image sharing platform similar to Imgur",
icons: {
icon: [
{ url: '/favicon.ico', sizes: '32x32', type: 'image/x-icon' },
{ url: '/favicon.svg', type: 'image/svg+xml' }
],
apple: [
{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }
]
},
};
export default async function RootLayout({
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const session = await getServerSession();
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<title>Open Gifame</title>
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
</head>
<body
className={cn(
"min-h-screen bg-background font-sans antialiased",
f.className,
)}
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<TRPCReactProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ClipboardProvider>
<ClipboardListener />
<Toaster />
<TailwindIndicator />
<TopNavbar items={dashboardConfig.mainNav} session={session} />
<main className="m-4">{children}</main>
</ClipboardProvider>
</ThemeProvider>
</TRPCReactProvider>
<ThemeProvider>
<AuthProvider>
<Header />
<main>{children}</main>
</AuthProvider>
</ThemeProvider>
</body>
</html>
);

View File

@@ -1,9 +1,151 @@
import React from "react";
import LandingPage from "@/components/pages/landing-page";
import { TrendingPosts } from "@/components/trending-posts";
import { getServerAuthSession } from "@/server/auth";
import { api, HydrateClient } from "@/trpc/server";
import { ImageCard } from '@/components/image-card';
import { getServerAuthSession } from '@/lib/server-auth';
export default async function Home() {
return <LandingPage />;
// Temporarily disable database queries to test basic functionality
const session = await getServerAuthSession();
/*
const session = await auth();
// Get images with related data
const imagesData = await db
.select({
id: images.id,
title: images.title,
description: images.description,
url: images.url,
upvotes: images.upvotes,
downvotes: images.downvotes,
createdAt: images.createdAt,
uploadedBy: {
name: users.name,
image: users.image,
},
})
.from(images)
.leftJoin(users, eq(images.uploadedBy, users.id))
.orderBy(desc(images.createdAt))
.limit(20);
// Get tags for each image
const imageIds = imagesData.map(img => img.id);
let imageTags_data: Array<{
imageId: string;
tag: { id: string; name: string } | null;
}> = [];
if (imageIds.length > 0) {
imageTags_data = await db
.select({
imageId: imageTags.imageId,
tag: {
id: tags.id,
name: tags.name,
},
})
.from(imageTags)
.leftJoin(tags, eq(imageTags.tagId, tags.id))
.where(inArray(imageTags.imageId, imageIds));
}
// Get user votes if logged in
let userVotes: Record<string, 'up' | 'down'> = {};
if (session?.user?.id && imageIds.length > 0) {
const userVotesData = await db
.select({
imageId: votes.imageId,
isUpvote: votes.isUpvote,
})
.from(votes)
.where(
eq(votes.userId, session.user.id)
);
const filteredVotes = userVotesData.filter(vote => imageIds.includes(vote.imageId));
userVotes = filteredVotes.reduce((acc: Record<string, 'up' | 'down'>, vote: { imageId: string; isUpvote: boolean }) => {
acc[vote.imageId] = vote.isUpvote ? 'up' : 'down';
return acc;
}, {});
}
// Get comment counts
let commentCounts: Array<{ imageId: string; count: number }> = [];
if (imageIds.length > 0) {
commentCounts = await db
.select({
imageId: comments.imageId,
count: sql<number>`count(*)`,
})
.from(comments)
.where(inArray(comments.imageId, imageIds))
.groupBy(comments.imageId);
}
const commentCountMap = commentCounts.reduce((acc: Record<string, number>, { imageId, count }: { imageId: string; count: number }) => {
acc[imageId] = count;
return acc;
}, {});
// Group tags by image
const tagsByImage = imageTags_data.reduce((acc: Record<string, Array<{ id: string; name: string }>>, item) => {
if (!acc[item.imageId]) acc[item.imageId] = [];
if (item.tag) {
acc[item.imageId].push(item.tag);
}
return acc;
}, {});
*/
// Temporary mock data for testing
const imagesData: never[] = [];
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold">Latest Images</h1>
<p className="text-muted-foreground">
Discover and share amazing images with the community
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Temporarily disabled while fixing auth issues */}
{/*
{imagesData.map((image) => (
<ImageCard
key={image.id}
id={image.id}
title={image.title}
description={image.description || undefined}
url={image.url}
upvotes={image.upvotes}
downvotes={image.downvotes}
createdAt={image.createdAt}
uploadedBy={{
name: image.uploadedBy?.name || undefined,
image: image.uploadedBy?.image || undefined,
}}
tags={tagsByImage[image.id] || []}
userVote={userVotes[image.id] || null}
commentCount={commentCountMap[image.id] || 0}
/>
))}
*/}
</div>
{imagesData.length === 0 && (
<div className="text-center py-12">
<p className="text-lg text-muted-foreground">
No images uploaded yet. Be the first to share!
</p>
<p className="text-sm text-muted-foreground mt-2">
Database setup required - please configure your PostgreSQL database first.
</p>
</div>
)}
</div>
);
}

140
src/app/trending/page.tsx Normal file
View File

@@ -0,0 +1,140 @@
import { db } from '@/lib/db';
import { images, users, imageTags, tags, votes, comments } from '@/lib/db/schema';
import { desc, sql, eq, inArray } from 'drizzle-orm';
import { ImageCard } from '@/components/image-card';
import { auth } from '@/lib/auth';
export default async function TrendingPage() {
const session = await auth();
// Get trending images based on score (upvotes - downvotes) and recent activity
const imagesData = await db
.select({
id: images.id,
title: images.title,
description: images.description,
url: images.url,
upvotes: images.upvotes,
downvotes: images.downvotes,
createdAt: images.createdAt,
uploadedBy: {
name: users.name,
image: users.image,
},
score: sql<number>`${images.upvotes} - ${images.downvotes}`,
})
.from(images)
.leftJoin(users, eq(images.uploadedBy, users.id))
.where(sql`${images.createdAt} > NOW() - INTERVAL '30 days'`) // Only images from last 30 days
.orderBy(desc(sql`${images.upvotes} - ${images.downvotes}`), desc(images.createdAt))
.limit(20);
// Get tags for each image
const imageIds = imagesData.map(img => img.id);
let imageTags_data: Array<{
imageId: string;
tag: { id: string; name: string } | null;
}> = [];
if (imageIds.length > 0) {
imageTags_data = await db
.select({
imageId: imageTags.imageId,
tag: {
id: tags.id,
name: tags.name,
},
})
.from(imageTags)
.leftJoin(tags, eq(imageTags.tagId, tags.id))
.where(inArray(imageTags.imageId, imageIds));
}
// Get user votes if logged in
let userVotes: Record<string, 'up' | 'down'> = {};
if (session?.user?.id && imageIds.length > 0) {
const userVotesData = await db
.select({
imageId: votes.imageId,
isUpvote: votes.isUpvote,
})
.from(votes)
.where(eq(votes.userId, session.user.id));
const filteredVotes = userVotesData.filter(vote => imageIds.includes(vote.imageId));
userVotes = filteredVotes.reduce((acc: Record<string, 'up' | 'down'>, vote: { imageId: string; isUpvote: boolean }) => {
acc[vote.imageId] = vote.isUpvote ? 'up' : 'down';
return acc;
}, {});
}
// Get comment counts
let commentCounts: Array<{ imageId: string; count: number }> = [];
if (imageIds.length > 0) {
commentCounts = await db
.select({
imageId: comments.imageId,
count: sql<number>`count(*)`,
})
.from(comments)
.where(inArray(comments.imageId, imageIds))
.groupBy(comments.imageId);
}
const commentCountMap = commentCounts.reduce((acc: Record<string, number>, { imageId, count }: { imageId: string; count: number }) => {
acc[imageId] = count;
return acc;
}, {});
// Group tags by image
const tagsByImage = imageTags_data.reduce((acc: Record<string, Array<{ id: string; name: string }>>, item) => {
if (!acc[item.imageId]) acc[item.imageId] = [];
if (item.tag) {
acc[item.imageId].push(item.tag);
}
return acc;
}, {});
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold">Trending Images</h1>
<p className="text-muted-foreground">
The hottest images from the last 30 days based on community votes
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{imagesData.map((image) => (
<ImageCard
key={image.id}
id={image.id}
title={image.title}
description={image.description || undefined}
url={image.url}
upvotes={image.upvotes}
downvotes={image.downvotes}
createdAt={image.createdAt}
uploadedBy={{
name: image.uploadedBy?.name || undefined,
image: image.uploadedBy?.image || undefined,
}}
tags={tagsByImage[image.id] || []}
userVote={userVotes[image.id] || null}
commentCount={commentCountMap[image.id] || 0}
/>
))}
</div>
{imagesData.length === 0 && (
<div className="text-center py-12">
<p className="text-lg text-muted-foreground">
No trending images found. Check back later!
</p>
</div>
)}
</div>
);
}

186
src/app/upload/page.tsx Normal file
View File

@@ -0,0 +1,186 @@
import Image from 'next/image';
import { useState } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Upload, X } from 'lucide-react';
export default function UploadPage() {
const { data: session } = useSession();
const router = useRouter();
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [tags, setTags] = useState('');
const [uploading, setUploading] = useState(false);
const [preview, setPreview] = useState<string | null>(null);
if (!session) {
return (
<div className="container mx-auto px-4 py-8">
<Card className="max-w-md mx-auto">
<CardHeader>
<CardTitle>Sign In Required</CardTitle>
</CardHeader>
<CardContent>
<p>You need to sign in to upload images.</p>
</CardContent>
</Card>
</div>
);
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
const reader = new FileReader();
reader.onload = () => setPreview(reader.result as string);
reader.readAsDataURL(selectedFile);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!file || !title) return;
setUploading(true);
const formData = new FormData();
formData.append('file', file);
formData.append('title', title);
formData.append('description', description);
formData.append('tags', tags);
try {
const response = await fetch('/api/images/upload', {
method: 'POST',
body: formData,
});
if (response.ok) {
const result = await response.json();
router.push(`/image/${result.id}`);
} else {
alert('Upload failed');
}
} catch (error) {
console.error('Upload error:', error);
alert('Upload failed');
} finally {
setUploading(false);
}
};
return (
<div className="container mx-auto px-4 py-8">
<Card className="max-w-2xl mx-auto">
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Upload className="h-6 w-6" />
<span>Upload Image</span>
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium mb-2">
Choose Image
</label>
<div className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-6 text-center">
{preview ? (
<div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={preview}
alt="Preview"
className="max-h-64 mx-auto rounded"
/>
<Button
type="button"
variant="destructive"
size="sm"
className="absolute top-2 right-2"
onClick={() => {
setFile(null);
setPreview(null);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<div>
<Upload className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground">
Click to select an image or drag and drop
</p>
</div>
)}
<Input
type="file"
accept="image/*"
onChange={handleFileChange}
className="mt-4"
required
/>
</div>
</div>
<div>
<label htmlFor="title" className="block text-sm font-medium mb-2">
Title *
</label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter image title"
required
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium mb-2">
Description
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter image description (optional)"
className="w-full p-3 border border-input rounded-md bg-background"
rows={3}
/>
</div>
<div>
<label htmlFor="tags" className="block text-sm font-medium mb-2">
Tags
</label>
<Input
id="tags"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="Enter tags separated by commas (e.g., nature, landscape, sunset)"
/>
<p className="text-sm text-muted-foreground mt-1">
Separate tags with commas. New tags will be created automatically.
</p>
</div>
<Button
type="submit"
className="w-full"
disabled={!file || !title || uploading}
>
{uploading ? 'Uploading...' : 'Upload Image'}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,7 @@
'use client';
import { SessionProvider } from 'next-auth/react';
export function AuthProvider({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}

View File

@@ -1,32 +0,0 @@
"use client";
import * as React from "react";
import { clipboardImageToFile } from "@/lib/clipboard/converters";
import { logger } from "@/lib/logger";
import { redirect, useRouter } from "next/navigation";
import { ClipboardContext } from "@/lib/clipboard/clipboard-context";
export const ClipboardListener = () => {
const clipboardContext = React.useContext(ClipboardContext);
const router = useRouter();
React.useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
logger.log("clipboard-listener", "Received clipboard data");
if (event.clipboardData && event.clipboardData.items.length >= 0) {
logger.log("clipboard-listener", "Received contains items");
clipboardImageToFile(event.clipboardData, (file) => {
clipboardContext.file = file;
router.push("/upload?c=1");
});
}
};
logger.log("clipboard-listener", "Adding clipboard event listener");
window.addEventListener("paste", handlePaste);
return () => {
logger.log("clipboard-listener", "Removing clipboard listener");
window.removeEventListener("paste", handlePaste);
};
});
return <></>;
};

View File

@@ -1,203 +0,0 @@
import { Icons } from "@/components/icons";
import { Button } from "@/components/ui/button";
import React from "react";
type GenericErrorProps = {
code: number;
title: string;
text: string;
};
const GenericError: React.FC<GenericErrorProps> = ({ code, title, text }) => {
return (
<>
<div className="mt-14 flex items-center">
<div className="container flex flex-col items-center justify-center px-5 md:flex-row">
<div className="max-w-md">
<div className="font-dark text-5xl font-bold">{code}</div>
<p className="text-2xl font-light leading-normal md:text-3xl">
{title}
</p>
<p className="mb-8">{text}</p>
<Button>
<Icons.undo className="mr-2 h-4 w-4" /> back to homepage
</Button>
</div>
<div className="max-w-lg">
<svg
id="Layer_1"
data-name="Layer 1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 2395 1800"
width="400"
>
<defs>
<style>{`.cls-1{fill:#d6b49a;}.cls-1,.cls-10,.cls-11,.cls-13,.cls-14,.cls-15,.cls-17,.cls-2,.cls-4,.cls-5,.cls-6{stroke:#000;}.cls-1,.cls-11,.cls-13,.cls-14,.cls-16,.cls-8{stroke-linecap:round;stroke-linejoin:round;}.cls-1,.cls-10,.cls-11,.cls-12,.cls-13,.cls-14,.cls-15,.cls-16,.cls-17,.cls-2,.cls-3,.cls-4,.cls-5,.cls-6,.cls-7,.cls-8,.cls-9{stroke-width:3px;}.cls-2{fill:#020202;}.cls-10,.cls-12,.cls-15,.cls-17,.cls-2,.cls-3,.cls-4,.cls-5,.cls-6,.cls-7,.cls-9{stroke-miterlimit:10;}.cls-3{fill:#818181;}.cls-12,.cls-16,.cls-3,.cls-7,.cls-8,.cls-9{stroke:#191818;}.cls-4{fill:#dcdbda;}.cls-5{fill:#4ea7f1;}.cls-14,.cls-6{fill:#f8f3ed;}.cls-16,.cls-7{fill:#333;}.cls-13,.cls-8{fill:none;}.cls-9{fill:#f8f59c;}.cls-10,.cls-11{fill:#f3d2c9;}.cls-15{fill:#8bb174;}.cls-17{fill:#da4e22;}`}</style>
</defs>
<title>Artboard 1 copy</title>
<path
className="cls-1"
d="M1073.3,1016.93c-43.75-72.44-119.63-96.48-144.56-103.2h0a121.1,121.1,0,0,1-6-58.67c5.65-38.81,14.87-101.89,15.77-106.5L750,821.89,558.27,886.31c3.64,3,51.12,45.51,80.31,71.69a121.07,121.07,0,0,1,33,48.89h0c-14.84,21.13-57.72,88.19-44.92,171.84,12.09,79,67.16,129,103.83,162.39a396.42,396.42,0,0,0,88,60.44,121.54,121.54,0,0,0,98.43,19.6c5.76-1.34,16.84-4.18,27.22-7.38,4.58-1.42,10.4-3.23,17.06-5.57v0l1.1-.41,1.1-.39h0c6.61-2.47,12.24-4.8,16.67-6.65,10-4.19,20.35-9.11,25.63-11.77a121.54,121.54,0,0,0,63-78.09,396.28,396.28,0,0,0,28.85-102.77C1104.37,1159.06,1114.61,1085.35,1073.3,1016.93Z"
/>
<ellipse
className="cls-2"
cx="748.2"
cy="816.89"
rx="202.22"
ry="30.98"
transform="translate(-233.49 303.67) rotate(-19.91)"
/>
<path
className="cls-3"
d="M959,1447l-.09,82.82c0,6.19,6.66,11.22,14.88,11.23h.3c8.22,0,14.9-5,14.9-11.2l.09-81.9c0-.53-6.95-1-15.39-1H959"
/>
<path
className="cls-3"
d="M1749,1447l-.09,82.82c0,6.19,6.66,11.22,14.88,11.23h.3c8.22,0,14.9-5,14.9-11.2l.09-81.9c0-.53-7-1-15.39-1H1749"
/>
<path
className="cls-4"
d="M1825.5,1426.5H755.25a10.75,10.75,0,0,0-10.75,10.75h0A10.75,10.75,0,0,0,755.25,1448H1815a10.75,10.75,0,0,0,10.74-11l-.24-10.5"
/>
<path
className="cls-5"
d="M701.74,867.5S663.12,1015,669.56,1076.79c3.84,36.88,2.64,98,1,141.4a52.4,52.4,0,0,1-104.76-1.3c-.27-22-2.78-38.74-.5-51.2,13.67-74.81-7.27-76,5.08-144.26q3.17-11.08,6.56-22.29c11.82-39,24.77-75.25,38.5-110.61,14.74-1.39,31.2-5.77,48.93-9.73C678,875.76,690.47,871.22,701.74,867.5Z"
/>
<path
className="cls-5"
d="M719.77,1182.37c-8.92,0-15.45-12.93-18-18-17.59-34.83,9-95.59,19.32-117.16,9.86,22.2,34.32,82.46,16.74,117.16C735.17,1169.52,728.66,1182.37,719.77,1182.37Z"
/>
<path
className="cls-6"
d="M1915.78,1027c-110.75-95.83-248-74.53-267.79-71.13-190.52,30.41-344.62,100-368.21,188.29a549.59,549.59,0,0,0-11.7,55.33c-47.15-8-126.29-11.92-172.38,38.22l-.23.26c-13.09,14.32-3.91,37.46,15.39,39.47,11.56,1.2,25.45,2.36,41.11,3.12,32.51,1.58,102.09,52,145.66,85.51A156.16,156.16,0,0,0,1404.34,1419l.66,0c12.09,8.11,44,27.11,88.17,26.43a153,153,0,0,0,66.95-16.73l160.38-2.2c74.24,21.55,133.85,19.3,170.18,14.75,52.21-6.53,71.81-19.57,80.58-26.78,30.3-25,41.33-63.94,49.13-102.93C2036.41,1231.43,2010.61,1109.06,1915.78,1027Z"
/>
<path
className="cls-6"
d="M1267,1198c-9.38-27.55-23.66-79.78-24.88-129.15a393.76,393.76,0,0,1,12.55-108.79,334.61,334.61,0,0,1-32.62-173.74,17.07,17.07,0,0,1,26-13l132.1,82.11a320.21,320.21,0,0,1,150.63-4.18l119.81-98a13.73,13.73,0,0,1,22.29,8.61,456.39,456.39,0,0,1-16.57,202.39,188.88,188.88,0,0,1,7.14,87.26"
/>
<path
className="cls-5"
d="M583.29,1375.5H583s-8.5-.11-16.44-7.73c-6.25-6-.85-32.43,18-63.08,16.1,31.14,20.08,57.13,14.16,63.08C591.12,1375.46,583.29,1375.5,583.29,1375.5Z"
/>
<path
className="cls-7"
d="M2024.5,1260.5c14.81,6.82,38.24,20.41,54,46,36.42,59.15,9.28,145.76-41.37,191.33-36.76,33.08-79.09,38.28-112.39,42.57-19.52,2.51-110,13.78-172.14-42.57-12.57-11.4-42-38.11-37.66-71.13,2.25-17,13.79-39.69,33.47-46,37.71-12.14,60.28,50.17,131.09,57.83,10.2,1.1,53.88,4.58,88-23,5.59-4.52,14.81-13,26-32C2005,1364,2024.43,1323.52,2024.5,1260.5Z"
/>
<path className="cls-8" d="M1560.5,1428.5s69-32,85-94" />
<path
className="cls-7"
d="M1530.83,851.27l119.81-98a13.73,13.73,0,0,1,22.29,8.61c3.24,22.58,4.13,45.46,4.35,81S1665,911,1656.5,964.5a284.8,284.8,0,0,0-125.67-113.23Z"
/>
<path
className="cls-8"
d="M1408.5,1420.5c-1.77-1.54-8.83-8-9-17.67-.11-7.92,4.52-13.56,6-15.33,12.18-14.84,33.82-8.35,59-15,11.91-3.15,28.36-10.22,46-28"
/>
<ellipse
className="cls-7"
cx="1452.5"
cy="998.5"
rx="153"
ry="117"
/>
<circle className="cls-9" cx="1355" cy="991" r="31.5" />
<path
className="cls-10"
d="M1672.5,762.5s-70,95-77,117c-5.24,16.45,18.62,8.3,31,3.14a2.87,2.87,0,0,1,3.69,3.88l-8.3,17.53a6.35,6.35,0,0,0,7.75,8.74l9.91-3.3a2.87,2.87,0,0,1,3.56,3.83l-3.59,17.18,17,34a457.51,457.51,0,0,0,16-202Z"
/>
<path
className="cls-7"
d="M1379.5,855.5c-43.86-27.19-89.35-56.1-133.21-83.29-9.07-5.62-23.66,1.62-23.79,12.29-.27,22.81-4,48.1,3,83,3.77,18.84,5.45,28.58,9.26,41.5a315.06,315.06,0,0,0,19.74,50.5,199,199,0,0,1,18-29c5.75-7.71,26.56-34.42,64-56A221.93,221.93,0,0,1,1379.5,855.5Z"
/>
<path
className="cls-11"
d="M1222.5,782.5s75.38,65.94,84.71,83.21c.55,1,2.89,5.62,1.16,7.71-3.3,4-17.41-6.08-23.87-.92a6.77,6.77,0,0,0-1.62,1.92,8,8,0,0,0,.75,8.68c2.16,2.87,5,7.47,4.73,11.84a6.33,6.33,0,0,1-1.15,3.63c-1.93,2.36-5.52,2.38-6.51,2.38-6.55,0-10.09-6.31-10.25-6.6a4.65,4.65,0,0,0-6,.13,3.51,3.51,0,0,0-.94,2,8.85,8.85,0,0,0,.82,5.06c2.17,4.39-.37,18.55-1.85,24.93a93.65,93.65,0,0,1-11,27c-9-19.66-21.15-51-27-89a326.82,326.82,0,0,1-3.49-62.74C1221.37,793.9,1222,787.3,1222.5,782.5Z"
/>
<circle className="cls-12" cx="1355" cy="991" r="22.5" />
<circle className="cls-9" cx="1557" cy="991" r="31.5" />
<circle className="cls-12" cx="1557" cy="991" r="22.5" />
<path
className="cls-10"
d="M1445.26,997.13l10.24,1.37,9.39-1.34a2.14,2.14,0,0,1,2.11,3.27l-9.09,14.28a3,3,0,0,1-4.94.08l-9.77-14.33A2.15,2.15,0,0,1,1445.26,997.13Z"
/>
<path
className="cls-13"
d="M1454.74,1016.08s2.76,17.42-17.24,15.42"
/>
<path
className="cls-13"
d="M1455.63,1017.08s-2.76,17.42,17.24,15.42"
/>
<path className="cls-14" d="M1664.5,1001.5,1735,980" />
<path className="cls-14" d="M1667,1017l66.5,10.5" />
<path className="cls-14" d="M1244,1017l-60.5,13.5" />
<path className="cls-14" d="M1246.5,1000.5,1180,990" />
<path
className="cls-15"
d="M497.79,404c44.57,20.37,95.3,66.11,155.71,124.48,92.79,89.66,150.8,234.43,169,289-5.77,2.68-30.23-42.68-36-40-19.27-52.74-57.27-138.85-139-223-66.8-68.78-125-119.67-172-142Z"
/>
<path
className="cls-15"
d="M745.55,850.16c-74.68-63-179.26-139.49-214.14-152.89-89.78-34.5-169.48-49.55-221.09-50.06q8.32-8.54,16.67-17.06c49-.22,119.61,13.39,199,41,31.84,11.09,153.72,90.48,241,170.65Z"
/>
<path
className="cls-15"
d="M823.54,819.3c-17.76-23.9-59.56-97.14-83.92-120.77a597.13,597.13,0,0,0-166.5-113.78l-22.31,8.44A635.18,635.18,0,0,1,733.58,724.52c17.7,18.29,54.44,85.77,68.42,104Z"
/>
<path
className="cls-7"
d="M1479.5,1367.5l34,76a192.85,192.85,0,0,1-51-1s-29.19-3.39-48.59-18c-13.48-10.12-14.12-17.25-14.29-19.38-.78-9.74,5.64-16.63,8.13-19l.75-.68c9-7.86,25-8.93,26-9C1444.74,1375.81,1458.89,1373.16,1479.5,1367.5Z"
/>
<path
className="cls-16"
d="M1173.28,1285.23l30.22-89.73a156.61,156.61,0,0,0-60,11,149.83,149.83,0,0,0-38,23c-1,.85-15,12.88-15.5,24.47,0,.63,0,1.22,0,1.26.23,9.77,7.33,16,10.06,18l.82.6c8.37,5.92,18.58,5.26,33.63,5.63,8.49.21,12.73.32,18,1A113.17,113.17,0,0,1,1173.28,1285.23Z"
/>
<path
className="cls-17"
d="M292.3,344.49l-28.05-15.3a40.34,40.34,0,0,1-20.8-39.64l2.35-22.21a61.8,61.8,0,0,1,26.57-44.52h0a29.52,29.52,0,0,1,29.48-2.22,82.16,82.16,0,0,0,8.28,3.32,234.66,234.66,0,0,1,86.78,54.37l-43.47,78.83Z"
/>
<path
className="cls-17"
d="M318.73,318l-.69.05a40.94,40.94,0,0,0-37,32l-2.68,12.12a53.57,53.57,0,0,0,33.25,61.63L394.1,455.2,406.8,365l-57-38.69A48.91,48.91,0,0,0,318.73,318Z"
/>
<path
className="cls-17"
d="M465,262.82l-32.13-42.59A53.66,53.66,0,0,0,379,200l-10.53,2.21A31.57,31.57,0,0,0,348.89,251l27,38.3,84.61,30.61Z"
/>
<circle className="cls-9" cx="395.47" cy="335.18" r="65.13" />
<path
className="cls-17"
d="M410.35,262.8l-3.18,24.43c-1.27,9.71,1.05,18.92,6.5,25.82l43.66,55.28,25.6,66.79a188.3,188.3,0,0,0,13.53-28.27s9.66-27.18,8.55-57.61c-2-56.48-41.85-101.41-48.51-108.74a21.18,21.18,0,0,0-11-7c-8.32-2-15.23,2.41-18.82,4.69C414.7,245.8,411.24,258.85,410.35,262.8Z"
/>
<path
className="cls-17"
d="M393,455.33,343.6,432.5a42.53,42.53,0,0,1-21-55.8l10.27-23.18a56,56,0,0,1,70.16-30l59.18,21.35A54.61,54.61,0,0,1,497.69,404h0a72.53,72.53,0,0,1-17.51,34.08c-22.74,24.35-55.11,23-60.87,22.72A83.93,83.93,0,0,1,393,455.33Z"
/>
<path
className="cls-17"
d="M220.48,538.45l-4.1-14.15a39.86,39.86,0,0,1,20.26-46.64h0a44.74,44.74,0,0,1,46.87,4c12.59,4.22,69.55,24.82,98.81,84.49a161.75,161.75,0,0,1,16.25,66.83A8.26,8.26,0,0,1,386,640.17Z"
/>
<path
className="cls-17"
d="M173.88,677.25,191,690a87.06,87.06,0,0,0,16.42,9.6,175.79,175.79,0,0,0,21.43,7.83c15.81,4.64,54.81,16.06,98.18.1,33.26-12.24,53.93-35,64.71-49.86a7,7,0,0,0-4.9-11.16L198.54,625.16a32.86,32.86,0,0,0-33,17.77A27.41,27.41,0,0,0,173.88,677.25Z"
/>
<path
className="cls-17"
d="M160.14,576h0a63.93,63.93,0,0,0,32.92,42l57.42,29.55c3.85,1.51,9.48,3.61,16.37,5.82a265.52,265.52,0,0,0,45,10.4c27.27,3.24,57.36-5.36,74.44-11.41a13.29,13.29,0,0,0,8.07-17c-10.22-28.29-25.28-44.58-33.77-52.46-15.68-14.55-34.71-24.26-49.92-32a314.15,314.15,0,0,0-29.59-13.23l-48.9-13.51A63.9,63.9,0,0,0,184.09,530l-4.91,2.74A39.23,39.23,0,0,0,160.14,576Z"
/>
<path
className="cls-17"
d="M525.79,497.88a10.12,10.12,0,0,0-10.16,11.81c4,23.68,14.18,75.92,28.34,89.12,18.47,17.22,48.15,116.37,130.7,95.46,56.68-14.36,39.26-73.52,22.76-109.22a117,117,0,0,0-41.89-48.75A228.19,228.19,0,0,0,597,509,260,260,0,0,0,525.79,497.88Z"
/>
<path
className="cls-15"
d="M857.63,805C860.5,803.5,830.5,512.5,746.5,400.5s-104-130-104-130-2,85,34,145,78,160,90,182,56,223,56,223Z"
/>
</svg>
</div>
</div>
</div>
</>
);
};
export default GenericError;

View File

@@ -1,152 +0,0 @@
// src/components/RegistrationForm.tsx
import React from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/trpc/react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { logger } from "@/lib/logger";
import { Icons } from "@/components/icons";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
const registrationSchema = z
.object({
email: z.string().email({ message: "Invalid email address" }),
password: z
.string()
.min(5, { message: "Password must be at least 5 characters long" }),
confirmPassword: z.string().min(5),
})
.superRefine(({ confirmPassword, password }, ctx) => {
if (confirmPassword !== password) {
ctx.addIssue({
code: "custom",
message: "The passwords did not match",
path: ["confirmPassword"],
});
}
});
type RegistrationFormValues = z.infer<typeof registrationSchema>;
const RegistrationForm: React.FC = () => {
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const router = useRouter();
const form = useForm<RegistrationFormValues>({
resolver: zodResolver(registrationSchema),
});
const createUser = api.auth.create.useMutation();
const onSubmit = async (data: RegistrationFormValues) => {
setIsLoading(true);
try {
await createUser.mutateAsync(data);
toast("User registered successfully");
router.push("/signin");
} catch (error) {
logger.error("RegistrationForm", "error", error);
toast("Failed to register user");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid gap-2">
<div className="grid gap-1">
<FormField
control={form.control}
name="email"
defaultValue={"fergal.moran+opengifame@gmail.com"}
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
{form.formState.errors.email && (
<FormMessage>
{form.formState.errors.email.message}
</FormMessage>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
defaultValue={"secret"}
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
{form.formState.errors.password && (
<FormMessage>
{form.formState.errors.password.message}
</FormMessage>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
defaultValue={"secret"}
render={({ field }) => (
<FormItem>
<FormLabel>Confirm password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
{form.formState.errors.confirmPassword && (
<FormMessage>
{form.formState.errors.confirmPassword.message}
</FormMessage>
)}
</FormItem>
)}
/>
{form.formState.errors && false && (
<Alert>
<Icons.terminal className="h-4 w-4" />
<AlertTitle>Heads up!</AlertTitle>
<AlertDescription>
{JSON.stringify(form.formState.errors)}
</AlertDescription>
</Alert>
)}
</div>
<Button
type="submit"
className={cn(buttonVariants())}
disabled={isLoading}
>
{isLoading && (
<Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
)}
Register
</Button>
</div>
</form>
</Form>
);
};
export default RegistrationForm;

View File

@@ -1,129 +0,0 @@
// src/components/RegistrationForm.tsx
import React from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/trpc/react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { logger } from "@/lib/logger";
import { Icons } from "@/components/icons";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { signIn } from "next-auth/react";
const signInSchema = z.object({
email: z.string().email({ message: "Invalid email address" }),
password: z
.string()
.min(5, { message: "Password must be at least 5 characters long" }),
});
type SignInFormValues = z.infer<typeof signInSchema>;
const SignInForm: React.FC = () => {
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const router = useRouter();
const form = useForm<SignInFormValues>({
resolver: zodResolver(signInSchema),
});
const onSubmit = async (data: SignInFormValues) => {
setIsLoading(true);
try {
const result = await signIn("credentials", {
redirect: false,
email: data.email,
password: data.password,
});
logger.debug("signin", "result", result);
if (result?.status === 200) {
router.push("/");
window.location.reload();
}
} catch (error) {
logger.error("SignInForm", "error", error);
toast("Failed to signin user");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid gap-2">
<div className="grid gap-1">
<FormField
control={form.control}
name="email"
defaultValue={"fergal.moran+opengifame@gmail.com"}
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
{form.formState.errors.email && (
<FormMessage>
{form.formState.errors.email.message}
</FormMessage>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
defaultValue={"secret"}
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
{form.formState.errors.password && (
<FormMessage>
{form.formState.errors.password.message}
</FormMessage>
)}
</FormItem>
)}
/>
{form.formState.errors && false && (
<Alert>
<Icons.terminal className="h-4 w-4" />
<AlertTitle>Heads up!</AlertTitle>
<AlertDescription>
{JSON.stringify(form.formState.errors)}
</AlertDescription>
</Alert>
)}
</div>
<Button
type="submit"
className={cn(buttonVariants())}
disabled={isLoading}
>
{isLoading && (
<Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
)}
Sign in
</Button>
</div>
</form>
</Form>
);
};
export default SignInForm;

275
src/components/header.tsx Normal file
View File

@@ -0,0 +1,275 @@
'use client';
import { signIn, signOut, useSession, getProviders } from 'next-auth/react';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Upload, User, LogOut, Github, Mail, AlertCircle } from 'lucide-react';
import { ThemeToggle } from '@/components/theme-toggle';
import { OpenGifameLogo } from '@/components/opengifame-logo';
import Link from 'next/link';
interface Provider {
id: string;
name: string;
type: string;
signinUrl: string;
callbackUrl: string;
}
export function Header() {
const { data: session } = useSession();
const [providers, setProviders] = useState<Record<string, Provider> | null>(null);
const [isSignInOpen, setIsSignInOpen] = useState(false);
const [credentials, setCredentials] = useState({ email: '', password: '' });
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
(async () => {
const res = await getProviders();
setProviders(res);
})();
}, []);
// Reset form state when modal closes
useEffect(() => {
if (!isSignInOpen) {
setCredentials({ email: '', password: '' });
setError('');
setIsLoading(false);
}
}, [isSignInOpen]);
const handleSignIn = (providerId: string) => {
signIn(providerId, { callbackUrl: '/' });
setIsSignInOpen(false);
};
const handleCredentialsSignIn = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
const result = await signIn('credentials', {
email: credentials.email,
password: credentials.password,
redirect: false,
});
if (result?.error) {
setError('Invalid credentials. Please check your email and password.');
} else if (result?.ok) {
setIsSignInOpen(false);
setCredentials({ email: '', password: '' });
}
} catch (error) {
console.error('Sign-in error:', error);
setError('Something went wrong. Please try again.');
} finally {
setIsLoading(false);
}
};
const getProviderIcon = (providerId: string) => {
switch (providerId) {
case 'github':
return <Github className="h-4 w-4" />;
case 'google':
return <Mail className="h-4 w-4" />;
case 'facebook':
return (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
);
default:
return null;
}
};
const getProviderColor = (providerId: string) => {
switch (providerId) {
case 'github':
return 'bg-gray-800 hover:bg-gray-700 text-white';
case 'google':
return 'bg-red-600 hover:bg-red-700 text-white';
case 'facebook':
return 'bg-blue-600 hover:bg-blue-700 text-white';
default:
return 'bg-primary hover:bg-primary/90';
}
};
return (
<header className="sticky top-0 z-50 w-full border-b bg-background">
<div className="container mx-auto flex h-16 max-w-7xl items-center justify-between px-4">
<div className="flex items-center space-x-6 flex-shrink-0">
<Link href="/" className="flex items-center space-x-2">
<OpenGifameLogo className="h-8 w-8 flex-shrink-0" />
<span className="text-xl font-bold">OpenGifame</span>
</Link>
<nav className="hidden sm:flex items-center space-x-6">
<Link
href="/"
className="text-sm font-medium transition-colors hover:text-primary"
>
Gallery
</Link>
<Link
href="/trending"
className="text-sm font-medium transition-colors hover:text-primary"
>
Trending
</Link>
</nav>
</div>
<div className="flex items-center space-x-3 flex-shrink-0">
<ThemeToggle />
{session ? (
<>
<Button asChild size="sm">
<Link href="/upload">
<Upload className="mr-2 h-4 w-4" />
Upload
</Link>
</Button>
<Button variant="ghost" size="sm" asChild>
<Link href="/profile">
<User className="mr-2 h-4 w-4" />
{session.user?.name}
</Link>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => signOut()}
>
<LogOut className="h-4 w-4" />
</Button>
</>
) : (
<Dialog open={isSignInOpen} onOpenChange={setIsSignInOpen}>
<DialogTrigger asChild>
<Button size="sm">
Sign In
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-center">Welcome to OpenGifame</DialogTitle>
<p className="text-center text-sm text-muted-foreground">
Sign in to share and discover amazing images
</p>
</DialogHeader>
<div className="space-y-6 pt-4">
{/* Credentials Form */}
<form onSubmit={handleCredentialsSignIn} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<label htmlFor="modal-email" className="text-sm font-medium">
Email
</label>
<Input
id="modal-email"
type="email"
value={credentials.email}
onChange={(e) => setCredentials(prev => ({ ...prev, email: e.target.value }))}
placeholder="Enter your email"
disabled={isLoading}
required
/>
</div>
<div className="space-y-2">
<label htmlFor="modal-password" className="text-sm font-medium">
Password
</label>
<Input
id="modal-password"
type="password"
value={credentials.password}
onChange={(e) => setCredentials(prev => ({ ...prev, password: e.target.value }))}
placeholder="Enter your password"
disabled={isLoading}
required
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
</form>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
{/* OAuth Providers */}
<div className="space-y-3">
{providers ? (
Object.values(providers)
.filter(provider => provider.id !== 'credentials')
.map((provider) => (
<Button
key={provider.id}
onClick={() => handleSignIn(provider.id)}
className={`w-full flex items-center justify-center space-x-2 ${getProviderColor(provider.id)}`}
variant="default"
disabled={isLoading}
>
{getProviderIcon(provider.id)}
<span>Continue with {provider.name}</span>
</Button>
))
) : (
<div className="space-y-4">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<p className="text-center text-muted-foreground">Loading sign-in options...</p>
</div>
)}
</div>
{/* Register Link */}
<div className="text-center pt-2">
<p className="text-sm text-muted-foreground">
Don&apos;t have an account?{' '}
<Link
href="/auth/register"
className="text-primary hover:underline font-medium"
onClick={() => setIsSignInOpen(false)}
>
Create one here
</Link>
</p>
</div>
</div>
</DialogContent>
</Dialog>
)}
</div>
</div>
</header>
);
}

View File

@@ -1,129 +0,0 @@
import {
AlertTriangle,
ArrowRight,
Check,
ChevronLeft,
ChevronRight,
Command,
CreditCard,
File,
FileText,
HelpCircle,
Image,
Laptop,
Loader2,
type LucideProps,
Moon,
MoreVertical,
Pizza,
Plus,
Settings,
SunMedium,
Trash,
Twitter,
User,
X,
type Icon as LucideIcon,
Terminal,
LogIn,
Upload,
Menu,
ThumbsUp,
ThumbsDown,
Heart,
Undo2,
LoaderCircle,
} from "lucide-react";
export type Icon = typeof LucideIcon;
export const Icons = {
close: X,
spinner: Loader2,
chevronLeft: ChevronLeft,
chevronRight: ChevronRight,
trash: Trash,
heart: Heart,
up: ThumbsUp,
down: ThumbsDown,
post: FileText,
page: File,
progress: LoaderCircle,
media: Image,
settings: Settings,
billing: CreditCard,
ellipsis: MoreVertical,
add: Plus,
warning: AlertTriangle,
user: User,
undo: Undo2,
arrowRight: ArrowRight,
help: HelpCircle,
login: LogIn,
logo: ({ ...props }: LucideProps) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" {...props}>
<rect
x="0"
y="0"
width="100"
height="100"
rx="20"
ry="20"
fill="#FF6B6B"
/>
<path d="M50,10 L90,80 L10,80 Z" fill="white" />
<path
d="M50,10 L62,80"
stroke="#FFD166"
strokeWidth={4}
strokeLinecap="round"
/>
<path
d="M50,10 L50,80"
stroke="#06D6A0"
strokeWidth={4}
strokeLinecap="round"
/>
<path
d="M50,10 L38,80"
stroke="#118AB2"
strokeWidth={4}
strokeLinecap="round"
/>
<circle
cx="50"
cy="35"
r="9"
fill="#FF6B6B"
stroke="white"
strokeWidth={2.5}
/>
</svg>
),
pizza: Pizza,
terminal: Terminal,
sun: SunMedium,
moon: Moon,
laptop: Laptop,
gitHub: ({ ...props }: LucideProps) => (
<svg
aria-hidden="true"
focusable="false"
data-prefix="fab"
data-icon="github"
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 496 512"
{...props}
>
<path
fill="currentColor"
d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"
></path>
</svg>
),
hamburger: Menu,
twitter: Twitter,
check: Check,
upload: Upload,
};

View File

@@ -0,0 +1,159 @@
'use client';
import { useState } from 'react';
import { useSession } from 'next-auth/react';
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { ArrowUp, ArrowDown, MessageCircle, Calendar } from 'lucide-react';
import Link from 'next/link';
import Image from 'next/image';
interface ImageCardProps {
id: string;
title: string;
description?: string;
url: string;
upvotes: number;
downvotes: number;
createdAt: Date;
uploadedBy: {
name?: string;
image?: string;
};
tags: {
id: string;
name: string;
}[];
userVote?: 'up' | 'down' | null;
commentCount: number;
}
export function ImageCard({
id,
title,
description,
url,
upvotes,
downvotes,
createdAt,
uploadedBy,
tags,
userVote,
commentCount,
}: ImageCardProps) {
const { data: session } = useSession();
const [currentVote, setCurrentVote] = useState(userVote);
const [voteCount, setVoteCount] = useState({ upvotes, downvotes });
const handleVote = async (voteType: 'up' | 'down') => {
if (!session) return;
try {
const response = await fetch('/api/images/vote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageId: id, isUpvote: voteType === 'up' }),
});
if (response.ok) {
const data = await response.json();
setCurrentVote(data.userVote);
setVoteCount({ upvotes: data.upvotes, downvotes: data.downvotes });
}
} catch (error) {
console.error('Error voting:', error);
}
};
const score = voteCount.upvotes - voteCount.downvotes;
return (
<Card className="overflow-hidden">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<Link
href={`/image/${id}`}
className="text-lg font-semibold hover:text-primary"
>
{title}
</Link>
<span className="text-sm text-muted-foreground">
{score > 0 ? `+${score}` : score}
</span>
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</CardHeader>
<CardContent className="p-0">
<Link href={`/image/${id}`}>
<div className="relative aspect-video w-full overflow-hidden">
<Image
src={url}
alt={title}
fill
className="object-cover transition-transform hover:scale-105"
/>
</div>
</Link>
</CardContent>
<CardFooter className="flex flex-col space-y-2 pt-4">
<div className="flex w-full items-center justify-between">
<div className="flex items-center space-x-2">
<Button
variant={currentVote === 'up' ? 'default' : 'outline'}
size="sm"
onClick={() => handleVote('up')}
disabled={!session}
>
<ArrowUp className="h-4 w-4" />
{voteCount.upvotes}
</Button>
<Button
variant={currentVote === 'down' ? 'default' : 'outline'}
size="sm"
onClick={() => handleVote('down')}
disabled={!session}
>
<ArrowDown className="h-4 w-4" />
{voteCount.downvotes}
</Button>
</div>
<div className="flex items-center space-x-2">
<Button variant="ghost" size="sm" asChild>
<Link href={`/image/${id}`}>
<MessageCircle className="mr-1 h-4 w-4" />
{commentCount}
</Link>
</Button>
</div>
</div>
<div className="flex w-full items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center space-x-1">
<Calendar className="h-3 w-3" />
<span>{createdAt.toLocaleDateString()}</span>
</div>
<span>by {uploadedBy.name}</span>
</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{tags.map((tag) => (
<Link
key={tag.id}
href={`/tag/${tag.name}`}
className="rounded-full bg-secondary px-2 py-1 text-xs hover:bg-secondary/80"
>
#{tag.name}
</Link>
))}
</div>
)}
</CardFooter>
</Card>
);
}

View File

@@ -1,44 +0,0 @@
import * as React from "react";
import Link from "next/link";
import { cn } from "@/lib/utils";
import { Icons } from "@/components/icons";
import { NavItem } from "@/types";
import { siteConfig } from "@/config/site.config";
interface MobileNavProps {
items: NavItem[];
children?: React.ReactNode;
}
export function MobileNav({ items, children }: MobileNavProps) {
return (
<div
className={cn(
"fixed inset-0 top-16 z-50 grid h-[calc(100vh-4rem)] grid-flow-row auto-rows-max overflow-auto p-6 pb-32 shadow-md animate-in slide-in-from-bottom-80 md:hidden",
)}
>
<div className="relative z-20 grid gap-6 rounded-md bg-popover p-4 text-popover-foreground shadow-md">
<Link href="/" className="flex items-center space-x-2">
<Icons.logo />
<span className="font-bold">{siteConfig.name}</span>
</Link>
<nav className="grid grid-flow-row auto-rows-max text-sm">
{items.map((item, index) => (
<Link
key={index}
href={item.disabled ? "#" : item.href}
className={cn(
"flex w-full items-center rounded-md p-2 text-sm font-medium hover:underline",
item.disabled && "cursor-not-allowed opacity-60",
)}
>
{item.title}
</Link>
))}
</nav>
{children}
</div>
</div>
);
}

View File

@@ -1,102 +0,0 @@
"use client";
import Link from "next/link";
import React from "react";
import { type NavItem } from "@/types";
import { type Session } from "next-auth";
import { Icons } from "@/components/icons";
import { siteConfig } from "@/config/site.config";
import { cn } from "@/lib/utils";
import { useSelectedLayoutSegment } from "next/navigation";
import LoginButton from "@/components/widgets/login/login-button";
import { MobileNav } from "./mobile-navbar";
import { buttonVariants } from "@/components/ui/button";
type TopNavbarProps = {
items: NavItem[];
session: Session | null;
};
const TopNavbar: React.FC<TopNavbarProps> = ({ items, session }) => {
const segment = useSelectedLayoutSegment();
const [showMobileMenu, setShowMobileMenu] = React.useState<boolean>(false);
return (
<div className="mx-auto px-2 sm:px-4 lg:divide-y lg:divide-gray-200 lg:px-8">
<div className="relative flex h-16 justify-between">
<div className="relative z-10 flex px-2 lg:px-0">
<div className="flex flex-shrink-0 items-center gap-4">
<Link href="/" className="hidden items-center space-x-2 md:flex">
<Icons.logo className="h-8 w-8" />
<span className="hidden font-bold sm:inline-block">
{siteConfig.name}
</span>
</Link>
<Link
className={buttonVariants({ variant: "secondary" })}
href={"/upload"}
>
<Icons.upload className="mr-2 h-4 w-4" />
Upload
</Link>
{items?.length ? (
<nav className="hidden gap-6 md:flex">
{items?.map((item, index) => (
<Link
key={index}
href={item.disabled ? "#" : item.href}
className={cn(
"flex items-center text-lg font-medium transition-colors hover:text-foreground/80 sm:text-sm",
item.href.startsWith(`/${segment}`)
? "text-foreground"
: "text-foreground/60",
item.disabled && "cursor-not-allowed opacity-80",
)}
>
{item.title}
</Link>
))}
</nav>
) : null}
</div>
</div>
<div className="relative z-0 flex flex-1 items-center justify-center px-2 sm:absolute sm:inset-0">
<div className="w-full sm:max-w-xs">
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
<div className="w-full flex-1 md:w-auto md:flex-none">
<button className="relative inline-flex h-8 w-full items-center justify-start whitespace-nowrap rounded-[0.5rem] border border-input bg-muted/50 px-4 py-2 text-sm font-normal text-muted-foreground shadow-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 sm:pr-12 md:w-40 lg:w-64">
<span className="hidden lg:inline-flex">
Search images...
</span>
<span className="inline-flex lg:hidden">Search...</span>
<kbd className="pointer-events-none absolute right-[0.3rem] top-[0.3rem] hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
<span className="text-xs"></span>K
</kbd>
</button>
</div>
</div>
</div>
</div>
<div className="relative z-10 flex items-center md:hidden">
<button
className="flex items-center space-x-2 md:hidden"
onClick={() => setShowMobileMenu(!showMobileMenu)}
>
{showMobileMenu ? (
<Icons.close className="w-8" />
) : (
<Icons.logo className="w-8" />
)}
</button>
{showMobileMenu && items && <MobileNav items={items} />}
</div>
<div className="hidden md:relative md:z-10 md:ml-4 md:flex md:items-center">
<LoginButton session={session} />
</div>
</div>
</div>
);
};
export default TopNavbar;

View File

@@ -0,0 +1,93 @@
export function OpenGifameLogo({ className = "h-8 w-8" }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 32 32"
width="32"
height="32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* Background circle */}
<circle
cx="16"
cy="16"
r="15"
fill="#3B82F6"
stroke="#2563EB"
strokeWidth="2"
/>
{/* Camera/Gallery icon */}
<g transform="translate(6, 8)">
{/* Camera body */}
<rect
x="2"
y="4"
width="16"
height="12"
rx="2"
fill="white"
opacity="0.95"
/>
{/* Camera lens outer */}
<circle
cx="10"
cy="10"
r="3.5"
fill="none"
stroke="#3B82F6"
strokeWidth="1.5"
/>
{/* Camera lens inner */}
<circle
cx="10"
cy="10"
r="2"
fill="#3B82F6"
/>
{/* Camera viewfinder */}
<rect
x="4"
y="2"
width="4"
height="2"
rx="1"
fill="white"
opacity="0.95"
/>
{/* Gallery indicator (small rectangles) */}
<rect
x="14.5"
y="5.5"
width="2"
height="1.5"
rx="0.3"
fill="#3B82F6"
opacity="0.6"
/>
<rect
x="14.5"
y="7.5"
width="2"
height="1.5"
rx="0.3"
fill="#3B82F6"
opacity="0.8"
/>
<rect
x="14.5"
y="9.5"
width="2"
height="1.5"
rx="0.3"
fill="#3B82F6"
/>
</g>
</svg>
);
}

View File

@@ -1,15 +0,0 @@
import React from "react";
import { TrendingPosts } from "@/components/trending-posts";
import { api, HydrateClient } from "@/trpc/server";
const LandingPage: React.FC = async () => {
void api.post.getTrending.prefetch();
return (
<HydrateClient>
<TrendingPosts />
</HydrateClient>
);
};
export default LandingPage;

View File

@@ -1,46 +0,0 @@
import { Post } from "@/lib/models/post";
import React from "react";
import ActionButton from "@/components/widgets/action-button";
import { Icons } from "@/components/icons";
import { api } from "@/trpc/react";
import VoteCount from "@/components/widgets/vote-count";
type PostActionsProps = {
post: Post;
};
const PostActions: React.FC<PostActionsProps> = ({ post }) => {
const vote = api.post.vote.useMutation();
const voteCount = api.post.getVoteCount.useQuery({ slug: post.slug });
return (
<div className="flex flex-col items-center space-y-4 rounded-md border p-4">
<ActionButton
title="Upvote"
action={async () => {
await vote.mutateAsync({ slug: post.slug, up: true });
await voteCount.refetch();
}}
icon={<Icons.up className="h-6 w-6" />}
/>
<VoteCount post={post} />
<ActionButton
title="Downvote"
action={async () => {
await vote.mutateAsync({ slug: post.slug, up: false });
await voteCount.refetch();
}}
icon={<Icons.down className="h-6 w-6" />}
/>
<ActionButton
title="Favourite"
action={async () => {
await vote.mutateAsync({ slug: post.slug, up: false });
await voteCount.refetch();
}}
icon={<Icons.heart className="h-6 w-6" />}
/>
</div>
);
};
export default PostActions;

View File

@@ -1,43 +0,0 @@
"use client";
import React from "react";
import { Post } from "@/lib/models/post";
import ActionButton from "@/components/widgets/action-button";
import { Icons } from "@/components/icons";
import PostActions from "./post-actions";
type PostPageProps = {
post: Post;
};
const PostPage: React.FC<PostPageProps> = ({ post }) => {
return (
<div className="flex h-full w-full items-stretch space-x-4">
<div id="left" className="w-1/6 flex-none">
<div className="mx-10">
<PostActions post={post} />
</div>
</div>
<div id="centre" className="flex-grow">
<h2 className="mb-2 text-xl font-bold">{post.title}</h2>
<div className="rounded-t-md border-l border-r border-t bg-card text-card-foreground shadow">
<div className="flex flex-col">
<div className="flex justify-center p-4">
<img
src={post.imageUrl}
className="h-auto max-w-full rounded-lg object-cover"
/>
</div>
</div>
</div>
<div className="rounded-b-md border-b border-l border-r bg-muted">
<p className="p-4 text-sm opacity-75">{post.description}</p>
</div>
</div>
<div id="right" className="w-1/6 flex-none">
Right Stuff
</div>
</div>
);
};
export default PostPage;

View File

@@ -1,169 +0,0 @@
"use client";
import React from "react";
import { FormProvider, useForm } from "react-hook-form";
import { type SubmitHandler, Controller } from "react-hook-form";
import { useRouter } from "next/navigation";
import ImageUpload from "@/components/widgets/image-upload";
import TaggedInput from "@/components/widgets/tagged-input";
import {
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { env } from "@/env";
import { logger } from "@/lib/logger";
import { api } from "@/trpc/react";
import { StatusCodes } from "http-status-codes";
type FormValues = {
title: string;
description: string;
tags: string[];
image: string | undefined;
};
type UploadPageProps = {
pastedImage?: File;
};
const UploadPage: React.FC<UploadPageProps> = ({ pastedImage }) => {
const utils = api.useUtils();
const router = useRouter();
const form = useForm<FormValues>({
defaultValues: {
title: env.NEXT_PUBLIC_DEBUG_MODE ? "This is my title" : "",
description: env.NEXT_PUBLIC_DEBUG_MODE ? "This is my description" : "",
tags: [],
},
});
if (pastedImage) {
logger.log("upload-page", "We gotta file", pastedImage);
} else {
logger.log("upload-page", "No file");
}
const createImage = api.post.create.useMutation({
onSuccess: async (e) => {
console.log("upload-page", "onSuccess", e);
const file = form.getValues().image;
if (e.id && file) {
const body = new FormData();
body.set("image", file);
const response = await fetch(`/api/upload/post?id=${e.id}`, {
method: "POST",
body,
});
if (response.status === StatusCodes.OK.valueOf()) {
await utils.post.getBySlug.invalidate();
await utils.post.getTrending.invalidate();
router.replace(`/post/${e.slug}`);
}
logger.error("upload-page", "createImage", response.statusText);
throw new Error(response.statusText);
} else {
//TODO: Probably need to delete the image from the database
logger.error("upload-page", "onSuccess", "Error uploading image");
}
},
});
const _submit: SubmitHandler<FormValues> = async (data) => {
console.log(data);
try {
await createImage.mutateAsync(data);
} catch (error) {
logger.error("UploadPage", "error", error);
}
};
return (
<div className="md:grid md:grid-cols-3 md:gap-6">
<div className="md:col-span-1">
<div className="px-4 sm:px-0">
<h3 className="text-lg font-extrabold leading-6">Upload a new gif</h3>
<p className="text-base-content/70 my-3 text-sm">
The more info you can give us the better.
</p>
</div>
</div>
<div className="md:col-span-2">
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(_submit)}>
<div className="space-y-4 px-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="Let's give your post a title"
type="text"
{...field}
/>
</FormControl>
{form.formState.errors.title && (
<FormMessage>
{form.formState.errors.title.message}
</FormMessage>
)}
</FormItem>
)}
/>
<Controller
control={form.control}
name="image"
render={({ field: { value, onChange } }) => (
<ImageUpload
value={value}
onChange={onChange}
pastedImage={pastedImage}
/>
)}
/>
<FormField
control={form.control}
name="description"
defaultValue={"fergal.moran+opengifame@gmail.com"}
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
placeholder="Add a description (if you want?)."
{...field}
rows={3}
/>
</FormControl>
{form.formState.errors.description && (
<FormMessage>
{form.formState.errors.description.message}
</FormMessage>
)}
</FormItem>
)}
/>
<div className="divider pt-4">optional stuff</div>
<Controller
control={form.control}
name="tags"
render={({ field: { value, onChange } }) => (
<TaggedInput label="Tags" value={value} onChange={onChange} />
)}
/>
</div>
<div className="w-full px-4 py-3 text-right">
<Button type="submit" className="btn btn-primary w-full">
Upload Gif
</Button>
</div>
</form>
</FormProvider>
</div>
</div>
);
};
export default UploadPage;

View File

@@ -1,16 +0,0 @@
export function TailwindIndicator() {
if (process.env.NODE_ENV === "production") return null;
return (
<div className="fixed bottom-1 left-1 z-50 flex h-6 w-6 items-center justify-center rounded-full bg-gray-800 p-3 font-mono text-xs text-white">
<div className="block sm:hidden">xs</div>
<div className="hidden sm:block md:hidden lg:hidden xl:hidden 2xl:hidden">
sm
</div>
<div className="hidden md:block lg:hidden xl:hidden 2xl:hidden">md</div>
<div className="hidden lg:block xl:hidden 2xl:hidden">lg</div>
<div className="hidden xl:block 2xl:hidden">xl</div>
<div className="hidden 2xl:block">2xl</div>
</div>
);
}

View File

@@ -1,9 +1,21 @@
"use client";
'use client';
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types";
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import * as React from 'react';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
interface ThemeProviderProps {
children: React.ReactNode;
}
export function ThemeProvider({ children }: ThemeProviderProps) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
import { useEffect, useState } from 'react';
export function ThemeToggle() {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<Button variant="ghost" size="sm" className="h-9 w-9 p-0">
<Sun className="h-4 w-4" />
<span className="sr-only">Toggle theme</span>
</Button>
);
}
return (
<Button
variant="ghost"
size="sm"
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
className="h-9 w-9 p-0"
>
{theme === 'light' ? (
<Moon className="h-4 w-4" />
) : (
<Sun className="h-4 w-4" />
)}
<span className="sr-only">Toggle theme</span>
</Button>
);
}

View File

@@ -1,57 +0,0 @@
import { api } from "@/trpc/server";
import Link from "next/link";
import { Icons } from "./icons";
import { type Post } from "@/lib/models/post";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "./ui/button";
import VoteCount from "./widgets/vote-count";
import Loading from "@/components/widgets/loading";
export const TrendingPosts: React.FC = async () => {
const trendingPosts = await api.post.getTrending();
return trendingPosts.length !== 0 ? (
<div className="masonry sm:masonry-sm md:masonry-md lg:masonry-lg">
{trendingPosts.map((post: Post) => (
<div className="py-2" key={post.slug}>
<Link href={`/post/${post.slug}`}>
<Card className="overflow-hidden">
<CardHeader className="p-0">
<img
src={post.imageUrl}
alt={post.title}
className="h-auto w-full object-cover"
/>
</CardHeader>
<CardContent className="p-4">
<CardTitle className="text-sm">{post.title}</CardTitle>
<p className="mt-2 line-clamp-1 text-xs text-muted-foreground lg:line-clamp-2">
{post.description}
</p>
</CardContent>
<CardFooter className="flex items-center justify-between px-1 text-xs">
<div className="flex items-center space-x-1">
<Button variant="ghost" size="sm">
<Icons.up className="h-2 w-2" />
</Button>
<VoteCount post={post} />
<Button variant="ghost" size="sm">
<Icons.down className="h-2 w-2" />
</Button>
</div>
<span className="font-semibold"></span>
</CardFooter>
</Card>
</Link>
</div>
))}
</div>
) : (
<Loading />
);
};

View File

@@ -4,11 +4,11 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-background text-foreground",
default: "bg-background text-foreground border-border",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
@@ -19,41 +19,48 @@ const alertVariants = cva(
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {

View File

@@ -21,11 +21,7 @@ const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
))
CardHeader.displayName = "CardHeader"

View File

@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -1,178 +0,0 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -1,26 +0,0 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -1,24 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -1,129 +0,0 @@
"use client"
import * as React from "react"
import { Cross2Icon } from "@radix-ui/react-icons"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<Cross2Icon className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@@ -1,35 +0,0 @@
"use client"
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@@ -1,19 +0,0 @@
"use client";
import React from "react";
import { Button } from "@/components/ui/button";
type ActionButtonProps = {
title: string;
action: React.MouseEventHandler<HTMLDivElement>;
icon: React.ReactNode;
};
const ActionButton: React.FC<ActionButtonProps> = ({ title, action, icon }) => {
return (
<div onClick={action} className="cursor-pointer hover:text-primary">
{icon}
</div>
);
};
export default ActionButton;

View File

@@ -1,69 +0,0 @@
'use client';
import { Transition } from '@headlessui/react';
import React from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { HiOutlineClipboardCopy } from 'react-icons/hi';
interface ICopyTextInput {
label: string;
text: string;
}
const CopyTextInput: React.FC<ICopyTextInput> = ({ label, text }) => {
const [showResult, setShowResult] = React.useState(false);
const _onCopy = () => {
setShowResult(true);
setTimeout(() => {
setShowResult(false);
}, 2000);
};
return (
<React.Fragment>
<div>
<label
htmlFor="text-to-copy"
className="block text-sm font-medium label"
>
{label}
</label>
<div className="flex mt-1 rounded-md shadow-sm ">
<div className="relative flex items-stretch flex-grow indicator focus-within:z-10">
<Transition
show={showResult}
enter="transition-opacity duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-500"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<span className="w-3/4 indicator-item indicator-center badge badge-accent">
Copied successfully
</span>
</Transition>
<input
id="text-to-copy"
readOnly={true}
value={text}
className="block w-full rounded-none input input-md rounded-l-md sm:text-sm"
placeholder="John Doe"
/>
</div>
<CopyToClipboard
text={text}
onCopy={_onCopy}
>
<button
type="button"
className="relative inline-flex items-center px-4 py-2 -ml-px space-x-2 text-sm font-medium btn rounded-r-md"
>
<HiOutlineClipboardCopy className="w-6 h-6 text-accent" />
</button>
</CopyToClipboard>
</div>
</div>
</React.Fragment>
);
};
export default CopyTextInput;

View File

@@ -1,99 +0,0 @@
"use client";
import React from "react";
import Image from "next/image";
import { TbThumbUp, TbThumbDown } from "react-icons/tb";
import Link from "next/link";
// interface IGifContainerProps {
// gif: Gif;
// isLink?: boolean;
// showDetails?: boolean;
// }
// const GifContainer: React.FC<IGifContainerProps> = ({
// gif,
// isLink = true,
// showDetails = true,
// }) => {
// const [upVotes, setUpVotes] = React.useState<number>(gif.upVotes);
// const [downVotes, setDownVotes] = React.useState<number>(gif.downVotes);
// const _doot = async (id: string, isUp: boolean) => {
// const response = await fetch(`api/votes?gifId=${id}&isUp=${isUp ? 1 : 0}`, {
// method: 'POST',
// });
// if (response.status === 200) {
// const result = (await response.json()) as Gif;
// setUpVotes(result.upVotes);
// setDownVotes(result.downVotes);
// }
// };
// return (
// <>
// <div className="group relative h-[17.5rem] transform overflow-hidden rounded-4xl">
// <div className="absolute inset-0">
// {isLink ? (
// <Link
// href={`gifs/${gif.slug}`}
// title={gif.title}
// >
// <Image
// alt={gif.title}
// className="absolute inset-0 transition duration-300 group-hover:scale-110"
// src={gif.fileName}
// fill
// sizes="100vw"
// style={{
// objectFit: 'fill',
// }}
// />
// </Link>
// ) : (
// <Image
// alt={gif.title}
// className="absolute inset-0 transition duration-300 group-hover:scale-110"
// src={gif.fileName}
// fill
// sizes="100vw"
// style={{
// objectFit: 'fill',
// }}
// />
// )}
// </div>
// </div>
// {showDetails && (
// <div className="flex flex-row p-2">
// <div className="flex-1 space-x-2 text-base">
// {gif.searchTerms?.map((t) => (
// <div
// key={t}
// className="mr-0.5 badge badge-info badge-md badge-outline"
// >
// {`#${t}`}
// </div>
// ))}
// </div>
// <div className="flex items-center justify-center space-x-1">
// <div className="flex transition duration-75 ease-in-out delay-150 hover:text-orange-700 hover:cursor-pointer">
// <span onClick={() => _doot(gif.id, true)}>
// <TbThumbUp className="w-5" />
// </span>
// <span className="text-xs">{upVotes}</span>
// </div>
// <div className="flex transition duration-75 ease-in-out delay-150 hover:text-orange-700 hover:cursor-pointer">
// <span
// onClick={() => _doot(gif.id, false)}
// className="pl-2 "
// >
// <TbThumbDown className="w-5" />
// </span>
// <span className="text-xs">{downVotes}</span>
// </div>
// </div>
// </div>
// )}
// </>
// );
// };
// export default GifContainer;

Some files were not shown because too many files have changed in this diff Show More