start on statuses table list thing
This commit is contained in:
11
bun.lock
11
bun.lock
@@ -7,8 +7,10 @@
|
|||||||
"@convex-dev/auth": "^0.0.81",
|
"@convex-dev/auth": "^0.0.81",
|
||||||
"@hookform/resolvers": "^5.2.1",
|
"@hookform/resolvers": "^5.2.1",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
@@ -29,12 +31,13 @@
|
|||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"typescript-eslint": "^8.42.0",
|
"typescript-eslint": "^8.42.0",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.1.5",
|
"zod": "^4.1.5",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@tailwindcss/postcss": "^4.1.12",
|
"@tailwindcss/postcss": "^4.1.12",
|
||||||
"@types/node": "^20.19.12",
|
"@types/node": "^20.19.13",
|
||||||
"@types/react": "^19.1.12",
|
"@types/react": "^19.1.12",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
@@ -555,6 +558,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
|
||||||
|
|
||||||
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
|
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
|
||||||
|
|
||||||
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
|
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
|
||||||
@@ -579,6 +584,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="],
|
||||||
|
|
||||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||||
|
|
||||||
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
|
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
|
||||||
@@ -1705,6 +1712,8 @@
|
|||||||
|
|
||||||
"validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="],
|
"validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="],
|
||||||
|
|
||||||
|
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
|
||||||
|
|
||||||
"vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="],
|
"vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="],
|
||||||
|
|
||||||
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
||||||
|
@@ -21,8 +21,8 @@ export const getUser = query(async (ctx) => {
|
|||||||
if (!user) throw new ConvexError('User not found.');
|
if (!user) throw new ConvexError('User not found.');
|
||||||
const image: Id<'_storage'> | null =
|
const image: Id<'_storage'> | null =
|
||||||
typeof user.image === 'string' && user.image.length > 0
|
typeof user.image === 'string' && user.image.length > 0
|
||||||
? user.image as Id<'_storage'>
|
? (user.image as Id<'_storage'>)
|
||||||
: null
|
: null;
|
||||||
return {
|
return {
|
||||||
id: user._id,
|
id: user._id,
|
||||||
email: user.email ?? null,
|
email: user.email ?? null,
|
||||||
@@ -56,7 +56,7 @@ export const updateUserEmail = mutation({
|
|||||||
if (!user) throw new ConvexError('User not found.');
|
if (!user) throw new ConvexError('User not found.');
|
||||||
await ctx.db.patch(userId, { email });
|
await ctx.db.patch(userId, { email });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateUserImage = mutation({
|
export const updateUserImage = mutation({
|
||||||
@@ -70,8 +70,7 @@ export const updateUserImage = mutation({
|
|||||||
if (!user) throw new ConvexError('User not found.');
|
if (!user) throw new ConvexError('User not found.');
|
||||||
const oldImage = user.image as Id<'_storage'> | undefined;
|
const oldImage = user.image as Id<'_storage'> | undefined;
|
||||||
await ctx.db.patch(userId, { image: storageId });
|
await ctx.db.patch(userId, { image: storageId });
|
||||||
if (oldImage && oldImage !== storageId)
|
if (oldImage && oldImage !== storageId) await ctx.storage.delete(oldImage);
|
||||||
await ctx.storage.delete(oldImage);
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -94,14 +93,14 @@ export const updateUserPassword = action({
|
|||||||
currentPassword: v.string(),
|
currentPassword: v.string(),
|
||||||
newPassword: v.string(),
|
newPassword: v.string(),
|
||||||
},
|
},
|
||||||
handler: async (ctx, {currentPassword, newPassword}) => {
|
handler: async (ctx, { currentPassword, newPassword }) => {
|
||||||
const userId = await getAuthUserId(ctx);
|
const userId = await getAuthUserId(ctx);
|
||||||
if (!userId) throw new ConvexError('Not authenticated.');
|
if (!userId) throw new ConvexError('Not authenticated.');
|
||||||
const user = await ctx.runQuery(api.auth.getUser);
|
const user = await ctx.runQuery(api.auth.getUser);
|
||||||
if (!user?.email) throw new ConvexError('User not found.');
|
if (!user?.email) throw new ConvexError('User not found.');
|
||||||
const verified = await retrieveAccount(ctx, {
|
const verified = await retrieveAccount(ctx, {
|
||||||
provider: 'password',
|
provider: 'password',
|
||||||
account: { id: user.email, secret: currentPassword }
|
account: { id: user.email, secret: currentPassword },
|
||||||
});
|
});
|
||||||
if (!verified) throw new ConvexError('Current password is incorrect.');
|
if (!verified) throw new ConvexError('Current password is incorrect.');
|
||||||
|
|
||||||
@@ -110,9 +109,9 @@ export const updateUserPassword = action({
|
|||||||
|
|
||||||
await modifyAccountCredentials(ctx, {
|
await modifyAccountCredentials(ctx, {
|
||||||
provider: 'password',
|
provider: 'password',
|
||||||
account: { id: user.email, secret: newPassword }
|
account: { id: user.email, secret: newPassword },
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
@@ -17,14 +17,14 @@ export default defineSchema({
|
|||||||
phoneVerificationTime: v.optional(v.number()),
|
phoneVerificationTime: v.optional(v.number()),
|
||||||
isAnonymous: v.optional(v.boolean()),
|
isAnonymous: v.optional(v.boolean()),
|
||||||
})
|
})
|
||||||
.index("email", ["email"])
|
.index('email', ['email'])
|
||||||
.index("phone", ["phone"]),
|
.index('phone', ['phone']),
|
||||||
statuses: defineTable({
|
statuses: defineTable({
|
||||||
userId: v.id('users'),
|
userId: v.id('users'),
|
||||||
message: v.string(),
|
message: v.string(),
|
||||||
updatedAt: v.number(),
|
updatedAt: v.number(),
|
||||||
updatedBy: v.id('users'),
|
updatedBy: v.id('users'),
|
||||||
})
|
})
|
||||||
.index('by_user', ['userId'])
|
.index('by_user', ['userId'])
|
||||||
.index('by_user_updatedAt', ['userId', 'updatedAt']),
|
.index('by_user_updatedAt', ['userId', 'updatedAt']),
|
||||||
});
|
});
|
||||||
|
@@ -11,22 +11,16 @@ import type { MutationCtx, QueryCtx } from './_generated/server';
|
|||||||
type RWCtx = MutationCtx | QueryCtx;
|
type RWCtx = MutationCtx | QueryCtx;
|
||||||
|
|
||||||
// CHANGED: typed helpers
|
// CHANGED: typed helpers
|
||||||
const ensureUser = async (
|
const ensureUser = async (ctx: RWCtx, userId: Id<'users'>) => {
|
||||||
ctx: RWCtx,
|
|
||||||
userId: Id<'users'>,
|
|
||||||
) => {
|
|
||||||
const user = await ctx.db.get(userId);
|
const user = await ctx.db.get(userId);
|
||||||
if (!user) throw new ConvexError('User not found.');
|
if (!user) throw new ConvexError('User not found.');
|
||||||
return user;
|
return user;
|
||||||
};
|
};
|
||||||
|
|
||||||
const latestStatusForOwner = async (
|
const latestStatusForOwner = async (ctx: RWCtx, ownerId: Id<'users'>) => {
|
||||||
ctx: RWCtx,
|
|
||||||
ownerId: Id<'users'>,
|
|
||||||
) => {
|
|
||||||
const [latest] = await ctx.db
|
const [latest] = await ctx.db
|
||||||
.query('statuses')
|
.query('statuses')
|
||||||
.withIndex('by_user_updatedAt', q => q.eq('userId', ownerId))
|
.withIndex('by_user_updatedAt', (q) => q.eq('userId', ownerId))
|
||||||
.order('desc')
|
.order('desc')
|
||||||
.take(1);
|
.take(1);
|
||||||
return latest as Doc<'statuses'> | null;
|
return latest as Doc<'statuses'> | null;
|
||||||
@@ -180,7 +174,7 @@ export const listHistoryByUser = query({
|
|||||||
|
|
||||||
return await ctx.db
|
return await ctx.db
|
||||||
.query('statuses')
|
.query('statuses')
|
||||||
.withIndex('by_user_updatedAt', q => q.eq('userId', userId))
|
.withIndex('by_user_updatedAt', (q) => q.eq('userId', userId))
|
||||||
.order('desc')
|
.order('desc')
|
||||||
.paginate(paginationOpts);
|
.paginate(paginationOpts);
|
||||||
},
|
},
|
||||||
|
@@ -21,8 +21,10 @@
|
|||||||
"@convex-dev/auth": "^0.0.81",
|
"@convex-dev/auth": "^0.0.81",
|
||||||
"@hookform/resolvers": "^5.2.1",
|
"@hookform/resolvers": "^5.2.1",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
@@ -43,6 +45,7 @@
|
|||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"typescript-eslint": "^8.42.0",
|
"typescript-eslint": "^8.42.0",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.1.5"
|
"zod": "^4.1.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@@ -7,7 +7,7 @@ import {
|
|||||||
ProfileHeader,
|
ProfileHeader,
|
||||||
ResetPasswordForm,
|
ResetPasswordForm,
|
||||||
SignOutForm,
|
SignOutForm,
|
||||||
UserInfoForm
|
UserInfoForm,
|
||||||
} from '@/components/layout/profile';
|
} from '@/components/layout/profile';
|
||||||
|
|
||||||
const Profile = async () => {
|
const Profile = async () => {
|
||||||
|
@@ -29,40 +29,46 @@ const signInFormSchema = z.object({
|
|||||||
email: z.email({
|
email: z.email({
|
||||||
message: 'Please enter a valid email address.',
|
message: 'Please enter a valid email address.',
|
||||||
}),
|
}),
|
||||||
password: z.string()
|
password: z
|
||||||
.regex(/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\S+$).{8,100}$/, {
|
.string()
|
||||||
message: 'Incorrect password. Does not meet requirements.'
|
.regex(
|
||||||
})
|
/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\S+$).{8,100}$/,
|
||||||
|
{
|
||||||
|
message: 'Incorrect password. Does not meet requirements.',
|
||||||
|
},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const signUpFormSchema = z.object({
|
const signUpFormSchema = z
|
||||||
name: z.string().min(2, {
|
.object({
|
||||||
message: 'Name must be at least 2 characters.',
|
name: z.string().min(2, {
|
||||||
}),
|
message: 'Name must be at least 2 characters.',
|
||||||
email: z.email({
|
|
||||||
message: 'Please enter a valid email address.',
|
|
||||||
}),
|
|
||||||
password: z.string()
|
|
||||||
.min(8, { message: 'Password must be at least 8 characters.' })
|
|
||||||
.max(100, { message: 'Password must be no more than 100 characters.' })
|
|
||||||
.regex(/[0-9]/, { message: 'Password must contain at least one digit.' })
|
|
||||||
.regex(/[a-z]/, {
|
|
||||||
message: 'Password must contain at least one lowercase letter.'
|
|
||||||
})
|
|
||||||
.regex(/[A-Z]/, {
|
|
||||||
message: 'Password must contain at least one uppercase letter.'
|
|
||||||
})
|
|
||||||
.regex(/[@#$%^&+=]/, {
|
|
||||||
message: 'Password must contain at least one special character.'
|
|
||||||
}),
|
}),
|
||||||
confirmPassword: z.string().min(8, {
|
email: z.email({
|
||||||
message: 'Password must be at least 8 characters.',
|
message: 'Please enter a valid email address.',
|
||||||
}),
|
}),
|
||||||
})
|
password: z
|
||||||
.refine((data) => data.password === data.confirmPassword, {
|
.string()
|
||||||
message: 'Passwords do not match!',
|
.min(8, { message: 'Password must be at least 8 characters.' })
|
||||||
path: ['confirmPassword'],
|
.max(100, { message: 'Password must be no more than 100 characters.' })
|
||||||
});
|
.regex(/[0-9]/, { message: 'Password must contain at least one digit.' })
|
||||||
|
.regex(/[a-z]/, {
|
||||||
|
message: 'Password must contain at least one lowercase letter.',
|
||||||
|
})
|
||||||
|
.regex(/[A-Z]/, {
|
||||||
|
message: 'Password must contain at least one uppercase letter.',
|
||||||
|
})
|
||||||
|
.regex(/[@#$%^&+=]/, {
|
||||||
|
message: 'Password must contain at least one special character.',
|
||||||
|
}),
|
||||||
|
confirmPassword: z.string().min(8, {
|
||||||
|
message: 'Password must be at least 8 characters.',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: 'Passwords do not match!',
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
});
|
||||||
|
|
||||||
export default function SignIn() {
|
export default function SignIn() {
|
||||||
const { signIn } = useAuthActions();
|
const { signIn } = useAuthActions();
|
||||||
|
@@ -1,12 +1,21 @@
|
|||||||
'use client';
|
'use server';
|
||||||
|
import { preloadQuery } from 'convex/nextjs';
|
||||||
import { useConvexAuth, useMutation, useQuery } from 'convex/react';
|
|
||||||
import { api } from '~/convex/_generated/api';
|
import { api } from '~/convex/_generated/api';
|
||||||
|
import { StatusList } from '@/components/layout/status/list'
|
||||||
|
import { useConvexAuth, useMutation, useQuery } from 'convex/react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useAuthActions } from '@convex-dev/auth/react';
|
import { useAuthActions } from '@convex-dev/auth/react';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
const Home = () => {
|
const Home = async () => {
|
||||||
return <main></main>;
|
const preloadedUser = await preloadQuery(api.auth.getUser);
|
||||||
|
const preloadedStatuses = await preloadQuery(api.statuses.getCurrentForAll)
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<StatusList
|
||||||
|
preloadedUser={preloadedUser}
|
||||||
|
preloadedStatuses={preloadedStatuses}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
export default Home;
|
export default Home;
|
||||||
|
@@ -27,11 +27,7 @@ export const AvatarDropdown = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
return (
|
return <BasedAvatar className='animate-pulse lg:h-10 lg:w-10' />;
|
||||||
<BasedAvatar
|
|
||||||
className='animate-pulse lg:h-10 lg:w-10'
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
if (!isAuthenticated) return <div />;
|
if (!isAuthenticated) return <div />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@@ -47,10 +47,12 @@ const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
|||||||
body: file,
|
body: file,
|
||||||
});
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
const msg = await result.text().catch(() => 'Upload failed.')
|
const msg = await result.text().catch(() => 'Upload failed.');
|
||||||
throw new Error(msg);
|
throw new Error(msg);
|
||||||
}
|
}
|
||||||
const uploadResponse = await result.json() as { storageId: Id<'_storage'> };
|
const uploadResponse = (await result.json()) as {
|
||||||
|
storageId: Id<'_storage'>;
|
||||||
|
};
|
||||||
await updateUserImage({ storageId: uploadResponse.storageId });
|
await updateUserImage({ storageId: uploadResponse.storageId });
|
||||||
toast.success('Profile picture updated.');
|
toast.success('Profile picture updated.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@@ -22,34 +22,42 @@ import {
|
|||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z
|
||||||
currentPassword: z.string()
|
.object({
|
||||||
.regex(/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\S+$).{8,100}$/, {
|
currentPassword: z
|
||||||
message: 'Incorrect current password. Does not meet requirements.',
|
.string()
|
||||||
}),
|
.regex(
|
||||||
newPassword: z.string()
|
/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\S+$).{8,100}$/,
|
||||||
.min(8, { message: 'New password must be at least 8 characters.' })
|
{
|
||||||
.max(100, { message: 'New password must be less than 100 characters.' })
|
message: 'Incorrect current password. Does not meet requirements.',
|
||||||
.regex(/[0-9]/, { message: 'New password must contain at least one digit.' })
|
},
|
||||||
.regex(/[a-z]/, {
|
),
|
||||||
message: 'New password must contain at least one lowercase letter.'
|
newPassword: z
|
||||||
})
|
.string()
|
||||||
.regex(/[A-Z]/, {
|
.min(8, { message: 'New password must be at least 8 characters.' })
|
||||||
message: 'New password must contain at least one uppercase letter.'
|
.max(100, { message: 'New password must be less than 100 characters.' })
|
||||||
})
|
.regex(/[0-9]/, {
|
||||||
.regex(/[@#$%^&+=]/, {
|
message: 'New password must contain at least one digit.',
|
||||||
message: 'New password must contain at least one special character.'
|
})
|
||||||
}),
|
.regex(/[a-z]/, {
|
||||||
confirmPassword: z.string(),
|
message: 'New password must contain at least one lowercase letter.',
|
||||||
})
|
})
|
||||||
.refine((data) => data.currentPassword !== data.newPassword, {
|
.regex(/[A-Z]/, {
|
||||||
message: 'New password must be different from current password.',
|
message: 'New password must contain at least one uppercase letter.',
|
||||||
path: ['newPassword'],
|
})
|
||||||
})
|
.regex(/[@#$%^&+=]/, {
|
||||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
message: 'New password must contain at least one special character.',
|
||||||
message: 'Passwords do not match.',
|
}),
|
||||||
path: ['confirmPassword'],
|
confirmPassword: z.string(),
|
||||||
});
|
})
|
||||||
|
.refine((data) => data.currentPassword !== data.newPassword, {
|
||||||
|
message: 'New password must be different from current password.',
|
||||||
|
path: ['newPassword'],
|
||||||
|
})
|
||||||
|
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||||
|
message: 'Passwords do not match.',
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
});
|
||||||
|
|
||||||
export const ResetPasswordForm = () => {
|
export const ResetPasswordForm = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -80,7 +88,7 @@ export const ResetPasswordForm = () => {
|
|||||||
console.error('Error updating password:', error);
|
console.error('Error updating password:', error);
|
||||||
toast.error('Error updating password.');
|
toast.error('Error updating password.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -161,4 +169,3 @@ export const ResetPasswordForm = () => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -1,10 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuthActions } from '@convex-dev/auth/react';
|
import { useAuthActions } from '@convex-dev/auth/react';
|
||||||
import {
|
import { CardHeader, SubmitButton } from '@/components/ui';
|
||||||
CardHeader,
|
|
||||||
SubmitButton,
|
|
||||||
} from '@/components/ui';
|
|
||||||
|
|
||||||
export const SignOutForm = () => {
|
export const SignOutForm = () => {
|
||||||
const { signOut } = useAuthActions();
|
const { signOut } = useAuthActions();
|
||||||
@@ -22,4 +19,4 @@ export const SignOutForm = () => {
|
|||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
@@ -1,10 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import { type Preloaded, usePreloadedQuery, useMutation } from 'convex/react';
|
||||||
type Preloaded,
|
|
||||||
usePreloadedQuery,
|
|
||||||
useMutation,
|
|
||||||
} from 'convex/react';
|
|
||||||
import { api } from '~/convex/_generated/api';
|
import { api } from '~/convex/_generated/api';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@@ -24,16 +20,17 @@ import {
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string()
|
name: z
|
||||||
.trim()
|
.string()
|
||||||
.min(5, {
|
.trim()
|
||||||
message: 'Full name is required & must be at least 5 characters.'
|
.min(5, {
|
||||||
})
|
message: 'Full name is required & must be at least 5 characters.',
|
||||||
.max(50, {
|
})
|
||||||
message: 'Full name must be less than 50 characters.'
|
.max(50, {
|
||||||
}),
|
message: 'Full name must be less than 50 characters.',
|
||||||
|
}),
|
||||||
email: z.email({
|
email: z.email({
|
||||||
message: 'Please enter a valid email address.'
|
message: 'Please enter a valid email address.',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,26 +50,24 @@ export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: user?.name ?? '',
|
name: user?.name ?? '',
|
||||||
email: user?.email ?? '',
|
email: user?.email ?? '',
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
|
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||||
const ops: Promise<unknown>[] = [];
|
const ops: Promise<unknown>[] = [];
|
||||||
const name = values.name.trim();
|
const name = values.name.trim();
|
||||||
const email = values.email.trim().toLowerCase();
|
const email = values.email.trim().toLowerCase();
|
||||||
if (name !== (user?.name ?? ''))
|
if (name !== (user?.name ?? '')) ops.push(updateUserName({ name }));
|
||||||
ops.push(updateUserName({name}));
|
if (email !== (user?.email ?? '')) ops.push(updateUserEmail({ email }));
|
||||||
if (email !== (user?.email ?? ''))
|
|
||||||
ops.push(updateUserEmail({email}));
|
|
||||||
if (ops.length === 0) return;
|
if (ops.length === 0) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await Promise.all(ops);
|
await Promise.all(ops);
|
||||||
form.reset({ name, email});
|
form.reset({ name, email });
|
||||||
toast.success('Profile updated successfully.');
|
toast.success('Profile updated successfully.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error('Error updating profile.')
|
toast.error('Error updating profile.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
109
src/components/layout/status/list/index.tsx
Normal file
109
src/components/layout/status/list/index.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
'use client';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
type Preloaded,
|
||||||
|
usePreloadedQuery,
|
||||||
|
} from 'convex/react';
|
||||||
|
import { api } from '~/convex/_generated/api';
|
||||||
|
import { useTVMode } from '@/components/providers';
|
||||||
|
import {
|
||||||
|
BasedAvatar,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Drawer,
|
||||||
|
DrawerTrigger,
|
||||||
|
Input,
|
||||||
|
SubmitButton,
|
||||||
|
} from '@/components/ui';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { ccn, formatTime, formatDate } from '@/lib/utils';
|
||||||
|
import { RefreshCw, Clock, Calendar, CheckCircle2 } from 'lucide-react';
|
||||||
|
|
||||||
|
type StatusListProps = {
|
||||||
|
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
||||||
|
preloadedStatuses: Preloaded<typeof api.statuses.getCurrentForAll>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StatusList = ({
|
||||||
|
preloadedUser,
|
||||||
|
preloadedStatuses,
|
||||||
|
}: StatusListProps) => {
|
||||||
|
const user = usePreloadedQuery(preloadedUser);
|
||||||
|
const statuses = usePreloadedQuery(preloadedStatuses);
|
||||||
|
const { tvMode } = useTVMode();
|
||||||
|
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||||
|
const [selectAll, setSelectAll] = useState(false);
|
||||||
|
const [statusInput, setStatusInput] = useState('');
|
||||||
|
|
||||||
|
const handleSelectAllClick = () => {
|
||||||
|
if (selectAll) setSelectedUsers([]);
|
||||||
|
else setSelectedUsers([]);
|
||||||
|
setSelectAll(!selectAll);
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerCn = ccn({
|
||||||
|
context: tvMode,
|
||||||
|
className: 'flex flex-col mx-auto items-center\
|
||||||
|
sm:w-5/6 md:w-3/4 lg:w-2/3 xl:w-1/2 min-w-[450px]',
|
||||||
|
on: 'mt-8',
|
||||||
|
off: 'px-10',
|
||||||
|
});
|
||||||
|
|
||||||
|
const headerCn = ccn({
|
||||||
|
context: tvMode,
|
||||||
|
className: 'w-full',
|
||||||
|
on: 'hidden',
|
||||||
|
off: 'flex mb-3 justify-between items-center',
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectAllIconCn = ccn({
|
||||||
|
context: selectAll,
|
||||||
|
className: 'w-4 h-4',
|
||||||
|
on: '',
|
||||||
|
off: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const cardContainerCn = ccn({
|
||||||
|
context: tvMode,
|
||||||
|
className: 'w-full space-y-2',
|
||||||
|
on: 'text-primary',
|
||||||
|
off: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerCn}>
|
||||||
|
<div className={headerCn}>
|
||||||
|
<div className='flex items-center gap-4'>
|
||||||
|
<Button
|
||||||
|
onClick={handleSelectAllClick}
|
||||||
|
variant={'outline'}
|
||||||
|
size={'sm'}
|
||||||
|
className='flex items-center gap2'
|
||||||
|
>
|
||||||
|
<CheckCircle2
|
||||||
|
className={selectAllIconCn}
|
||||||
|
/>
|
||||||
|
{selectAll ? 'Unselect All' : 'Select All'}
|
||||||
|
</Button>
|
||||||
|
{!tvMode && (
|
||||||
|
<div className='flex items-center gap-2 text-xs'>
|
||||||
|
<span className='text-muted-foreground'>Miss the old table?</span>
|
||||||
|
<Link
|
||||||
|
href='/table'
|
||||||
|
className='font-medium hover:underline'
|
||||||
|
>
|
||||||
|
Find it here!
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cardContainerCn}>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
51
src/components/ui/based-progress.tsx
Normal file
51
src/components/ui/based-progress.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type BasedProgressProps = React.ComponentProps<typeof ProgressPrimitive.Root> & {
|
||||||
|
/** how many ms between updates */
|
||||||
|
intervalMs?: number;
|
||||||
|
/** fraction of the remaining distance to add each tick */
|
||||||
|
alpha?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BasedProgress = ({
|
||||||
|
intervalMs = 50,
|
||||||
|
alpha = 0.1,
|
||||||
|
className,
|
||||||
|
value = 0,
|
||||||
|
...props
|
||||||
|
}: BasedProgressProps) => {
|
||||||
|
const [progress, setProgress] = React.useState<number>(value ?? 0);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
setProgress((prev) => {
|
||||||
|
const next = prev + (100 - prev) * alpha;
|
||||||
|
return Math.min(100, Math.round(next * 10) / 10);
|
||||||
|
});
|
||||||
|
}, intervalMs);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [intervalMs, alpha]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot='progress'
|
||||||
|
className={cn(
|
||||||
|
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot='progress-indicator'
|
||||||
|
className='bg-primary h-full w-full flex-1 transition-all'
|
||||||
|
style={{ transform: `translateX(-${100 - (progress ?? 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { BasedProgress };
|
135
src/components/ui/drawer.tsx
Normal file
135
src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Drawer({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||||
|
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||||
|
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||||
|
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||||
|
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
data-slot="drawer-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DrawerPortal data-slot="drawer-portal">
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
data-slot="drawer-content"
|
||||||
|
className={cn(
|
||||||
|
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||||
|
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||||
|
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||||
|
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||||
|
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-header"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
data-slot="drawer-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
data-slot="drawer-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
}
|
@@ -1,5 +1,6 @@
|
|||||||
export { Avatar, AvatarImage, AvatarFallback } from './avatar';
|
export { Avatar, AvatarImage, AvatarFallback } from './avatar';
|
||||||
export { BasedAvatar } from './based-avatar';
|
export { BasedAvatar } from './based-avatar';
|
||||||
|
export { BasedProgress } from './based-progress';
|
||||||
export { Button, buttonVariants } from './button';
|
export { Button, buttonVariants } from './button';
|
||||||
export {
|
export {
|
||||||
Card,
|
Card,
|
||||||
@@ -10,6 +11,18 @@ export {
|
|||||||
CardDescription,
|
CardDescription,
|
||||||
CardContent,
|
CardContent,
|
||||||
} from './card';
|
} from './card';
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
} from './drawer';
|
||||||
export {
|
export {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -30,6 +43,7 @@ export {
|
|||||||
} from './form';
|
} from './form';
|
||||||
export { Input } from './input';
|
export { Input } from './input';
|
||||||
export { Label } from './label';
|
export { Label } from './label';
|
||||||
|
export { Progress } from './progress';
|
||||||
export { Separator } from './separator';
|
export { Separator } from './separator';
|
||||||
export { StatusMessage } from './status-message';
|
export { StatusMessage } from './status-message';
|
||||||
export { SubmitButton } from './submit-button';
|
export { SubmitButton } from './submit-button';
|
||||||
|
31
src/components/ui/progress.tsx
Normal file
31
src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot='progress'
|
||||||
|
className={cn(
|
||||||
|
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot='progress-indicator'
|
||||||
|
className='bg-primary h-full w-full flex-1 transition-all'
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress };
|
@@ -18,3 +18,19 @@ export const ccn = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return twMerge(className, context ? on : off);
|
return twMerge(className, context ? on : off);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const formatTime = (timestamp: string) => {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const time = date.toLocaleTimeString('en-US', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: 'numeric',
|
||||||
|
});
|
||||||
|
return time;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDate = (timestamp: string) => {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const day = date.getDate();
|
||||||
|
const month = date.toLocaleString('default', { month: 'long' });
|
||||||
|
return `${month} ${day}`;
|
||||||
|
};
|
||||||
|
Reference in New Issue
Block a user