├── .eslintrc.json ├── .github └── FUNDING.yml ├── .gitignore ├── .husky └── pre-commit ├── .idea ├── .gitignore ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── conventionalCommit.xml ├── inspectionProfiles │ └── Project_Default.xml ├── jsLinters │ └── eslint.xml ├── lemmy-ui-next.iml ├── misc.xml ├── modules.xml ├── prettier.xml └── vcs.xml ├── .lintstagedrc.js ├── .nvmrc ├── .prettierrc ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── lemmy-icon-96x96.webp ├── src ├── app │ ├── (api) │ │ └── next │ │ │ ├── README.md │ │ │ └── api │ │ │ ├── healthz │ │ │ └── route.ts │ │ │ └── settings │ │ │ └── export.json │ │ │ └── route.ts │ ├── (theme) │ │ ├── ThemePicker.tsx │ │ ├── themeActions.ts │ │ └── themes.ts │ ├── (ui) │ │ ├── AgeIcon.tsx │ │ ├── Avatar.tsx │ │ ├── EditIndicator.tsx │ │ ├── FormattedTimestamp.tsx │ │ ├── Header.tsx │ │ ├── Image.tsx │ │ ├── InlineExpandedMedia.tsx │ │ ├── NotImplemented.tsx │ │ ├── Pagination.tsx │ │ ├── SearchParamLinks.tsx │ │ ├── Spinner.tsx │ │ ├── StyledLink.tsx │ │ ├── TopLoader.tsx │ │ ├── button │ │ │ ├── Button.tsx │ │ │ ├── ButtonLink.tsx │ │ │ └── SubmitButton.tsx │ │ ├── form │ │ │ ├── Input.tsx │ │ │ ├── Select.tsx │ │ │ └── TextArea.tsx │ │ ├── markdown │ │ │ ├── Markdown.tsx │ │ │ ├── MarkdownImageReplacement.tsx │ │ │ ├── MarkdownTextArea.tsx │ │ │ ├── MarkdownWithFetchedContent.tsx │ │ │ ├── Prose.tsx │ │ │ ├── imageActions.ts │ │ │ ├── markdownActions.ts │ │ │ └── md.ts │ │ └── vote │ │ │ ├── VoteButtons.tsx │ │ │ ├── getVoteConfig.ts │ │ │ └── voteActions.ts │ ├── (utils) │ │ ├── formatCompactNumber.ts │ │ ├── getFormBoolean.ts │ │ ├── getRemoteImageProps.ts │ │ ├── isImage.ts │ │ └── isVideo.ts │ ├── Navbar.tsx │ ├── NavbarCollapsibleLinks.tsx │ ├── PageWithSidebar.tsx │ ├── admin │ │ └── page.tsx │ ├── apiClient.ts │ ├── c │ │ ├── CommunityLink.tsx │ │ ├── [name] │ │ │ └── page.tsx │ │ └── formatCommunityName.ts │ ├── comment │ │ ├── Comment.tsx │ │ ├── CommentEditor.tsx │ │ ├── CommentTree.tsx │ │ ├── LazyChildComments.tsx │ │ ├── LazyComments.tsx │ │ ├── ShowMoreCommentsLink.tsx │ │ ├── [id] │ │ │ └── page.tsx │ │ ├── commentActions.ts │ │ └── constants.ts │ ├── communities │ │ ├── SubscribeButton.tsx │ │ ├── page.tsx │ │ └── subscribeActions.ts │ ├── create_post │ │ ├── PostEditor.tsx │ │ └── page.tsx │ ├── create_private_message │ │ ├── [userid] │ │ │ ├── PrivateMessageEditor.tsx │ │ │ └── page.tsx │ │ └── privateMessageActions.ts │ ├── error.tsx │ ├── globals.css │ ├── inbox │ │ ├── InboxMention.tsx │ │ ├── InboxPrivateMessage.tsx │ │ ├── InboxReply.tsx │ │ ├── PrivateMessage.tsx │ │ ├── inboxActions.ts │ │ └── page.tsx │ ├── instances │ │ └── page.tsx │ ├── layout.tsx │ ├── legal │ │ └── page.tsx │ ├── login │ │ ├── LoginForm.tsx │ │ ├── authActions.ts │ │ └── page.tsx │ ├── login_reset │ │ ├── loginResetActions.ts │ │ ├── page.tsx │ │ └── sent │ │ │ └── page.tsx │ ├── modlog │ │ └── page.tsx │ ├── page.tsx │ ├── password_change │ │ └── [token] │ │ │ └── page.tsx │ ├── post │ │ ├── PostList.tsx │ │ ├── PostListItem.tsx │ │ ├── PostListItemContent.tsx │ │ ├── PostShareButton.tsx │ │ ├── PostThumbnail.tsx │ │ ├── [id] │ │ │ ├── CommentsSection.tsx │ │ │ ├── PostPageWithSidebar.tsx │ │ │ ├── edit │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── getActiveSortAndListingType.tsx │ │ ├── getPostThumbnailSrc.ts │ │ ├── hasExpandableMedia.ts │ │ ├── postActions.ts │ │ └── postListSortOptions.ts │ ├── registration_applications │ │ └── page.tsx │ ├── reports │ │ └── page.tsx │ ├── saved │ │ └── page.tsx │ ├── search │ │ ├── CombinedPostsAndComments.tsx │ │ ├── page.tsx │ │ └── searchAction.ts │ ├── settings │ │ ├── 2fa │ │ │ ├── disable │ │ │ │ └── page.tsx │ │ │ └── enable │ │ │ │ └── page.tsx │ │ ├── AuthForm.tsx │ │ ├── BlocksForm.tsx │ │ ├── ExportImportForm.tsx │ │ ├── LogoutForm.tsx │ │ ├── ProfileForm.tsx │ │ ├── ProfileImageInput.tsx │ │ ├── SettingsForm.tsx │ │ ├── SettingsInputWithLabel.tsx │ │ ├── page.tsx │ │ └── userActions.ts │ ├── signup │ │ ├── SignupForm.tsx │ │ ├── page.tsx │ │ ├── signupActions.ts │ │ └── success │ │ │ └── page.tsx │ ├── u │ │ ├── LoggedInUserIcons.tsx │ │ ├── UserLink.tsx │ │ ├── UsernameBadge.tsx │ │ ├── [username] │ │ │ └── page.tsx │ │ └── formatPersonUsername.ts │ └── verify_email │ │ └── [token] │ │ └── page.tsx └── middleware.ts ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next/core-web-vitals", 4 | "prettier" 5 | ], 6 | "rules": { 7 | "react/no-access-state-in-setstate": [ 8 | "error" 9 | ], 10 | "react/prefer-read-only-props": [ 11 | "error" 12 | ], 13 | "react/hook-use-state": [ 14 | "error" 15 | ], 16 | "react/jsx-sort-props": [ 17 | "error" 18 | ], 19 | "react/jsx-no-useless-fragment": [ 20 | "error" 21 | ], 22 | "react/jsx-curly-brace-presence": [ 23 | "error", 24 | { 25 | "props": "always", 26 | "children": "always", 27 | "propElementValues": "always" 28 | } 29 | ], 30 | "react/function-component-definition": [ 31 | "error", 32 | { 33 | "namedComponents": "arrow-function", 34 | "unnamedComponents": "arrow-function" 35 | } 36 | ], 37 | "no-restricted-imports": [ 38 | "error", 39 | { 40 | "paths": [ 41 | { 42 | "name": "next/image", 43 | "message": "Please use @/app/(ui)/Image instead." 44 | } 45 | ] 46 | } 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [ sunaurus ] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # sunaurus 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 15 | -------------------------------------------------------------------------------- /.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.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 14 | 15 | 17 | 18 | 20 | 21 | 24 | 25 | 33 | 34 | 41 | 42 | 49 | 50 | 57 | 58 | 60 | 61 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/conventionalCommit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/jsLinters/eslint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/lemmy-ui-next.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/prettier.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const buildEslintCommand = (filenames) => 4 | `next lint --fix --file ${filenames 5 | .map((f) => path.relative(process.cwd(), f)) 6 | .join(' --file ')}` 7 | 8 | module.exports = { 9 | '*.{js,jsx,ts,tsx}': [buildEslintCommand], 10 | } -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.11.1 -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "prettier-plugin-tailwindcss", 4 | "prettier-plugin-classnames", 5 | "prettier-plugin-merge" 6 | ] 7 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:bookworm 2 | 3 | RUN apt-get update \ 4 | && apt-get install -y curl \ 5 | && apt-get -y autoclean 6 | 7 | SHELL ["/bin/bash", "--login", "-c"] 8 | RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash 9 | 10 | WORKDIR /app 11 | 12 | COPY .nvmrc . 13 | RUN nvm install 14 | RUN nvm use 15 | 16 | COPY package*.json ./ 17 | RUN npm ci --omit=dev --ignore-scripts 18 | 19 | COPY . . 20 | ENV NEXT_TELEMETRY_DISABLED=1 21 | RUN npm run build 22 | 23 | EXPOSE 3000 24 | CMD ["/root/.nvm/nvm-exec", "npm", "run", "start"] 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lemmy-ui-next 2 | Alternative frontend for Lemmy. Built with [Next.js](https://nextjs.org/). 3 | 4 | Discussions & announcements: [!lemmy_ui_next@lemm.ee](https://lemm.ee/c/lemmy_ui_next) 5 | 6 | **Screenshots** (desktop & mobile) 7 | ![Screenshot 2024-03-28 at 23 59 44](https://github.com/sunaurus/lemmy-ui-next/assets/5356547/f5aa05e2-b539-4baa-9fb2-13eefb506723) 8 | 9 | ## Goals 10 | 11 | * Drop-in replacement for lemmy-ui 12 | * Minimalistic design, following in the footsteps of other timeless link aggregator UIs 13 | * Fast! 14 | * Super basic NextJS architecture, taking advantage of features like the app router & server actions 15 | 16 | ## Motivation 17 | 18 | The original [lemmy-ui](https://github.com/LemmyNet/lemmy-ui) has been extremely important for the growth of Lemmy, and the new [lemmy-ui-leptos](https://github.com/LemmyNet/lemmy-ui-leptos) also looks quite interesting. One issue with both of these is that they are built using quite obscure technologies (Inferno and Leptos). 19 | 20 | This project was created as an alternative for contributors who are already familiar with NextJs and want to use those skills on Lemmy. The beauty of open source is that anybody can build what they want, and all these alternative projects can happily coexist! 21 | 22 | ## Current status 23 | 24 | The project is in its initial setup phase and not open to PRs yet. PRs will be welcome once the basic structure is in place and there is at least one staging environment running the code. 25 | 26 | ## Running 27 | 28 | **Prerequisites**: You only need nodejs. Check the [.nvmrc](.nvmrc) file in this repo to find out the exact version we are developing against. Optionally, you can use [nvm](https://github.com/nvm-sh/nvm) to automate downloading & setting up the correct nodejs version for you (it automatically checks the `.nvmrc` file and sets everything up for you). 29 | 30 | ### Locally 31 | 32 | ```bash 33 | # Install dependencies 34 | npm i 35 | # Configure Lemmy backend 36 | echo "LEMMY_BACKEND=https://lemm.ee" > .env.local 37 | # Start dev server with hot-reload 38 | npm run dev 39 | ``` 40 | 41 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 42 | 43 | ### In production 44 | 45 | ```bash 46 | # Install deps 47 | npm ci --omit=dev --ignore-scripts 48 | # Build 49 | npm run build 50 | # Start 51 | LEMMY_BACKEND=https:// LEMMY_UI_NEXT_PUBLIC_URL=https:// npm run start 52 | ``` 53 | 54 | At this point, you will have lemmy-ui-next listening on 0.0.0.0:3000, and you can point your nginx or any other reverse proxy at it. You can improve performance at least a little by pointing lemmy-ui-next directly at your Lemmy backend process. For example, if running on the same host, you can use something like `LEMMY_BACKEND=https://localhost:8536`. 55 | 56 | *Sample systemd service file will be coming soon. It's on the to-do list 😅. Alternatively, a sample Dockerfile is provided.* 57 | 58 | 59 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | formats: [ 5 | // 'image/avif', //disabled because the conversion is seriously slow 6 | 'image/webp', 7 | ], 8 | remotePatterns: [{protocol: 'https', hostname: '**'}] 9 | }, 10 | logging: { 11 | fetches: { 12 | fullUrl: true, 13 | }, 14 | }, 15 | }; 16 | 17 | export default nextConfig; 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lemmy-ui-next", 3 | "version": "0.11.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "prepare": "husky" 11 | }, 12 | "release": { 13 | "branches": "main", 14 | "plugins": [ 15 | "@semantic-release/commit-analyzer", 16 | "@semantic-release/release-notes-generator", 17 | "@semantic-release/changelog", 18 | "@semantic-release/npm", 19 | [ 20 | "@semantic-release/git", 21 | { 22 | "assets": [ 23 | "package.json", 24 | "package-lock.json", 25 | "CHANGELOG.md" 26 | ], 27 | "message": "chore: ${nextRelease.version}\n\n${nextRelease.notes}" 28 | } 29 | ], 30 | "@semantic-release/github" 31 | ] 32 | }, 33 | "dependencies": { 34 | "@heroicons/react": "^2.1.3", 35 | "@tailwindcss/forms": "^0.5.7", 36 | "@tailwindcss/typography": "^0.5.12", 37 | "@types/jsonwebtoken": "^9.0.6", 38 | "@types/markdown-it": "^14.0.1", 39 | "@types/markdown-it-container": "^2.0.10", 40 | "@types/markdown-it-footnote": "^3.0.4", 41 | "@types/markdown-it-highlightjs": "^3.3.4", 42 | "@types/node": "^20", 43 | "@types/nprogress": "^0.2.3", 44 | "@types/qrcode": "^1.5.5", 45 | "@types/react": "^18", 46 | "@types/react-dom": "^18", 47 | "autoprefixer": "^10.4.19", 48 | "classnames": "^2.5.1", 49 | "date-fns": "^3.6.0", 50 | "eslint-config-next": "14.2.1", 51 | "eslint-config-prettier": "^9.1.0", 52 | "jsonwebtoken": "^9.0.2", 53 | "lemmy-js-client": "^0.19.4-alpha.16", 54 | "markdown-it": "^14.1.0", 55 | "markdown-it-bidi": "^0.1.0", 56 | "markdown-it-container": "^4.0.0", 57 | "markdown-it-footnote": "^4.0.0", 58 | "markdown-it-highlightjs": "^4.0.1", 59 | "markdown-it-ruby": "^0.1.1", 60 | "markdown-it-sub": "^2.0.0", 61 | "markdown-it-sup": "^2.0.0", 62 | "next": "14.2.1", 63 | "nprogress": "^0.2.0", 64 | "postcss": "^8", 65 | "prettier-plugin-classnames": "^0.6.5", 66 | "prettier-plugin-merge": "^0.6.0", 67 | "prettier-plugin-tailwindcss": "^0.5.13", 68 | "qrcode": "^1.5.3", 69 | "react": "^18", 70 | "react-dom": "^18", 71 | "server-only": "^0.0.1", 72 | "sharp": "^0.33.3", 73 | "tailwindcss": "^3.4.3", 74 | "typescript": "^5", 75 | "usehooks-ts": "^3.1.0" 76 | }, 77 | "devDependencies": { 78 | "@semantic-release/changelog": "^6.0.3", 79 | "@semantic-release/git": "^10.0.1", 80 | "husky": "^9.0.11", 81 | "lint-staged": "^15.2.2", 82 | "prettier": "^3.2.5", 83 | "semantic-release": "^23.0.8" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "tailwindcss/nesting": {}, 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /public/lemmy-icon-96x96.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunaurus/lemmy-ui-next/5181d188dd2db756c2be43d921914f3a7e53a464/public/lemmy-icon-96x96.webp -------------------------------------------------------------------------------- /src/app/(api)/next/README.md: -------------------------------------------------------------------------------- 1 | This directory contains API endpoints which are served directly by lemmy-ui-next. 2 | The base path is /next/api in order to de-conflict with the main Lemmy api, in case it is running on the same 3 | domain. 4 | -------------------------------------------------------------------------------- /src/app/(api)/next/api/healthz/route.ts: -------------------------------------------------------------------------------- 1 | export const GET = async (_request: Request) => { 2 | return Response.json({ status: "ok" }); 3 | }; 4 | -------------------------------------------------------------------------------- /src/app/(api)/next/api/settings/export.json/route.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from "@/app/apiClient"; 2 | 3 | export const GET = async (_request: Request) => { 4 | const { my_user: loggedInUser } = await apiClient.getSite(); 5 | 6 | if (!loggedInUser) { 7 | return Response.json({ status: "not_authorized" }, { status: 401 }); 8 | } 9 | 10 | const res = await apiClient.exportSettings(); 11 | 12 | const fileName = `export_${new URL(loggedInUser.local_user_view.person.actor_id).hostname}_${loggedInUser.local_user_view.person.name}_${Number(new Date())}.json`; 13 | return Response.json(res, { 14 | headers: { "Content-Disposition": `attachment; filename=${fileName}` }, 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /src/app/(theme)/ThemePicker.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ThemeName, themes } from "@/app/(theme)/themes"; 4 | import { setTheme } from "@/app/(theme)/themeActions"; 5 | 6 | export const ThemePicker = (props: { readonly themeName: ThemeName }) => { 7 | return ( 8 |
9 | 14 | 15 | {Object.keys(themes).map((key) => { 16 | return ( 17 | 24 | ); 25 | })} 26 |
27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/app/(theme)/themeActions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { cookies } from "next/headers"; 4 | import process from "process"; 5 | import { THEME_COOKIE_NAME, ThemeName } from "@/app/(theme)/themes"; 6 | 7 | const oneYearMillis = 12 * 30 * 24 * 60 * 60 * 1000; 8 | export const setTheme = async (theme: ThemeName) => { 9 | cookies().set({ 10 | name: THEME_COOKIE_NAME, 11 | value: theme, 12 | expires: Date.now() + oneYearMillis, 13 | httpOnly: true, 14 | path: "/", 15 | secure: process.env.NODE_ENV !== "development", 16 | sameSite: "lax", 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /src/app/(theme)/themes.ts: -------------------------------------------------------------------------------- 1 | export const THEME_COOKIE_NAME = "theme"; 2 | 3 | export const themes: Record<"red" | "green" | "blue", Theme> = { 4 | red: { 5 | "--color-primary-50": "250 246 246", 6 | "--color-primary-100": "246 237 237", 7 | "--color-primary-200": "238 221 221", 8 | "--color-primary-300": "223 194 194", 9 | "--color-primary-400": "205 159 162", 10 | "--color-primary-500": "183 124 129", 11 | "--color-primary-600": "159 95 103", 12 | "--color-primary-700": "132 76 84", 13 | "--color-primary-800": "112 65 74", 14 | "--color-primary-900": "97 58 67", 15 | "--color-primary-950": "58 32 37", 16 | }, 17 | green: { 18 | "--color-primary-50": "247 248 245", 19 | "--color-primary-100": "229 233 222", 20 | "--color-primary-200": "204 211 188", 21 | "--color-primary-300": "169 181 147", 22 | "--color-primary-400": "133 148 109", 23 | "--color-primary-500": "105 121 83", 24 | "--color-primary-600": "83 97 64", 25 | "--color-primary-700": "68 79 54", 26 | "--color-primary-800": "57 65 46", 27 | "--color-primary-900": "48 55 42", 28 | "--color-primary-950": "5 6 4", 29 | }, 30 | blue: { 31 | "--color-primary-50": "246 247 249", 32 | "--color-primary-100": "236 239 242", 33 | "--color-primary-200": "212 219 227", 34 | "--color-primary-300": "175 188 202", 35 | "--color-primary-400": "131 152 173", 36 | "--color-primary-500": "100 123 147", 37 | "--color-primary-600": "79 99 122", 38 | "--color-primary-700": "65 81 99", 39 | "--color-primary-800": "57 69 83", 40 | "--color-primary-900": "51 60 71", 41 | "--color-primary-950": "41 48 58", 42 | }, 43 | }; 44 | 45 | export type ThemeName = keyof typeof themes; 46 | 47 | export const availableThemes = Object.keys(themes) as ThemeName[]; 48 | 49 | type Theme = { 50 | "--color-primary-50": string; 51 | "--color-primary-100": string; 52 | "--color-primary-200": string; 53 | "--color-primary-300": string; 54 | "--color-primary-400": string; 55 | "--color-primary-500": string; 56 | "--color-primary-600": string; 57 | "--color-primary-700": string; 58 | "--color-primary-800": string; 59 | "--color-primary-900": string; 60 | "--color-primary-950": string; 61 | }; 62 | -------------------------------------------------------------------------------- /src/app/(ui)/AgeIcon.tsx: -------------------------------------------------------------------------------- 1 | import { CakeIcon, SparklesIcon } from "@heroicons/react/16/solid"; 2 | 3 | export const AgeIcon = (props: { 4 | readonly published: string; 5 | readonly type: "person" | "community"; 6 | }) => { 7 | const today = new Date(); 8 | const joinDate = new Date(props.published); 9 | 10 | const oneWeekMillis = 7 * 24 * 60 * 60 * 1000; 11 | const isNew = today.valueOf() - joinDate.valueOf() < oneWeekMillis; 12 | 13 | if (isNew) { 14 | return ( 15 | 19 | ); 20 | } 21 | 22 | const isCakeDay = 23 | joinDate.getMonth() === today.getMonth() && 24 | joinDate.getDate() == today.getDate(); 25 | 26 | if (isCakeDay) { 27 | return ( 28 | 36 | ); 37 | } 38 | 39 | return null; 40 | }; 41 | -------------------------------------------------------------------------------- /src/app/(ui)/Avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Image } from "@/app/(ui)/Image"; 4 | import classNames from "classnames"; 5 | import { memo, useState } from "react"; 6 | 7 | export const Avatar = memo( 8 | (props: { 9 | readonly avatarSrc?: string; 10 | readonly size: "mini" | "regular"; 11 | }) => { 12 | const [isImageLoading, setIsImageLoading] = useState(true); 13 | const [isImageError, setIsImageError] = useState(false); 14 | 15 | const showFallback = isImageError || isImageLoading || !props.avatarSrc; 16 | if (showFallback) { 17 | } 18 | 19 | return ( 20 |
26 | {props.avatarSrc && ( 27 | {"Avatar"} setIsImageError(true)} 34 | onLoad={() => setIsImageLoading(false)} 35 | placeholder={"empty"} 36 | sizes={props.size === "mini" ? "20px" : "100px"} 37 | src={props.avatarSrc} 38 | /> 39 | )} 40 | {showFallback && ( 41 | {"Avatar"} 50 | )} 51 |
52 | ); 53 | }, 54 | (prevProps, newProps) => prevProps.avatarSrc === newProps.avatarSrc, 55 | ); 56 | 57 | Avatar.displayName = "Avatar"; 58 | -------------------------------------------------------------------------------- /src/app/(ui)/EditIndicator.tsx: -------------------------------------------------------------------------------- 1 | import { formatDistanceToNowStrict } from "date-fns"; 2 | 3 | export const EditIndicator = (props: { readonly editTime?: string }) => { 4 | if (!props.editTime) { 5 | return null; 6 | } 7 | const date = new Date(props.editTime); 8 | 9 | return ( 10 |
15 | {"*"} 16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/app/(ui)/FormattedTimestamp.tsx: -------------------------------------------------------------------------------- 1 | import { formatDistanceToNowStrict } from "date-fns"; 2 | 3 | type Props = { 4 | /** 5 | * Time string from Lemmy API 6 | */ 7 | readonly timeString: string; 8 | readonly className?: string; 9 | }; 10 | 11 | export const FormattedTimestamp = (props: Props) => { 12 | const date = new Date(props.timeString); 13 | const formatted = formatDistanceToNowStrict(date, { 14 | addSuffix: true, 15 | }); 16 | 17 | return ( 18 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/app/(ui)/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Image } from "@/app/(ui)/Image"; 2 | import { getRemoteImageProps } from "@/app/(utils)/getRemoteImageProps"; 3 | import { CommunityView, PersonView } from "lemmy-js-client"; 4 | import classNames from "classnames"; 5 | import { Avatar } from "@/app/(ui)/Avatar"; 6 | import { formatCommunityName } from "@/app/c/formatCommunityName"; 7 | import { AgeIcon } from "@/app/(ui)/AgeIcon"; 8 | import { FormattedTimestamp } from "@/app/(ui)/FormattedTimestamp"; 9 | import { formatPersonUsername } from "@/app/u/formatPersonUsername"; 10 | import { UsernameBadge } from "@/app/u/UsernameBadge"; 11 | import { formatCompactNumber } from "@/app/(utils)/formatCompactNumber"; 12 | 13 | export const Header = async (props: { 14 | readonly view: CommunityView | PersonView; 15 | }) => { 16 | const bannerSrc = isPerson(props.view) 17 | ? props.view.person.banner 18 | : props.view.community.banner; 19 | 20 | return ( 21 |
22 | {bannerSrc && ( 23 | 27 | )} 28 | 29 | 30 |
31 | ); 32 | }; 33 | 34 | export const Banner = async (props: { 35 | readonly src: string; 36 | readonly alt: string; 37 | }) => { 38 | const remoteImageProps = await getRemoteImageProps(props.src, 1000); 39 | 40 | return ( 41 |
46 | {props.alt} 47 |
48 | ); 49 | }; 50 | 51 | const Summary = (props: { 52 | readonly view: CommunityView | PersonView; 53 | readonly addBackground: boolean; 54 | }) => { 55 | const avatarSrc = isPerson(props.view) 56 | ? props.view.person.avatar 57 | : props.view.community.icon; 58 | const displayName = isPerson(props.view) 59 | ? props.view.person.display_name ?? props.view.person.name 60 | : props.view.community.title ?? props.view.community.name; 61 | 62 | const canonicalName = isPerson(props.view) 63 | ? formatPersonUsername(props.view.person, true) 64 | : `!${formatCommunityName(props.view.community)}`; 65 | 66 | return ( 67 |
73 | 74 | 75 |
76 |

79 | {displayName} 80 | {isPerson(props.view) && props.view.is_admin && ( 81 | 86 | )} 87 | {isPerson(props.view) && props.view.person.bot_account && ( 88 | 93 | )} 94 |

95 |
{canonicalName}
96 |
97 | 105 | {isPerson(props.view) ? "Joined" : "Established"} 106 | 113 |
114 |
115 | {isPerson(props.view) ? ( 116 |
117 | {formatCompactNumber(props.view.counts.post_count)} 118 | {" posts •"}{" "} 119 | {formatCompactNumber(props.view.counts.comment_count)}{" "} 120 | {"comments"} 121 |
122 | ) : ( 123 |
124 | )} 125 |
126 |
127 |
128 | ); 129 | }; 130 | 131 | const isPerson = (input: PersonView | CommunityView): input is PersonView => { 132 | return (input as PersonView).person !== undefined; 133 | }; 134 | -------------------------------------------------------------------------------- /src/app/(ui)/Image.tsx: -------------------------------------------------------------------------------- 1 | // The Image component from next/image adds inline styles for some reason. This doesn't work with a secure CSP. 2 | // This component attempts to remove inline styles - it doesn't work for all cases 3 | // TODO: check if getImageProps can solve remaining issues. 4 | 5 | // eslint-disable-next-line no-restricted-imports 6 | import { default as NextImage, ImageProps } from "next/image"; 7 | import classNames from "classnames"; 8 | 9 | export const Image = (props: ImageProps) => { 10 | return ( 11 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/app/(ui)/NotImplemented.tsx: -------------------------------------------------------------------------------- 1 | import { StyledLink } from "@/app/(ui)/StyledLink"; 2 | 3 | export const NotImplemented = () => { 4 | return ( 5 |
6 |

{"Not implemented yet!"}

7 | 8 |
9 |

{"This frontend (lemmy-ui-next) is under active development."}

10 |

11 | { 12 | "Unfortunately, the page you opened is not yet finished in this project." 13 | } 14 |

15 |

16 | {"Please use the default Lemmy UI for this functionality."} 17 |

18 |

19 | {"You can follow the development process at"} 20 | 21 | {" !lemmy_ui_next@lemm.ee"} 22 | 23 | {"."} 24 |

25 |
26 |
27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/app/(ui)/Pagination.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { StyledLink } from "@/app/(ui)/StyledLink"; 4 | import { usePathname, useSearchParams } from "next/navigation"; 5 | import classNames from "classnames"; 6 | import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/16/solid"; 7 | 8 | export const Pagination = (props: { 9 | readonly prevPage?: string | number; 10 | readonly nextPage?: string | number; 11 | readonly className?: string; 12 | }) => { 13 | const pathname = usePathname(); 14 | const searchParams = useSearchParams(); 15 | 16 | let nextPageLink = null; 17 | 18 | if (props.nextPage) { 19 | const nextPageParams = new URLSearchParams(searchParams.toString()); 20 | nextPageParams.set("page", String(props.nextPage)); 21 | nextPageLink = ( 22 | 29 | {"Next page "} 30 | 31 | 32 | ); 33 | } 34 | 35 | let prevPageLink = null; 36 | 37 | if (props.prevPage) { 38 | const prevPageParams = new URLSearchParams(searchParams.toString()); 39 | prevPageParams.set("page", String(props.prevPage)); 40 | prevPageLink = ( 41 | 48 | 49 | {"Previous page"} 50 | 51 | ); 52 | } 53 | 54 | return ( 55 |
56 | {prevPageLink} 57 | {nextPageLink} 58 |
59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/app/(ui)/SearchParamLinks.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { StyledLink } from "@/app/(ui)/StyledLink"; 4 | import classNames from "classnames"; 5 | import { usePathname, useSearchParams } from "next/navigation"; 6 | 7 | type Option = string | { target: string; badge?: string }; 8 | type Props = { 9 | readonly label: string; 10 | readonly searchParamKey: string; 11 | readonly options: Option[]; 12 | readonly currentActiveValue?: string; 13 | readonly className?: string; 14 | }; 15 | 16 | export const SearchParamLinks = (props: Props) => { 17 | return ( 18 |
24 |
25 | {props.label} 26 | {":"} 27 |
28 | {props.options.map((option) => { 29 | let badge = undefined; 30 | let target; 31 | 32 | if (typeof option === "string") { 33 | target = option; 34 | } else { 35 | badge = option.badge; 36 | target = option.target; 37 | } 38 | 39 | return ( 40 | 47 | ); 48 | })} 49 |
50 | ); 51 | }; 52 | 53 | const SearchParamLink = (props: { 54 | readonly currentActiveValue?: string; 55 | readonly targetValue: string; 56 | readonly searchParamKey: string; 57 | readonly badge?: string; 58 | }) => { 59 | const path = usePathname(); 60 | const searchParams = useSearchParams(); 61 | 62 | const newSearchParams = new URLSearchParams(searchParams.toString()); 63 | newSearchParams.set(props.searchParamKey, props.targetValue); 64 | newSearchParams.delete("page"); // When changing sort, filters, etc, it makes sense to reset to the first page 65 | 66 | return ( 67 | 74 | {props.targetValue} 75 | {props.badge && ( 76 | 80 | {props.badge} 81 | 82 | )} 83 | 84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /src/app/(ui)/Spinner.tsx: -------------------------------------------------------------------------------- 1 | export const Spinner = () => { 2 | return ( 3 | 9 | 17 | 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/app/(ui)/StyledLink.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import classNames from "classnames"; 3 | import Link, { LinkProps } from "next/link"; 4 | 5 | type Props = { 6 | readonly children?: React.ReactNode; 7 | readonly className?: string; 8 | readonly download?: string; 9 | } & LinkProps & 10 | React.RefAttributes; 11 | 12 | export const StyledLink = ({ className, ...rest }: Props) => { 13 | return ( 14 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/app/(ui)/button/Button.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, MouseEvent } from "react"; 2 | import classNames from "classnames"; 3 | 4 | export type ButtonProps = { 5 | readonly className?: string; 6 | readonly disabled?: boolean; 7 | readonly children: ReactNode | ReactNode[]; 8 | readonly type?: "submit"; 9 | readonly size?: "xs" | "sm" | "md" | "lg" | "xl"; 10 | readonly color?: "neutral" | "primary" | "danger"; 11 | onClick?(e?: MouseEvent): void; 12 | }; 13 | 14 | export const Button = (props: ButtonProps) => { 15 | return ( 16 | 24 | ); 25 | }; 26 | 27 | export const getButtonClassnames = (props: ButtonProps) => { 28 | return classNames( 29 | `flex flex-wrap justify-center rounded border p-1.5 text-${props.size ?? "sm"} 30 | min-w-24 font-semibold text-white shadow-sm hover:brightness-125 31 | disabled:brightness-75 focus-visible:outline focus-visible:outline-2 32 | focus-visible:outline-offset-2`, 33 | { 34 | "border-neutral-600 bg-neutral-500 focus-visible:outline-neutral-600": 35 | !props.color || props.color === "neutral", 36 | "border-primary-600 bg-primary-500 focus-visible:outline-primary-600": 37 | props.color === "primary", 38 | "border-rose-600 bg-rose-500 focus-visible:outline-rose-600": 39 | props.color === "danger", 40 | }, 41 | { "p-1.5": props.size === "xs", "p-2": props.size === "md" }, 42 | props.className, 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /src/app/(ui)/button/ButtonLink.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonProps, getButtonClassnames } from "@/app/(ui)/button/Button"; 2 | import { StyledLink } from "@/app/(ui)/StyledLink"; 3 | import { Route } from "next"; 4 | 5 | type ButtonLinkProps = Pick< 6 | ButtonProps, 7 | "className" | "children" | "size" | "color" 8 | > & { 9 | readonly href: Route; 10 | readonly download?: string; 11 | }; 12 | 13 | export const ButtonLink = (props: ButtonLinkProps) => { 14 | return ( 15 | 20 | {props.children} 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/app/(ui)/button/SubmitButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useFormStatus } from "react-dom"; 4 | import { Button, ButtonProps } from "@/app/(ui)/button/Button"; 5 | 6 | type Props = Omit; 7 | 8 | export const SubmitButton = (props: Props) => { 9 | const { pending } = useFormStatus(); 10 | 11 | return ( 12 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/app/(ui)/form/Input.tsx: -------------------------------------------------------------------------------- 1 | import { DetailedHTMLProps, InputHTMLAttributes } from "react"; 2 | import classNames from "classnames"; 3 | 4 | export type Props = DetailedHTMLProps< 5 | InputHTMLAttributes, 6 | HTMLInputElement 7 | >; 8 | export const Input = ({ className, ...rest }: Props) => { 9 | return ( 10 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/app/(ui)/form/Select.tsx: -------------------------------------------------------------------------------- 1 | import { DetailedHTMLProps, InputHTMLAttributes } from "react"; 2 | import classNames from "classnames"; 3 | 4 | export type Props = DetailedHTMLProps< 5 | InputHTMLAttributes, 6 | HTMLSelectElement 7 | >; 8 | export const Select = ({ className, ...rest }: Props) => { 9 | return ( 10 |