├── docker-compose.yml ├── public ├── bg.jpg ├── 404.webp ├── avatar.jpg ├── vercel.svg ├── window.svg ├── file.svg ├── Apple_logo.svg ├── Lumix_logo.svg ├── globe.svg ├── DJI_logo.svg └── next.svg ├── src ├── app │ ├── favicon.ico │ ├── api │ │ ├── auth │ │ │ └── [...all] │ │ │ │ └── route.ts │ │ └── trpc │ │ │ └── [trpc] │ │ │ └── route.ts │ ├── (dashboard) │ │ ├── dashboard │ │ │ ├── posts │ │ │ │ ├── new │ │ │ │ │ └── page.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── [slug] │ │ │ │ │ └── page.tsx │ │ │ ├── cities │ │ │ │ ├── [city] │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── profile │ │ │ │ └── page.tsx │ │ │ ├── photos │ │ │ │ ├── [id] │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ └── layout.tsx │ ├── (home) │ │ ├── layout.tsx │ │ ├── discover │ │ │ └── page.tsx │ │ ├── blog │ │ │ ├── page.tsx │ │ │ └── [slug] │ │ │ │ └── page.tsx │ │ └── travel │ │ │ ├── page.tsx │ │ │ └── [city] │ │ │ └── page.tsx │ ├── (auth) │ │ ├── layout.tsx │ │ └── sign-in │ │ │ └── page.tsx │ ├── (photograph) │ │ ├── screensaver │ │ │ └── page.tsx │ │ └── p │ │ │ └── [id] │ │ │ └── page.tsx │ └── layout.tsx ├── modules │ ├── auth │ │ ├── lib │ │ │ ├── auth-types.ts │ │ │ ├── auth-client.ts │ │ │ ├── get-session.ts │ │ │ ├── auth.ts │ │ │ └── utils.ts │ │ └── ui │ │ │ └── components │ │ │ └── user-button.tsx │ ├── discover │ │ ├── ui │ │ │ └── components │ │ │ │ ├── index.ts │ │ │ │ ├── photo-popup.tsx │ │ │ │ ├── photo-marker.tsx │ │ │ │ └── cluster-marker.tsx │ │ ├── types.ts │ │ ├── server │ │ │ └── procedures.ts │ │ └── hooks │ │ │ └── use-photo-clustering.ts │ ├── blog │ │ ├── types.ts │ │ ├── ui │ │ │ └── components │ │ │ │ ├── latest-travel-card.tsx │ │ │ │ ├── latest-blog-section.tsx │ │ │ │ └── blog-items.tsx │ │ └── server │ │ │ └── procedures.ts │ ├── travel │ │ ├── types.ts │ │ ├── ui │ │ │ └── components │ │ │ │ ├── introduction.tsx │ │ │ │ ├── cover-photo.tsx │ │ │ │ └── city-item.tsx │ │ └── server │ │ │ └── procedures.ts │ ├── photos │ │ ├── types.ts │ │ ├── params.ts │ │ ├── ui │ │ │ └── components │ │ │ │ ├── create-photo-modal.tsx │ │ │ │ ├── photos-search-filter.tsx │ │ │ │ ├── multi-step-form │ │ │ │ └── components │ │ │ │ │ ├── progress-bar.tsx │ │ │ │ │ ├── success-screen.tsx │ │ │ │ │ └── step-indicator.tsx │ │ │ │ ├── photo-uploader.tsx │ │ │ │ ├── photo-upload-modal.tsx │ │ │ │ ├── delete-photo-button.tsx │ │ │ │ └── favorite-toggle.tsx │ │ └── hooks │ │ │ └── use-photos-filters.ts │ ├── s3 │ │ └── lib │ │ │ ├── key-to-url.ts │ │ │ └── server-client.ts │ ├── posts │ │ ├── ui │ │ │ ├── views │ │ │ │ ├── new-post-view.tsx │ │ │ │ ├── post-view.tsx │ │ │ │ └── dashboard-post-view.tsx │ │ │ └── components │ │ │ │ ├── posts-search-filter.tsx │ │ │ │ ├── dashboard-post-view-header.tsx │ │ │ │ ├── columns.tsx │ │ │ │ ├── posts-list-header.tsx │ │ │ │ └── delete-post-button.tsx │ │ ├── types.ts │ │ ├── hooks │ │ │ └── use-posts-filters.ts │ │ ├── params.ts │ │ ├── schemas.ts │ │ └── lib │ │ │ └── utils.ts │ ├── dashboard │ │ ├── types.ts │ │ └── ui │ │ │ └── components │ │ │ └── dashboard-sidebar │ │ │ ├── icon-map.tsx │ │ │ └── nav-secondary.tsx │ ├── cities │ │ ├── types.ts │ │ └── ui │ │ │ ├── components │ │ │ └── city-card.tsx │ │ │ └── views │ │ │ └── city-list-view.tsx │ ├── home │ │ ├── ui │ │ │ ├── components │ │ │ │ ├── header │ │ │ │ │ ├── logo.tsx │ │ │ │ │ ├── navbar.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── mobile-menu-button.tsx │ │ │ │ ├── camera-card.tsx │ │ │ │ ├── latest-travel-card.tsx │ │ │ │ ├── vector-top-left-animation.tsx │ │ │ │ ├── about-card.tsx │ │ │ │ ├── city-card.tsx │ │ │ │ └── word-rotate.tsx │ │ │ └── views │ │ │ │ └── cities-view.tsx │ │ └── server │ │ │ └── procedures.ts │ └── mapbox │ │ └── hooks │ │ └── use-get-address.ts ├── constants.ts ├── components │ ├── ui │ │ ├── aspect-ratio.tsx │ │ ├── skeleton.tsx │ │ ├── spinner.tsx │ │ ├── label.tsx │ │ ├── separator.tsx │ │ ├── textarea.tsx │ │ ├── progress.tsx │ │ ├── input.tsx │ │ ├── collapsible.tsx │ │ ├── kbd.tsx │ │ ├── sonner.tsx │ │ ├── switch.tsx │ │ ├── avatar.tsx │ │ ├── checkbox.tsx │ │ ├── radio-group.tsx │ │ ├── hover-card.tsx │ │ ├── toggle.tsx │ │ ├── badge.tsx │ │ ├── popover.tsx │ │ ├── alert.tsx │ │ ├── scroll-area.tsx │ │ ├── password-input.tsx │ │ ├── tooltip.tsx │ │ ├── tabs.tsx │ │ ├── resizable.tsx │ │ └── slider.tsx │ ├── theme-provider.tsx │ ├── card-container.tsx │ ├── footer │ │ └── footer-nav.tsx │ ├── editor │ │ ├── toolbars │ │ │ ├── toolbar-provider.tsx │ │ │ ├── hard-break.tsx │ │ │ ├── horizontal-rule.tsx │ │ │ ├── undo.tsx │ │ │ ├── redo.tsx │ │ │ ├── image-placeholder-toolbar.tsx │ │ │ ├── code.tsx │ │ │ ├── code-block.tsx │ │ │ ├── bold.tsx │ │ │ ├── bullet-list.tsx │ │ │ ├── blockquote.tsx │ │ │ ├── italic.tsx │ │ │ ├── ordered-list.tsx │ │ │ └── strikethrough.tsx │ │ └── extensions │ │ │ └── font-size.ts │ ├── graphic.tsx │ ├── marquee-card.tsx │ ├── data-pagination.tsx │ ├── vector-combined.tsx │ ├── empty-state.tsx │ ├── link-rotate.tsx │ ├── responsive-modal.tsx │ ├── framed-photo.tsx │ ├── flip-link.tsx │ ├── theme-toggle.tsx │ ├── contact-card.tsx │ ├── blur-image.tsx │ └── tech-marquee.tsx ├── hooks │ ├── use-modal.ts │ ├── use-mobile.ts │ └── use-confirm.tsx ├── db │ └── index.ts ├── trpc │ ├── query-client.ts │ ├── server.tsx │ ├── init.ts │ └── routers │ │ └── _app.ts ├── proxy.ts └── lib │ ├── cloudflare-image-loader.ts │ └── get-strict-context.ts ├── postcss.config.mjs ├── drizzle.config.ts ├── components.json ├── .env.example ├── eslint.config.mjs ├── .gitignore ├── Dockerfile ├── tsconfig.json ├── next.config.ts ├── scripts └── seed-user.ts ├── docker-compose.cloud.yml ├── LICENSE └── CHANGELOG.md /docker-compose.yml: -------------------------------------------------------------------------------- 1 | docker-compose.standalone.yml -------------------------------------------------------------------------------- /public/bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ECarry/photography-website/HEAD/public/bg.jpg -------------------------------------------------------------------------------- /public/404.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ECarry/photography-website/HEAD/public/404.webp -------------------------------------------------------------------------------- /public/avatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ECarry/photography-website/HEAD/public/avatar.jpg -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ECarry/photography-website/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | 7 | export default config; 8 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/auth/lib/auth-types.ts: -------------------------------------------------------------------------------- 1 | import type { auth } from "./auth"; 2 | 3 | export type Session = typeof auth.$Infer.Session; 4 | export type User = typeof auth.$Infer.Session.user; 5 | -------------------------------------------------------------------------------- /src/modules/discover/ui/components/index.ts: -------------------------------------------------------------------------------- 1 | export { PhotoMarker } from "./photo-marker"; 2 | export { ClusterMarker } from "./cluster-marker"; 3 | export { PhotoPopup } from "./photo-popup"; 4 | -------------------------------------------------------------------------------- /src/app/api/auth/[...all]/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/modules/auth/lib/auth"; 2 | import { toNextJsHandler } from "better-auth/next-js"; 3 | 4 | export const { GET, POST } = toNextJsHandler(auth.handler); 5 | -------------------------------------------------------------------------------- /src/app/(dashboard)/dashboard/posts/new/page.tsx: -------------------------------------------------------------------------------- 1 | import { NewPostView } from "@/modules/posts/ui/views/new-post-view"; 2 | 3 | const page = () => { 4 | return ; 5 | }; 6 | 7 | export default page; 8 | -------------------------------------------------------------------------------- /src/modules/auth/lib/auth-client.ts: -------------------------------------------------------------------------------- 1 | import { createAuthClient } from "better-auth/react"; 2 | import { nextCookies } from "better-auth/next-js"; 3 | 4 | export const authClient = createAuthClient({ 5 | plugins: [nextCookies()], 6 | }); 7 | -------------------------------------------------------------------------------- /src/modules/blog/types.ts: -------------------------------------------------------------------------------- 1 | import { inferRouterOutputs } from "@trpc/server"; 2 | import type { appRouter } from "@/trpc/routers/_app"; 3 | 4 | export type postsGetMany = inferRouterOutputs< 5 | typeof appRouter 6 | >["blog"]["getMany"]; 7 | -------------------------------------------------------------------------------- /src/modules/travel/types.ts: -------------------------------------------------------------------------------- 1 | import { inferRouterOutputs } from "@trpc/server"; 2 | import { appRouter } from "@/trpc/routers/_app"; 3 | 4 | export type TravelGetOne = inferRouterOutputs< 5 | typeof appRouter 6 | >["travel"]["getOne"]; 7 | -------------------------------------------------------------------------------- /src/modules/photos/types.ts: -------------------------------------------------------------------------------- 1 | import { inferRouterOutputs } from "@trpc/server"; 2 | import type { appRouter } from "@/trpc/routers/_app"; 3 | 4 | export type photoGetMany = inferRouterOutputs< 5 | typeof appRouter 6 | >["photos"]["getMany"]["items"]; 7 | -------------------------------------------------------------------------------- /src/modules/discover/types.ts: -------------------------------------------------------------------------------- 1 | import { inferRouterOutputs } from "@trpc/server"; 2 | import type { appRouter } from "@/trpc/routers/_app"; 3 | 4 | export type DiscoverGetManyPhotos = inferRouterOutputs< 5 | typeof appRouter 6 | >["discover"]["getManyPhotos"]; 7 | -------------------------------------------------------------------------------- /src/modules/s3/lib/key-to-url.ts: -------------------------------------------------------------------------------- 1 | const BASE_URL = process.env.NEXT_PUBLIC_S3_PUBLIC_URL || ""; 2 | 3 | export const keyToUrl = (key: string | undefined | null) => { 4 | if (!key) { 5 | return ""; 6 | } 7 | 8 | return `${BASE_URL}/${key}`; 9 | }; 10 | -------------------------------------------------------------------------------- /src/modules/posts/ui/views/new-post-view.tsx: -------------------------------------------------------------------------------- 1 | import { PostForm } from "../components/post-form"; 2 | 3 | export const NewPostView = () => { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | }; 10 | -------------------------------------------------------------------------------- /src/modules/dashboard/types.ts: -------------------------------------------------------------------------------- 1 | import { inferRouterOutputs } from "@trpc/server"; 2 | import type { appRouter } from "@/trpc/routers/_app"; 3 | 4 | export type DashboardGetPhotosCountByMonth = inferRouterOutputs< 5 | typeof appRouter 6 | >["dashboard"]["getPhotosCountByMonth"]; 7 | -------------------------------------------------------------------------------- /src/modules/cities/types.ts: -------------------------------------------------------------------------------- 1 | import { inferRouterOutputs } from "@trpc/server"; 2 | import { appRouter } from "@/trpc/routers/_app"; 3 | 4 | export type CityGetMany = inferRouterOutputs< 5 | typeof appRouter 6 | >["city"]["getMany"]; 7 | 8 | export type CityGetOne = inferRouterOutputs["city"]["getOne"]; 9 | -------------------------------------------------------------------------------- /src/app/(home)/layout.tsx: -------------------------------------------------------------------------------- 1 | import Header from "@/modules/home/ui/components/header"; 2 | 3 | const HomeLayout = ({ children }: { children: React.ReactNode }) => { 4 | return ( 5 | <> 6 |
7 |
{children}
8 | 9 | ); 10 | }; 11 | 12 | export default HomeLayout; 13 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | import { config } from "dotenv"; 3 | 4 | config({ path: ".env" }); 5 | 6 | export default defineConfig({ 7 | schema: "./src/db/schema.ts", 8 | out: "./drizzle", 9 | dialect: "postgresql", 10 | dbCredentials: { 11 | url: process.env.DATABASE_URL!, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /src/app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | const AuthLayout = ({ children }: { children: React.ReactNode }) => { 2 | return ( 3 |
4 |
{children}
5 |
6 | ); 7 | }; 8 | 9 | export default AuthLayout; 10 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_PAGE = 1; 2 | export const DEFAULT_PAGE_SIZE = 10; 3 | export const MIN_PAGE_SIZE = 1; 4 | export const MAX_PAGE_SIZE = 100; 5 | 6 | // Upload image limit is 20MB 7 | export const IMAGE_SIZE_LIMIT = 50 * 1024 * 1024; 8 | 9 | // Upload default folder 10 | export const DEFAULT_PHOTOS_UPLOAD_FOLDER = "photos"; 11 | -------------------------------------------------------------------------------- /src/modules/posts/types.ts: -------------------------------------------------------------------------------- 1 | import { inferRouterOutputs } from "@trpc/server"; 2 | import type { appRouter } from "@/trpc/routers/_app"; 3 | 4 | export type PostGetOne = inferRouterOutputs< 5 | typeof appRouter 6 | >["posts"]["getOne"]; 7 | 8 | export type PostGetMany = inferRouterOutputs< 9 | typeof appRouter 10 | >["posts"]["getMany"]["items"]; 11 | -------------------------------------------------------------------------------- /src/components/ui/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" 4 | 5 | function AspectRatio({ 6 | ...props 7 | }: React.ComponentProps) { 8 | return 9 | } 10 | 11 | export { AspectRatio } 12 | -------------------------------------------------------------------------------- /src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) { 4 | return ( 5 |
10 | ) 11 | } 12 | 13 | export { Skeleton } 14 | -------------------------------------------------------------------------------- /src/hooks/use-modal.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | interface ModalStore { 4 | isOpen: boolean; 5 | onOpen: () => void; 6 | onClose: () => void; 7 | } 8 | 9 | export const useModal = create((set) => ({ 10 | isOpen: false, 11 | onOpen: () => set({ isOpen: true }), 12 | onClose: () => set({ isOpen: false }), 13 | })); 14 | -------------------------------------------------------------------------------- /src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | 6 | export function ThemeProvider({ 7 | children, 8 | ...props 9 | }: React.ComponentProps) { 10 | return {children}; 11 | } 12 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | import { SignInView } from "@/modules/auth/ui/views/sign-in-view"; 3 | import { getSession } from "@/modules/auth/lib/get-session"; 4 | 5 | const page = async () => { 6 | const session = await getSession(); 7 | 8 | if (!!session) { 9 | redirect("/"); 10 | } 11 | 12 | return ; 13 | }; 14 | 15 | export default page; 16 | -------------------------------------------------------------------------------- /src/components/ui/spinner.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2Icon } from "lucide-react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Spinner({ className, ...props }: React.ComponentProps<"svg">) { 6 | return ( 7 | 13 | ) 14 | } 15 | 16 | export { Spinner } 17 | -------------------------------------------------------------------------------- /src/app/api/trpc/[trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; 2 | import { createTRPCContext } from "@/trpc/init"; 3 | import { appRouter } from "@/trpc/routers/_app"; 4 | const handler = (req: Request) => 5 | fetchRequestHandler({ 6 | endpoint: "/api/trpc", 7 | req, 8 | router: appRouter, 9 | createContext: createTRPCContext, 10 | }); 11 | export { handler as GET, handler as POST }; 12 | -------------------------------------------------------------------------------- /src/components/card-container.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * CardContainer component displays a card container. 3 | * 4 | * @param {ReactNode} children - The children of the CardContainer component. 5 | * @returns {JSX.Element} - The CardContainer component. 6 | */ 7 | const CardContainer = ({ children }: { children: React.ReactNode }) => { 8 | return
{children}
; 9 | }; 10 | 11 | export default CardContainer; 12 | -------------------------------------------------------------------------------- /src/modules/auth/lib/get-session.ts: -------------------------------------------------------------------------------- 1 | import { cache } from "react"; 2 | import { auth } from "./auth"; 3 | import { headers } from "next/headers"; 4 | 5 | export const getSession = cache(async () => { 6 | return await auth.api.getSession({ 7 | headers: await headers(), 8 | }); 9 | }); 10 | 11 | export const getActiveSessions = cache(async () => { 12 | return await auth.api.listSessions({ 13 | headers: await headers(), 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/modules/posts/hooks/use-posts-filters.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_PAGE } from "@/constants"; 2 | import { parseAsInteger, parseAsString, useQueryStates } from "nuqs"; 3 | 4 | export const usePostsFilters = () => { 5 | return useQueryStates({ 6 | search: parseAsString.withDefault("").withOptions({ clearOnDefault: true }), 7 | page: parseAsInteger 8 | .withDefault(DEFAULT_PAGE) 9 | .withOptions({ clearOnDefault: true }), 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /src/modules/home/ui/components/header/logo.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import WordRotate from "../word-rotate"; 3 | import { RiCameraLensFill } from "react-icons/ri"; 4 | 5 | const Logo = () => { 6 | return ( 7 | 8 | 9 | 10 | 11 | ); 12 | }; 13 | 14 | export default Logo; 15 | -------------------------------------------------------------------------------- /src/modules/posts/params.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_PAGE } from "@/constants"; 2 | import { createLoader, parseAsInteger, parseAsString } from "nuqs/server"; 3 | 4 | export const filtersSearchParams = { 5 | search: parseAsString.withDefault("").withOptions({ clearOnDefault: true }), 6 | page: parseAsInteger 7 | .withDefault(DEFAULT_PAGE) 8 | .withOptions({ clearOnDefault: true }), 9 | }; 10 | 11 | export const loadSearchParams = createLoader(filtersSearchParams); 12 | -------------------------------------------------------------------------------- /src/modules/photos/params.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_PAGE } from "@/constants"; 2 | import { createLoader, parseAsInteger, parseAsString } from "nuqs/server"; 3 | 4 | export const filtersSearchParams = { 5 | search: parseAsString.withDefault("").withOptions({ clearOnDefault: true }), 6 | page: parseAsInteger 7 | .withDefault(DEFAULT_PAGE) 8 | .withOptions({ clearOnDefault: true }), 9 | }; 10 | 11 | export const loadSearchParams = createLoader(filtersSearchParams); 12 | -------------------------------------------------------------------------------- /src/modules/posts/schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const postFormSchema = z.object({ 4 | title: z.string().min(1, { 5 | message: "Title is required", 6 | }), 7 | slug: z.string().min(1, { 8 | message: "Slug is required", 9 | }), 10 | content: z.string().optional(), 11 | visibility: z.enum(["public", "private"]), 12 | coverImage: z.string().optional(), 13 | tags: z.array(z.string()), 14 | description: z.string().optional(), 15 | }); 16 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "iconLibrary": "lucide", 14 | "aliases": { 15 | "components": "@/components", 16 | "utils": "@/lib/utils", 17 | "ui": "@/components/ui", 18 | "lib": "@/lib", 19 | "hooks": "@/hooks" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/posts/ui/views/post-view.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTRPC } from "@/trpc/client"; 4 | import { useSuspenseQuery } from "@tanstack/react-query"; 5 | import { PostForm } from "../components/post-form"; 6 | 7 | export const PostView = ({ slug }: { slug: string }) => { 8 | const trpc = useTRPC(); 9 | const { data } = useSuspenseQuery(trpc.posts.getOne.queryOptions({ slug })); 10 | return ( 11 |
12 | 13 |
14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /src/modules/s3/lib/server-client.ts: -------------------------------------------------------------------------------- 1 | import { S3Client } from "@aws-sdk/client-s3"; 2 | 3 | /** 4 | * Server-side S3 client initialization. 5 | * This client is used for backend operations like generating presigned URLs and deleting files. 6 | * It uses the AWS SDK v3. 7 | */ 8 | export const s3Client = new S3Client({ 9 | region: "auto", 10 | endpoint: process.env.S3_ENDPOINT, 11 | credentials: { 12 | accessKeyId: process.env.S3_ACCESS_KEY_ID!, 13 | secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Database 2 | DATABASE_URL= 3 | 4 | # Better Auth 5 | BETTER_AUTH_SECRET= 6 | BETTER_AUTH_URL=http://localhost:3000 #Base URL of your app 7 | 8 | # Next.js 9 | NEXT_PUBLIC_APP_URL='http://localhost:3000' 10 | 11 | # S3 12 | S3_ENDPOINT= 13 | S3_BUCKET_NAME= 14 | S3_PUBLIC_URL= 15 | S3_ACCESS_KEY_ID= 16 | S3_SECRET_ACCESS_KEY= 17 | NEXT_PUBLIC_S3_PUBLIC_URL= 18 | 19 | # Mapbox access token 20 | NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN= 21 | 22 | # Seed admin user 23 | SEED_USER_EMAIL= 24 | SEED_USER_PASSWORD= 25 | SEED_USER_NAME= 26 | -------------------------------------------------------------------------------- /src/modules/auth/lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { betterAuth } from "better-auth"; 2 | import { drizzleAdapter } from "better-auth/adapters/drizzle"; 3 | 4 | import { db } from "@/db"; 5 | import * as schema from "@/db/schema"; 6 | import { nextCookies } from "better-auth/next-js"; 7 | 8 | export const auth = betterAuth({ 9 | database: drizzleAdapter(db, { 10 | provider: "pg", 11 | schema: { 12 | ...schema, 13 | }, 14 | }), 15 | emailAndPassword: { 16 | enabled: true, 17 | }, 18 | 19 | plugins: [nextCookies()], 20 | }); 21 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig, globalIgnores } from "eslint/config"; 2 | import nextVitals from "eslint-config-next/core-web-vitals"; 3 | import nextTs from "eslint-config-next/typescript"; 4 | 5 | const eslintConfig = defineConfig([ 6 | ...nextVitals, 7 | ...nextTs, 8 | // Override default ignores of eslint-config-next. 9 | globalIgnores([ 10 | // Default ignores of eslint-config-next: 11 | ".next/**", 12 | "out/**", 13 | "build/**", 14 | "next-env.d.ts", 15 | ]), 16 | ]); 17 | 18 | export default eslintConfig; 19 | -------------------------------------------------------------------------------- /src/modules/posts/lib/utils.ts: -------------------------------------------------------------------------------- 1 | // Generate slug from title (supports Unicode) 2 | export const generateSlug = (text: string) => { 3 | return text 4 | .toString() 5 | .toLowerCase() 6 | .trim() 7 | .replace(/\s+/g, "-") // Replace spaces with - 8 | .replace(/&/g, "-and-") // Replace & with 'and' 9 | .replace(/[^\p{L}\p{N}\-]+/gu, "") // Keep Unicode letters, numbers, hyphens 10 | .replace(/\-\-+/g, "-") // Replace multiple - with single - 11 | .replace(/^-+/, "") // Trim - from start of text 12 | .replace(/-+$/, ""); // Trim - from end of text 13 | }; 14 | -------------------------------------------------------------------------------- /src/db/index.ts: -------------------------------------------------------------------------------- 1 | import * as schema from "./schema"; 2 | import { neon } from "@neondatabase/serverless"; 3 | import { drizzle as drizzleNeon } from "drizzle-orm/neon-http"; 4 | import { drizzle as drizzlePg } from "drizzle-orm/node-postgres"; 5 | import pg from "pg"; 6 | 7 | const isLocal = process.env.DATABASE_PROVIDER === "local"; 8 | 9 | // Use 'pg' for local/docker development and 'neon-http' for serverless/production 10 | export const db = isLocal 11 | ? drizzlePg(new pg.Pool({ connectionString: process.env.DATABASE_URL! }), { 12 | schema, 13 | }) 14 | : drizzleNeon(neon(process.env.DATABASE_URL!), { schema }); 15 | -------------------------------------------------------------------------------- /src/modules/photos/ui/components/create-photo-modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ResponsiveModal } from "@/components/responsive-modal"; 4 | import MultiStepForm from "./multi-step-form"; 5 | import { useModal } from "@/hooks/use-modal"; 6 | 7 | const CreatePhotoModal = () => { 8 | const { isOpen, onClose } = useModal(); 9 | 10 | return ( 11 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default CreatePhotoModal; 23 | -------------------------------------------------------------------------------- /src/modules/photos/hooks/use-photos-filters.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_PAGE } from "@/constants"; 2 | import { 3 | parseAsInteger, 4 | parseAsString, 5 | parseAsStringEnum, 6 | useQueryStates, 7 | } from "nuqs"; 8 | 9 | export const usePhotosFilters = () => { 10 | return useQueryStates({ 11 | search: parseAsString.withDefault("").withOptions({ clearOnDefault: true }), 12 | page: parseAsInteger 13 | .withDefault(DEFAULT_PAGE) 14 | .withOptions({ clearOnDefault: true }), 15 | orderBy: parseAsStringEnum(["asc", "desc"] as const) 16 | .withDefault("desc") 17 | .withOptions({ clearOnDefault: true }), 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /src/hooks/use-mobile.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | const MOBILE_BREAKPOINT = 768 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState(undefined) 7 | 8 | React.useEffect(() => { 9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) 10 | const onChange = () => { 11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 12 | } 13 | mql.addEventListener("change", onChange) 14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 15 | return () => mql.removeEventListener("change", onChange) 16 | }, []) 17 | 18 | return !!isMobile 19 | } 20 | -------------------------------------------------------------------------------- /public/Apple_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /src/modules/travel/ui/components/introduction.tsx: -------------------------------------------------------------------------------- 1 | import CardContainer from "@/components/card-container"; 2 | 3 | export const Introduction = () => ( 4 | 5 |
6 |

Travel

7 |
8 |

9 | Exploring the world one step at a time, capturing life through street 10 | photography and city walks. From bustling urban corners to hidden 11 | alleyways, every journey tells a unique story through the lens. 12 |

13 |
14 |
15 |
16 | ); 17 | -------------------------------------------------------------------------------- /src/trpc/query-client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defaultShouldDehydrateQuery, 3 | QueryClient, 4 | } from "@tanstack/react-query"; 5 | import superjson from "superjson"; 6 | 7 | export function makeQueryClient() { 8 | return new QueryClient({ 9 | defaultOptions: { 10 | queries: { 11 | staleTime: 30 * 1000, 12 | }, 13 | dehydrate: { 14 | serializeData: superjson.serialize, 15 | shouldDehydrateQuery: (query) => 16 | defaultShouldDehydrateQuery(query) || 17 | query.state.status === "pending", 18 | }, 19 | hydrate: { 20 | deserializeData: superjson.deserialize, 21 | }, 22 | }, 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /src/trpc/server.tsx: -------------------------------------------------------------------------------- 1 | import "server-only"; // <-- ensure this file cannot be imported from the client 2 | import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query"; 3 | import { cache } from "react"; 4 | import { createTRPCContext } from "./init"; 5 | import { makeQueryClient } from "./query-client"; 6 | import { appRouter } from "./routers/_app"; 7 | // IMPORTANT: Create a stable getter for the query client that 8 | // will return the same client during the same request. 9 | export const getQueryClient = cache(makeQueryClient); 10 | export const trpc = createTRPCOptionsProxy({ 11 | ctx: createTRPCContext, 12 | router: appRouter, 13 | queryClient: getQueryClient, 14 | }); 15 | -------------------------------------------------------------------------------- /src/modules/posts/ui/components/posts-search-filter.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from "@/components/ui/input"; 2 | import { usePostsFilters } from "../../hooks/use-posts-filters"; 3 | import { SearchIcon } from "lucide-react"; 4 | 5 | export const PostsSearchFilter = () => { 6 | const [filters, setFilters] = usePostsFilters(); 7 | 8 | return ( 9 |
10 | setFilters({ search: e.target.value })} 15 | /> 16 | 17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/modules/photos/ui/components/photos-search-filter.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from "@/components/ui/input"; 2 | import { usePhotosFilters } from "../../hooks/use-photos-filters"; 3 | import { SearchIcon } from "lucide-react"; 4 | 5 | export const PhotosSearchFilter = () => { 6 | const [filters, setFilters] = usePhotosFilters(); 7 | 8 | return ( 9 |
10 | setFilters({ search: e.target.value })} 15 | /> 16 | 17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/proxy.ts: -------------------------------------------------------------------------------- 1 | import { headers } from "next/headers"; 2 | import { auth } from "./modules/auth/lib/auth"; 3 | import { NextResponse } from "next/server"; 4 | import type { NextRequest } from "next/server"; 5 | 6 | // This function can be marked `async` if using `await` inside 7 | export async function proxy(request: NextRequest) { 8 | const session = await auth.api.getSession({ 9 | headers: await headers(), 10 | }); 11 | 12 | if (!session) { 13 | return NextResponse.redirect(new URL("/sign-in", request.url)); 14 | } 15 | 16 | return NextResponse.next(); 17 | } 18 | 19 | // See "Matching Paths" below to learn more 20 | export const config = { 21 | matcher: ["/dashboard", "/dashboard/:path*"], 22 | }; 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM oven/bun:1 AS base 2 | 3 | # Install dependencies 4 | FROM base AS deps 5 | WORKDIR /app 6 | COPY package.json bun.lock ./ 7 | RUN bun install 8 | 9 | # Runner stage 10 | FROM base AS runner 11 | WORKDIR /app 12 | 13 | # Copy dependencies 14 | COPY --from=deps /app/node_modules ./node_modules 15 | 16 | # Copy source code 17 | COPY . . 18 | 19 | # Environment setup 20 | ENV NODE_ENV=production 21 | ENV NEXT_TELEMETRY_DISABLED=1 22 | ENV PORT=3000 23 | 24 | EXPOSE 3000 25 | 26 | # We don't run the server here by default in this strategy, 27 | # because we want to allow the command to be overridden by docker-compose 28 | # to perform "build && start". 29 | # However, providing a default CMD is good practice. 30 | CMD ["bun", "start"] -------------------------------------------------------------------------------- /src/modules/home/ui/components/header/navbar.tsx: -------------------------------------------------------------------------------- 1 | import Logo from "./logo"; 2 | import FlipLink from "@/components/flip-link"; 3 | import { ThemeSwitch } from "@/components/theme-toggle"; 4 | 5 | const Navbar = () => { 6 | return ( 7 | 19 | ); 20 | }; 21 | 22 | export default Navbar; 23 | -------------------------------------------------------------------------------- /src/modules/posts/ui/components/dashboard-post-view-header.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | 3 | interface DashboardPostViewHeaderProps { 4 | title: string; 5 | onRemove: () => void; 6 | onSave: () => void; 7 | } 8 | 9 | export const DashboardPostViewHeader = ({ 10 | title, 11 | onRemove, 12 | onSave, 13 | }: DashboardPostViewHeaderProps) => { 14 | return ( 15 |
16 |

{title}

17 |
18 | 21 | 22 |
23 |
24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/modules/dashboard/ui/components/dashboard-sidebar/icon-map.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | IconLayoutDashboard, 3 | IconPhoto, 4 | IconUser, 5 | IconBuildingPavilion, 6 | IconNotebook, 7 | } from "@tabler/icons-react"; 8 | 9 | interface IconMapProps { 10 | icon: string; 11 | } 12 | 13 | const IconMap = ({ icon }: IconMapProps) => { 14 | switch (icon) { 15 | case "dashboard": 16 | return ; 17 | case "photo": 18 | return ; 19 | case "user": 20 | return ; 21 | case "city": 22 | return ; 23 | case "post": 24 | return ; 25 | default: 26 | return ; 27 | } 28 | }; 29 | 30 | export default IconMap; 31 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as LabelPrimitive from "@radix-ui/react-label"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | function Label({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 22 | ); 23 | } 24 | 25 | export { Label }; 26 | -------------------------------------------------------------------------------- /src/modules/home/ui/components/camera-card.tsx: -------------------------------------------------------------------------------- 1 | import CardContainer from "@/components/card-container"; 2 | 3 | const CameraCard = () => { 4 | return ( 5 | 6 |
7 |
8 |

Camera

9 |

& Camera Lenses

10 |
11 | 12 |
13 |

14 | I have a passion for photography and camera lenses. I use a variety 15 | of lenses to capture the beauty of nature and people in their 16 | different moments. 17 |

18 |
19 |
20 |
21 | ); 22 | }; 23 | 24 | export default CameraCard; 25 | -------------------------------------------------------------------------------- /src/modules/discover/ui/components/photo-popup.tsx: -------------------------------------------------------------------------------- 1 | import BlurImage from "@/components/blur-image"; 2 | import { keyToUrl } from "@/modules/s3/lib/key-to-url"; 3 | import type { PhotoPoint } from "@/modules/discover/lib/clustering"; 4 | 5 | interface PhotoPopupProps { 6 | photo: PhotoPoint; 7 | } 8 | 9 | export const PhotoPopup = ({ photo }: PhotoPopupProps) => ( 10 |
11 | 19 |
20 |

{photo.title}

21 |
22 |
23 | ); 24 | -------------------------------------------------------------------------------- /src/modules/photos/ui/components/multi-step-form/components/progress-bar.tsx: -------------------------------------------------------------------------------- 1 | import { Progress } from "@/components/ui/progress"; 2 | 3 | interface ProgressBarProps { 4 | currentStep: number; 5 | totalSteps: number; 6 | } 7 | 8 | export function ProgressBar({ currentStep, totalSteps }: ProgressBarProps) { 9 | const progress = ((currentStep + 1) / totalSteps) * 100; 10 | 11 | return ( 12 |
13 |
14 | 15 | Step {currentStep + 1} of {totalSteps} 16 | 17 | {Math.round(progress)}% 18 |
19 | 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/cloudflare-image-loader.ts: -------------------------------------------------------------------------------- 1 | const S3_PUBLIC_URL = process.env.NEXT_PUBLIC_S3_PUBLIC_URL || ""; 2 | 3 | const normalizeSrc = (src: string) => { 4 | return src.startsWith("/") ? src.slice(1) : src; 5 | }; 6 | 7 | export default function cloudflareLoader({ 8 | src, 9 | width, 10 | quality, 11 | }: { 12 | src: string; 13 | width: number; 14 | quality?: number; 15 | }) { 16 | if (src.startsWith("/")) { 17 | return src; 18 | } 19 | // if (process.env.NODE_ENV === "development") { 20 | // return src; 21 | // } 22 | const params = [`width=${width}`]; 23 | if (quality) { 24 | params.push(`quality=${quality}`); 25 | } 26 | const paramsString = params.join(","); 27 | 28 | return `${S3_PUBLIC_URL}/cdn-cgi/image/${paramsString}/${normalizeSrc(src)}`; 29 | } 30 | -------------------------------------------------------------------------------- /public/Lumix_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "react-jsx", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": [ 26 | "next-env.d.ts", 27 | "**/*.ts", 28 | "**/*.tsx", 29 | ".next/types/**/*.ts", 30 | ".next/dev/types/**/*.ts", 31 | "**/*.mts" 32 | ], 33 | "exclude": ["node_modules"] 34 | } 35 | -------------------------------------------------------------------------------- /src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Separator({ 9 | className, 10 | orientation = "horizontal", 11 | decorative = true, 12 | ...props 13 | }: React.ComponentProps) { 14 | return ( 15 | 25 | ) 26 | } 27 | 28 | export { Separator } 29 | -------------------------------------------------------------------------------- /src/components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { 6 | return ( 7 |