Compare commits
10 Commits
99397e774c
...
bb89a84ba8
| Author | SHA1 | Date | |
|---|---|---|---|
| bb89a84ba8 | |||
| 90329f6b22 | |||
| d4661dc8e3 | |||
| 24d90a9412 | |||
| 6115851147 | |||
| 011695cf46 | |||
| 9f04130726 | |||
| 82d1e74af1 | |||
| 453264d9cd | |||
| b63e3ae77f |
87
README.md
87
README.md
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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
6
tailwind.config.ts
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{js,ts,jsx,tsx}", // Ensure your paths are correct
|
||||
],
|
||||
plugins: [require("@tailwindcss/typography")],
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user