Skip to content

Commit 978abec

Browse files
committedFeb 24, 2024
logo in header, display creation time and user avatar in cards, move download button
1 parent e7a386e commit 978abec

File tree

16 files changed

+334
-39
lines changed

16 files changed

+334
-39
lines changed
 

‎README.md

+9-4
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,12 @@ Check out our [Next.js deployment documentation](https://nextjs.org/docs/deploym
3737

3838
## TODO
3939

40-
- filtering
41-
- favorites
42-
- trash
43-
- sharing
40+
- uploaded by
41+
- upload date
42+
- filters by type
43+
- shared with me
44+
- table view
45+
- view toggle
46+
- folders
47+
- icon
48+
- landing page

‎convex/_generated/api.d.ts

+2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
FunctionReference,
1616
} from "convex/server";
1717
import type * as clerk from "../clerk.js";
18+
import type * as crons from "../crons.js";
1819
import type * as files from "../files.js";
1920
import type * as http from "../http.js";
2021
import type * as users from "../users.js";
@@ -29,6 +30,7 @@ import type * as users from "../users.js";
2930
*/
3031
declare const fullApi: ApiFromModules<{
3132
clerk: typeof clerk;
33+
crons: typeof crons;
3234
files: typeof files;
3335
http: typeof http;
3436
users: typeof users;

‎convex/crons.ts

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { cronJobs } from "convex/server";
2+
import { internal } from "./_generated/api";
3+
4+
const crons = cronJobs();
5+
6+
crons.interval(
7+
"delete any old files marked for deletion",
8+
{ minutes: 1 },
9+
internal.files.deleteAllFiles
10+
);
11+
12+
export default crons;

‎convex/files.ts

+58-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { ConvexError, v } from "convex/values";
2-
import { MutationCtx, QueryCtx, mutation, query } from "./_generated/server";
2+
import {
3+
MutationCtx,
4+
QueryCtx,
5+
internalMutation,
6+
mutation,
7+
query,
8+
} from "./_generated/server";
39
import { getUser } from "./users";
410
import { fileTypes } from "./schema";
511
import { Id } from "./_generated/dataModel";
@@ -62,6 +68,7 @@ export const createFile = mutation({
6268
orgId: args.orgId,
6369
fileId: args.fileId,
6470
type: args.type,
71+
userId: hasAccess.user._id,
6572
});
6673
},
6774
});
@@ -71,6 +78,7 @@ export const getFiles = query({
7178
orgId: v.string(),
7279
query: v.optional(v.string()),
7380
favorites: v.optional(v.boolean()),
81+
deletedOnly: v.optional(v.boolean()),
7482
},
7583
async handler(ctx, args) {
7684
const hasAccess = await hasAccessToOrg(ctx, args.orgId);
@@ -105,10 +113,33 @@ export const getFiles = query({
105113
);
106114
}
107115

116+
if (args.deletedOnly) {
117+
files = files.filter((file) => file.shouldDelete);
118+
} else {
119+
files = files.filter((file) => !file.shouldDelete);
120+
}
121+
108122
return files;
109123
},
110124
});
111125

126+
export const deleteAllFiles = internalMutation({
127+
args: {},
128+
async handler(ctx) {
129+
const files = await ctx.db
130+
.query("files")
131+
.withIndex("by_shouldDelete", (q) => q.eq("shouldDelete", true))
132+
.collect();
133+
134+
await Promise.all(
135+
files.map(async (file) => {
136+
await ctx.storage.delete(file.fileId);
137+
return await ctx.db.delete(file._id);
138+
})
139+
);
140+
},
141+
});
142+
112143
export const deleteFile = mutation({
113144
args: { fileId: v.id("files") },
114145
async handler(ctx, args) {
@@ -126,7 +157,32 @@ export const deleteFile = mutation({
126157
throw new ConvexError("you have no admin access to delete");
127158
}
128159

129-
await ctx.db.delete(args.fileId);
160+
await ctx.db.patch(args.fileId, {
161+
shouldDelete: true,
162+
});
163+
},
164+
});
165+
166+
export const restoreFile = mutation({
167+
args: { fileId: v.id("files") },
168+
async handler(ctx, args) {
169+
const access = await hasAccessToFile(ctx, args.fileId);
170+
171+
if (!access) {
172+
throw new ConvexError("no access to file");
173+
}
174+
175+
const isAdmin =
176+
access.user.orgIds.find((org) => org.orgId === access.file.orgId)
177+
?.role === "admin";
178+
179+
if (!isAdmin) {
180+
throw new ConvexError("you have no admin access to delete");
181+
}
182+
183+
await ctx.db.patch(args.fileId, {
184+
shouldDelete: false,
185+
});
130186
},
131187
});
132188

‎convex/http.ts

+17-4
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,33 @@ http.route({
2525
switch (result.type) {
2626
case "user.created":
2727
await ctx.runMutation(internal.users.createUser, {
28-
tokenIdentifier: `https://poetic-salmon-66.clerk.accounts.dev|${result.data.id}`,
28+
tokenIdentifier: `https://${process.env.CLERK_HOSTNAME}|${result.data.id}`,
29+
name: `${result.data.first_name ?? ""} ${
30+
result.data.last_name ?? ""
31+
}`,
32+
image: result.data.image_url,
33+
});
34+
break;
35+
case "user.updated":
36+
await ctx.runMutation(internal.users.updateUser, {
37+
tokenIdentifier: `https://${process.env.CLERK_HOSTNAME}|${result.data.id}`,
38+
name: `${result.data.first_name ?? ""} ${
39+
result.data.last_name ?? ""
40+
}`,
41+
image: result.data.image_url,
2942
});
3043
break;
3144
case "organizationMembership.created":
3245
await ctx.runMutation(internal.users.addOrgIdToUser, {
33-
tokenIdentifier: `https://poetic-salmon-66.clerk.accounts.dev|${result.data.public_user_data.user_id}`,
46+
tokenIdentifier: `https://${process.env.CLERK_HOSTNAME}|${result.data.public_user_data.user_id}`,
3447
orgId: result.data.organization.id,
35-
role: result.data.role === "admin" ? "admin" : "member",
48+
role: result.data.role === "org:admin" ? "admin" : "member",
3649
});
3750
break;
3851
case "organizationMembership.updated":
3952
console.log(result.data.role);
4053
await ctx.runMutation(internal.users.updateRoleInOrgForUser, {
41-
tokenIdentifier: `https://poetic-salmon-66.clerk.accounts.dev|${result.data.public_user_data.user_id}`,
54+
tokenIdentifier: `https://${process.env.CLERK_HOSTNAME}|${result.data.public_user_data.user_id}`,
4255
orgId: result.data.organization.id,
4356
role: result.data.role === "org:admin" ? "admin" : "member",
4457
});

‎convex/schema.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,20 @@ export default defineSchema({
1515
type: fileTypes,
1616
orgId: v.string(),
1717
fileId: v.id("_storage"),
18-
}).index("by_orgId", ["orgId"]),
18+
userId: v.id("users"),
19+
shouldDelete: v.optional(v.boolean()),
20+
})
21+
.index("by_orgId", ["orgId"])
22+
.index("by_shouldDelete", ["shouldDelete"]),
1923
favorites: defineTable({
2024
fileId: v.id("files"),
2125
orgId: v.string(),
2226
userId: v.id("users"),
2327
}).index("by_userId_orgId_fileId", ["userId", "orgId", "fileId"]),
2428
users: defineTable({
2529
tokenIdentifier: v.string(),
30+
name: v.optional(v.string()),
31+
image: v.optional(v.string()),
2632
orgIds: v.array(
2733
v.object({
2834
orgId: v.string(),

‎convex/users.ts

+42-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { ConvexError, v } from "convex/values";
2-
import { MutationCtx, QueryCtx, internalMutation } from "./_generated/server";
2+
import {
3+
MutationCtx,
4+
QueryCtx,
5+
internalMutation,
6+
query,
7+
} from "./_generated/server";
38
import { roles } from "./schema";
49

510
export async function getUser(
@@ -21,11 +26,34 @@ export async function getUser(
2126
}
2227

2328
export const createUser = internalMutation({
24-
args: { tokenIdentifier: v.string() },
29+
args: { tokenIdentifier: v.string(), name: v.string(), image: v.string() },
2530
async handler(ctx, args) {
2631
await ctx.db.insert("users", {
2732
tokenIdentifier: args.tokenIdentifier,
2833
orgIds: [],
34+
name: args.name,
35+
image: args.image,
36+
});
37+
},
38+
});
39+
40+
export const updateUser = internalMutation({
41+
args: { tokenIdentifier: v.string(), name: v.string(), image: v.string() },
42+
async handler(ctx, args) {
43+
const user = await ctx.db
44+
.query("users")
45+
.withIndex("by_tokenIdentifier", (q) =>
46+
q.eq("tokenIdentifier", args.tokenIdentifier)
47+
)
48+
.first();
49+
50+
if (!user) {
51+
throw new ConvexError("no user with this token found");
52+
}
53+
54+
await ctx.db.patch(user._id, {
55+
name: args.name,
56+
image: args.image,
2957
});
3058
},
3159
});
@@ -61,3 +89,15 @@ export const updateRoleInOrgForUser = internalMutation({
6189
});
6290
},
6391
});
92+
93+
export const getUserProfile = query({
94+
args: { userId: v.id("users") },
95+
async handler(ctx, args) {
96+
const user = await ctx.db.get(args.userId);
97+
98+
return {
99+
name: user?.name,
100+
image: user?.image,
101+
};
102+
},
103+
});

‎package-lock.json

+37
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

+2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"@clerk/nextjs": "^4.29.7",
1414
"@hookform/resolvers": "^3.3.4",
1515
"@radix-ui/react-alert-dialog": "^1.0.5",
16+
"@radix-ui/react-avatar": "^1.0.4",
1617
"@radix-ui/react-dialog": "^1.0.5",
1718
"@radix-ui/react-dropdown-menu": "^2.0.6",
1819
"@radix-ui/react-label": "^2.0.2",
@@ -21,6 +22,7 @@
2122
"class-variance-authority": "^0.7.0",
2223
"clsx": "^2.1.0",
2324
"convex": "^1.9.1",
25+
"date-fns": "^3.3.1",
2426
"lucide-react": "^0.336.0",
2527
"next": "14.1.0",
2628
"react": "^18",

‎public/logo.png

111 KB
Loading

‎src/app/dashboard/_components/file-browser.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,11 @@ function Placeholder() {
3030
export function FileBrowser({
3131
title,
3232
favoritesOnly,
33+
deletedOnly,
3334
}: {
3435
title: string;
3536
favoritesOnly?: boolean;
37+
deletedOnly?: boolean;
3638
}) {
3739
const organization = useOrganization();
3840
const user = useUser();
@@ -50,7 +52,7 @@ export function FileBrowser({
5052

5153
const files = useQuery(
5254
api.files.getFiles,
53-
orgId ? { orgId, query, favorites: favoritesOnly } : "skip"
55+
orgId ? { orgId, query, favorites: favoritesOnly, deletedOnly } : "skip"
5456
);
5557
const isLoading = files === undefined;
5658

‎src/app/dashboard/_components/file-card.tsx

+61-23
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
CardHeader,
66
CardTitle,
77
} from "@/components/ui/card";
8+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
9+
import { format, formatDistance, formatRelative, subDays } from "date-fns";
810

911
import { Doc, Id } from "../../../../convex/_generated/dataModel";
1012
import { Button } from "@/components/ui/button";
@@ -16,13 +18,15 @@ import {
1618
DropdownMenuTrigger,
1719
} from "@/components/ui/dropdown-menu";
1820
import {
21+
FileIcon,
1922
FileTextIcon,
2023
GanttChartIcon,
2124
ImageIcon,
2225
MoreVertical,
2326
StarHalf,
2427
StarIcon,
2528
TrashIcon,
29+
UndoIcon,
2630
} from "lucide-react";
2731
import {
2832
AlertDialog,
@@ -35,7 +39,7 @@ import {
3539
AlertDialogTitle,
3640
} from "@/components/ui/alert-dialog";
3741
import { ReactNode, useState } from "react";
38-
import { useMutation } from "convex/react";
42+
import { useMutation, useQuery } from "convex/react";
3943
import { api } from "../../../../convex/_generated/api";
4044
import { useToast } from "@/components/ui/use-toast";
4145
import Image from "next/image";
@@ -49,6 +53,7 @@ function FileCardActions({
4953
isFavorited: boolean;
5054
}) {
5155
const deleteFile = useMutation(api.files.deleteFile);
56+
const restoreFile = useMutation(api.files.restoreFile);
5257
const toggleFavorite = useMutation(api.files.toggleFavorite);
5358
const { toast } = useToast();
5459

@@ -61,8 +66,8 @@ function FileCardActions({
6166
<AlertDialogHeader>
6267
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
6368
<AlertDialogDescription>
64-
This action cannot be undone. This will permanently delete your
65-
account and remove your data from our servers.
69+
This action will mark the file for our deletion process. Files are
70+
deleted periodically
6671
</AlertDialogDescription>
6772
</AlertDialogHeader>
6873
<AlertDialogFooter>
@@ -74,8 +79,8 @@ function FileCardActions({
7479
});
7580
toast({
7681
variant: "default",
77-
title: "File deleted",
78-
description: "Your file is now gone from the system",
82+
title: "File marked for deletion",
83+
description: "Your file will be deleted soon",
7984
});
8085
}}
8186
>
@@ -90,6 +95,15 @@ function FileCardActions({
9095
<MoreVertical />
9196
</DropdownMenuTrigger>
9297
<DropdownMenuContent>
98+
<DropdownMenuItem
99+
onClick={() => {
100+
window.open(getFileUrl(file.fileId), "_blank");
101+
}}
102+
className="flex gap-1 items-center cursor-pointer"
103+
>
104+
<FileIcon className="w-4 h-4" /> Download
105+
</DropdownMenuItem>
106+
93107
<DropdownMenuItem
94108
onClick={() => {
95109
toggleFavorite({
@@ -108,15 +122,32 @@ function FileCardActions({
108122
</div>
109123
)}
110124
</DropdownMenuItem>
111-
{/* <Protect role="org:admin" fallback={<></>}> */}
112-
<DropdownMenuSeparator />
113-
<DropdownMenuItem
114-
onClick={() => setIsConfirmOpen(true)}
115-
className="flex gap-1 text-red-600 items-center cursor-pointer"
116-
>
117-
<TrashIcon className="w-4 h-4" /> Delete
118-
</DropdownMenuItem>
119-
{/* </Protect> */}
125+
126+
<Protect role="org:admin" fallback={<></>}>
127+
<DropdownMenuSeparator />
128+
<DropdownMenuItem
129+
onClick={() => {
130+
if (file.shouldDelete) {
131+
restoreFile({
132+
fileId: file._id,
133+
});
134+
} else {
135+
setIsConfirmOpen(true);
136+
}
137+
}}
138+
className="flex gap-1 items-center cursor-pointer"
139+
>
140+
{file.shouldDelete ? (
141+
<div className="flex gap-1 text-green-600 items-center cursor-pointer">
142+
<UndoIcon className="w-4 h-4" /> Restore
143+
</div>
144+
) : (
145+
<div className="flex gap-1 text-red-600 items-center cursor-pointer">
146+
<TrashIcon className="w-4 h-4" /> Delete
147+
</div>
148+
)}
149+
</DropdownMenuItem>
150+
</Protect>
120151
</DropdownMenuContent>
121152
</DropdownMenu>
122153
</>
@@ -134,6 +165,10 @@ export function FileCard({
134165
file: Doc<"files">;
135166
favorites: Doc<"favorites">[];
136167
}) {
168+
const userProfile = useQuery(api.users.getUserProfile, {
169+
userId: file.userId,
170+
});
171+
137172
const typeIcons = {
138173
image: <ImageIcon />,
139174
pdf: <FileTextIcon />,
@@ -147,7 +182,7 @@ export function FileCard({
147182
return (
148183
<Card>
149184
<CardHeader className="relative">
150-
<CardTitle className="flex gap-2">
185+
<CardTitle className="flex gap-2 text-base font-normal">
151186
<div className="flex justify-center">{typeIcons[file.type]}</div>{" "}
152187
{file.name}
153188
</CardTitle>
@@ -168,14 +203,17 @@ export function FileCard({
168203
{file.type === "csv" && <GanttChartIcon className="w-20 h-20" />}
169204
{file.type === "pdf" && <FileTextIcon className="w-20 h-20" />}
170205
</CardContent>
171-
<CardFooter className="flex justify-center">
172-
<Button
173-
onClick={() => {
174-
window.open(getFileUrl(file.fileId), "_blank");
175-
}}
176-
>
177-
Download
178-
</Button>
206+
<CardFooter className="flex justify-between">
207+
<div className="flex gap-2 text-xs text-gray-700 w-40 items-center">
208+
<Avatar className="w-6 h-6">
209+
<AvatarImage src={userProfile?.image} />
210+
<AvatarFallback>CN</AvatarFallback>
211+
</Avatar>
212+
{userProfile?.name}
213+
</div>
214+
<div className="text-xs text-gray-700">
215+
Uploaded on {formatRelative(new Date(file._creationTime), new Date())}
216+
</div>
179217
</CardFooter>
180218
</Card>
181219
);

‎src/app/dashboard/side-nav.tsx

+12-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { Button } from "@/components/ui/button";
44
import clsx from "clsx";
5-
import { FileIcon, StarIcon } from "lucide-react";
5+
import { FileIcon, StarIcon, TrashIcon } from "lucide-react";
66
import Link from "next/link";
77
import { usePathname } from "next/navigation";
88

@@ -32,6 +32,17 @@ export function SideNav() {
3232
<StarIcon /> Favorites
3333
</Button>
3434
</Link>
35+
36+
<Link href="/dashboard/trash">
37+
<Button
38+
variant={"link"}
39+
className={clsx("flex gap-2", {
40+
"text-blue-500": pathname.includes("/dashboard/trash"),
41+
})}
42+
>
43+
<TrashIcon /> Trash
44+
</Button>
45+
</Link>
3546
</div>
3647
);
3748
}

‎src/app/dashboard/trash/page.tsx

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"use client";
2+
3+
import { FileBrowser } from "../_components/file-browser";
4+
5+
export default function FavoritesPage() {
6+
return (
7+
<div>
8+
<FileBrowser title="Trash" deletedOnly />
9+
</div>
10+
);
11+
}

‎src/app/header.tsx

+11-1
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,22 @@ import {
55
SignedOut,
66
UserButton,
77
} from "@clerk/nextjs";
8+
import Image from "next/image";
9+
import Link from "next/link";
810

911
export function Header() {
1012
return (
1113
<div className="border-b py-4 bg-gray-50">
1214
<div className="items-center container mx-auto justify-between flex">
13-
<div>FileDrive</div>
15+
<Link href="/" className="flex gap-2 items-center text-xl text-black">
16+
<Image src="/logo.png" width="50" height="50" alt="file drive logo" />
17+
FileDrive
18+
</Link>
19+
20+
<Button variant={"outline"}>
21+
<Link href="/dashboard/files">Your Files</Link>
22+
</Button>
23+
1424
<div className="flex gap-2">
1525
<OrganizationSwitcher />
1626
<UserButton />

‎src/components/ui/avatar.tsx

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"use client"
2+
3+
import * as React from "react"
4+
import * as AvatarPrimitive from "@radix-ui/react-avatar"
5+
6+
import { cn } from "@/lib/utils"
7+
8+
const Avatar = React.forwardRef<
9+
React.ElementRef<typeof AvatarPrimitive.Root>,
10+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
11+
>(({ className, ...props }, ref) => (
12+
<AvatarPrimitive.Root
13+
ref={ref}
14+
className={cn(
15+
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
16+
className
17+
)}
18+
{...props}
19+
/>
20+
))
21+
Avatar.displayName = AvatarPrimitive.Root.displayName
22+
23+
const AvatarImage = React.forwardRef<
24+
React.ElementRef<typeof AvatarPrimitive.Image>,
25+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
26+
>(({ className, ...props }, ref) => (
27+
<AvatarPrimitive.Image
28+
ref={ref}
29+
className={cn("aspect-square h-full w-full", className)}
30+
{...props}
31+
/>
32+
))
33+
AvatarImage.displayName = AvatarPrimitive.Image.displayName
34+
35+
const AvatarFallback = React.forwardRef<
36+
React.ElementRef<typeof AvatarPrimitive.Fallback>,
37+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
38+
>(({ className, ...props }, ref) => (
39+
<AvatarPrimitive.Fallback
40+
ref={ref}
41+
className={cn(
42+
"flex h-full w-full items-center justify-center rounded-full bg-muted",
43+
className
44+
)}
45+
{...props}
46+
/>
47+
))
48+
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49+
50+
export { Avatar, AvatarImage, AvatarFallback }

0 commit comments

Comments
 (0)
Please sign in to comment.