Merge pull request #14 from StevanFreeborn/stevanfreeborn/feat/user-post-crud
feat: add post CRUD operations
This commit is contained in:
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#/-------------------.env.vault---------------------/
|
||||||
|
#/ cloud-agnostic vaulting standard /
|
||||||
|
#/ [how it works](https://dotenv.org/env-vault) /
|
||||||
|
#/--------------------------------------------------/
|
||||||
|
|
||||||
|
# development
|
||||||
|
DOTENV_VAULT_DEVELOPMENT="7Yh8nxf+F6T+IFaThHtebN50Xo/Xtff0/1gNDvghGlMD12w9wzXqcyCxKh+xQvZ7ixmhwcWP1gnGpH7NG+ryicGBISLdTBTNRabLfuWLnsE2CiwdiSf7B+iOxkXlJ1Rm+aTnEvyaDNdDfNHxdGx0TzxnsJb4oJYgGyPgtA8H9Gsky9krxFuhmAzqlx3o+dsJh0bQlc1FgOxbLYIf8a6Jas5h0h2Ke2fWvd+cTayTmBGcqepwF7CiIWZaDSEU1CPqf5Wd0SlLoF2SAv/Z2GwGAI9whTZtCshhisTFtsRt0Gi7oVZvqMSfAXPxhmRf7kWh6myJjT4QtDnFIQ6Pt7BQpIPCbBMzunhKcIAKqOWZJWlxYzjy6Yz1nZa5UcHer7MWWsI05rQjPv1ZqVS00gqp/9fVGbI+K1dSsG4YpSC6G0r4FGwyCCfcCqusTUoEO5IJFABbHYby0lABjTHL4lBF55ZoDei3lQqH+mxPcVnsxgHMRYf7MdHoQpiPYMjJxbFis0sQ6H6EBTkpis4ohSjWG6X21YwiWwqK3wB1FXiqPEBnEM3ge5Ijj0voYkyaDmkeD1pcnuU8cLuRVwsfntotacC7svHJBW+IaiRCbrYWUraISLp2xtX2kK9RGrVOolDjDHUBqrJIlpHJDbNhytHZG7Qpmi1lchQpZXTaCCYP1uLuQJvH6tzXzBFA1WKMR9cSy10eVvrhaPo1ThAh5yYd3ftrocrYX/sDM19rwCDnhvWplJcKou0hFLTzrQLiTbSatbHmbCkZHy3Zo6yBFvlgmI1r9T1LkLmJ"
|
||||||
|
DOTENV_VAULT_DEVELOPMENT_VERSION=2
|
||||||
|
|
||||||
|
# ci
|
||||||
|
DOTENV_VAULT_CI="jzU/UgVliVdu2u6xMnw+h0gp9iFZAWh88AGztJRhHgQvOwuHADkmxV88eozBOL3ul6Kz+UlaYU4j5azkcSxg3pNkC/dcjjLiBbIWdXuJ8HIuKRGIS7tHk0QxZHnbElGAwAcU2w+MQ2wUqvtTKjgAnzg/Ug7V73PnsMsHi1FEmN9WiOfyrt3ALR0W2c9QTvjRussm4sS73CgCPBn+edB+GxcBmuUDcH6CfkbfLFWLZ40/dGauI7xud1bn6s94yh6yYAjfZHzw+QYU8P/FGumVQqQCcPkFBNR3bBrUU6lLyZb9TOPIYVhvSst2F/b7Ec8LJvTbAP8XipVP41dgDaad/rcTh3fQmx+bB412tMBZ1UPa2+Qw7utjjW+mLN6x866m0Cz0gZhpJGP4P8fMJkjFN3ViPLELPhA="
|
||||||
|
DOTENV_VAULT_CI_VERSION=2
|
||||||
|
|
||||||
|
# staging
|
||||||
|
DOTENV_VAULT_STAGING="0fOcFfXwVh9NPxG9Fa5NoV9FJQpgZHu0DwMuQXDuBdXNbvxfG+ol1ou9b6Kkg3Xujad9IevocXqovFtmabkfHvKpBwniRmF6XBaIBiUngZgQ2+7CGiEwN9o5o1KOJAs3joLbXzilSsJDG94PJxtOKj5+LBVZwfI5WVsoJZ3lXez6FkCxRtm8yYT9kUTnTReS4ef5faKdoSqR83QA5vqK11cNT603wkaPwV3r/FqgvheXyTY7o4FMuG5bCJbSerWDKQEvMNRm4nJfNqthNOHW/EtrqzeZaCX+0XpH8+l+e75jFcsEL/UTPeTeau9cd4fIn1xrQReNJfx2UuM+RMzZX9e77GJ0kbaUo11lhKEw1bRaSiMxzRjOzE4jVJeadj6GD5gVFgtKxVfHg/GwEMtL+IDAW+U1iXY="
|
||||||
|
DOTENV_VAULT_STAGING_VERSION=2
|
||||||
|
|
||||||
|
# production
|
||||||
|
DOTENV_VAULT_PRODUCTION="eiAKQmDbFoIHyBTYgXkVwI9NPX/33kKM0lcmWk31zDmFGGb1nY1nHFtvHOopxK5HNN2yWNoSUEardzUFEtfiQYHWvLDp2su5nqByN4aRgBxbaQnfZdh65CEXVMPUaWNjF6Qo4JO0bEpuw0Bmn6gQPDg14xRJQC//2ercwe263sBsJb3qweXFJxlGXv3z/ROFPLW3KoCFpSBeBlPPnRzEfI76X6jbudV66ZISdAvE9IuvxOVZAsQUfbNSh707G+X0Zwe/84rYpbG0esQVdSbqkDuZWtSiD1w5zywq4NrrqY0LHIMhAmSgxHWRPGv6sAWOdv+n9nLKadORGNMziw6wrqJ0Akd2At9D8yu3ga7R16dxigZyKikS/0Q8q/b3Ah6tiynD5W/SshSaozTVHaXgcNYKkKeM3Ks="
|
||||||
|
DOTENV_VAULT_PRODUCTION_VERSION=2
|
||||||
|
|
||||||
|
#/----------------settings/metadata-----------------/
|
||||||
|
DOTENV_VAULT="vlt_bcfa889c073e47e1d82b0f64169e874a5975ed73c6698153ee90ade40c3b19cc"
|
||||||
|
DOTENV_API_URL="https://vault.dotenv.org"
|
||||||
|
DOTENV_CLI="npx dotenv-vault@latest"
|
||||||
+4
-1
@@ -1,3 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": "next/core-web-vitals"
|
"extends": "next/core-web-vitals",
|
||||||
|
"rules": {
|
||||||
|
"no-console": "warn"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,3 +33,8 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
.env*
|
||||||
|
.flaskenv*
|
||||||
|
!.env.project
|
||||||
|
!.env.vault
|
||||||
Vendored
+11
-1
@@ -1,4 +1,14 @@
|
|||||||
{
|
{
|
||||||
"css.lint.unknownAtRules": "ignore",
|
"css.lint.unknownAtRules": "ignore",
|
||||||
"cSpell.words": ["conve", "nextjs", "signup", "svix"]
|
"cSpell.words": [
|
||||||
|
"codemirror",
|
||||||
|
"conve",
|
||||||
|
"Keymap",
|
||||||
|
"lezer",
|
||||||
|
"nextjs",
|
||||||
|
"rehype",
|
||||||
|
"signup",
|
||||||
|
"svix",
|
||||||
|
"tailwindcss"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+2
@@ -15,6 +15,7 @@ import type {
|
|||||||
FunctionReference,
|
FunctionReference,
|
||||||
} from "convex/server";
|
} from "convex/server";
|
||||||
import type * as http from "../http";
|
import type * as http from "../http";
|
||||||
|
import type * as posts from "../posts";
|
||||||
import type * as users from "../users";
|
import type * as users from "../users";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,6 +28,7 @@ import type * as users from "../users";
|
|||||||
*/
|
*/
|
||||||
declare const fullApi: ApiFromModules<{
|
declare const fullApi: ApiFromModules<{
|
||||||
http: typeof http;
|
http: typeof http;
|
||||||
|
posts: typeof posts;
|
||||||
users: typeof users;
|
users: typeof users;
|
||||||
}>;
|
}>;
|
||||||
export declare const api: FilterApi<
|
export declare const api: FilterApi<
|
||||||
|
|||||||
+2
-2
@@ -28,7 +28,7 @@ async function validateRequest(
|
|||||||
try {
|
try {
|
||||||
evt = wh.verify(payloadString, svixHeaders) as Event;
|
evt = wh.verify(payloadString, svixHeaders) as Event;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.error(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ const handleClerkWebhook = httpAction(async (ctx, request) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
console.log('ignored Clerk webhook event', event.type);
|
console.warn('ignored Clerk webhook event', event.type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { paginationOptsValidator } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
import { PostWithUserDto } from '../src/app/types';
|
||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
import { userQuery } from './users';
|
||||||
|
|
||||||
|
export const createOrUpdatePost = mutation({
|
||||||
|
args: {
|
||||||
|
id: v.optional(v.id('posts')),
|
||||||
|
clerkUserId: v.string(),
|
||||||
|
content: v.array(v.string()),
|
||||||
|
parentPostId: v.optional(v.id('posts')),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userIdentity = await ctx.auth.getUserIdentity();
|
||||||
|
const user = await userQuery(ctx, args.clerkUserId);
|
||||||
|
|
||||||
|
if (userIdentity === null) {
|
||||||
|
return 'USER_NOT_AUTHORIZED';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user === null) {
|
||||||
|
return 'USER_NOT_FOUND';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userIdentity.subject !== args.clerkUserId) {
|
||||||
|
return 'CANNOT_POST_ON_BEHALF_OF_ANOTHER_USER';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.id) {
|
||||||
|
await ctx.db.patch(args.id, { content: args.content });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ctx.db.insert('posts', {
|
||||||
|
userId: user._id,
|
||||||
|
content: args.content,
|
||||||
|
parentPostId: args.parentPostId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getUsersPostById = query({
|
||||||
|
args: { userId: v.id('users'), paginationOpts: paginationOptsValidator },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db
|
||||||
|
.query('posts')
|
||||||
|
.withIndex('by_user_id', q => q.eq('userId', args.userId))
|
||||||
|
.order('desc')
|
||||||
|
.paginate(args.paginationOpts);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deletePostById = mutation({
|
||||||
|
args: { id: v.id('posts') },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await ctx.db.delete(args.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getPostById = query({
|
||||||
|
args: { id: v.id('posts') },
|
||||||
|
handler: async (
|
||||||
|
ctx,
|
||||||
|
args
|
||||||
|
): Promise<
|
||||||
|
PostWithUserDto | 'POST_NOT_FOUND' | 'USER_FOR_POST_NOT_FOUND'
|
||||||
|
> => {
|
||||||
|
const post = await ctx.db.get(args.id);
|
||||||
|
|
||||||
|
if (post === null) {
|
||||||
|
return 'POST_NOT_FOUND';
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await ctx.db.get(post.userId);
|
||||||
|
|
||||||
|
if (user === null) {
|
||||||
|
return 'USER_FOR_POST_NOT_FOUND';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...post,
|
||||||
|
user: {
|
||||||
|
_id: user._id,
|
||||||
|
_creationTime: user._creationTime,
|
||||||
|
clerkUsername: user.clerkUser.username,
|
||||||
|
clerkImageUrl: user.clerkUser.image_url,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
+41
-4
@@ -1,8 +1,45 @@
|
|||||||
import { defineSchema, defineTable } from 'convex/server';
|
import { defineSchema, defineTable } from 'convex/server';
|
||||||
import { v } from 'convex/values';
|
import { v } from 'convex/values';
|
||||||
|
export default defineSchema(
|
||||||
export default defineSchema({
|
{
|
||||||
users: defineTable({
|
users: defineTable({
|
||||||
clerkUser: v.any(),
|
clerkUser: v.object({
|
||||||
|
id: v.string(),
|
||||||
|
object: v.string(),
|
||||||
|
username: v.union(v.string(), v.null()),
|
||||||
|
first_name: v.string(),
|
||||||
|
last_name: v.string(),
|
||||||
|
gender: v.string(),
|
||||||
|
birthday: v.string(),
|
||||||
|
profile_image_url: v.string(),
|
||||||
|
image_url: v.string(),
|
||||||
|
has_image: v.boolean(),
|
||||||
|
primary_email_address_id: v.boolean(),
|
||||||
|
primary_phone_number_id: v.union(v.string(), v.null()),
|
||||||
|
primary_web3_wallet_id: v.union(v.string(), v.null()),
|
||||||
|
password_enabled: v.boolean(),
|
||||||
|
totp_enabled: v.boolean(),
|
||||||
|
backup_code_enabled: v.boolean(),
|
||||||
|
two_factor_enabled: v.boolean(),
|
||||||
|
banned: v.boolean(),
|
||||||
|
email_addresses: v.any(),
|
||||||
|
phone_numbers: v.any(),
|
||||||
|
web3_wallets: v.any(),
|
||||||
|
external_accounts: v.any(),
|
||||||
|
external_id: v.union(v.string(), v.null()),
|
||||||
|
last_sign_in_at: v.union(v.number(), v.null()),
|
||||||
|
public_metadata: v.any(),
|
||||||
|
private_metadata: v.any(),
|
||||||
|
unsafe_metadata: v.any(),
|
||||||
|
created_at: v.number(),
|
||||||
|
updated_at: v.number(),
|
||||||
|
}),
|
||||||
}).index('by_clerk_id', ['clerkUser.id']),
|
}).index('by_clerk_id', ['clerkUser.id']),
|
||||||
});
|
posts: defineTable({
|
||||||
|
parentPostId: v.optional(v.id('posts')),
|
||||||
|
userId: v.id('users'),
|
||||||
|
content: v.array(v.string()),
|
||||||
|
}).index('by_user_id', ['userId']),
|
||||||
|
},
|
||||||
|
{ schemaValidation: false }
|
||||||
|
);
|
||||||
|
|||||||
+64
-14
@@ -1,7 +1,54 @@
|
|||||||
import { UserJSON } from '@clerk/nextjs/dist/types/server';
|
|
||||||
import { v } from 'convex/values';
|
import { v } from 'convex/values';
|
||||||
import { Doc } from './_generated/dataModel';
|
import { UserDto } from './../src/app/types/index';
|
||||||
import { QueryCtx, internalMutation, internalQuery } from './_generated/server';
|
import {
|
||||||
|
QueryCtx,
|
||||||
|
internalMutation,
|
||||||
|
internalQuery,
|
||||||
|
query,
|
||||||
|
} from './_generated/server';
|
||||||
|
|
||||||
|
export async function userQuery(ctx: QueryCtx, clerkUserId: string) {
|
||||||
|
return await ctx.db
|
||||||
|
.query('users')
|
||||||
|
.withIndex('by_clerk_id', q => q.eq('clerkUser.id', clerkUserId))
|
||||||
|
.unique();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getUserByClerkId = query({
|
||||||
|
args: { clerkUserId: v.string() },
|
||||||
|
async handler(ctx, args): Promise<UserDto | 'USER_NOT_FOUND'> {
|
||||||
|
const user = await userQuery(ctx, args.clerkUserId);
|
||||||
|
|
||||||
|
if (user === null) {
|
||||||
|
return 'USER_NOT_FOUND';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: user._id,
|
||||||
|
_creationTime: user._creationTime,
|
||||||
|
clerkUsername: user.clerkUser.username,
|
||||||
|
clerkImageUrl: user.clerkUser.image_url,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getUserById = query({
|
||||||
|
args: { id: v.id('users') },
|
||||||
|
async handler(ctx, args): Promise<UserDto | 'USER_NOT_FOUND'> {
|
||||||
|
const user = await ctx.db.get(args.id);
|
||||||
|
|
||||||
|
if (user === null) {
|
||||||
|
return 'USER_NOT_FOUND';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: user._id,
|
||||||
|
_creationTime: user._creationTime,
|
||||||
|
clerkUsername: user.clerkUser.username,
|
||||||
|
clerkImageUrl: user.clerkUser.image_url,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const getUser = internalQuery({
|
export const getUser = internalQuery({
|
||||||
args: { subject: v.string() },
|
args: { subject: v.string() },
|
||||||
@@ -10,19 +57,9 @@ export const getUser = internalQuery({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function userQuery(
|
|
||||||
ctx: QueryCtx,
|
|
||||||
clerkUserId: string
|
|
||||||
): Promise<(Omit<Doc<'users'>, 'clerkUser'> & { clerkUser: UserJSON }) | null> {
|
|
||||||
return await ctx.db
|
|
||||||
.query('users')
|
|
||||||
.withIndex('by_clerk_id', q => q.eq('clerkUser.id', clerkUserId))
|
|
||||||
.unique();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updateOrCreateUser = internalMutation({
|
export const updateOrCreateUser = internalMutation({
|
||||||
args: { clerkUser: v.any() },
|
args: { clerkUser: v.any() },
|
||||||
async handler(ctx, { clerkUser }: { clerkUser: UserJSON }) {
|
async handler(ctx, { clerkUser }) {
|
||||||
const userRecord = await userQuery(ctx, clerkUser.id);
|
const userRecord = await userQuery(ctx, clerkUser.id);
|
||||||
|
|
||||||
if (userRecord === null) {
|
if (userRecord === null) {
|
||||||
@@ -44,6 +81,19 @@ export const deleteUser = internalMutation({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userPosts = await ctx.db
|
||||||
|
.query('posts')
|
||||||
|
.withIndex('by_user_id', q => q.eq('userId', userRecord._id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const userPostDeletePromises = [];
|
||||||
|
|
||||||
|
for (const post of userPosts) {
|
||||||
|
userPostDeletePromises.push(ctx.db.delete(post._id));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(userPostDeletePromises);
|
||||||
|
|
||||||
await ctx.db.delete(userRecord._id);
|
await ctx.db.delete(userRecord._id);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-3
@@ -1,4 +1,14 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
const { hostname } = require('os');
|
||||||
const nextConfig = {}
|
|
||||||
|
|
||||||
module.exports = nextConfig
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
hostname: '*',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
|
|||||||
Generated
+2150
-16
File diff suppressed because it is too large
Load Diff
@@ -10,19 +10,35 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/nextjs": "^4.23.5",
|
"@clerk/nextjs": "^4.23.5",
|
||||||
|
"@codemirror/commands": "^6.2.5",
|
||||||
|
"@codemirror/lang-markdown": "^6.2.0",
|
||||||
|
"@codemirror/language": "^6.9.0",
|
||||||
|
"@codemirror/language-data": "^6.3.1",
|
||||||
|
"@codemirror/state": "^6.2.1",
|
||||||
|
"@codemirror/view": "^6.18.0",
|
||||||
|
"@lezer/highlight": "^1.1.6",
|
||||||
"@types/node": "20.6.0",
|
"@types/node": "20.6.0",
|
||||||
"@types/react": "18.2.21",
|
"@types/react": "18.2.21",
|
||||||
"@types/react-dom": "18.2.7",
|
"@types/react-dom": "18.2.7",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.7",
|
||||||
"autoprefixer": "10.4.15",
|
"autoprefixer": "10.4.15",
|
||||||
|
"codemirror": "^6.0.1",
|
||||||
"convex": "^1.2.1",
|
"convex": "^1.2.1",
|
||||||
|
"encoding": "^0.1.13",
|
||||||
"eslint": "8.49.0",
|
"eslint": "8.49.0",
|
||||||
"eslint-config-next": "13.4.19",
|
"eslint-config-next": "13.4.19",
|
||||||
"next": "13.4.19",
|
"next": "13.4.19",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
|
"nextjs-toploader": "^1.4.2",
|
||||||
|
"nprogress": "^0.2.0",
|
||||||
"postcss": "8.4.29",
|
"postcss": "8.4.29",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-icons": "^4.11.0",
|
"react-icons": "^4.11.0",
|
||||||
|
"react-markdown": "^8.0.7",
|
||||||
|
"react-syntax-highlighter": "^15.5.0",
|
||||||
|
"remark-emoji": "^4.0.0",
|
||||||
|
"remark-gfm": "^3.0.1",
|
||||||
"svix": "^1.11.0",
|
"svix": "^1.11.0",
|
||||||
"tailwindcss": "3.3.3",
|
"tailwindcss": "3.3.3",
|
||||||
"typescript": "5.2.2"
|
"typescript": "5.2.2"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export default function AccountPage() {
|
|||||||
navbar: 'dark:bg-secondary-gray [&_*]:dark:text-white',
|
navbar: 'dark:bg-secondary-gray [&_*]:dark:text-white',
|
||||||
formFieldInput: 'dark:bg-primary-gray dark:text-white',
|
formFieldInput: 'dark:bg-primary-gray dark:text-white',
|
||||||
profileSectionTitle: 'dark:border-white dark:border-opacity-10',
|
profileSectionTitle: 'dark:border-white dark:border-opacity-10',
|
||||||
|
breadcrumbsItem: 'dark:text-white',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+11
-2
@@ -1,8 +1,10 @@
|
|||||||
|
import Footer from '@/components/Footer';
|
||||||
import Navbar from '@/components/Navbar';
|
import Navbar from '@/components/Navbar';
|
||||||
import { ConvexProvider, NextThemesProvider } from '@/providers';
|
import { ConvexProvider, NextThemesProvider } from '@/providers';
|
||||||
import { ClerkProvider } from '@clerk/nextjs';
|
import { ClerkProvider } from '@clerk/nextjs';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
|
import NextTopLoader from 'nextjs-toploader';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] });
|
const inter = Inter({ subsets: ['latin'] });
|
||||||
@@ -23,15 +25,22 @@ export default function RootLayout({
|
|||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body
|
<body
|
||||||
className={`w-full h-full flex flex-col bg-white text-primary-gray dark:bg-primary-gray dark:text-white ${inter.className}`}
|
className={`w-full h-full flex flex-col bg-white text-primary-gray dark:bg-primary-gray dark:text-white ${inter.className} overflow-auto`}
|
||||||
>
|
>
|
||||||
<ClerkProvider
|
<ClerkProvider
|
||||||
publishableKey={process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY}
|
publishableKey={process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY}
|
||||||
>
|
>
|
||||||
<ConvexProvider>
|
<ConvexProvider>
|
||||||
<NextThemesProvider>
|
<NextThemesProvider>
|
||||||
|
<NextTopLoader
|
||||||
|
color='#3743e5'
|
||||||
|
showSpinner={false}
|
||||||
|
/>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div className='p-4'>{children}</div>
|
<div className='p-4 w-full h-full flex flex-col'>
|
||||||
|
{children}
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
</NextThemesProvider>
|
</NextThemesProvider>
|
||||||
</ConvexProvider>
|
</ConvexProvider>
|
||||||
</ClerkProvider>
|
</ClerkProvider>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Editor from '@/components/Editor';
|
||||||
|
import { getConvexClient } from '@/lib/convex';
|
||||||
|
import { api } from '../../../../../convex/_generated/api';
|
||||||
|
import { Id } from '../../../../../convex/_generated/dataModel';
|
||||||
|
|
||||||
|
export default async function EditPostPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { id: string };
|
||||||
|
}) {
|
||||||
|
const client = await getConvexClient();
|
||||||
|
const post = await client.query(api.posts.getPostById, {
|
||||||
|
id: params.id as Id<'posts'>,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (post === 'POST_NOT_FOUND' || post === 'USER_FOR_POST_NOT_FOUND') {
|
||||||
|
// TODO: Return actual not found component
|
||||||
|
return <h1>Not Found</h1>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className='flex flex-col w-full h-full'>
|
||||||
|
<Editor post={post} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Post from '@/components/Post';
|
||||||
|
import SpinningLoader from '@/components/SpinningLoader';
|
||||||
|
import { useQuery } from 'convex/react';
|
||||||
|
import { api } from '../../../../convex/_generated/api';
|
||||||
|
import { Id } from '../../../../convex/_generated/dataModel';
|
||||||
|
|
||||||
|
export default function PostPage({ params }: { params: { id: string } }) {
|
||||||
|
const post = useQuery(api.posts.getPostById, {
|
||||||
|
id: params.id as Id<'posts'>,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (post === 'POST_NOT_FOUND' || post === 'USER_FOR_POST_NOT_FOUND') {
|
||||||
|
// TODO: Return actual not found component
|
||||||
|
return <h1>Not Found</h1>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className='flex flex-col flex-1 w-full items-center'>
|
||||||
|
<div className='w-full max-w-4xl'>
|
||||||
|
{post === undefined ? (
|
||||||
|
<SpinningLoader className='animate-spin w-5 h-5' />
|
||||||
|
) : (
|
||||||
|
<Post
|
||||||
|
post={post}
|
||||||
|
limit={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import Editor from '@/components/Editor';
|
||||||
|
|
||||||
|
export default function AddPost() {
|
||||||
|
return (
|
||||||
|
<main className='flex flex-col w-full h-full'>
|
||||||
|
<Editor />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import UserPosts from '@/components/UserPosts';
|
||||||
|
import UserProfile from '@/components/UserProfile';
|
||||||
|
import { getConvexClient } from '@/lib/convex';
|
||||||
|
import { api } from '../../../../convex/_generated/api';
|
||||||
|
|
||||||
|
export default async function UserProfilePage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { id: string };
|
||||||
|
}) {
|
||||||
|
const client = await getConvexClient();
|
||||||
|
const user = await client.query(api.users.getUserByClerkId, {
|
||||||
|
clerkUserId: params.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user === 'USER_NOT_FOUND') {
|
||||||
|
// TODO: Show real not found component
|
||||||
|
return <div>Not Found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className='flex flex-col flex-1 w-full items-center'>
|
||||||
|
<div className='w-full max-w-4xl'>
|
||||||
|
<UserProfile user={user} />
|
||||||
|
<UserPosts user={user} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Doc, Id } from '../../../convex/_generated/dataModel';
|
||||||
|
|
||||||
|
export type UserDto = {
|
||||||
|
_id: Id<'users'>;
|
||||||
|
_creationTime: number;
|
||||||
|
clerkUsername: string | null;
|
||||||
|
clerkImageUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PostWithUserDto = Doc<'posts'> & { user: UserDto };
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCodeMirror, useRouter } from '@/hooks';
|
||||||
|
import { basicDark } from '@/lib/codemirror';
|
||||||
|
import { useUser } from '@clerk/nextjs';
|
||||||
|
import { indentWithTab } from '@codemirror/commands';
|
||||||
|
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
||||||
|
import { languages } from '@codemirror/language-data';
|
||||||
|
import { Compartment, EditorState, Text } from '@codemirror/state';
|
||||||
|
import { EditorView, keymap } from '@codemirror/view';
|
||||||
|
import { basicSetup } from 'codemirror';
|
||||||
|
import { useMutation } from 'convex/react';
|
||||||
|
import { FormEvent, useState } from 'react';
|
||||||
|
import { api } from '../../convex/_generated/api';
|
||||||
|
import { Doc } from '../../convex/_generated/dataModel';
|
||||||
|
import PostContent from './PostContent';
|
||||||
|
|
||||||
|
export default function Editor({ post }: { post?: Doc<'posts'> }) {
|
||||||
|
const { user, isSignedIn } = useUser();
|
||||||
|
const editorTheme = new Compartment();
|
||||||
|
const [currentDoc, setCurrentDoc] = useState(post?.content ?? ['']);
|
||||||
|
const createOrUpdatePost = useMutation(api.posts.createOrUpdatePost);
|
||||||
|
const router = useRouter();
|
||||||
|
const [creatingOrUpdating, setCreatingOrUpdating] = useState(false);
|
||||||
|
|
||||||
|
const extensions = [
|
||||||
|
basicSetup,
|
||||||
|
markdown({ base: markdownLanguage, codeLanguages: languages }),
|
||||||
|
EditorState.tabSize.of(2),
|
||||||
|
editorTheme.of(basicDark),
|
||||||
|
keymap.of([indentWithTab]),
|
||||||
|
EditorView.updateListener.of(vu => {
|
||||||
|
setCurrentDoc(vu.state.doc.toJSON());
|
||||||
|
}),
|
||||||
|
EditorView.lineWrapping,
|
||||||
|
];
|
||||||
|
|
||||||
|
const [mode, setMode] = useState<'write' | 'preview'>('write');
|
||||||
|
const { editorRef } = useCodeMirror({ doc: currentDoc, extensions });
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
if (isSignedIn !== true) {
|
||||||
|
router.push('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreatingOrUpdating(true);
|
||||||
|
|
||||||
|
const result = await createOrUpdatePost({
|
||||||
|
id: post?._id,
|
||||||
|
clerkUserId: user.id,
|
||||||
|
content: currentDoc,
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (result) {
|
||||||
|
case 'USER_NOT_FOUND':
|
||||||
|
case 'USER_NOT_AUTHORIZED':
|
||||||
|
case 'CANNOT_POST_ON_BEHALF_OF_ANOTHER_USER':
|
||||||
|
throw Error('Unable to create post');
|
||||||
|
default:
|
||||||
|
router.push('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
} finally {
|
||||||
|
setCreatingOrUpdating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className='flex flex-col flex-1 shadow-md rounded-md dark:bg-secondary-gray border border-gray-600'
|
||||||
|
>
|
||||||
|
<div className='flex items-center justify-between rounded-t-md p-4 pb-0 border-b border-gray-600 dark:bg-primary-gray'>
|
||||||
|
<div className='flex items-center'>
|
||||||
|
<button
|
||||||
|
className='flex items-center justify-center px-3 py-2 rounded-t-md -mb-[1px] disabled:bg-white disabled:border disabled:border-b-0 disabled:border-gray-600 disabled:dark:bg-secondary-gray'
|
||||||
|
disabled={mode === 'write'}
|
||||||
|
onClick={() => setMode('write')}
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
Write
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className='flex items-center justify-center px-3 py-2 rounded-t-md -mb-[1px] disabled:bg-white disabled:border disabled:border-b-0 disabled:border-gray-600 disabled:dark:bg-secondary-gray'
|
||||||
|
disabled={mode === 'preview'}
|
||||||
|
onClick={() => setMode('preview')}
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col pt-4 px-4 h-0 flex-grow overflow-auto'>
|
||||||
|
<div
|
||||||
|
className={`${mode === 'write' ? '' : 'hidden'} flex-1 overflow-auto`}
|
||||||
|
ref={editorRef}
|
||||||
|
></div>
|
||||||
|
<div className={`${mode === 'preview' ? '' : 'hidden'} flex-1`}>
|
||||||
|
<PostContent content={Text.of(currentDoc).toString()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center justify-end p-4 gap-4'>
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='submit'
|
||||||
|
disabled={
|
||||||
|
!currentDoc.join() ||
|
||||||
|
!currentDoc.join().trim() ||
|
||||||
|
creatingOrUpdating
|
||||||
|
}
|
||||||
|
className='py-1 px-4 bg-primary-accent text-white rounded-md disabled:opacity-50 flex items-center justify-center gap-2'
|
||||||
|
>
|
||||||
|
Post
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col w-full items-center justify-center px-4 mt-4'>
|
||||||
|
<footer className='flex flex-col items-center justify-between gap-2 w-full max-w-4xl text-sm text-primary-white border-t border-gray-600 py-4'>
|
||||||
|
<div className='flex items-center justify-center w-full flex-wrap gap-4'>
|
||||||
|
<Link
|
||||||
|
href='/about'
|
||||||
|
className='hover:text-primary-orange'
|
||||||
|
>
|
||||||
|
About
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href='/contact'
|
||||||
|
className='hover:text-primary-orange'
|
||||||
|
>
|
||||||
|
Contact
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href='/terms'
|
||||||
|
className='hover:text-primary-orange'
|
||||||
|
>
|
||||||
|
Terms
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href='/privacy'
|
||||||
|
className='hover:text-primary-orange'
|
||||||
|
>
|
||||||
|
Privacy
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href='https://github.com/StevanFreeborn/conve-x'
|
||||||
|
className='hover:text-primary-orange'
|
||||||
|
>
|
||||||
|
Code
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className='self-center whitespace-nowrap italic'>
|
||||||
|
conve<span className='font-bold text-primary-accent'>X</span>
|
||||||
|
</span>
|
||||||
|
<span> © 2023</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+35
-15
@@ -1,8 +1,12 @@
|
|||||||
import { SignedIn, SignedOut, UserButton } from '@clerk/nextjs';
|
import { SignedIn, SignedOut, currentUser } from '@clerk/nextjs';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { BiPlus, BiSolidMessageSquare } from 'react-icons/bi';
|
||||||
import ThemeButton from './ThemeButton';
|
import ThemeButton from './ThemeButton';
|
||||||
|
import UserButton from './UserButton';
|
||||||
|
|
||||||
|
export default async function Navbar() {
|
||||||
|
const user = await currentUser();
|
||||||
|
|
||||||
export default function Navbar() {
|
|
||||||
return (
|
return (
|
||||||
<nav className='flex items-center justify-between p-5 shadow-md dark:bg-secondary-gray'>
|
<nav className='flex items-center justify-between p-5 shadow-md dark:bg-secondary-gray'>
|
||||||
<ul className='flex items-center gap-4'>
|
<ul className='flex items-center gap-4'>
|
||||||
@@ -15,6 +19,34 @@ export default function Navbar() {
|
|||||||
<span className='font-bold text-4xl text-primary-accent'>X</span>
|
<span className='font-bold text-4xl text-primary-accent'>X</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
<SignedIn>
|
||||||
|
<li>
|
||||||
|
{/* TODO: Style this */}
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='Search'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href='#'>Following</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={`/profile/${user?.id}`}>Profile</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div className='mx-4'>
|
||||||
|
<Link
|
||||||
|
href='/posts/add'
|
||||||
|
className='relative'
|
||||||
|
>
|
||||||
|
<BiSolidMessageSquare className='absolute w-10 h-10 top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-primary-accent' />
|
||||||
|
<BiPlus className='absolute w-5 h-5 top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-white' />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</SignedIn>
|
||||||
</ul>
|
</ul>
|
||||||
<ul className='flex items-center gap-4'>
|
<ul className='flex items-center gap-4'>
|
||||||
<li>
|
<li>
|
||||||
@@ -22,19 +54,7 @@ export default function Navbar() {
|
|||||||
</li>
|
</li>
|
||||||
<SignedIn>
|
<SignedIn>
|
||||||
<li>
|
<li>
|
||||||
<UserButton
|
<UserButton />
|
||||||
appearance={{
|
|
||||||
elements: {
|
|
||||||
card: 'dark:bg-secondary-gray [&_*]:dark:text-white',
|
|
||||||
userButtonTrigger:
|
|
||||||
'border-solid border-2 border-primary-accent focus:shadow-none',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
userProfileMode='navigation'
|
|
||||||
userProfileUrl='/account'
|
|
||||||
signInUrl='/login'
|
|
||||||
afterSignOutUrl='/'
|
|
||||||
/>
|
|
||||||
</li>
|
</li>
|
||||||
</SignedIn>
|
</SignedIn>
|
||||||
<SignedOut>
|
<SignedOut>
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PostWithUserDto } from '@/app/types';
|
||||||
|
import { Text } from '@codemirror/state';
|
||||||
|
import { useMutation } from 'convex/react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { BiDotsHorizontalRounded } from 'react-icons/bi';
|
||||||
|
import { GoDotFill } from 'react-icons/go';
|
||||||
|
import { api } from '../../convex/_generated/api';
|
||||||
|
import PostContent from './PostContent';
|
||||||
|
|
||||||
|
export default function Post({
|
||||||
|
post,
|
||||||
|
limit = true,
|
||||||
|
}: {
|
||||||
|
post: PostWithUserDto;
|
||||||
|
limit?: boolean;
|
||||||
|
}) {
|
||||||
|
const deletePostById = useMutation(api.posts.deletePostById);
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const username = post.user.clerkUsername ?? post.user._id;
|
||||||
|
const createdPostDate = new Date(post._creationTime);
|
||||||
|
const postYear = createdPostDate.getFullYear();
|
||||||
|
const postMonth = createdPostDate.toLocaleString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
});
|
||||||
|
const dayOfMonth = createdPostDate.getDate();
|
||||||
|
const content = limit ? post.content.slice(0, 10) : post.content;
|
||||||
|
|
||||||
|
// TODO: Add reply and like buttons
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (
|
||||||
|
modalOpen &&
|
||||||
|
modalRef.current &&
|
||||||
|
modalRef.current.contains(e.target as Node) === false
|
||||||
|
) {
|
||||||
|
setModalOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('click', handleClickOutside);
|
||||||
|
}, [modalOpen]);
|
||||||
|
|
||||||
|
async function handleDeleteClick() {
|
||||||
|
setModalOpen(false);
|
||||||
|
const result = confirm('Are you sure you want to delete this post?');
|
||||||
|
|
||||||
|
if (result === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await deletePostById({ id: post._id });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex w-full gap-4 p-8 bg-white dark:bg-secondary-gray'>
|
||||||
|
<div>
|
||||||
|
<Image
|
||||||
|
alt='user profile image'
|
||||||
|
src={post.user.clerkImageUrl}
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
className='rounded-full object-cover border-4 border-primary-accent'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-1 flex-col gap-2'>
|
||||||
|
<div className='flex items-center justify-between gap-2'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<div>{username}</div>
|
||||||
|
<GoDotFill className='w-3 h-3' />
|
||||||
|
<div className='text-xs'>
|
||||||
|
{`${postMonth} ${dayOfMonth}, ${postYear}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='relative'>
|
||||||
|
<button onClick={() => setModalOpen(!modalOpen)}>
|
||||||
|
<BiDotsHorizontalRounded />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
className={`${
|
||||||
|
modalOpen ? '' : 'hidden'
|
||||||
|
} max-w-min whitespace-nowrap absolute top-5 right-0 rounded-md py-2 pl-4 pr-10 shadow-md bg-white dark:bg-primary-gray`}
|
||||||
|
>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
onClick={() => setModalOpen(false)}
|
||||||
|
href={`/posts/${post._id}/edit`}
|
||||||
|
>
|
||||||
|
Edit post
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className='text-red-600'>
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
Delete post
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`${limit ? 'max-h-80' : ''} overflow-hidden text-ellipsis`}
|
||||||
|
>
|
||||||
|
<PostContent content={Text.of(content).toString()} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{limit && post.content.length > 10 ? (
|
||||||
|
<Link
|
||||||
|
className='font-semibold text-primary-accent'
|
||||||
|
href={`/posts/${post._id}`}
|
||||||
|
>
|
||||||
|
Show more
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
|
import remarkEmoji from 'remark-emoji';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import './MarkdownContent.css';
|
||||||
|
|
||||||
|
export default function PostContent({ content }: { content: string }) {
|
||||||
|
return (
|
||||||
|
<div className='markdown-content'>
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm, remarkEmoji]}
|
||||||
|
components={{
|
||||||
|
code({ node, inline, className, children, ...props }) {
|
||||||
|
const match = /language-(\w+)/.exec(className || '');
|
||||||
|
|
||||||
|
return !inline && match ? (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
{...props}
|
||||||
|
style={oneDark}
|
||||||
|
language={match[1]}
|
||||||
|
PreTag='div'
|
||||||
|
>
|
||||||
|
{String(children).replace(/\n$/, '')}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
) : (
|
||||||
|
<code
|
||||||
|
{...props}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { ImSpinner2 } from 'react-icons/im';
|
||||||
|
|
||||||
|
export default function SpinningLoader({ className }: { className: string }) {
|
||||||
|
return <ImSpinner2 className={className} />;
|
||||||
|
}
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useMountedEffect, useProgressEffect } from '@/hooks';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { BsMoonStarsFill, BsSunFill } from 'react-icons/bs';
|
import { BsMoonStarsFill, BsSunFill } from 'react-icons/bs';
|
||||||
import { ImSpinner2 } from 'react-icons/im';
|
|
||||||
|
|
||||||
export default function ThemeButton() {
|
export default function ThemeButton() {
|
||||||
const [mounted, setMounted] = useState(false);
|
useProgressEffect();
|
||||||
|
const { mounted, Loader } = useMountedEffect();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
useEffect(() => setMounted(true), []);
|
|
||||||
|
|
||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -19,7 +17,7 @@ export default function ThemeButton() {
|
|||||||
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
||||||
>
|
>
|
||||||
{mounted === false ? (
|
{mounted === false ? (
|
||||||
<ImSpinner2 className='w-5 h-5 animate-spin' />
|
<Loader className='animate-spin w-5 h-5' />
|
||||||
) : isDark ? (
|
) : isDark ? (
|
||||||
<BsMoonStarsFill className='w-5 h-5' />
|
<BsMoonStarsFill className='w-5 h-5' />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
'use client';
|
||||||
|
import { useMountedEffect } from '@/hooks';
|
||||||
|
import { UserButton as ClerkUserButton } from '@clerk/nextjs';
|
||||||
|
|
||||||
|
export default function UserButton() {
|
||||||
|
const { mounted } = useMountedEffect();
|
||||||
|
|
||||||
|
if (mounted === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ClerkUserButton
|
||||||
|
appearance={{
|
||||||
|
elements: {
|
||||||
|
card: 'dark:bg-secondary-gray [&_*]:dark:text-white',
|
||||||
|
userButtonTrigger:
|
||||||
|
'border-solid border-2 border-primary-accent focus:shadow-none',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
userProfileMode='navigation'
|
||||||
|
userProfileUrl='/account'
|
||||||
|
signInUrl='/login'
|
||||||
|
afterSignOutUrl='/'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { UserDto } from '@/app/types';
|
||||||
|
import { usePaginatedQuery } from 'convex/react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { api } from '../../convex/_generated/api';
|
||||||
|
import Post from './Post';
|
||||||
|
import SpinningLoader from './SpinningLoader';
|
||||||
|
|
||||||
|
export default function UserPosts({ user }: { user: UserDto }) {
|
||||||
|
const PAGE_SIZE = 2;
|
||||||
|
const pager = usePaginatedQuery(
|
||||||
|
api.posts.getUsersPostById,
|
||||||
|
{
|
||||||
|
userId: user._id,
|
||||||
|
},
|
||||||
|
{ initialNumItems: PAGE_SIZE }
|
||||||
|
);
|
||||||
|
|
||||||
|
const postsWithUser = pager.results.map(post => ({ ...post, user }));
|
||||||
|
|
||||||
|
if (pager.isLoading === false && postsWithUser.length == 0) {
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col items-center justify-center border-t border-gray-600 w-full h-full'>
|
||||||
|
<div className='flex flex-col items-center gap-2'>
|
||||||
|
<h2 className='text-xl font-semibold'>
|
||||||
|
Hmm...it doesn't look like you've posted anything.
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
href='/posts/add'
|
||||||
|
className='inline-flex max-w-max items-center justify-center px-3 py-1 bg-primary-accent rounded-md text-white'
|
||||||
|
>
|
||||||
|
Make a post
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col items-center w-full'>
|
||||||
|
<div className='flex flex-col items-center w-full'>
|
||||||
|
{postsWithUser.map(post => (
|
||||||
|
<div
|
||||||
|
key={post._id}
|
||||||
|
className='w-full border border-t-0 border-gray-600 first:border-t last:rounded-b-md p-1'
|
||||||
|
>
|
||||||
|
<Post post={post} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{pager.isLoading ? (
|
||||||
|
<div className='flex w-full items-center justify-center gap-2 p-5 border-t border-gray-600'>
|
||||||
|
<SpinningLoader className='animate-spin w-5 h-5' />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : pager.status === 'CanLoadMore' ? (
|
||||||
|
<div className='flex items-center p-5 pb-1'>
|
||||||
|
<button
|
||||||
|
className='bg-primary-accent text-white px-3 py-1 rounded-full'
|
||||||
|
onClick={() => pager.loadMore(PAGE_SIZE)}
|
||||||
|
disabled={pager.status !== 'CanLoadMore'}
|
||||||
|
>
|
||||||
|
{pager.isLoading ? (
|
||||||
|
<>
|
||||||
|
<SpinningLoader className='animate-spin w-5 h-5' />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Load More'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { UserDto } from '@/app/types';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { AiFillCalendar } from 'react-icons/ai';
|
||||||
|
|
||||||
|
export default function UserProfile({ user }: { user: UserDto }) {
|
||||||
|
const username = user.clerkUsername ?? user._id;
|
||||||
|
const userCreatedDate = new Date(user._creationTime);
|
||||||
|
const monthJoined = userCreatedDate.toLocaleString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
});
|
||||||
|
const yearJoined = userCreatedDate.getFullYear();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full'>
|
||||||
|
<div className='shadow-md bg-gradient-to-r from-violet-600 via-violet-600 to-indigo-600 rounded-t-md pt-20 pl-5 pr-40'>
|
||||||
|
<div className='flex items-center gap-4 -mb-[25px]'>
|
||||||
|
<div>
|
||||||
|
<Image
|
||||||
|
alt='user profile image'
|
||||||
|
src={user.clerkImageUrl}
|
||||||
|
width={100}
|
||||||
|
height={100}
|
||||||
|
className='rounded-full object-cover border-4 border-primary-accent'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='text-white'>
|
||||||
|
<h1 className='font-bold'>{username}</h1>
|
||||||
|
<div className='flex gap-2 items-center text-sm'>
|
||||||
|
<AiFillCalendar className='w-4 h-4' />
|
||||||
|
{`Joined ${monthJoined} ${yearJoined}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='h-[50px] bg-white dark:bg-primary-gray pl-5 pr-40 border border-gray-600 border-b-0'></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import SpinningLoader from '@/components/SpinningLoader';
|
||||||
|
import { EditorState, Extension, Text } from '@codemirror/state';
|
||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
|
import {
|
||||||
|
useRouter as useNextRouter,
|
||||||
|
usePathname,
|
||||||
|
useSearchParams,
|
||||||
|
} from 'next/navigation';
|
||||||
|
import NProgress from 'nprogress';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export function useCodeMirror({
|
||||||
|
doc,
|
||||||
|
extensions,
|
||||||
|
}: {
|
||||||
|
doc: string[];
|
||||||
|
extensions: Extension;
|
||||||
|
}) {
|
||||||
|
const editorRef = useRef(null);
|
||||||
|
const [editorView, setEditorView] = useState<EditorView | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editorRef.current === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: Text.of(doc),
|
||||||
|
extensions: extensions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const view = new EditorView({
|
||||||
|
state: state,
|
||||||
|
parent: editorRef.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
setEditorView(view);
|
||||||
|
return () => view.destroy();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [editorRef.current]);
|
||||||
|
|
||||||
|
return { editorRef, editorView };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMountedEffect() {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
return { mounted, Loader: SpinningLoader };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRouter() {
|
||||||
|
const router = useNextRouter();
|
||||||
|
const { push, back } = router;
|
||||||
|
|
||||||
|
router.push = (href, options) => {
|
||||||
|
NProgress.start();
|
||||||
|
push(href, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
router.back = () => {
|
||||||
|
NProgress.start();
|
||||||
|
back();
|
||||||
|
};
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProgressEffect() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
NProgress.done();
|
||||||
|
}, [pathname, searchParams]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||||
|
import { Extension } from '@codemirror/state';
|
||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
|
import { tags as t } from '@lezer/highlight';
|
||||||
|
|
||||||
|
const base00 = '#24272d';
|
||||||
|
const base01 = '#DDDDDD';
|
||||||
|
const base02 = '#B9D2FF';
|
||||||
|
const base03 = '#b0b0b0';
|
||||||
|
const base05 = '#e0e0e0';
|
||||||
|
const base06 = '#808080';
|
||||||
|
const base07 = '#000000';
|
||||||
|
const base08 = '#A54543';
|
||||||
|
const base09 = '#fc6d24';
|
||||||
|
const base0A = '#fda331';
|
||||||
|
const base0B = '#8abeb7';
|
||||||
|
const base0C = '#b5bd68';
|
||||||
|
const base0D = '#6fb3d2';
|
||||||
|
const base0E = '#cc99cc';
|
||||||
|
const base0F = '#6987AF';
|
||||||
|
|
||||||
|
const invalid = base09;
|
||||||
|
const darkBackground = base00;
|
||||||
|
const highlightBackground = base02 + '30';
|
||||||
|
const background = base00;
|
||||||
|
const tooltipBackground = base01;
|
||||||
|
const selection = '#202325';
|
||||||
|
const cursor = base01;
|
||||||
|
|
||||||
|
const basicDarkTheme = EditorView.theme(
|
||||||
|
{
|
||||||
|
'&': {
|
||||||
|
height: '100%',
|
||||||
|
color: base01,
|
||||||
|
backgroundColor: background,
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
},
|
||||||
|
'.cm-gutter': {
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
'.cm-content': {
|
||||||
|
caretColor: cursor,
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
'.cm-scroller': {
|
||||||
|
overflow: 'auto',
|
||||||
|
},
|
||||||
|
'.cm-cursor, .cm-dropCursor': { borderLeftColor: cursor },
|
||||||
|
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
|
||||||
|
{ backgroundColor: selection },
|
||||||
|
'.cm-panels': { backgroundColor: darkBackground, color: base03 },
|
||||||
|
'.cm-panels.cm-panels-top': { borderBottom: '2px solid black' },
|
||||||
|
'.cm-panels.cm-panels-bottom': { borderTop: '2px solid black' },
|
||||||
|
'.cm-searchMatch': {
|
||||||
|
backgroundColor: base02,
|
||||||
|
outline: `1px solid ${base03}`,
|
||||||
|
color: base07,
|
||||||
|
},
|
||||||
|
'.cm-searchMatch.cm-searchMatch-selected': {
|
||||||
|
backgroundColor: base05,
|
||||||
|
color: base07,
|
||||||
|
},
|
||||||
|
'.cm-activeLine': { backgroundColor: highlightBackground },
|
||||||
|
'.cm-selectionMatch': { backgroundColor: highlightBackground },
|
||||||
|
'&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket': {
|
||||||
|
outline: `1px solid ${base03}`,
|
||||||
|
},
|
||||||
|
'&.cm-focused .cm-matchingBracket': {
|
||||||
|
backgroundColor: base02,
|
||||||
|
color: base07,
|
||||||
|
},
|
||||||
|
'.cm-gutters': {
|
||||||
|
borderRight: `none`,
|
||||||
|
color: base06,
|
||||||
|
backgroundColor: darkBackground,
|
||||||
|
},
|
||||||
|
'.cm-activeLineGutter': {
|
||||||
|
backgroundColor: highlightBackground,
|
||||||
|
},
|
||||||
|
'.cm-foldPlaceholder': {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: base02,
|
||||||
|
},
|
||||||
|
'.cm-tooltip': {
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: tooltipBackground,
|
||||||
|
},
|
||||||
|
'.cm-tooltip .cm-tooltip-arrow:before': {
|
||||||
|
borderTopColor: 'transparent',
|
||||||
|
borderBottomColor: 'transparent',
|
||||||
|
},
|
||||||
|
'.cm-tooltip .cm-tooltip-arrow:after': {
|
||||||
|
borderTopColor: tooltipBackground,
|
||||||
|
borderBottomColor: tooltipBackground,
|
||||||
|
},
|
||||||
|
'.cm-tooltip-autocomplete': {
|
||||||
|
'& > ul > li[aria-selected]': {
|
||||||
|
backgroundColor: highlightBackground,
|
||||||
|
color: base03,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ dark: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const basicDarkHighlightStyle = HighlightStyle.define([
|
||||||
|
{ tag: t.keyword, color: base0A },
|
||||||
|
{
|
||||||
|
tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName],
|
||||||
|
color: base0C,
|
||||||
|
},
|
||||||
|
{ tag: [t.variableName], color: base0D },
|
||||||
|
{ tag: [t.function(t.variableName)], color: base0A },
|
||||||
|
{ tag: [t.labelName], color: base09 },
|
||||||
|
{
|
||||||
|
tag: [t.color, t.constant(t.name), t.standard(t.name)],
|
||||||
|
color: base0A,
|
||||||
|
},
|
||||||
|
{ tag: [t.definition(t.name), t.separator], color: base0E },
|
||||||
|
{ tag: [t.brace], color: base0E },
|
||||||
|
{
|
||||||
|
tag: [t.annotation],
|
||||||
|
color: invalid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: [t.number, t.changed, t.annotation, t.modifier, t.self, t.namespace],
|
||||||
|
color: base0A,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: [t.typeName, t.className],
|
||||||
|
color: base0D,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: [t.operator, t.operatorKeyword],
|
||||||
|
color: base0E,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: [t.tagName],
|
||||||
|
color: base0A,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: [t.squareBracket],
|
||||||
|
color: base0E,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: [t.angleBracket],
|
||||||
|
color: base0E,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: [t.attributeName],
|
||||||
|
color: base0D,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: [t.regexp],
|
||||||
|
color: base0A,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: [t.quote],
|
||||||
|
color: base01,
|
||||||
|
},
|
||||||
|
{ tag: [t.string], color: base0C },
|
||||||
|
{
|
||||||
|
tag: t.link,
|
||||||
|
color: base0F,
|
||||||
|
textDecoration: 'underline',
|
||||||
|
textUnderlinePosition: 'under',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: [t.url, t.escape, t.special(t.string)],
|
||||||
|
color: base0B,
|
||||||
|
},
|
||||||
|
{ tag: [t.meta], color: base08 },
|
||||||
|
{ tag: [t.comment], color: base06, fontStyle: 'italic' },
|
||||||
|
{ tag: t.monospace, color: base01 },
|
||||||
|
{ tag: t.strong, fontWeight: 'bold', color: base0A },
|
||||||
|
{ tag: t.emphasis, fontStyle: 'italic', color: base0D },
|
||||||
|
{ tag: t.strikethrough, textDecoration: 'line-through' },
|
||||||
|
{ tag: t.heading, fontWeight: 'bold', color: base01 },
|
||||||
|
{ tag: t.special(t.heading1), fontWeight: 'bold', color: base01 },
|
||||||
|
{ tag: t.heading1, fontWeight: 'bold', color: base01 },
|
||||||
|
{
|
||||||
|
tag: [t.heading2, t.heading3, t.heading4],
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: base01,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: [t.heading5, t.heading6],
|
||||||
|
color: base01,
|
||||||
|
},
|
||||||
|
{ tag: [t.atom, t.bool, t.special(t.variableName)], color: base0B },
|
||||||
|
{
|
||||||
|
tag: [t.processingInstruction, t.inserted],
|
||||||
|
color: base0B,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: [t.contentSeparator],
|
||||||
|
color: base0D,
|
||||||
|
},
|
||||||
|
{ tag: t.invalid, color: base02, borderBottom: `1px dotted ${invalid}` },
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const basicDark: Extension = [
|
||||||
|
basicDarkTheme,
|
||||||
|
syntaxHighlighting(basicDarkHighlightStyle),
|
||||||
|
];
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { auth } from '@clerk/nextjs';
|
||||||
|
import { ConvexHttpClient } from 'convex/browser';
|
||||||
|
|
||||||
|
export async function getConvexClient() {
|
||||||
|
const { user, getToken } = auth();
|
||||||
|
const token = await getToken({ template: 'convex' });
|
||||||
|
|
||||||
|
if (token === null || user === null) {
|
||||||
|
throw new Error('NOT_AUTHORIZED');
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL;
|
||||||
|
|
||||||
|
if (CONVEX_URL === undefined) {
|
||||||
|
throw new Error('NEXT_PUBLIC_CONVEX_URL is undefined');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new ConvexHttpClient(CONVEX_URL);
|
||||||
|
client.setAuth(token);
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user