Compare commits

...

10 Commits

13 changed files with 240 additions and 366 deletions

View File

@ -1,29 +1,90 @@
# Create T3 App
# File Hosting App
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
This is a [T3 Stack](https://create.t3.gg/) project. It provides a file hosting service where users can upload, download, and manage files.
## What's next? How do I make an app with this?
## Features
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
- User authentication with NextAuth.js
- File upload and download using MinIO
- File metadata management with Prisma
- Real-time updates using Server-Sent Events (SSE)
- Responsive UI built with Tailwind CSS
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
## Technologies Used
- [Next.js](https://nextjs.org)
- [NextAuth.js](https://next-auth.js.org)
- [Prisma](https://prisma.io)
- [Drizzle](https://orm.drizzle.team)
- [MinIO](https://min.io)
- [Tailwind CSS](https://tailwindcss.com)
- [tRPC](https://trpc.io)
## Learn More
## API Documentation
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
### **Download File**
- [Documentation](https://create.t3.gg/)
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
**Endpoint**: `/api/files/download`
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome!
**Method**: `GET`
## How do I deploy this?
**Query Parameters**:
- `fileId` (required): The ID of the file to download.
- `fileName` (required): The name of the file to download.
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.
**Response**:
- **200 OK**: Returns the file as a binary stream.
- **400 Bad Request**: If `fileId` or `fileName` is missing.
- **404 Not Found**: If the file does not exist.
- **500 Internal Server Error**: If there is an error fetching the file.
---
### **Serve File**
**Endpoint**: `/api/files/serv`
**Method**: `GET`
**Query Parameters**:
- `id` (required): The ID of the file to serve.
**Response**:
- **200 OK**: Returns the file as a binary stream with the appropriate MIME type.
- **400 Bad Request**: If `id` is missing.
- **404 Not Found**: If the file does not exist.
- **500 Internal Server Error**: If there is an error fetching the file.
---
### **List Files**
**Endpoint**: `/api/files`
**Method**: `GET`
**Response**:
- **200 OK**: Returns a list of files with metadata.
- **500 Internal Server Error**: If there is an error fetching the files.
---
### **Real-Time Updates**
**Endpoint**: `/api/files/stream`
**Method**: `GET`
**Response**:
- **200 OK**: Streams real-time updates for file additions and removals.
---
## How to Run Locally
1. Clone the repository:
```bash
git clone https://github.com/your-username/file-hosting.git
cd file-hosting
npm install
npm run build
npm run start

View File

@ -15,6 +15,17 @@ datasource db {
url = env("DATABASE_URL")
}
model Post {
id Int @id @default(autoincrement())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy User @relation(fields: [createdById], references: [id])
createdById String
@@index([name])
}
// Necessary for Next auth
model Account {
@ -52,6 +63,7 @@ model User {
image String?
accounts Account[]
sessions Session[]
posts Post[]
files File[] // Relation to the File model
}
@ -73,5 +85,4 @@ model File {
description String @default("")
uploadedBy User? @relation(fields: [uploadedById], references: [id], onDelete: SetNull)
uploadedById String?
public Boolean @default(false) // Indicates if the file is public or private
}

View File

@ -1,4 +1,4 @@
"use client";
'use client';
import { useRef, useState } from "react";
import { useFileActions } from "~/app/_components/FileActions";
@ -7,13 +7,11 @@ export function FileActionsContainer({
fileName,
fileUrl,
isOwner,
isPublic,
}: {
fileId: string;
fileName: string;
fileUrl: string;
isOwner: boolean;
isPublic: boolean;
}) {
const { handleDownload, handleCopyUrl, handleRemove} = useFileActions(() => fileId, (description: string) => {
if (isOwner) {
@ -38,55 +36,14 @@ export function FileActionsContainer({
>
<img src="/icons/copy.svg" alt="Copy URL" className="w-6 h-6" />
</button>
{/* Remove Button */}
{isOwner && (
<button
onClick={() => handleRemove(fileId)}
className="flex items-center justify-center rounded-full bg-red-500 p-2 hover:bg-red-600"
>
<img src="/icons/delete.svg" alt="Remove" className="w-6 h-6" />
</button>
)}
{isOwner && (
<div className="mt-4 flex items-center gap-2">
<label className="text-sm">
Public:
</label>
<label
htmlFor={`public-toggle-${fileId}`}
className="relative inline-flex items-center cursor-pointer"
>
<input
id={`public-toggle-${fileId}`}
type="checkbox"
checked={isPublic} // Ensure this reflects the prop
onChange={async (e) => {
const newIsPublic = e.target.checked;
try {
const response = await fetch(`/api/files/update-public`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ fileId: fileId, isPublic: newIsPublic }),
});
if (!response.ok) {
throw new Error("Failed to update public status");
}
console.log("Public status updated successfully");
} catch (err) {
console.error("Error updating public status:", err);
}
}}
className="sr-only peer"
/>
{/* Toggle Background */}
<div className="w-10 h-5 bg-gray-300 rounded-full peer-checked:bg-green-500 peer-focus:ring-2 peer-focus:ring-green-300 transition-colors"></div>
{/* Toggle Handle */}
<div className="absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow-md peer-checked:translate-x-5 transition-transform"></div>
</label>
</div>
)}
</div>
);
}

View File

@ -15,7 +15,6 @@ interface FileDetails {
description: string;
extension: string;
isOwner: boolean; // Indicates if the user owns the file
isPublic: boolean; // Indicates if the file is public
}
interface FileGridProps {
@ -117,8 +116,7 @@ export default function FileGrid({ session }: FileGridProps) {
fileId={file.id}
fileName={file.name}
fileUrl={file.url}
isOwner={true}
isPublic={file.isPublic}
isOwner={file.isOwner}
/>
</div>
</div>

View File

@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import path from "path";
import { promises as fs } from "fs";
import { db } from "~/server/db";
import { minioClient } from "~/utils/minioClient";
export async function GET(req: Request) {
const url = new URL(req.url);
@ -10,22 +10,43 @@ export async function GET(req: Request) {
if (!fileId) {
return NextResponse.json({ error: "File id is required" }, { status: 400 });
}
if (!fileName){
if (!fileName) {
return NextResponse.json({ error: "File name is required" }, { status: 400 });
}
try {
const filePath = path.join(process.cwd(), "uploads", fileId);
const fileBuffer = await fs.readFile(filePath);
// Fetch file metadata from the database
const file = await db.file.findFirst({
where: { id: fileId },
});
return new Response(fileBuffer, {
if (!file) {
return NextResponse.json({ error: "File not found" }, { status: 404 });
}
const bucketName = process.env.MINIO_BUCKET || "file-hosting";
const objectName = `${file.id}-${file.name}`; // Construct the object name in MinIO
// Fetch the file from MinIO
const stream = await minioClient.getObject(bucketName, objectName);
// Return the file as a binary response
const readableStream = new ReadableStream({
start(controller) {
stream.on("data", (chunk) => controller.enqueue(chunk));
stream.on("end", () => controller.close());
stream.on("error", (err) => controller.error(err));
},
});
return new Response(readableStream, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${fileName}"`,
},
});
} catch (error) {
console.error("Error reading file:", error);
return NextResponse.json({ error: "File not found" }, { status: 404 });
console.error("Error fetching file from MinIO:", error);
return NextResponse.json({ error: "Failed to fetch file" }, { status: 500 });
}
}

View File

@ -1,25 +0,0 @@
import { NextResponse } from "next/server";
import { db } from "~/server/db";
export async function GET(req: Request) {
const url = new URL(req.url);
const query = url.searchParams.get("query") || "";
try {
// if query is empty, return no files
const files = await db.file.findMany({
where: {
OR: [
{ name: { contains: query } },
{ description: { contains: query } },
],
public: true,
},
});
return NextResponse.json({ files });
} catch (error) {
console.error("Error fetching files:", error);
return NextResponse.json({ error: "Failed to fetch files" }, { status: 500 });
}
}

View File

@ -32,7 +32,6 @@ export async function GET(req: Request) {
type: file.extension,
url: file.url,
description: file.description,
isPublic: file.public, // Ensure this is included
});
} catch (error) {
console.error("Error fetching file details:", error);

View File

@ -1,22 +0,0 @@
import { NextResponse } from "next/server";
import { db } from "~/server/db";
export async function POST(req: Request) {
try {
const { fileId, isPublic } = await req.json();
if (!fileId) {
return NextResponse.json({ error: "File ID is required" }, { status: 400 });
}
await db.file.update({
where: { id: fileId },
data: { public: isPublic },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error updating public status:", error);
return NextResponse.json({ error: "Failed to update public status" }, { status: 500 });
}
}

View File

@ -8,35 +8,14 @@ import { Toaster } from "react-hot-toast";
export default async function Home() {
const session = await auth();
return (
<HydrateClient>
<Toaster position="top-right" reverseOrder={false} />
<main className="relative flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white">
{/* Top-right corner sign-out button */}
{session?.user && (
<div className="absolute top-4 right-4 flex items-center gap-4">
{/* Search Button */}
<Link
href="/search"
className="rounded-full bg-white/10 p-2 transition hover:bg-white/20"
aria-label="Search"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-4.35-4.35M16.65 10.65a6 6 0 11-12 0 6 6 0 0112 0z"
/>
</svg>
</Link>
<div className="absolute top-4 right-4">
<Link
href="/api/auth/signout"
className="rounded-full bg-white/10 px-4 py-2 font-semibold no-underline transition hover:bg-white/20"
@ -54,7 +33,7 @@ export default async function Home() {
{session?.user ? (
<>
<FileGrid session={session} />
<UploadForm />
<UploadForm/>
</>
) : (
<p className="text-center text-2xl text-white">
@ -62,16 +41,16 @@ export default async function Home() {
</p>
)}
{!session?.user && (
<div className="flex flex-col items-center gap-2">
<div className="flex flex-col items-center justify-center gap-4">
<Link
href={session ? "/api/auth/signout" : "/api/auth/signin"}
className="rounded-full bg-white/10 px-10 py-3 font-semibold no-underline transition hover:bg-white/20"
>
{session ? "Sign out" : "Sign in"}
</Link>
</div>
<div className="flex flex-col items-center gap-2">
<div className="flex flex-col items-center justify-center gap-4">
<Link
href={session ? "/api/auth/signout" : "/api/auth/signin"}
className="rounded-full bg-white/10 px-10 py-3 font-semibold no-underline transition hover:bg-white/20"
>
{session ? "Sign out" : "Sign in"}
</Link>
</div>
</div>
)}
</div>
</main>

View File

@ -1,121 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { env } from "~/env.js";
import { FilePreview } from "~/app/_components/FilePreview";
import { FileActionsContainer } from "~/app/_components/ActionButtons";
import { HomeButton } from "~/app/_components/HomeButton";
interface FileDetails {
name: string;
size: number;
owner: string;
ownerAvatar: string | null;
uploadDate: string;
id: string;
isOwner: boolean;
extension: string;
url: string;
description: string;
isPublic: boolean;
}
export default function SearchFile() {
const [searchQuery, setSearchQuery] = useState<string>("");
const [files, setFiles] = useState<FileDetails[]>([]);
const [error, setError] = useState<string | null>(null);
const pageUrl = env.NEXT_PUBLIC_PAGE_URL;
const router = useRouter();
useEffect(() => {
const fetchFiles = async () => {
try {
const response = await fetch(
`/api/files/search?query=${encodeURIComponent(searchQuery)}`
);
if (!response.ok) {
throw new Error("Failed to fetch files");
}
const data = await response.json();
setFiles(data.files);
} catch (err) {
console.error(err);
setError("Failed to load files.");
}
};
fetchFiles();
}, [searchQuery]);
return (
<main className="flex min-h-screen flex-col items-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white">
<div className="absolute top-4 left-4 z-20">
<HomeButton />
</div>
{/* Search Bar */}
<div className="sticky top-0 z-10 w-full bg-[#2e026d] px-4 py-4 shadow-md">
<div className="relative w-full max-w-md mx-auto">
<input
type="text"
placeholder="Search files..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full p-3 pl-12 text-white bg-[#3b0764] rounded-full shadow-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent placeholder-gray-400"
/>
<svg
xmlns="http://www.w3.org/2000/svg"
className="absolute left-4 top-1/2 transform -translate-y-1/2 h-6 w-6 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-4.35-4.35M16.65 10.65a6 6 0 11-12 0 6 6 0 0112 0z"
/>
</svg>
</div>
</div>
{/* File Grid */}
<div className="flex-grow flex flex-col items-center justify-center w-full px-4">
{error && <div className="text-red-500">{error}</div>}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 w-full max-w-7xl">
{files.map((file) => {
return (
<div
key={file.id}
className="flex place-content-end w-xxs flex-col gap-4 rounded-xl bg-white/10 p-4 hover:bg-white/20"
>
<div className="self-center max-w-100 sm:max-w-50">
<FilePreview fileId={file.id} fileType={file.extension} />
</div>
<button onClick={() => router.push(pageUrl + file.url)}>
<h3 className="text-2xl font-bold">{file.name}</h3>
</button>
{file.description && (
<p className="text-sm text-gray-400">
Description: {file.description}
</p>
)}
<div className="flex self-center gap-2">
<FileActionsContainer
fileId={file.id}
fileName={file.name}
fileUrl={file.url}
isOwner={false} // Check if the user is the owner
isPublic={file.isPublic} // Check if the file is public
/>
</div>
</div>
);
})}
</div>
</div>
</main>
);
}

View File

@ -2,8 +2,11 @@ import { notFound } from "next/navigation";
import { FilePreview } from "~/app/_components/FilePreview";
import { HomeButton } from "~/app/_components/HomeButton"; // Import the client component
import { Toaster } from "react-hot-toast";
import { FileActionsContainer, FileDescriptionContainer } from "~/app/_components/ActionButtons"; // Import the client component
import Head from "next/head";
import {
FileActionsContainer,
FileDescriptionContainer,
} from "~/app/_components/ActionButtons"; // Import the client component
import type { Metadata } from "next";
interface FileDetails {
name: string;
@ -16,7 +19,47 @@ interface FileDetails {
type: string;
url: string;
description: string;
isPublic: boolean;
}
export async function generateMetadata({
searchParams,
}: {
searchParams: Promise<{ id?: string }>;
}): Promise<Metadata> {
const resolvedSearchParams = await searchParams; // Resolve the promise
const fileId = resolvedSearchParams.id;
if (!fileId) {
return {
title: "File Not Found",
description: "The file you are looking for does not exist.",
};
}
const fileDetails = await fetchFileDetails(fileId);
if (!fileDetails) {
return {
title: "File Not Found",
description: "The file you are looking for does not exist.",
};
}
return {
title: fileDetails.name,
description: fileDetails.description || fileDetails.name,
openGraph: {
title: fileDetails.name,
description: fileDetails.description || fileDetails.name,
url: `${process.env.NEXT_PUBLIC_PAGE_URL}/share?id=${fileDetails.id}`,
images: [
{
url: `${process.env.NEXT_PUBLIC_PAGE_URL}/api/files/serv?id=${fileDetails.id}`,
alt: `${fileDetails.name} preview`,
},
],
},
};
}
async function fetchFileDetails(fileId: string): Promise<FileDetails | null> {
@ -52,7 +95,6 @@ export default async function FilePreviewContainer({
}
const fileDetails = await fetchFileDetails(fileId);
if (!fileDetails) {
return (
@ -71,98 +113,64 @@ export default async function FilePreviewContainer({
}
return (
<>
<Head>
<title>{fileDetails.name} - File Details</title>
<meta
property="og:title"
content={`${fileDetails.name} - File Details`}
/>
<meta
property="og:description"
content={`Size: ${
fileDetails.size > 1024 * 1024 * 1024
<main className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white">
<div className="absolute top-4 left-4">
<HomeButton />
</div>
<Toaster position="top-right" reverseOrder={false} />
<div className="container flex flex-col items-center gap-12 px-4 py-16">
<h1 className="text-5xl font-extrabold tracking-tight sm:text-[5rem]">
<span className="text-[hsl(280,100%,70%)]">File</span> Details
</h1>
<div className="mt-6">
{fileDetails.type !== "unknown" && (
<FilePreview fileId={fileDetails.id} fileType={fileDetails.type} />
)}
</div>
<div className="w-full max-w-md rounded-lg bg-white/10 p-6 text-white shadow-md">
<p>
<strong>Name:</strong> {fileDetails.name}
</p>
<p>
<strong>Size:</strong>{" "}
{fileDetails.size > 1024 * 1024 * 1024
? (fileDetails.size / (1024 * 1024 * 1024)).toFixed(2) + " GB"
: fileDetails.size > 1024 * 1024
? (fileDetails.size / (1024 * 1024)).toFixed(2) + " MB"
: fileDetails.size > 1024
? (fileDetails.size / 1024).toFixed(2) + " KB"
: fileDetails.size + " Bytes"
}, Owner: ${fileDetails.owner}, Uploaded on: ${new Date(
fileDetails.uploadDate
).toLocaleString()}`}
/>
<meta property="og:type" content="website" />
<meta
property="og:image"
content={`${process.env.NEXT_PUBLIC_PAGE_URL}/api/files/serv?id=${fileId}`}
/>
<meta property="og:image:alt" content={`${fileDetails.name} preview`} />
<meta
property="og:url"
content={`${process.env.NEXT_PUBLIC_PAGE_URL}/share?id=${fileId}`}
/>
</Head>
<main className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white">
<div className="absolute top-4 left-4">
<HomeButton /> {/* Use the client component */}
</div>
<div className="container flex flex-col items-center gap-12 px-4 py-16">
<h1 className="text-5xl font-extrabold tracking-tight sm:text-[5rem]">
<span className="text-[hsl(280,100%,70%)]">File</span> Details
</h1>
<div className="mt-6">
{fileDetails.type !== "unknown" && (
<FilePreview
fileId={fileDetails.id}
fileType={fileDetails.type}
/>
)}
: fileDetails.size + " Bytes"}
</p>
<p>
<strong>Owner:</strong>{" "}
<img
className="inline size-5 rounded-md"
src={fileDetails.ownerAvatar || ""}
alt="Owner avatar"
/>{" "}
{fileDetails.owner}
</p>
<p>
<strong>Upload Date:</strong>{" "}
{new Date(fileDetails.uploadDate).toLocaleString()}
</p>
<div>
<strong>Description:</strong>{" "}
<FileDescriptionContainer
fileId={fileDetails.id}
fileDescription={fileDetails.description}
/>
</div>
<div className="bg-white/10 shadow-md rounded-lg p-6 w-full max-w-md text-white">
<p>
<strong>Name:</strong> {fileDetails.name}
</p>
<p>
<strong>Size:</strong>{" "}
{fileDetails.size > 1024 * 1024 * 1024
? (fileDetails.size / (1024 * 1024 * 1024)).toFixed(2) + " GB"
: fileDetails.size > 1024 * 1024
? (fileDetails.size / (1024 * 1024)).toFixed(2) + " MB"
: fileDetails.size > 1024
? (fileDetails.size / 1024).toFixed(2) + " KB"
: fileDetails.size + " Bytes"}
</p>
<p>
<strong>Owner:</strong>{" "}
<img
className="rounded-md inline size-5"
src={fileDetails.ownerAvatar || ""}
alt="Owner avatar"
/>{" "}
{fileDetails.owner}
</p>
<p>
<strong>Upload Date:</strong>{" "}
{new Date(fileDetails.uploadDate).toLocaleString()}
</p>
<div>
<strong>Description:</strong>{" "}
<FileDescriptionContainer fileId={fileDetails.id} fileDescription={fileDetails.description}/>
</div>
<div className="mt-4 flex justify-center">
<FileActionsContainer
fileId={fileDetails.id}
fileName={fileDetails.name}
fileUrl={fileDetails.url}
isOwner={fileDetails.isOwner}
isPublic={fileDetails.isPublic}
/>
</div>
<div className="mt-4 flex justify-center">
<FileActionsContainer
fileId={fileDetails.id}
fileName={fileDetails.name}
fileUrl={fileDetails.url}
isOwner={fileDetails.isOwner}
/>
</div>
</div>
</main>
</>
</div>
</main>
);
}

View File

@ -18,18 +18,20 @@ export const postRouter = createTRPCRouter({
create: protectedProcedure
.input(z.object({ name: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
return ctx.db.post.create({
return ctx.db.file.create({
data: {
name: input.name,
createdBy: { connect: { id: ctx.session.user.id } },
url: "default-url", // Replace with actual URL logic
size: 0, // Replace with actual size logic
extension: "txt", // Replace with actual extension logic
},
});
}),
getLatest: protectedProcedure.query(async ({ ctx }) => {
const post = await ctx.db.post.findFirst({
orderBy: { createdAt: "desc" },
where: { createdBy: { id: ctx.session.user.id } },
const post = await ctx.db.file.findFirst({
orderBy: { uploadDate: "desc" }, // Replace 'createdAt' with the correct field name from your schema
where: { uploadedById: ctx.session.user.id },
});
return post ?? null;

6
tailwind.config.ts Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
content: [
"./src/**/*.{js,ts,jsx,tsx}", // Ensure your paths are correct
],
plugins: [require("@tailwindcss/typography")],
};