Skip to content

Commit

Permalink
refactoring into layouts and adding favorites feature
Browse files Browse the repository at this point in the history
  • Loading branch information
webdevcody committed Feb 23, 2024
1 parent 51ca4a7 commit ce87bf4
Show file tree
Hide file tree
Showing 11 changed files with 335 additions and 42 deletions.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,10 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.

Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

## TODO

- filtering
- favorites
- trash
- sharing
126 changes: 109 additions & 17 deletions convex/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ConvexError, v } from "convex/values";
import { MutationCtx, QueryCtx, mutation, query } from "./_generated/server";
import { getUser } from "./users";
import { fileTypes } from "./schema";
import { Id } from "./_generated/dataModel";

export const generateUploadUrl = mutation(async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
Expand Down Expand Up @@ -62,6 +63,8 @@ export const createFile = mutation({
export const getFiles = query({
args: {
orgId: v.string(),
query: v.optional(v.string()),
favorites: v.optional(v.boolean()),
},
async handler(ctx, args) {
const identity = await ctx.auth.getUserIdentity();
Expand All @@ -80,38 +83,127 @@ export const getFiles = query({
return [];
}

return ctx.db
let files = await ctx.db
.query("files")
.withIndex("by_orgId", (q) => q.eq("orgId", args.orgId))
.collect();

const query = args.query;

if (query) {
files = files.filter((file) =>
file.name.toLowerCase().includes(query.toLowerCase())
);
}

if (args.favorites) {
const user = await ctx.db
.query("users")
.withIndex("by_tokenIdentifier", (q) =>
q.eq("tokenIdentifier", identity.tokenIdentifier)
)
.first();

if (!user) {
return files;
}

const favorites = await ctx.db
.query("favorites")
.withIndex("by_userId_orgId_fileId", (q) =>
q.eq("userId", user._id).eq("orgId", args.orgId)
)
.collect();

files = files.filter((file) =>
favorites.some((favorite) => favorite.fileId === file._id)
);
}

return files;
},
});

export const deleteFile = mutation({
args: { fileId: v.id("files") },
async handler(ctx, args) {
const identity = await ctx.auth.getUserIdentity();
const access = await hasAccessToFile(ctx, args.fileId);

if (!identity) {
throw new ConvexError("you do not have access to this org");
if (!access) {
throw new ConvexError("no access to file");
}

const file = await ctx.db.get(args.fileId);

if (!file) {
throw new ConvexError("this file does not exist");
}
await ctx.db.delete(args.fileId);
},
});

const hasAccess = await hasAccessToOrg(
ctx,
identity.tokenIdentifier,
file.orgId
);
export const toggleFavorite = mutation({
args: { fileId: v.id("files") },
async handler(ctx, args) {
const access = await hasAccessToFile(ctx, args.fileId);

if (!hasAccess) {
throw new ConvexError("you do not have access to delete this file");
if (!access) {
throw new ConvexError("no access to file");
}

await ctx.db.delete(args.fileId);
const favorite = await ctx.db
.query("favorites")
.withIndex("by_userId_orgId_fileId", (q) =>
q
.eq("userId", access.user._id)
.eq("orgId", access.file.orgId)
.eq("fileId", access.file._id)
)
.first();

if (!favorite) {
await ctx.db.insert("favorites", {
fileId: access.file._id,
userId: access.user._id,
orgId: access.file.orgId,
});
} else {
await ctx.db.delete(favorite._id);
}
},
});

async function hasAccessToFile(
ctx: QueryCtx | MutationCtx,
fileId: Id<"files">
) {
const identity = await ctx.auth.getUserIdentity();

if (!identity) {
return null;
}

const file = await ctx.db.get(fileId);

if (!file) {
return null;
}

const hasAccess = await hasAccessToOrg(
ctx,
identity.tokenIdentifier,
file.orgId
);

if (!hasAccess) {
return null;
}

const user = await ctx.db
.query("users")
.withIndex("by_tokenIdentifier", (q) =>
q.eq("tokenIdentifier", identity.tokenIdentifier)
)
.first();

if (!user) {
return null;
}

return { user, file };
}
5 changes: 5 additions & 0 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export default defineSchema({
orgId: v.string(),
fileId: v.id("_storage"),
}).index("by_orgId", ["orgId"]),
favorites: defineTable({
fileId: v.id("files"),
orgId: v.string(),
userId: v.id("users"),
}).index("by_userId_orgId_fileId", ["userId", "orgId", "fileId"]),
users: defineTable({
tokenIdentifier: v.string(),
orgIds: v.array(v.string()),
Expand Down
62 changes: 41 additions & 21 deletions src/app/page.tsx → ...pp/dashboard/_components/file-browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,61 +2,81 @@

import { useOrganization, useUser } from "@clerk/nextjs";
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import { api } from "../../../../convex/_generated/api";
import { UploadButton } from "./upload-button";
import { FileCard } from "./file-card";
import Image from "next/image";
import { Loader2 } from "lucide-react";
import { FileIcon, Loader2, StarIcon } from "lucide-react";
import { SearchBar } from "./search-bar";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import Link from "next/link";

export default function Home() {
function Placeholder() {
return (
<div className="flex flex-col gap-8 w-full items-center mt-24">
<Image
alt="an image of a picture and directory icon"
width="300"
height="300"
src="/empty.svg"
/>
<div className="text-2xl">You have no files, upload one now</div>
<UploadButton />
</div>
);
}

export function FileBrowser({
title,
favorites,
}: {
title: string;
favorites?: boolean;
}) {
const organization = useOrganization();
const user = useUser();
const [query, setQuery] = useState("");

let orgId: string | undefined = undefined;
if (organization.isLoaded && user.isLoaded) {
orgId = organization.organization?.id ?? user.user?.id;
}

const files = useQuery(api.files.getFiles, orgId ? { orgId } : "skip");
const files = useQuery(
api.files.getFiles,
orgId ? { orgId, query, favorites } : "skip"
);
const isLoading = files === undefined;

return (
<main className="container mx-auto pt-12">
<div>
{isLoading && (
<div className="flex flex-col gap-8 w-full items-center mt-24">
<Loader2 className="h-32 w-32 animate-spin text-gray-500" />
<div className="text-2xl">Loading your images...</div>
</div>
)}

{!isLoading && files.length === 0 && (
<div className="flex flex-col gap-8 w-full items-center mt-24">
<Image
alt="an image of a picture and directory icon"
width="300"
height="300"
src="/empty.svg"
/>
<div className="text-2xl">You have no files, upload one now</div>
<UploadButton />
</div>
)}

{!isLoading && files.length > 0 && (
{!isLoading && (
<>
<div className="flex justify-between items-center mb-8">
<h1 className="text-4xl font-bold">Your Files</h1>
<h1 className="text-4xl font-bold">{title}</h1>

<SearchBar query={query} setQuery={setQuery} />

<UploadButton />
</div>

{files.length === 0 && <Placeholder />}

<div className="grid grid-cols-3 gap-4">
{files?.map((file) => {
return <FileCard key={file._id} file={file} />;
})}
</div>
</>
)}
</main>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,21 @@ import {
CardTitle,
} from "@/components/ui/card";

import { Doc, Id } from "../../convex/_generated/dataModel";
import { Doc, Id } from "../../../../convex/_generated/dataModel";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
FileTextIcon,
GanttChartIcon,
ImageIcon,
MoreVertical,
StarIcon,
TrashIcon,
} from "lucide-react";
import {
Expand All @@ -33,12 +35,13 @@ import {
} from "@/components/ui/alert-dialog";
import { ReactNode, useState } from "react";
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import { api } from "../../../../convex/_generated/api";
import { useToast } from "@/components/ui/use-toast";
import Image from "next/image";

function FileCardActions({ file }: { file: Doc<"files"> }) {
const deleteFile = useMutation(api.files.deleteFile);
const toggleFavorite = useMutation(api.files.toggleFavorite);
const { toast } = useToast();

const [isConfirmOpen, setIsConfirmOpen] = useState(false);
Expand Down Expand Up @@ -79,6 +82,17 @@ function FileCardActions({ file }: { file: Doc<"files"> }) {
<MoreVertical />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => {
toggleFavorite({
fileId: file._id,
});
}}
className="flex gap-1 items-center cursor-pointer"
>
<StarIcon className="w-4 h-4" /> Favorite
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setIsConfirmOpen(true)}
className="flex gap-1 text-red-600 items-center cursor-pointer"
Expand Down
Loading

0 comments on commit ce87bf4

Please sign in to comment.