diff --git a/apps/social/next.config.mjs b/apps/social/next.config.mjs index dcecbf43..32f7991c 100644 --- a/apps/social/next.config.mjs +++ b/apps/social/next.config.mjs @@ -1,6 +1,14 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - transpilePackages: ["@umamin/ui", "@umamin/server"], + pageExtensions: ["js", "jsx", "mdx", "ts", "tsx"], + experimental: { + serverComponentsExternalPackages: ["@node-rs/argon2"], + }, + compiler: { + removeConsole: process.env.NODE_ENV === "production", + }, + + transpilePackages: ["@umamin/ui", "@umamin/db", "@umamin/gql"], images: { remotePatterns: [ { diff --git a/apps/social/package.json b/apps/social/package.json index 54554677..95f58f2d 100644 --- a/apps/social/package.json +++ b/apps/social/package.json @@ -7,44 +7,58 @@ "build": "next build", "start": "next start", "clean": "rm -rf ./node_modules .turbo .next", - "check-types": "tsc --noEmit", - "lint": "next lint" + "check-types": "tsc --noEmit && gql.tada check", + "lint": "next lint", + "gql:check": "gql.tada check", + "gql:generate-persisted": "gql.tada generate-persisted", + "gql:generate-schema": "gql.tada generate-schema http://localhost:3000/api/graphql" }, "dependencies": { - "@graphql-yoga/plugin-apq": "^3.3.0", - "@graphql-yoga/plugin-csrf-prevention": "^3.3.0", - "@graphql-yoga/plugin-disable-introspection": "^2.3.0", + "@fingerprintjs/botd": "^1.9.1", + "@graphql-yoga/plugin-csrf-prevention": "^3.6.2", + "@graphql-yoga/plugin-disable-introspection": "^2.6.2", + "@graphql-yoga/plugin-persisted-operations": "^3.6.2", + "@graphql-yoga/plugin-response-cache": "^3.8.2", + "@lucia-auth/adapter-drizzle": "^1.0.7", + "@node-rs/argon2": "^1.8.3", "@umamin/db": "workspace:*", "@umamin/gql": "workspace:*", "@umamin/ui": "workspace:*", - "@lucia-auth/adapter-drizzle": "^1.0.7", - "@urql/core": "^5.0.3", - "@urql/exchange-graphcache": "^7.0.2", + "@urql/core": "^5.0.5", + "@urql/exchange-graphcache": "^7.1.1", + "@urql/exchange-persisted": "^4.3.0", "@urql/next": "^1.1.1", + "@whatwg-node/server": "^0.9.46", "arctic": "^1.8.1", - "geist": "^1.3.0", - "gql.tada": "^1.7.5", - "graphql": "^16.8.1", - "graphql-yoga": "^5.3.1", - "nanoid": "^5.0.7", - "oslo": "^1.2.0", - "urql": "^4.1.0", + "date-fns": "^3.6.0", + "geist": "^1.3.1", + "gql.tada": "^1.8.5", + "graphql": "^16.9.0", + "graphql-yoga": "^5.6.2", "lucia": "^3.2.0", - "next": "14.2.3", + "lucide-react": "^0.424.0", + "nanoid": "^5.0.7", + "next": "14.2.5", "nextjs-toploader": "^1.6.12", + "oslo": "^1.2.0", "react": "^18", "react-dom": "^18", - "sonner": "^1.4.41" + "sonner": "^1.5.0", + "urql": "^4.1.0", + "zod": "^3.23.8" }, "devDependencies": { - "@umamin/eslint-config": "workspace:*", - "@umamin/tsconfig": "workspace:*", + "@0no-co/graphqlsp": "^1.12.12", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@umamin/eslint-config": "workspace:*", + "@umamin/tsconfig": "workspace:*", + "autoprefixer": "^10.0.1", "eslint": "^8", - "eslint-config-next": "14.2.3", - "postcss": "^8", - "tailwindcss": "^3.4.1" + "eslint-config-next": "14.2.5", + "postcss": "^8.4.40", + "tailwindcss": "^3.4.7", + "typescript": "^5.5.4" } } diff --git a/apps/social/postcss.config.cjs b/apps/social/postcss.config.cjs new file mode 100644 index 00000000..a4917b21 --- /dev/null +++ b/apps/social/postcss.config.cjs @@ -0,0 +1 @@ +module.exports = require("@umamin/ui/postcss.config"); diff --git a/apps/social/postcss.config.mjs b/apps/social/postcss.config.mjs deleted file mode 100644 index 1a69fd2a..00000000 --- a/apps/social/postcss.config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -/** @type {import('postcss-load-config').Config} */ -const config = { - plugins: { - tailwindcss: {}, - }, -}; - -export default config; diff --git a/apps/social/public/logo.svg b/apps/social/public/logo.svg new file mode 100644 index 00000000..9778ea93 --- /dev/null +++ b/apps/social/public/logo.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/social/src/actions.ts b/apps/social/src/actions.ts new file mode 100644 index 00000000..2b9d9197 --- /dev/null +++ b/apps/social/src/actions.ts @@ -0,0 +1,259 @@ +"use server"; + +import { nanoid } from "nanoid"; +import { db, eq } from "@umamin/db"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { hash, verify } from "@node-rs/argon2"; +import { + user as userSchema, + account as accountSchema, +} from "@umamin/db/schema/user"; +import { note as noteSchema } from "@umamin/db/schema/note"; +import { message as messageSchema } from "@umamin/db/schema/message"; + +import { getSession, lucia } from "./lib/auth"; +import { z } from "zod"; + +export async function logout(): Promise { + const { session } = await getSession(); + + if (!session) { + throw new Error("Unauthorized"); + } + + await lucia.invalidateSession(session.id); + + const sessionCookie = lucia.createBlankSessionCookie(); + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes, + ); + + return redirect("/login"); +} + +const signupSchema = z + .object({ + username: z + .string() + .min(5, { + message: "Username must be at least 5 characters", + }) + .max(20, { + message: "Username must not exceed 20 characters", + }) + .refine((url) => /^[a-zA-Z0-9_-]+$/.test(url), { + message: "Username must be alphanumeric with no spaces", + }), + password: z + .string() + .min(5, { + message: "Password must be at least 5 characters", + }) + .max(255, { + message: "Password must not exceed 255 characters", + }), + confirmPassword: z.string(), + }) + .refine( + (values) => { + return values.password === values.confirmPassword; + }, + { + message: "Password does not match", + path: ["confirmPassword"], + }, + ); + +export async function signup(_: any, formData: FormData) { + const validatedFields = signupSchema.safeParse({ + username: formData.get("username"), + password: formData.get("password"), + confirmPassword: formData.get("confirmPassword"), + }); + + if (!validatedFields.success) { + return { + errors: validatedFields.error.flatten().fieldErrors, + }; + } + + const passwordHash = await hash(validatedFields.data.password, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }); + + const userId = nanoid(); + + try { + await db.insert(userSchema).values({ + id: userId, + username: validatedFields.data.username.toLowerCase(), + passwordHash, + }); + } catch (err: any) { + if (err.code === "SQLITE_CONSTRAINT") { + if (err.message.includes("user.username")) { + return { + errors: { + username: ["Username already taken"], + }, + }; + } + } + + throw new Error("Something went wrong"); + } + + const session = await lucia.createSession(userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes, + ); + + return redirect("/"); +} + +export async function login(_: any, formData: FormData): Promise { + const username = formData.get("username"); + + if ( + typeof username !== "string" || + username.length < 5 || + username.length > 20 || + !/^[a-zA-Z0-9_-]+$/.test(username) + ) { + return { + error: "Incorrect username or password", + }; + } + + const password = formData.get("password"); + + if ( + typeof password !== "string" || + password.length < 5 || + password.length > 255 + ) { + return { + error: "Incorrect username or password", + }; + } + + const existingUser = await db.query.user.findFirst({ + where: eq(userSchema.username, username.toLowerCase()), + }); + + if (!existingUser || !existingUser.passwordHash) { + return { + error: "Incorrect username or password", + }; + } + + const validPassword = await verify(existingUser.passwordHash, password, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }); + + if (!validPassword) { + return { + error: "Incorrect username or password", + }; + } + + const session = await lucia.createSession(existingUser.id, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes, + ); + + return redirect("/"); +} + +export async function updatePassword({ + currentPassword, + password, +}: { + currentPassword?: string; + password: string; +}): Promise { + const { user } = await getSession(); + + if (!user) { + throw new Error("Unauthorized"); + } + + if (currentPassword && user.passwordHash) { + const validPassword = await verify(user.passwordHash, currentPassword, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }); + + if (!validPassword) { + return { + error: "Incorrect password", + }; + } + } + + const passwordHash = await hash(password, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }); + + await db + .update(userSchema) + .set({ passwordHash }) + .where(eq(userSchema.id, user.id)); + + return redirect("/settings"); +} + +export async function deleteAccount() { + const { user } = await getSession(); + + if (!user) { + throw new Error("Unauthorized"); + } + + try { + await db.batch([ + db.delete(messageSchema).where(eq(messageSchema.receiverId, user.id)), + db.delete(accountSchema).where(eq(accountSchema.userId, user.id)), + db.delete(noteSchema).where(eq(noteSchema.userId, user.id)), + db.delete(userSchema).where(eq(userSchema.id, user.id)), + ]); + + await lucia.invalidateSession(user.id); + + const sessionCookie = lucia.createBlankSessionCookie(); + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes, + ); + } catch (err) { + throw new Error("Failed to delete account"); + } + + return redirect("/login"); +} + +interface ActionResult { + error: string | null; +} diff --git a/apps/social/src/app/(authentication)/login/components/form.tsx b/apps/social/src/app/(authentication)/login/components/form.tsx new file mode 100644 index 00000000..3d0d937a --- /dev/null +++ b/apps/social/src/app/(authentication)/login/components/form.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { login } from "@/actions"; +import { useFormState } from "react-dom"; + +import { LoginButton } from "./login-button"; +import { Input } from "@umamin/ui/components/input"; +import { Label } from "@umamin/ui/components/label"; + +export function LoginForm() { + const [state, formAction] = useFormState(login, { error: "" }); + + return ( +
+
+ + +
+ +
+ + + {!!state?.error && ( +

{state.error}

+ )} +
+ + + + ); +} diff --git a/apps/social/src/app/(authentication)/login/components/login-button.tsx b/apps/social/src/app/(authentication)/login/components/login-button.tsx new file mode 100644 index 00000000..1ca484aa --- /dev/null +++ b/apps/social/src/app/(authentication)/login/components/login-button.tsx @@ -0,0 +1,25 @@ +"use client"; + +import Link from "next/link"; +import { Loader2 } from "lucide-react"; +import { useFormStatus } from "react-dom"; +import { Button } from "@umamin/ui/components/button"; + +export function LoginButton() { + const { pending } = useFormStatus(); + + return ( +
+ + + +
+ ); +} diff --git a/apps/social/src/app/(authentication)/login/google/callback/route.ts b/apps/social/src/app/(authentication)/login/google/callback/route.ts new file mode 100644 index 00000000..261b8560 --- /dev/null +++ b/apps/social/src/app/(authentication)/login/google/callback/route.ts @@ -0,0 +1,158 @@ +import { nanoid } from "nanoid"; +import { generateId } from "lucia"; +import { cookies } from "next/headers"; +import { db, and, eq } from "@umamin/db"; +import { OAuth2RequestError } from "arctic"; +import { + user as userSchema, + account as accountSchema, +} from "@umamin/db/schema/user"; + +import { getSession, google, lucia } from "@/lib/auth"; + +export async function GET(request: Request): Promise { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + + const storedState = cookies().get("google_oauth_state")?.value ?? null; + const storedCodeVerifier = cookies().get("code_verifier")?.value ?? null; + + if ( + !code || + !state || + !storedState || + !storedCodeVerifier || + state !== storedState + ) { + return new Response(null, { + status: 400, + }); + } + + try { + const tokens = await google.validateAuthorizationCode( + code, + storedCodeVerifier, + ); + + const googleUserResponse = await fetch( + "https://openidconnect.googleapis.com/v1/userinfo", + { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }, + ); + + const googleUser: GoogleUser = await googleUserResponse.json(); + + const { user } = await getSession(); + + const existingUser = await db.query.account.findFirst({ + where: and( + eq(accountSchema.providerId, "google"), + eq(accountSchema.providerUserId, googleUser.sub), + ), + }); + + if (user && existingUser) { + return new Response(null, { + status: 302, + headers: { + Location: "/settings?error=already_linked", + }, + }); + } else if (user) { + await db + .update(userSchema) + .set({ + imageUrl: googleUser.picture, + }) + .where(eq(userSchema.id, user.id)); + + await db.insert(accountSchema).values({ + providerId: "google", + providerUserId: googleUser.sub, + userId: user.id, + picture: googleUser.picture, + email: googleUser.email, + }); + + return new Response(null, { + status: 302, + headers: { + Location: "/settings", + }, + }); + } + + if (existingUser) { + const session = await lucia.createSession(existingUser.userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes, + ); + + return new Response(null, { + status: 302, + headers: { + Location: "/login", + }, + }); + } + + const usernameId = generateId(5); + const userId = nanoid(); + + await db.insert(userSchema).values({ + id: userId, + imageUrl: googleUser.picture, + username: `umamin_${usernameId}`, + }); + + await db.insert(accountSchema).values({ + providerId: "google", + providerUserId: googleUser.sub, + userId, + picture: googleUser.picture, + email: googleUser.email, + }); + + const session = await lucia.createSession(userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes, + ); + + return new Response(null, { + status: 302, + headers: { + Location: "/login", + }, + }); + } catch (err: any) { + console.log(err); + if (err instanceof OAuth2RequestError) { + return new Response(null, { + status: 400, + }); + } + + return new Response(null, { + status: 500, + }); + } +} + +interface GoogleUser { + sub: string; + picture: string; + email: string; +} diff --git a/apps/social/src/app/(authentication)/login/google/route.ts b/apps/social/src/app/(authentication)/login/google/route.ts new file mode 100644 index 00000000..88f65bc7 --- /dev/null +++ b/apps/social/src/app/(authentication)/login/google/route.ts @@ -0,0 +1,31 @@ +import { generateState, generateCodeVerifier } from "arctic"; +import { cookies } from "next/headers"; +import { google } from "@/lib/auth"; + +export async function GET(): Promise { + const state = generateState(); + const codeVerifier = generateCodeVerifier(); + const url = await google.createAuthorizationURL(state, codeVerifier, { + scopes: ["profile", "email"], + }); + + url.searchParams.set("access_type", "offline"); + + cookies().set("google_oauth_state", state, { + path: "/", + secure: process.env.NODE_ENV === "production", + httpOnly: true, + maxAge: 60 * 10, + sameSite: "lax", + }); + + cookies().set("code_verifier", codeVerifier, { + path: "/", + secure: process.env.NODE_ENV === "production", + httpOnly: true, + maxAge: 60 * 10, + sameSite: "lax", + }); + + return Response.redirect(url); +} diff --git a/apps/social/src/app/(authentication)/login/loading.tsx b/apps/social/src/app/(authentication)/login/loading.tsx new file mode 100644 index 00000000..5ba9d85f --- /dev/null +++ b/apps/social/src/app/(authentication)/login/loading.tsx @@ -0,0 +1,28 @@ +import { Skeleton } from "@umamin/ui/components/skeleton"; + +export default function Loading() { + return ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+
+ ); +} diff --git a/apps/social/src/app/(authentication)/login/page.tsx b/apps/social/src/app/(authentication)/login/page.tsx new file mode 100644 index 00000000..a2e86b09 --- /dev/null +++ b/apps/social/src/app/(authentication)/login/page.tsx @@ -0,0 +1,77 @@ +import Link from "next/link"; +import dynamic from "next/dynamic"; +import { getSession } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import { LoginForm } from "./components/form"; + +const BrowserWarning = dynamic( + () => import("@umamin/ui/components/browser-warning"), + { ssr: false } +); + +export const metadata = { + title: "Umamin Social — Login", + description: + "Log in to Umamin to send and receive encrypted anonymous messages. Secure your privacy and communicate freely.", + keywords: [ + "Umamin login", + "anonymous messaging login", + "encrypted messages login", + ], + robots: { + index: false, + follow: false, + }, + openGraph: { + type: "website", + title: "Umamin Social — Login", + description: + "Log in to Umamin to send and receive encrypted anonymous messages. Secure your privacy and communicate freely.", + url: "https://social.umamin.link/login", + }, + twitter: { + card: "summary_large_image", + title: "Umamin Social — Login", + description: + "Log in to Umamin to send and receive encrypted anonymous messages. Secure your privacy and communicate freely.", + }, +}; + +export default async function Login() { + const { user } = await getSession(); + + if (user) { + redirect("/"); + } + + return ( +
+ +
+

+ Umamin Social +

+

+ The Umamin v2.0{" "} + Next generation open-source social platform +

+
+ +
+
+

Account

+

+ Proceed with your Umamin v2.0 profile +

+
+ +
+ Don't have an account?{" "} + + Sign up + +
+
+
+ ); +} diff --git a/apps/social/src/app/(authentication)/register/components/button.tsx b/apps/social/src/app/(authentication)/register/components/button.tsx new file mode 100644 index 00000000..047ce82d --- /dev/null +++ b/apps/social/src/app/(authentication)/register/components/button.tsx @@ -0,0 +1,25 @@ +"use client"; + +import Link from "next/link"; +import { Loader2 } from "lucide-react"; +import { useFormStatus } from "react-dom"; +import { Button } from "@umamin/ui/components/button"; + +export function RegisterButton() { + const { pending } = useFormStatus(); + + return ( +
+ + + +
+ ); +} diff --git a/apps/social/src/app/(authentication)/register/components/form.tsx b/apps/social/src/app/(authentication)/register/components/form.tsx new file mode 100644 index 00000000..03f325ac --- /dev/null +++ b/apps/social/src/app/(authentication)/register/components/form.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { signup } from "@/actions"; +import { useFormState } from "react-dom"; +import { RegisterButton } from "./button"; +import { cn } from "@umamin/ui/lib/utils"; +import { Input } from "@umamin/ui/components/input"; +import { Label } from "@umamin/ui/components/label"; + +export function RegisterForm() { + const [state, formAction] = useFormState(signup, { errors: {} }); + + return ( +
+
+ + +

+ {state.errors.username + ? state.errors.username[0] + : "You can still change this later"} +

+
+ +
+ + + {state.errors.password && ( +

+ {state.errors.password[0]} +

+ )} +
+ +
+ + + {state.errors.confirmPassword && ( +

+ {state.errors.confirmPassword[0]} +

+ )} +
+ + + + ); +} diff --git a/apps/social/src/app/(authentication)/register/loading.tsx b/apps/social/src/app/(authentication)/register/loading.tsx new file mode 100644 index 00000000..d623ffdd --- /dev/null +++ b/apps/social/src/app/(authentication)/register/loading.tsx @@ -0,0 +1,44 @@ +import { Skeleton } from "@umamin/ui/components/skeleton"; + +export default function Loading() { + return ( +
+
+
+ + +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+
+ +
+
+
+ + +
+ + +
+ + + +
+
+ ); +} diff --git a/apps/social/src/app/(authentication)/register/page.tsx b/apps/social/src/app/(authentication)/register/page.tsx new file mode 100644 index 00000000..beda3f71 --- /dev/null +++ b/apps/social/src/app/(authentication)/register/page.tsx @@ -0,0 +1,77 @@ +import Link from "next/link"; +import dynamic from "next/dynamic"; +import { getSession } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import { RegisterForm } from "./components/form"; + +const BrowserWarning = dynamic( + () => import("@umamin/ui/components/browser-warning"), + { ssr: false } +); + +export const metadata = { + title: "Umamin Social — Register", + description: + "Create an account on Umamin to start sending and receiving encrypted anonymous messages. Join our secure platform and ensure your privacy today.", + keywords: [ + "Umamin Social register", + "sign up for Umamin", + "anonymous messaging sign up", + ], + robots: { + index: false, + follow: false, + }, + openGraph: { + type: "website", + title: "Umamin Social — Register", + description: + "Create an account on Umamin to start sending and receiving encrypted anonymous messages. Join our secure platform and ensure your privacy today.", + url: "https://social.umamin.link/register", + }, + twitter: { + card: "summary_large_image", + title: "Umamin Social — Register", + description: + "Create an account on Umamin to start sending and receiving encrypted anonymous messages. Join our secure platform and ensure your privacy today.", + }, +}; + +export default async function Register() { + const { user } = await getSession(); + + if (user) { + redirect("/"); + } + + return ( +
+ + +
+

+ Umamin Account +

+

+ By creating an account, you agree to our{" "} + + Privacy Policy + {" "} + and{" "} + + Terms of Service + +

+
+ + + +
+ Already have an account?{" "} + + Login + +
+
+ ); +} diff --git a/apps/social/src/app/(user)/queries.ts b/apps/social/src/app/(user)/queries.ts new file mode 100644 index 00000000..ce853087 --- /dev/null +++ b/apps/social/src/app/(user)/queries.ts @@ -0,0 +1,37 @@ +import { cache } from "react"; +import getClient from "@/lib/gql/rsc"; +import { ResultOf, graphql } from "gql.tada"; + +export const USER_BY_USERNAME_QUERY = graphql(` + query UserByUsername($username: String!) { + userByUsername(username: $username) { + __typename + id + bio + question + username + displayName + question + quietMode + imageUrl + createdAt + } + } +`); + +const userByUsernamePersisted = graphql.persisted( + "e56708c4cacdba6c698de1f4bc45a999ca535fb519bd806caf9a62a6582159e4", + USER_BY_USERNAME_QUERY, +); + +export const getUserByUsername = cache(async (username: string) => { + const result = await getClient().query(userByUsernamePersisted, { + username, + }); + + return result.data?.userByUsername; +}); + +export type UserByUsernameQueryResult = ResultOf< + typeof USER_BY_USERNAME_QUERY +>["userByUsername"]; diff --git a/apps/social/src/app/(user)/user/[username]/loading.tsx b/apps/social/src/app/(user)/user/[username]/loading.tsx new file mode 100644 index 00000000..506a8665 --- /dev/null +++ b/apps/social/src/app/(user)/user/[username]/loading.tsx @@ -0,0 +1,35 @@ +import { Skeleton } from "@umamin/ui/components/skeleton"; + +export default function Loading() { + return ( +
+
+
+ + +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+
+ +
+ + +
+
+ ); +} diff --git a/apps/social/src/app/(user)/user/[username]/page.tsx b/apps/social/src/app/(user)/user/[username]/page.tsx new file mode 100644 index 00000000..b2ff2988 --- /dev/null +++ b/apps/social/src/app/(user)/user/[username]/page.tsx @@ -0,0 +1,88 @@ +import Link from "next/link"; +import dynamic from "next/dynamic"; +import { redirect } from "next/navigation"; +import { MessageSquareMore, UserPlus } from "lucide-react"; + +import { cn } from "@umamin/ui/lib/utils"; +import { getUserByUsername } from "../../queries"; +import { UserCard } from "@/app/components/user-card"; +import { Button, buttonVariants } from "@umamin/ui/components/button"; + +const AdContainer = dynamic(() => import("@umamin/ui/ad"), { ssr: false }); + +export async function generateMetadata({ + params, +}: { + params: { username: string }; +}) { + const username = params.username.startsWith("%40") + ? params.username.split("%40").at(1) + : params.username; + + const title = username + ? `(@${username}) on Umamin` + : "Umamin — User not found"; + + const description = username + ? `Profile of @${username} on Umamin. Join Umamin to connect with @${username} and engage in anonymous messaging.` + : "This user does not exist on Umamin."; + + return { + title, + description, + keywords: [ + `Umamin profile`, + `@${username}`, + `anonymous messaging`, + `user activity`, + `Umamin user`, + ], + openGraph: { + type: "profile", + title, + description, + url: `https://www.umamin.link/user/${username}`, + }, + twitter: { + card: "summary", + title, + description, + }, + }; +} + +export default async function Page({ + params, +}: { + params: { username: string }; +}) { + const user = await getUserByUsername(params.username); + + if (!user) { + redirect("/404"); + } + + return ( +
+ + +
+ + + + + Message + +
+ + {/* v2-user */} + +
+ ); +} diff --git a/apps/social/src/app/(user)/user/components/copy-link.tsx b/apps/social/src/app/(user)/user/components/copy-link.tsx new file mode 100644 index 00000000..4efbab02 --- /dev/null +++ b/apps/social/src/app/(user)/user/components/copy-link.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { toast } from "sonner"; +import { Link2 } from "lucide-react"; +import { Badge } from "@umamin/ui/components/badge"; +import { Skeleton } from "@umamin/ui/components/skeleton"; + +const onCopy = (url: string) => { + if (typeof window !== "undefined") { + navigator.clipboard.writeText(url); + toast.success("Copied to clipboard"); + } +}; + +export default function CopyLink({ username }: { username: string }) { + const url = + typeof window !== "undefined" + ? `${window.location.origin}/to/${username}` + : ""; + + if (!url) { + return ( +
+ + +
+ ); + } + + return ( + + ); +} diff --git a/apps/social/src/app/(user)/user/components/share-button.tsx b/apps/social/src/app/(user)/user/components/share-button.tsx new file mode 100644 index 00000000..db9a726b --- /dev/null +++ b/apps/social/src/app/(user)/user/components/share-button.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { Share2 } from "lucide-react"; +// import { analytics } from "@/lib/firebase"; +// import { logEvent } from "firebase/analytics"; + +const onShare = (username: string) => { + try { + if (typeof window !== "undefined") { + const url = `${window.location.origin}/user/${username}`; + + if ( + navigator.share && + navigator.canShare({ url }) && + process.env.NODE_ENV === "production" + ) { + navigator.share({ url }); + } else { + navigator.clipboard.writeText( + `${window.location.origin}/user/${username}` + ); + } + + // logEvent(analytics, "share_profile"); + } + } catch (err) { + console.log(err); + } +}; + +export function ShareButton({ username }: { username: string }) { + return ( + + ); +} diff --git a/apps/social/src/app/api/graphql/route.ts b/apps/social/src/app/api/graphql/route.ts new file mode 100644 index 00000000..e59d6b17 --- /dev/null +++ b/apps/social/src/app/api/graphql/route.ts @@ -0,0 +1,92 @@ +import { cookies } from "next/headers"; +import { createYoga } from "graphql-yoga"; +import { getSession, lucia } from "@/lib/auth"; +import persistedOperations from "@/persisted-operations.json"; +import { social_schema, initContextCache } from "@umamin/gql"; +import { useResponseCache } from "@graphql-yoga/plugin-response-cache"; +import { useCSRFPrevention } from "@graphql-yoga/plugin-csrf-prevention"; +import { usePersistedOperations } from "@graphql-yoga/plugin-persisted-operations"; +import { useDisableIntrospection } from "@graphql-yoga/plugin-disable-introspection"; + +const { handleRequest } = createYoga({ + schema: social_schema, + context: async () => { + const { session } = await getSession(); + + return { + ...initContextCache(), + userId: session?.userId, + }; + }, + graphqlEndpoint: "/api/graphql", + graphiql: process.env.NODE_ENV === "development", + fetchAPI: { Response }, + cors: { + origin: + process.env.NODE_ENV === "production" + ? "https://social.umamin.link" + : "http://localhost:3000", + credentials: true, + methods: ["POST", "GET", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization"], + }, + plugins: [ + useCSRFPrevention({ + requestHeaders: ["x-graphql-yoga-csrf"], + }), + useResponseCache({ + session: () => cookies().get(lucia.sessionCookieName)?.value, + invalidateViaMutation: false, + scopePerSchemaCoordinate: { + "Query.user": "PRIVATE", + }, + ttl: 30_000, + ttlPerSchemaCoordinate: { + "Query.userByUsername": 120_000, + }, + }), + useDisableIntrospection({ + isDisabled: () => process.env.NODE_ENV === "production", + }), + usePersistedOperations({ + allowArbitraryOperations: process.env.NODE_ENV === "development", + customErrors: { + notFound: { + message: "Operation is not found", + extensions: { + http: { + status: 404, + }, + }, + }, + keyNotFound: { + message: "Key is not found", + extensions: { + http: { + status: 404, + }, + }, + }, + persistedQueryOnly: { + message: "Operation is not allowed", + extensions: { + http: { + status: 403, + }, + }, + }, + }, + skipDocumentValidation: true, + async getPersistedOperation(key: string) { + // @ts-ignore + return persistedOperations[key]; + }, + }), + ], +}); + +export { + handleRequest as GET, + handleRequest as POST, + handleRequest as OPTIONS, +}; diff --git a/apps/social/src/app/components/feed.tsx b/apps/social/src/app/components/feed.tsx new file mode 100644 index 00000000..2e08917b --- /dev/null +++ b/apps/social/src/app/components/feed.tsx @@ -0,0 +1,44 @@ +import { PostCard } from "./post-card"; + +const data = [ + { + id: "C-r5lAwpJUg", + imageUrl: + "https://lh3.googleusercontent.com/a/ACg8ocK4CtuGuDZlPy9H_DMb3EQIue9Hrd5bqYcMZOY-Xb8LcuyqsBI=s96-c", + username: "umamin", + displayName: "Umamin Official", + createdAt: 1718604131, + content: + "An open-source social platform built exclusively for the Umamin community.", + isLiked: true, + isVerified: true, + likes: 24, + comments: 9, + }, + { + id: "C-r5lAwpJUg", + imageUrl: + "https://lh3.googleusercontent.com/a/ACg8ocJf40m8VVe3wNxhgBe11Bm7ukLSPeR0SDPPg6q8wq6NYRZtCYk=s96-c", + username: "josh", + displayName: "Josh Daniel", + createdAt: 1718342984, + content: + "We're building Umamin Social, a new platform to connect the community. Coming soon! 🚀", + isLiked: false, + isVerified: false, + likes: 7, + comments: 4, + }, +]; + +export function Feed() { + return ( +
+
+ {data.map((props) => ( + + ))} +
+
+ ); +} diff --git a/apps/social/src/app/components/navbar.tsx b/apps/social/src/app/components/navbar.tsx new file mode 100644 index 00000000..1c9c0535 --- /dev/null +++ b/apps/social/src/app/components/navbar.tsx @@ -0,0 +1,132 @@ +import Link from "next/link"; +import Image from "next/image"; +import { logout } from "@/actions"; +import logo from "/public/logo.svg"; +import { getSession } from "@/lib/auth"; +import { SignOutButton } from "./sign-out-btn"; +import { + ArrowLeft, + LinkIcon, + LogIn, + MessagesSquare, + ScanFace, + ScrollText, + UserCog, +} from "lucide-react"; + +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@umamin/ui/components/avatar"; + +import { + DropdownMenu, + DropdownMenuItem, + DropdownMenuContent, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@umamin/ui/components/dropdown-menu"; + +export async function Navbar() { + const { user, session } = await getSession(); + + return ( + <> + + +
+ + + + + + + + + + + + + + + + + + + {user ? ( + + + + ) : ( + + + + )} +
+ + ); +} diff --git a/apps/social/src/app/components/post-card.tsx b/apps/social/src/app/components/post-card.tsx new file mode 100644 index 00000000..5e44582d --- /dev/null +++ b/apps/social/src/app/components/post-card.tsx @@ -0,0 +1,142 @@ +import Link from "next/link"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@umamin/ui/components/avatar"; +import { cn } from "@umamin/ui/lib/utils"; +import { shortTimeAgo } from "@/lib/utils"; +import { BadgeCheck, Heart, MessageCircle, ScanFace } from "lucide-react"; + +type Props = { + id: string; + imageUrl: string; + username: string; + displayName: string; + createdAt: number; + content: string; + isLiked: boolean; + isVerified: boolean; + likes: number; + comments: number; +}; + +export function PostCard(props: Props) { + return ( +
+ + + + + + + +
+
+
+ + {props.displayName} + + + {props.isVerified && ( + + )} + @{props.username} +
+ +

+ {shortTimeAgo(props.createdAt)} +

+
+ +

{props.content}

+ +
+
+ + {props.likes} +
+ +
+ + + + {props.comments} +
+
+
+
+ ); +} + +export function PostCardMain(props: Props) { + return ( +
+
+ + + + + + + +
+
+ + {props.displayName} + + + {props.isVerified && ( + + )} + @{props.username} +
+ +

+ {shortTimeAgo(props.createdAt)} +

+
+
+ +
+

{props.content}

+ +
+
+ + {props.likes} +
+ +
+ + + + {props.comments} +
+
+
+
+ ); +} diff --git a/apps/social/src/app/components/sign-out-btn.tsx b/apps/social/src/app/components/sign-out-btn.tsx new file mode 100644 index 00000000..a882bae8 --- /dev/null +++ b/apps/social/src/app/components/sign-out-btn.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import { useFormStatus } from "react-dom"; + +export function SignOutButton() { + const { pending } = useFormStatus(); + + return ( + + ); +} diff --git a/apps/social/src/app/components/user-card.tsx b/apps/social/src/app/components/user-card.tsx new file mode 100644 index 00000000..1da44b8b --- /dev/null +++ b/apps/social/src/app/components/user-card.tsx @@ -0,0 +1,80 @@ +import type { User } from "lucia"; +import dynamic from "next/dynamic"; +import { formatDistanceToNow, fromUnixTime } from "date-fns"; +import { BadgeCheck, CalendarDays, MessageCircleOff } from "lucide-react"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@umamin/ui/components/avatar"; + +import { cn } from "@umamin/ui/lib/utils"; +import { ShareButton } from "../(user)/user/components/share-button"; + +const CopyLink = dynamic(() => import("../(user)/user/components/copy-link"), { + ssr: false, +}); + +export function UserCard({ + ...user +}: Omit) { + return ( +
+
+ + + + {user?.username?.slice(0, 2).toUpperCase()} + + + +
+
+
+

+ {user.displayName ? user.displayName : user.username} +

+ {process.env.NEXT_PUBLIC_VERIFIED_USERS?.split(",").includes( + user.username + ) && } + {user.quietMode && ( + + )} +
+ + +
+

+ @{user.username} +

+
+
+ +
+

+ {user?.bio} +

+ +
+ + +
+ + Joined{" "} + {formatDistanceToNow(fromUnixTime(user.createdAt), { + addSuffix: true, + })} +
+
+
+
+ ); +} diff --git a/apps/social/src/app/layout.tsx b/apps/social/src/app/layout.tsx index 26b5c20b..93fcd463 100644 --- a/apps/social/src/app/layout.tsx +++ b/apps/social/src/app/layout.tsx @@ -1,25 +1,60 @@ import "@umamin/ui/globals.css"; - +import Script from "next/script"; import { Toaster } from "sonner"; -import type { Metadata } from "next"; import { GeistSans } from "geist/font/sans"; import NextTopLoader from "nextjs-toploader"; +import type { Metadata, Viewport } from "next"; + +import { Navbar } from "./components/navbar"; import { ThemeProvider } from "@umamin/ui/components/theme-provider"; +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + themeColor: "black", +}; + export const metadata: Metadata = { - title: "Umamin Social — A Platform Built to Share Stories", + metadataBase: new URL("https://www.umamin.link"), + alternates: { + canonical: "/", + }, + title: "Umamin — The Platform for Anonymity", + authors: [{ name: "Omsimos Collective" }], description: - "A social platform built for the Umamin community, connect with others by sharing your stories and experiences.", - robots: "index, follow", + "Umamin is an open-source social platform for sending and receiving encrypted anonymous messages. Ensure your privacy and share your thoughts freely without revealing your identity. Perfect for secure communication and anonymous interactions.", + keywords: [ + "anonymous messaging", + "open-source platform", + "encrypted messages", + "privacy", + "anonymity", + ], openGraph: { type: "website", - title: "Umamin Social — A Platform Built to Share Stories", + siteName: "Umamin", + url: "https://www.umamin.link", + title: "Umamin — The Platform for Anonymity", description: - "A social platform built for the Umamin community, connect with others by sharing your stories and experiences.", - // images: [], + "Umamin is an open-source social platform for sending and receiving encrypted anonymous messages. Ensure your privacy and share your thoughts freely without revealing your identity. Perfect for secure communication and anonymous interactions.", }, twitter: { card: "summary_large_image", + title: "Umamin — The Platform for Anonymity", + description: + "Umamin is an open-source social platform for sending and receiving encrypted anonymous messages. Ensure your privacy and share your thoughts freely without revealing your identity. Perfect for secure communication and anonymous interactions.", + }, + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + noimageindex: false, + "max-video-preview": -1, + "max-image-preview": "large", + "max-snippet": -1, + }, }, }; @@ -41,6 +76,7 @@ export default function RootLayout({ - {children} + +
{children}
+ + {process.env.NODE_ENV === "production" && ( +