=> {
124 | const content = data.get("content")?.toString() ?? "";
125 | const response = await apiClient.editComment({
126 | comment_id: commentId,
127 | content,
128 | });
129 |
130 | return response.comment_view;
131 | };
132 | export const deleteCommentAction = async (commentId: number) => {
133 | await apiClient.deleteComment({
134 | comment_id: commentId,
135 | deleted: true,
136 | });
137 | };
138 | export const restoreCommentAction = async (commentId: number) => {
139 | await apiClient.deleteComment({
140 | comment_id: commentId,
141 | deleted: false,
142 | });
143 | };
144 |
145 | export const toggleSaveCommentAction = async (
146 | commentId: number,
147 | currentlySaved: boolean,
148 | ) => {
149 | if (!(await isLoggedIn())) {
150 | await loginPageWithRedirectAction(`/comment/${commentId}}`);
151 | return;
152 | }
153 |
154 | await apiClient.saveComment({
155 | comment_id: commentId,
156 | save: currentlySaved,
157 | });
158 | };
159 |
--------------------------------------------------------------------------------
/src/app/comment/constants.ts:
--------------------------------------------------------------------------------
1 | export const ROOT_NODES_BATCH_SIZE = 5;
2 |
--------------------------------------------------------------------------------
/src/app/communities/SubscribeButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import {
3 | subscribeAction,
4 | unsubscribeAction,
5 | } from "@/app/communities/subscribeActions";
6 | import { MyUserInfo, SubscribedType } from "lemmy-js-client";
7 | import { useFormStatus } from "react-dom";
8 | import { Spinner } from "@/app/(ui)/Spinner";
9 | import { SubmitButton } from "@/app/(ui)/button/SubmitButton";
10 |
11 | export const SubscribeButton = (props: {
12 | readonly loggedInUser: MyUserInfo | undefined;
13 | readonly currentStatus: SubscribedType;
14 | readonly communityId: number;
15 | readonly communityName: string;
16 | }) => {
17 | switch (props.currentStatus) {
18 | case "Subscribed":
19 | return (
20 |
34 | );
35 | case "NotSubscribed":
36 | return (
37 |
48 | );
49 | case "Pending":
50 | return (
51 |
68 | );
69 | }
70 | };
71 |
--------------------------------------------------------------------------------
/src/app/communities/subscribeActions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { apiClient } from "@/app/apiClient";
4 | import { revalidatePath } from "next/cache";
5 | import {
6 | isLoggedIn,
7 | loginPageWithRedirectAction,
8 | } from "@/app/login/authActions";
9 |
10 | export const subscribeAction = async (
11 | communityId: number,
12 | communityName: string,
13 | ) => {
14 | if (!(await isLoggedIn())) {
15 | await loginPageWithRedirectAction(`/c/${communityName}`);
16 | return;
17 | }
18 |
19 | await apiClient.followCommunity({ community_id: communityId, follow: true });
20 | revalidatePath("/communities");
21 | };
22 | export const unsubscribeAction = async (
23 | communityId: number,
24 | communityName: string,
25 | ) => {
26 | if (!(await isLoggedIn())) {
27 | await loginPageWithRedirectAction(`/c/${communityName}`);
28 | return;
29 | }
30 |
31 | await apiClient.followCommunity({ community_id: communityId, follow: false });
32 | revalidatePath("/communities");
33 | };
34 |
--------------------------------------------------------------------------------
/src/app/create_post/page.tsx:
--------------------------------------------------------------------------------
1 | import { apiClient } from "@/app/apiClient";
2 | import { formatCommunityName } from "@/app/c/formatCommunityName";
3 | import { PageWithSidebar } from "@/app/PageWithSidebar";
4 | import { PostEditor } from "@/app/create_post/PostEditor";
5 | import { loginPageWithRedirectAction } from "@/app/login/authActions";
6 |
7 | const CreatePostPage = async (props: {
8 | readonly searchParams: { community_id?: number };
9 | }) => {
10 | const { my_user: loggedInUser } = await apiClient.getSite();
11 | if (!loggedInUser) {
12 | await loginPageWithRedirectAction(
13 | `/create_post?community_id=${props.searchParams.community_id}`,
14 | true,
15 | );
16 | }
17 |
18 | const { community_view: communityView, moderators: mods } =
19 | await apiClient.getCommunity({
20 | id: props.searchParams.community_id,
21 | });
22 |
23 | return (
24 | mod.moderator)}
28 | stats={communityView.counts}
29 | >
30 |
31 |
32 | {"Submitting new post to "}
33 | {formatCommunityName(communityView.community)}
34 | {"..."}
35 |
36 |
40 |
41 |
42 | );
43 | };
44 |
45 | export default CreatePostPage;
46 |
--------------------------------------------------------------------------------
/src/app/create_private_message/[userid]/PrivateMessageEditor.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { PersonView } from "lemmy-js-client";
4 | import { SubmitButton } from "@/app/(ui)/button/SubmitButton";
5 | import { ButtonLink } from "@/app/(ui)/button/ButtonLink";
6 | import classNames from "classnames";
7 | import { createPrivateMessageAction } from "@/app/create_private_message/privateMessageActions";
8 | import { UserLink } from "@/app/u/UserLink";
9 | import { MarkdownTextArea } from "@/app/(ui)/markdown/MarkdownTextArea";
10 |
11 | export const PrivateMessageEditor = (props: {
12 | readonly recipientPersonView: PersonView;
13 | }) => {
14 | return (
15 |
54 | );
55 | };
56 |
--------------------------------------------------------------------------------
/src/app/create_private_message/[userid]/page.tsx:
--------------------------------------------------------------------------------
1 | import { apiClient } from "@/app/apiClient";
2 | import { loginPageWithRedirectAction } from "@/app/login/authActions";
3 | import { PrivateMessageEditor } from "@/app/create_private_message/[userid]/PrivateMessageEditor";
4 |
5 | const CreateDirectMessagePage = async (props: {
6 | readonly params: { userid: number };
7 | }) => {
8 | const { my_user: loggedInUser } = await apiClient.getSite();
9 | if (!loggedInUser) {
10 | await loginPageWithRedirectAction(
11 | `/create_private_message/${props.params.userid}`,
12 | true,
13 | );
14 | }
15 |
16 | const { person_view: personView } = await apiClient.getPersonDetails({
17 | person_id: props.params.userid,
18 | });
19 |
20 | return (
21 |
22 |
23 | {"Composing direct message..."}
24 |
25 |
26 |
27 | );
28 | };
29 |
30 | export default CreateDirectMessagePage;
31 |
--------------------------------------------------------------------------------
/src/app/create_private_message/privateMessageActions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { apiClient } from "@/app/apiClient";
4 |
5 | export const createPrivateMessageAction = async (
6 | recipientPersonId: number,
7 | form: FormData,
8 | ) => {
9 | await apiClient.createPrivateMessage({
10 | content: form.get("content")?.toString()!,
11 | recipient_id: recipientPersonId,
12 | });
13 | };
14 |
--------------------------------------------------------------------------------
/src/app/error.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | export const Error = ({
4 | error,
5 | reset,
6 | }: {
7 | readonly error: Error & { digest?: string };
8 | readonly reset: () => void;
9 | }) => {
10 | return (
11 |
12 |
13 | {error.message ? `Error: ${error.message}` : "Something went wrong!"}
14 |
15 |
16 | {
17 | "Unfortunately, we ran into an issue when trying to display this page."
18 | }
19 |
20 | {error.digest && (
21 | <>
22 |
23 | {
24 | "When reporting this to instance admins, please include the error digest in your report, as it may be used to find relevant logs on the server."
25 | }
26 |
27 |
28 | {"Error digest: "}
29 | {error.digest}
30 |
31 | >
32 | )}
33 |
40 |
41 | );
42 | };
43 |
44 | export default Error;
45 |
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | /*
6 | Spoiler tags in markdown
7 | */
8 |
9 | summary {
10 | cursor: pointer;
11 | }
12 |
13 | /*
14 | Input pseudo elements
15 | */
16 | html {
17 | input[type='search']::-webkit-search-cancel-button {
18 | filter: grayscale(1) brightness(1.5);
19 | }
20 | input[type='date']::-webkit-calendar-picker-indicator {
21 | filter: invert(1);
22 | }
23 | }
24 |
25 | /*Styles for TopLoader*/
26 | #nprogress {
27 | pointer-events: none;
28 | }
29 |
30 | #nprogress .bar {
31 | background: #94a3b8;
32 | position: fixed;
33 | z-index: 1600;
34 | top: 0;
35 | left: 0;
36 | width: 100%;
37 | height: 3px;
38 | }
39 |
40 | #nprogress .peg {
41 | display: block;
42 | position: absolute;
43 | right: 0;
44 | width: 100px;
45 | height: 100%;
46 | box-shadow: 0 0 10px #94a3b8,
47 | 0 0 5px #94a3b8;
48 | opacity: 1;
49 | -webkit-transform: rotate(3deg) translate(0px, -4px);
50 | -ms-transform: rotate(3deg) translate(0px, -4px);
51 | transform: rotate(3deg) translate(0px, -4px);
52 | }
53 |
54 | #nprogress .spinner {
55 | display: block;
56 | position: fixed;
57 | z-index: 1600;
58 | top: 15px;
59 | right: 15px;
60 | }
61 |
62 | #nprogress .spinner-icon {
63 | width: 18px;
64 | height: 18px;
65 | box-sizing: border-box;
66 | border: 2px solid transparent;
67 | border-top-color: #94a3b8;
68 | border-left-color: #94a3b8;
69 | border-radius: 50%;
70 | -webkit-animation: nprogress-spinner 400ms linear infinite;
71 | animation: nprogress-spinner 400ms linear infinite;
72 | }
73 |
74 | .nprogress-custom-parent {
75 | overflow: hidden;
76 | position: relative;
77 | }
78 |
79 | .nprogress-custom-parent #nprogress .bar,
80 | .nprogress-custom-parent #nprogress .spinner {
81 | position: absolute;
82 | }
83 |
84 | @-webkit-keyframes nprogress-spinner {
85 | 0% {
86 | -webkit-transform: rotate(0deg);
87 | }
88 | 100% {
89 | -webkit-transform: rotate(360deg);
90 | }
91 | }
92 |
93 | @keyframes nprogress-spinner {
94 | 0% {
95 | transform: rotate(0deg);
96 | }
97 | 100% {
98 | transform: rotate(360deg);
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/app/inbox/InboxMention.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { MyUserInfo, PersonMentionView, SiteView } from "lemmy-js-client";
4 | import { useState } from "react";
5 | import { Comment } from "@/app/comment/Comment";
6 | import { getVoteConfig } from "@/app/(ui)/vote/getVoteConfig";
7 | import { toggleMentionRead } from "@/app/inbox/inboxActions";
8 | import { SubmitButton } from "@/app/(ui)/button/SubmitButton";
9 | import { MarkdownProps } from "@/app/(ui)/markdown/Markdown";
10 | import { EnvelopeIcon, EnvelopeOpenIcon } from "@heroicons/react/16/solid";
11 | import classNames from "classnames";
12 |
13 | export const InboxMention = (props: {
14 | readonly loggedInUser: MyUserInfo;
15 | readonly personMentionView: PersonMentionView;
16 | readonly siteView: SiteView;
17 | readonly markdown: MarkdownProps;
18 | }) => {
19 | const [read, setRead] = useState(props.personMentionView.person_mention.read);
20 |
21 | return (
22 |
23 | {
28 | const newState = !read;
29 | const action = toggleMentionRead.bind(
30 | null,
31 | props.personMentionView.person_mention.id,
32 | newState,
33 | );
34 | await action();
35 | setRead(newState);
36 | }}
37 | >
38 |
46 | {read && }
47 | {!read && }
48 | {`Mark ${read ? "unread" : "read"}`}
49 |
50 |
51 | }
52 | commentView={props.personMentionView}
53 | loggedInUser={props.loggedInUser}
54 | markdown={props.markdown}
55 | voteConfig={getVoteConfig(
56 | props.siteView.local_site,
57 | props.loggedInUser,
58 | )}
59 | />
60 |
61 | );
62 | };
63 |
--------------------------------------------------------------------------------
/src/app/inbox/InboxPrivateMessage.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { MyUserInfo, PrivateMessageView, SiteView } from "lemmy-js-client";
4 | import { useState } from "react";
5 | import { toggleMessageRead } from "@/app/inbox/inboxActions";
6 | import { SubmitButton } from "@/app/(ui)/button/SubmitButton";
7 | import { MarkdownProps } from "@/app/(ui)/markdown/Markdown";
8 | import { EnvelopeIcon, EnvelopeOpenIcon } from "@heroicons/react/16/solid";
9 | import { PrivateMessage } from "@/app/inbox/PrivateMessage";
10 | import classNames from "classnames";
11 |
12 | export const InboxPrivateMessage = (props: {
13 | readonly loggedInUser: MyUserInfo;
14 | readonly privateMessageView: PrivateMessageView;
15 | readonly siteView: SiteView;
16 | readonly markdown: MarkdownProps;
17 | }) => {
18 | const [read, setRead] = useState(
19 | props.privateMessageView.private_message.read,
20 | );
21 |
22 | const isOwnMessage =
23 | props.privateMessageView.private_message.creator_id ===
24 | props.loggedInUser?.local_user_view.person.id;
25 |
26 | return (
27 |
28 |
{
33 | const newState = !read;
34 | const action = toggleMessageRead.bind(
35 | null,
36 | props.privateMessageView.private_message.id,
37 | newState,
38 | );
39 | await action();
40 | setRead(newState);
41 | }}
42 | >
43 |
51 | {read && }
52 | {!read && }
53 | {`Mark ${read ? "unread" : "read"}`}
54 |
55 |
56 | )
57 | }
58 | loggedInUser={props.loggedInUser}
59 | markdown={props.markdown}
60 | privateMessageView={props.privateMessageView}
61 | siteView={props.siteView}
62 | />
63 |
64 | );
65 | };
66 |
--------------------------------------------------------------------------------
/src/app/inbox/InboxReply.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { CommentReplyView, MyUserInfo, SiteView } from "lemmy-js-client";
4 | import { useState } from "react";
5 | import { Comment } from "@/app/comment/Comment";
6 | import { getVoteConfig } from "@/app/(ui)/vote/getVoteConfig";
7 | import { toggleReplyRead } from "@/app/inbox/inboxActions";
8 | import { SubmitButton } from "@/app/(ui)/button/SubmitButton";
9 | import { MarkdownProps } from "@/app/(ui)/markdown/Markdown";
10 | import { EnvelopeIcon, EnvelopeOpenIcon } from "@heroicons/react/16/solid";
11 | import classNames from "classnames";
12 |
13 | export const InboxReply = (props: {
14 | readonly loggedInUser: MyUserInfo;
15 | readonly commentReplyView: CommentReplyView;
16 | readonly siteView: SiteView;
17 | readonly markdown: MarkdownProps;
18 | }) => {
19 | const [read, setRead] = useState(props.commentReplyView.comment_reply.read);
20 |
21 | return (
22 |
23 | {
28 | const newState = !read;
29 | const action = toggleReplyRead.bind(
30 | null,
31 | props.commentReplyView.comment_reply.id,
32 | newState,
33 | );
34 | await action();
35 | setRead(newState);
36 | }}
37 | >
38 |
46 | {read && }
47 | {!read && }
48 | {`Mark ${read ? "unread" : "read"}`}
49 |
50 |
51 | }
52 | commentView={props.commentReplyView}
53 | loggedInUser={props.loggedInUser}
54 | markdown={props.markdown}
55 | voteConfig={getVoteConfig(
56 | props.siteView.local_site,
57 | props.loggedInUser,
58 | )}
59 | />
60 |
61 | );
62 | };
63 |
--------------------------------------------------------------------------------
/src/app/inbox/PrivateMessage.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { MyUserInfo, PrivateMessageView, SiteView } from "lemmy-js-client";
4 | import React, { ReactNode, useState } from "react";
5 | import { Markdown, MarkdownProps } from "@/app/(ui)/markdown/Markdown";
6 | import { TrashIcon } from "@heroicons/react/16/solid";
7 | import {
8 | deleteCommentAction,
9 | restoreCommentAction,
10 | } from "@/app/comment/commentActions";
11 | import classNames from "classnames";
12 | import { FormattedTimestamp } from "@/app/(ui)/FormattedTimestamp";
13 | import { EditIndicator } from "@/app/(ui)/EditIndicator";
14 | import { UserLink } from "@/app/u/UserLink";
15 | import { StyledLink } from "@/app/(ui)/StyledLink";
16 |
17 | export const PrivateMessage = (props: {
18 | readonly loggedInUser: MyUserInfo;
19 | readonly privateMessageView: PrivateMessageView;
20 | readonly siteView: SiteView;
21 | readonly markdown: MarkdownProps;
22 | readonly additionalActions: ReactNode;
23 | }) => {
24 | const [editedContent, setEditedContent] = useState(null);
25 | const [deleted, setDeleted] = useState(
26 | props.privateMessageView.private_message.deleted,
27 | );
28 | const isOwnMessage =
29 | props.privateMessageView.private_message.creator_id ===
30 | props.loggedInUser?.local_user_view.person.id;
31 |
32 | const markdownProps =
33 | editedContent !== null ? { content: editedContent } : props.markdown;
34 | let body = (
35 |
39 | );
40 |
41 | if (deleted) {
42 | body = (
43 |
48 |
49 | {"Deleted"}
50 |
51 | );
52 | }
53 |
54 | return (
55 |
61 |
62 |
63 | {!isOwnMessage && (
64 | <>
65 |
{"from"}
66 |
67 |
68 |
69 | >
70 | )}
71 | {isOwnMessage && (
72 | <>
73 |
{"to"}
74 |
75 |
76 |
77 | >
78 | )}
79 |
80 |
84 |
87 |
88 |
89 |
90 |
{body}
91 |
92 | {props.additionalActions}
93 | {!isOwnMessage && (
94 |
95 | {"report"}
96 |
97 | )}
98 |
99 | {!isOwnMessage && (
100 |
104 | {"reply"}
105 |
106 | )}
107 | {isOwnMessage && (
108 |
130 | )}
131 |
132 |
133 |
134 |
135 | );
136 | };
137 |
--------------------------------------------------------------------------------
/src/app/inbox/inboxActions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { apiClient } from "@/app/apiClient";
4 | import { revalidatePath } from "next/cache";
5 |
6 | export const toggleMentionRead = async (mentionId: number, read: boolean) => {
7 | await apiClient.markPersonMentionAsRead({
8 | person_mention_id: mentionId,
9 | read,
10 | });
11 |
12 | revalidatePath("/inbox");
13 | };
14 |
15 | export const toggleReplyRead = async (replyId: number, read: boolean) => {
16 | await apiClient.markCommentReplyAsRead({
17 | comment_reply_id: replyId,
18 | read,
19 | });
20 |
21 | revalidatePath("/inbox");
22 | };
23 |
24 | export const toggleMessageRead = async (messageId: number, read: boolean) => {
25 | await apiClient.markPrivateMessageAsRead({
26 | private_message_id: messageId,
27 | read,
28 | });
29 |
30 | revalidatePath("/inbox");
31 | };
32 |
--------------------------------------------------------------------------------
/src/app/instances/page.tsx:
--------------------------------------------------------------------------------
1 | import { apiClient } from "@/app/apiClient";
2 | import { InstanceWithFederationState } from "lemmy-js-client";
3 | import { StyledLink } from "@/app/(ui)/StyledLink";
4 |
5 | const InstancesPage = async () => {
6 | const { federated_instances: federatedInstances } =
7 | await apiClient.getFederatedInstances();
8 |
9 | return (
10 |
13 |
17 |
21 |
22 | );
23 | };
24 |
25 | const InstanceList = (props: {
26 | readonly title: string;
27 | readonly instances?: InstanceWithFederationState[];
28 | }) => {
29 | if (!props.instances) {
30 | return null;
31 | }
32 |
33 | return (
34 |
35 |
{props.title}
36 | {props.instances
37 | .sort((a, b) => {
38 | const aSoftware = a.software ?? "x";
39 | const bSoftware = b.software ?? "x";
40 | const aVersion = a.version?.split("-")[0] ?? "";
41 | const bVersion = b.version?.split("-")[0] ?? "";
42 | if (aSoftware === "lemmy" && bSoftware !== "lemmy") {
43 | return -1;
44 | } else if (aSoftware !== "lemmy" && bSoftware === "lemmy") {
45 | return 1;
46 | } else {
47 | if (aSoftware > bSoftware) {
48 | return 1;
49 | } else if (aSoftware < bSoftware) {
50 | return -1;
51 | } else {
52 | if (aVersion < bVersion) {
53 | return 1;
54 | }
55 | return -1;
56 | }
57 | }
58 | })
59 | .map((instance) => (
60 |
66 |
70 | {instance.domain}
71 |
72 |
73 | {instance.software ? `• ${instance.software}` : ""}
74 |
75 |
76 | {instance.version ? `• ${instance.version}` : ""}
77 |
78 |
79 | ))}
80 |
81 | );
82 | };
83 |
84 | export default InstancesPage;
85 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata, Viewport } from "next";
2 | import { apiClient } from "@/app/apiClient";
3 |
4 | // These styles apply to every route in the application
5 | import "./globals.css";
6 | import { Navbar } from "@/app/Navbar";
7 | import { ReactNode } from "react";
8 | import { StyledLink } from "@/app/(ui)/StyledLink";
9 | import { TopLoader } from "@/app/(ui)/TopLoader";
10 | import { ThemePicker } from "@/app/(theme)/ThemePicker";
11 | import {
12 | availableThemes,
13 | THEME_COOKIE_NAME,
14 | ThemeName,
15 | } from "@/app/(theme)/themes";
16 | import { cookies } from "next/headers";
17 |
18 | export const generateMetadata = async (): Promise => {
19 | const site = await apiClient.getSite();
20 |
21 | let images: string[] = [];
22 |
23 | if (site.site_view.site.icon) {
24 | images = [site.site_view.site.icon, ...images];
25 | }
26 |
27 | if (site.site_view.site.banner) {
28 | images = [site.site_view.site.banner];
29 | }
30 |
31 | return {
32 | title: site.site_view.site.name,
33 | description: site.site_view.site.description,
34 | metadataBase: process.env.LEMMY_UI_NEXT_PUBLIC_URL
35 | ? new URL(process.env.LEMMY_UI_NEXT_PUBLIC_URL)
36 | : undefined,
37 | alternates: {
38 | canonical: "/",
39 | },
40 | keywords: [
41 | site.site_view.site.name,
42 | "lemmy",
43 | "vote",
44 | "comment",
45 | "post",
46 | "threadiverse",
47 | "fediverse",
48 | ],
49 | openGraph: {
50 | title: site.site_view.site.name,
51 | description: site.site_view.site.description,
52 | siteName: site.site_view.site.name,
53 | images: [images[0]],
54 | },
55 | icons: {
56 | icon: [
57 | {
58 | url: site.site_view.site.icon ?? "/lemmy-icon-96x96.webp",
59 | },
60 | ],
61 | apple: [
62 | {
63 | url: site.site_view.site.icon ?? "/lemmy-icon-96x96.webp",
64 | },
65 | ],
66 | },
67 | };
68 | };
69 |
70 | // noinspection JSUnusedGlobalSymbols
71 | export const viewport: Viewport = {
72 | themeColor: "#171717",
73 | width: "device-width",
74 | initialScale: 1,
75 | };
76 |
77 | type Props = {
78 | readonly children: ReactNode;
79 | };
80 | const RootLayout = (props: Props) => {
81 | return (
82 |
88 |
92 |
93 |
94 |
95 | {props.children}
96 |
97 |
98 |
99 | );
100 | };
101 |
102 | const Footer = async () => {
103 | const site = await apiClient.getSite();
104 | let themeName = cookies().get(THEME_COOKIE_NAME)?.value as
105 | | ThemeName
106 | | undefined;
107 |
108 | if (!themeName || !availableThemes.includes(themeName)) {
109 | themeName = "blue";
110 | }
111 |
112 | return (
113 |
170 | );
171 | };
172 |
173 | export default RootLayout;
174 |
--------------------------------------------------------------------------------
/src/app/legal/page.tsx:
--------------------------------------------------------------------------------
1 | import { Markdown } from "@/app/(ui)/markdown/Markdown";
2 | import { apiClient } from "@/app/apiClient";
3 |
4 | const LegalPage = async () => {
5 | const { site_view: siteView } = await apiClient.getSite();
6 | return (
7 |
8 |
{"Legal info"}
9 |
10 |
11 |
12 |
13 | );
14 | };
15 |
16 | export default LegalPage;
17 |
--------------------------------------------------------------------------------
/src/app/login/LoginForm.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { loginAction } from "@/app/login/authActions";
4 | import { Input } from "@/app/(ui)/form/Input";
5 | import { StyledLink } from "@/app/(ui)/StyledLink";
6 | import { SubmitButton } from "@/app/(ui)/button/SubmitButton";
7 |
8 | export const LoginForm = (props: { readonly redirect?: string }) => {
9 | return (
10 |
77 | );
78 | };
79 |
--------------------------------------------------------------------------------
/src/app/login/authActions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { cookies } from "next/headers";
4 | import process from "process";
5 | import { apiClient } from "@/app/apiClient";
6 | import { redirect } from "next/navigation";
7 |
8 | const AUTH_COOKIE_NAME = "session";
9 |
10 | export const loginAction = async (
11 | redirectUrl: string | undefined,
12 | data: FormData,
13 | ) => {
14 | const username = data.get("username")?.toString();
15 | const password = data.get("password")?.toString();
16 | const twofactor = data.get("twofactor")?.toString();
17 |
18 | if (!username || !password) {
19 | throw new Error("Missing username or password");
20 | }
21 |
22 | const loginResponse = await apiClient.login({
23 | username_or_email: username,
24 | password: password,
25 | totp_2fa_token: twofactor,
26 | });
27 |
28 | if (!loginResponse.jwt) {
29 | throw new Error("Authentication failed");
30 | }
31 |
32 | await setAuthCookie(loginResponse.jwt);
33 |
34 | redirect(redirectUrl ?? "/");
35 | };
36 |
37 | export const logoutAction = async () => {
38 | cookies().delete(AUTH_COOKIE_NAME);
39 | redirect("/login");
40 | };
41 |
42 | export const loginPageWithRedirectAction = async (
43 | redirectUrl: string,
44 | skipCookieDelete?: boolean,
45 | ) => {
46 | if (!skipCookieDelete) {
47 | cookies().delete(AUTH_COOKIE_NAME);
48 | }
49 | redirect(`/login?redirect=${encodeURIComponent(redirectUrl)}`);
50 | };
51 |
52 | export type AuthData = {
53 | jwt: string;
54 | userId: string;
55 | };
56 |
57 | export const isLoggedIn = async (): Promise => {
58 | return cookies().has(AUTH_COOKIE_NAME);
59 | };
60 |
61 | const oneMonthMillis = 30 * 24 * 60 * 60 * 1000;
62 |
63 | export const setAuthCookie = async (token: string) => {
64 | cookies().set({
65 | name: AUTH_COOKIE_NAME,
66 | value: token,
67 | expires: Date.now() + oneMonthMillis,
68 | httpOnly: true,
69 | path: "/",
70 | secure: process.env.NODE_ENV !== "development",
71 | sameSite: "lax",
72 | });
73 | };
74 | export const getJwtFromAuthCookie = async (): Promise => {
75 | return cookies().get(AUTH_COOKIE_NAME)?.value ?? null;
76 | };
77 |
--------------------------------------------------------------------------------
/src/app/login/page.tsx:
--------------------------------------------------------------------------------
1 | import { apiClient } from "@/app/apiClient";
2 | import { Image } from "@/app/(ui)/Image";
3 | import { LoginForm } from "@/app/login/LoginForm";
4 | import { StyledLink } from "@/app/(ui)/StyledLink";
5 |
6 | const LoginPage = async (props: {
7 | readonly searchParams: { redirect?: string };
8 | }) => {
9 | const { site_view: siteView } = await apiClient.getSite();
10 | return (
11 |
16 | {siteView.site.banner && (
17 |
23 | )}
24 |
29 | {"Sign in to "}
30 | {siteView.site.name}
31 |
32 |
33 |
34 |
35 |
36 |
37 | {"No account?"}{" "}
38 |
39 | {"Sign up here"}
40 |
41 |
42 |
43 |
44 | );
45 | };
46 |
47 | export default LoginPage;
48 |
--------------------------------------------------------------------------------
/src/app/login_reset/loginResetActions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { apiClient } from "@/app/apiClient";
4 | import { redirect } from "next/navigation";
5 |
6 | export const resetPasswordAction = async (form: FormData) => {
7 | await apiClient.passwordReset({ email: form.get("email")?.toString()! });
8 | redirect("/login_reset/sent");
9 | };
10 |
11 | export const setNewPasswordAfterReset = async (
12 | token: string,
13 | form: FormData,
14 | ) => {
15 | const request = {
16 | token,
17 | password: form.get("new-password")?.toString()!,
18 | password_verify: form.get("confirm-password")?.toString()!,
19 | };
20 |
21 | await apiClient.passwordChangeAfterReset(request);
22 | redirect("/login");
23 | };
24 |
--------------------------------------------------------------------------------
/src/app/login_reset/page.tsx:
--------------------------------------------------------------------------------
1 | import { Input } from "@/app/(ui)/form/Input";
2 | import { SubmitButton } from "@/app/(ui)/button/SubmitButton";
3 | import { resetPasswordAction } from "@/app/login_reset/loginResetActions";
4 |
5 | const ForgotPasswordPage = () => {
6 | return (
7 |
8 |
{"Forgot password"}
9 |
10 | {
11 | "Note: if you don't have access to the e-mail address associated with your account, then you will not be able to reset your password."
12 | }
13 |
14 |
15 |
16 |
28 |
29 |
30 | );
31 | };
32 |
33 | export default ForgotPasswordPage;
34 |
--------------------------------------------------------------------------------
/src/app/login_reset/sent/page.tsx:
--------------------------------------------------------------------------------
1 | const ResetLinkSentPage = () => {
2 | return (
3 |
4 |
{"Forgot password"}
5 |
6 | {"A password reset link has been sent to your e-mail."}
7 |
8 |
9 | );
10 | };
11 |
12 | export default ResetLinkSentPage;
13 |
--------------------------------------------------------------------------------
/src/app/modlog/page.tsx:
--------------------------------------------------------------------------------
1 | import { NotImplemented } from "@/app/(ui)/NotImplemented";
2 |
3 | const ModLogPage = () => {
4 | return ;
5 | };
6 |
7 | export default ModLogPage;
8 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { apiClient } from "@/app/apiClient";
2 | import { PageWithSidebar } from "@/app/PageWithSidebar";
3 | import { PostList, PostListSearchParams } from "@/app/post/PostList";
4 |
5 | const FrontPage = async ({
6 | searchParams,
7 | }: {
8 | readonly searchParams: PostListSearchParams;
9 | }) => {
10 | const { site_view: siteView, admins } = await apiClient.getSite();
11 |
12 | return (
13 | admin.person)}
15 | site={siteView.site}
16 | stats={siteView.counts}
17 | >
18 |
21 |
22 | );
23 | };
24 |
25 | export default FrontPage;
26 |
--------------------------------------------------------------------------------
/src/app/password_change/[token]/page.tsx:
--------------------------------------------------------------------------------
1 | import { Input } from "@/app/(ui)/form/Input";
2 | import { SubmitButton } from "@/app/(ui)/button/SubmitButton";
3 | import { setNewPasswordAfterReset } from "@/app/login_reset/loginResetActions";
4 |
5 | const PasswordChangePage = (props: { readonly params: { token: string } }) => {
6 | return (
7 |
8 |
{"Set new password"}
9 |
10 |
11 |
45 |
46 |
47 | );
48 | };
49 |
50 | export default PasswordChangePage;
51 |
--------------------------------------------------------------------------------
/src/app/post/PostList.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Community,
3 | ListingType,
4 | Person,
5 | PostView,
6 | SortType,
7 | } from "lemmy-js-client";
8 | import { apiClient } from "@/app/apiClient";
9 | import { getActiveSortAndListingType } from "@/app/post/getActiveSortAndListingType";
10 | import { PostListItem } from "@/app/post/PostListItem";
11 | import { Pagination } from "@/app/(ui)/Pagination";
12 | import { SearchParamLinks } from "@/app/(ui)/SearchParamLinks";
13 | import { postListSortOptions } from "@/app/post/postListSortOptions";
14 |
15 | export type PostListSearchParams = {
16 | listingType?: ListingType;
17 | sortType?: SortType;
18 | page?: string;
19 | };
20 |
21 | export const PostList = async (props: {
22 | readonly community?: Community;
23 | readonly person?: Person;
24 | readonly searchParams: PostListSearchParams;
25 | }) => {
26 | const { site_view: siteView, my_user: loggedInUser } =
27 | await apiClient.getSite();
28 |
29 | const { sortType, listingType } = getActiveSortAndListingType(
30 | siteView,
31 | loggedInUser,
32 | props.searchParams,
33 | );
34 |
35 | const { posts, next_page: nextPage } = await apiClient.getPosts({
36 | community_id: props.community?.id,
37 | limit: 25,
38 | type_: listingType,
39 | sort: sortType,
40 | page_cursor: props.searchParams.page,
41 | });
42 |
43 | let basePath = "/";
44 | if (props.community) {
45 | basePath = `/c/${props.community.name}`;
46 | if (!props.community.local) {
47 | basePath = `${basePath}@${new URL(props.community.actor_id).host}`;
48 | }
49 | }
50 |
51 | return (
52 |
53 |
59 |
60 | {posts.map((postView: PostView) => (
61 |
69 | ))}
70 |
71 | {nextPage &&
}
72 |
73 | );
74 | };
75 |
--------------------------------------------------------------------------------
/src/app/post/PostListItem.tsx:
--------------------------------------------------------------------------------
1 | import { PostView } from "lemmy-js-client";
2 | import { apiClient } from "@/app/apiClient";
3 | import { PostListItemContent } from "@/app/post/PostListItemContent";
4 | import { getRemoteImageProps } from "@/app/(utils)/getRemoteImageProps";
5 | import { isImage } from "@/app/(utils)/isImage";
6 | import { getVoteConfig } from "@/app/(ui)/vote/getVoteConfig";
7 |
8 | type Props = {
9 | readonly postView: PostView;
10 | readonly hideCommunityName?: boolean;
11 | readonly autoExpandMedia?: boolean;
12 | };
13 |
14 | export const PostListItem = async (props: Props) => {
15 | const { my_user: loggedInUser, site_view: siteView } =
16 | await apiClient.getSite();
17 |
18 | // noinspection ES6MissingAwait
19 | const remoteImageProps = isImage(props.postView.post.url)
20 | ? getRemoteImageProps(props.postView.post.url, 880)
21 | : undefined;
22 | return (
23 |
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/src/app/post/PostShareButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import dynamic from "next/dynamic";
3 |
4 | const PostShareButtonInternal = (props: {
5 | readonly title: string;
6 | readonly url: string;
7 | }) => {
8 | if (navigator === undefined || !navigator.share || !navigator.canShare(props))
9 | return null;
10 |
11 | return (
12 | {
17 | try {
18 | await navigator.share(props);
19 | } catch (e) {
20 | console.warn("Unable to share: ", e);
21 | }
22 | }}
23 | >
24 | {"share"}
25 |
26 | );
27 | };
28 |
29 | export const PostShareButton = dynamic(
30 | () => Promise.resolve(PostShareButtonInternal),
31 | { ssr: false },
32 | );
33 |
--------------------------------------------------------------------------------
/src/app/post/PostThumbnail.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { isImage } from "@/app/(utils)/isImage";
4 | import classNames from "classnames";
5 | import { Image } from "@/app/(ui)/Image";
6 | import {
7 | ArrowsPointingOutIcon,
8 | ChatBubbleBottomCenterTextIcon,
9 | LinkIcon,
10 | PhotoIcon,
11 | } from "@heroicons/react/24/outline";
12 | import { PlayIcon } from "@heroicons/react/16/solid";
13 | import { MyUserInfo, Post } from "lemmy-js-client";
14 | import { hasExpandableMedia } from "@/app/post/hasExpandableMedia";
15 | import React, { Dispatch, memo, SetStateAction, useState } from "react";
16 | import { getPostThumbnailSrc } from "@/app/post/getPostThumbnailSrc";
17 |
18 | type ThumbnailProps = {
19 | readonly post: Post;
20 | readonly className: string;
21 | readonly loggedInUser: MyUserInfo | undefined;
22 | readonly setInlineExpanded: Dispatch>;
23 | };
24 |
25 | export const PostThumbnail = memo(
26 | (props: ThumbnailProps) => {
27 | let src = getPostThumbnailSrc(props.post);
28 |
29 | return (
30 |
37 | {src ? (
38 | <>
39 |
40 | {
}
41 | >
42 | ) : props.post.url ? (
43 |
44 | ) : (
45 |
46 | )}
47 | {hasExpandableMedia(props.post) && (
48 |
52 | )}
53 |
54 | );
55 | },
56 | (prevProps, newProps) => prevProps.post.id === newProps.post.id,
57 | );
58 |
59 | PostThumbnail.displayName = "PostThumbnail";
60 |
61 | const ThumbnailImage = memo(
62 | (props: {
63 | readonly props: ThumbnailProps;
64 |
65 | readonly src: string;
66 | }) => {
67 | const [isImageLoading, setIsImageLoading] = useState(true);
68 | const [isImageError, setIsImageError] = useState(false);
69 |
70 | return (
71 | setIsImageError(true)}
82 | onLoad={() => setIsImageLoading(false)}
83 | placeholder={"empty"}
84 | sizes={"70px"}
85 | src={props.src}
86 | />
87 | );
88 | },
89 | (prevProps, newProps) => prevProps.src === newProps.src,
90 | );
91 |
92 | ThumbnailImage.displayName = "ThumbnailImage";
93 |
94 | const ExpandOverlay = (props: {
95 | readonly url: string;
96 | readonly onToggleExpanded: Dispatch>;
97 | }) => {
98 | return (
99 | {
104 | e.preventDefault();
105 | e.nativeEvent.stopImmediatePropagation();
106 | props.onToggleExpanded((prev) => !prev);
107 | }}
108 | >
109 | {isImage(props.url) ? (
110 |
111 | ) : (
112 | // Videos get a different icon
113 |
114 | )}
115 |
116 | );
117 | };
118 |
--------------------------------------------------------------------------------
/src/app/post/[id]/CommentsSection.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { VoteConfig } from "@/app/(ui)/vote/getVoteConfig";
4 | import { MyUserInfo } from "lemmy-js-client";
5 | import { CommentEditor } from "@/app/comment/CommentEditor";
6 | import { CommentTree } from "@/app/comment/CommentTree";
7 | import { ROOT_NODES_BATCH_SIZE } from "@/app/comment/constants";
8 | import { LazyComments } from "@/app/comment/LazyComments";
9 | import { StyledLink } from "@/app/(ui)/StyledLink";
10 | import { ArrowRightIcon } from "@heroicons/react/16/solid";
11 | import { useState } from "react";
12 | import { CommentNode, CommentTrees } from "@/app/comment/commentActions";
13 | import { GetComments } from "lemmy-js-client/dist/types/GetComments";
14 |
15 | export const CommentsSection = (props: {
16 | readonly postId: number;
17 | readonly singleCommentThreadRootId?: number;
18 | readonly highlightCommentId?: number;
19 | readonly voteConfig: VoteConfig;
20 | readonly loggedInUser?: MyUserInfo;
21 | readonly initialCommentTrees: CommentTrees;
22 | readonly commentRequestForm: GetComments;
23 | }) => {
24 | const [addedReplies, setAddedReplies] = useState([]);
25 |
26 | const commentCount =
27 | addedReplies.length + props.initialCommentTrees.rootNodes.length;
28 |
29 | return (
30 | <>
31 | {props.singleCommentThreadRootId ? (
32 |
36 | ) : (
37 |
42 | )}
43 | {commentCount === 0 && }
44 | {props.initialCommentTrees.rootNodes.map((node) => (
45 |
54 | ))}
55 | {props.initialCommentTrees.rootNodes.length >= ROOT_NODES_BATCH_SIZE && (
56 |
62 | )}
63 | >
64 | );
65 | };
66 |
67 | const SingleThreadInfo = (props: {
68 | readonly postId: number;
69 | readonly path: string;
70 | }) => {
71 | const pathParts = props.path.split(".");
72 | const parentId =
73 | pathParts.length > 2 ? pathParts[pathParts.length - 2] : null;
74 |
75 | return (
76 |
77 |
{"You are viewing a single thread."}
78 |
82 | {"View all comments "}
83 |
84 |
85 | {parentId && (
86 |
90 | {"View context "}
91 |
92 |
93 | )}
94 |
95 | );
96 | };
97 |
98 | const NoComments = () => {
99 | return (
100 |
103 | {"No comments yet!"}
104 |
105 | );
106 | };
107 |
--------------------------------------------------------------------------------
/src/app/post/[id]/PostPageWithSidebar.tsx:
--------------------------------------------------------------------------------
1 | import { PageWithSidebar } from "@/app/PageWithSidebar";
2 | import { PostListItem } from "@/app/post/PostListItem";
3 | import { apiClient } from "@/app/apiClient";
4 | import { CommentSortType, MyUserInfo } from "lemmy-js-client";
5 | import { SearchParamLinks } from "@/app/(ui)/SearchParamLinks";
6 | import { getVoteConfig, VoteConfig } from "@/app/(ui)/vote/getVoteConfig";
7 | import { MarkdownWithFetchedContent } from "@/app/(ui)/markdown/MarkdownWithFetchedContent";
8 | import { buildCommentTreesAction } from "@/app/comment/commentActions";
9 | import { GetComments } from "lemmy-js-client/dist/types/GetComments";
10 | import { CommentsSection } from "@/app/post/[id]/CommentsSection";
11 |
12 | export const PostPageWithSidebar = async (props: {
13 | readonly postId: number;
14 | readonly singleCommentThreadRootId?: number;
15 | readonly searchParams: Record;
16 | readonly highlightCommentId?: number;
17 | }) => {
18 | const [
19 | { post_view: postView },
20 | { site_view: siteView, my_user: loggedInUser },
21 | ] = await Promise.all([
22 | apiClient.getPost({
23 | id: props.postId,
24 | }),
25 | apiClient.getSite(),
26 | ]);
27 |
28 | const { community_view: communityView, moderators } =
29 | await apiClient.getCommunity({
30 | id: postView.community.id,
31 | });
32 |
33 | return (
34 | mod.moderator)}
37 | stats={communityView.counts}
38 | >
39 |
40 |
41 | {postView.post.body && }
42 |
50 |
51 |
52 | );
53 | };
54 |
55 | const PostBody = (props: { readonly id: number }) => {
56 | return (
57 |
58 |
59 |
60 | );
61 | };
62 |
63 | const Comments = async (props: {
64 | readonly postId: number;
65 | readonly singleCommentThreadRootId?: number;
66 | readonly searchParams: Record;
67 | readonly highlightCommentId?: number;
68 | readonly voteConfig: VoteConfig;
69 | readonly loggedInUser?: MyUserInfo;
70 | }) => {
71 | const searchParamsSortType = props.searchParams[
72 | "sortType"
73 | ] as CommentSortType;
74 | const currentSortType = searchParamsSortType ?? "Hot";
75 |
76 | let maxDepth = 4;
77 | if (props.singleCommentThreadRootId) {
78 | // If we're only rendering a single thread, we can fetch a few more comments at once
79 | maxDepth = 6;
80 | }
81 |
82 | const commentRequestForm: GetComments = {
83 | post_id: props.postId,
84 | parent_id: props.singleCommentThreadRootId,
85 | max_depth: maxDepth,
86 | sort: currentSortType,
87 | type_: "All",
88 | saved_only: false,
89 | };
90 | const initialCommentTrees = await buildCommentTreesAction(
91 | commentRequestForm,
92 | new Set(),
93 | );
94 |
95 | const enabledSortOptions: CommentSortType[] = [
96 | "Hot",
97 | "Top",
98 | "Controversial",
99 | "New",
100 | "Old",
101 | ];
102 |
103 | return (
104 |
105 |
112 |
120 |
121 | );
122 | };
123 |
--------------------------------------------------------------------------------
/src/app/post/[id]/edit/page.tsx:
--------------------------------------------------------------------------------
1 | import { apiClient } from "@/app/apiClient";
2 | import { formatCommunityName } from "@/app/c/formatCommunityName";
3 | import { PageWithSidebar } from "@/app/PageWithSidebar";
4 | import { PostEditor } from "@/app/create_post/PostEditor";
5 | import { loginPageWithRedirectAction } from "@/app/login/authActions";
6 |
7 | const EditPostPage = async (props: { readonly params: { id: number } }) => {
8 | const { my_user: loggedInUser } = await apiClient.getSite();
9 | if (!loggedInUser) {
10 | await loginPageWithRedirectAction(`/post/${props.params.id}/edit`, true);
11 | }
12 |
13 | const {
14 | post_view: postView,
15 | community_view: communityView,
16 | moderators: mods,
17 | } = await apiClient.getPost({
18 | id: props.params.id,
19 | });
20 |
21 | const communityName = formatCommunityName(communityView.community);
22 |
23 | return (
24 | mod.moderator)}
28 | stats={communityView.counts}
29 | >
30 |
31 |
32 | {"Editing post in "}
33 | {communityName}
34 | {"..."}
35 |
36 |
41 |
42 |
43 | );
44 | };
45 |
46 | export default EditPostPage;
47 |
--------------------------------------------------------------------------------
/src/app/post/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import { PostPageWithSidebar } from "@/app/post/[id]/PostPageWithSidebar";
2 | import { Metadata, ResolvingMetadata } from "next";
3 | import { apiClient } from "@/app/apiClient";
4 | import { formatCommunityName } from "@/app/c/formatCommunityName";
5 | import { formatPersonUsername } from "@/app/u/formatPersonUsername";
6 |
7 | type PostPageProps = {
8 | readonly params: { id: Number };
9 | readonly searchParams: Record;
10 | };
11 | export const generateMetadata = async (
12 | props: PostPageProps,
13 | parent: ResolvingMetadata,
14 | ): Promise => {
15 | const [{ post_view: postView }, { site_view: siteView }] = await Promise.all([
16 | apiClient.getPost({
17 | id: Number(props.params.id),
18 | }),
19 | apiClient.getSite(),
20 | ]);
21 |
22 | let images = (await parent).openGraph?.images || [];
23 |
24 | if (postView.community.icon) {
25 | images = [postView.community.icon, ...images];
26 | }
27 | if (postView.community.banner) {
28 | images = [postView.community.banner, ...images];
29 | }
30 |
31 | if (postView.post.thumbnail_url) {
32 | images = [postView.post.thumbnail_url, ...images];
33 | }
34 |
35 | return {
36 | title: postView.post.name,
37 | description: postView.community.title,
38 | alternates: {
39 | canonical: `/post/${postView.post.id}`,
40 | },
41 | openGraph: {
42 | title: postView.post.name,
43 | description: `Posted in ${formatCommunityName(postView.community)} by ${formatPersonUsername(postView.creator)} • ${postView.counts.score} points and ${postView.counts.comments} comments`,
44 | siteName: siteView.site.name,
45 | images: [images[0]],
46 | },
47 | };
48 | };
49 |
50 | const PostPage = async ({ params, searchParams }: PostPageProps) => {
51 | return (
52 |
56 | );
57 | };
58 |
59 | export default PostPage;
60 |
--------------------------------------------------------------------------------
/src/app/post/getActiveSortAndListingType.tsx:
--------------------------------------------------------------------------------
1 | import { MyUserInfo, SiteView } from "lemmy-js-client";
2 | import { PostListSearchParams } from "@/app/post/PostList";
3 |
4 | export const getActiveSortAndListingType = (
5 | siteView: SiteView,
6 | loggedInUser: MyUserInfo | undefined,
7 | searchParams: PostListSearchParams,
8 | ) => {
9 | let sortType = siteView.local_site.default_sort_type ?? "Active";
10 | let listingType = siteView.local_site.default_post_listing_type;
11 |
12 | if (loggedInUser) {
13 | sortType = loggedInUser.local_user_view.local_user.default_sort_type;
14 | listingType = loggedInUser.local_user_view.local_user.default_listing_type;
15 | }
16 |
17 | if (searchParams.listingType) {
18 | listingType = searchParams.listingType;
19 | }
20 |
21 | if (searchParams.sortType) {
22 | sortType = searchParams.sortType;
23 | }
24 | return { sortType, listingType };
25 | };
26 |
--------------------------------------------------------------------------------
/src/app/post/getPostThumbnailSrc.ts:
--------------------------------------------------------------------------------
1 | import { Post } from "lemmy-js-client";
2 | import { isImage } from "@/app/(utils)/isImage";
3 |
4 | export const getPostThumbnailSrc = (post: Post) => {
5 | let src = post.thumbnail_url ?? (isImage(post.url) ? post.url : undefined);
6 |
7 | if (src?.includes("/pictrs/")) {
8 | // If image is hosted on pictrs, request it with a smaller resolution
9 | const srcUrl = new URL(src);
10 | srcUrl.searchParams.delete("thumbnail");
11 | srcUrl.searchParams.append("thumbnail", "280");
12 | src = srcUrl.toString();
13 | }
14 | return src;
15 | };
16 |
--------------------------------------------------------------------------------
/src/app/post/hasExpandableMedia.ts:
--------------------------------------------------------------------------------
1 | import { Post } from "lemmy-js-client";
2 | import { isImage } from "@/app/(utils)/isImage";
3 | import { isVideo } from "@/app/(utils)/isVideo";
4 |
5 | export const hasExpandableMedia = (post: Post) => {
6 | const url = post.url;
7 | return isImage(url) || isVideo(url) || post.embed_video_url;
8 | };
9 |
--------------------------------------------------------------------------------
/src/app/post/postActions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { apiClient } from "@/app/apiClient";
4 | import { revalidatePath } from "next/cache";
5 | import {
6 | isLoggedIn,
7 | loginPageWithRedirectAction,
8 | } from "@/app/login/authActions";
9 | import { redirect } from "next/navigation";
10 |
11 | export const toggleSavePostAction = async (postId: number, save: boolean) => {
12 | if (!(await isLoggedIn())) {
13 | await loginPageWithRedirectAction(`/post/${postId}}`);
14 | return;
15 | }
16 |
17 | await apiClient.savePost({
18 | post_id: postId,
19 | save,
20 | });
21 | revalidatePath(`/post/${postId}`);
22 | revalidatePath(`/comment/[id]`, "page");
23 | revalidatePath(`/saved`);
24 | revalidatePath(`/`);
25 | };
26 |
27 | export const toggleDeletePostAction = async (
28 | postId: number,
29 | deleted: boolean,
30 | ) => {
31 | if (!(await isLoggedIn())) {
32 | await loginPageWithRedirectAction(`/post/${postId}}`);
33 | return;
34 | }
35 |
36 | await apiClient.deletePost({
37 | post_id: postId,
38 | deleted,
39 | });
40 | revalidatePath(`/post/${postId}`);
41 | revalidatePath(`/comment/[id]`, "page");
42 | revalidatePath(`/u/[name]`, "page");
43 | revalidatePath(`/`);
44 | };
45 |
46 | export const createPostAction = async (communityId: number, form: FormData) => {
47 | if (!(await isLoggedIn())) {
48 | await loginPageWithRedirectAction(
49 | `/create_post?community_id=${communityId}}`,
50 | );
51 | return;
52 | }
53 |
54 | const { post_view: postView } = await apiClient.createPost({
55 | name: form.get("title")?.toString()!,
56 | community_id: communityId,
57 | url: form.get("url")?.toString() || undefined,
58 | body: form.get("body")?.toString() || undefined,
59 | honeypot: form.get("honey")?.toString() || undefined,
60 | nsfw: form.get("nsfw")?.toString() === "on",
61 | });
62 |
63 | redirect(`/post/${postView.post.id}`);
64 | };
65 | export const editPostAction = async (postId: number, form: FormData) => {
66 | if (!(await isLoggedIn())) {
67 | await loginPageWithRedirectAction(`/post/${postId}/edit`);
68 | return;
69 | }
70 |
71 | const { post_view: postView } = await apiClient.editPost({
72 | name: form.get("title")?.toString()!,
73 | post_id: postId,
74 | url: form.get("url")?.toString() || undefined,
75 | body: form.get("body")?.toString() || undefined,
76 | nsfw: form.get("nsfw")?.toString() === "on",
77 | });
78 |
79 | redirect(`/post/${postView.post.id}`);
80 | };
81 |
--------------------------------------------------------------------------------
/src/app/post/postListSortOptions.ts:
--------------------------------------------------------------------------------
1 | import { SortType } from "lemmy-js-client";
2 |
3 | export const postListSortOptions: SortType[] = [
4 | "Active",
5 | "Scaled",
6 | "Hot",
7 | "New",
8 | "TopAll",
9 | "TopYear",
10 | "TopMonth",
11 | "TopWeek",
12 | "TopDay",
13 | ];
14 |
--------------------------------------------------------------------------------
/src/app/registration_applications/page.tsx:
--------------------------------------------------------------------------------
1 | import { NotImplemented } from "@/app/(ui)/NotImplemented";
2 |
3 | const RegistrationApplicationsPage = () => {
4 | return ;
5 | };
6 |
7 | export default RegistrationApplicationsPage;
8 |
--------------------------------------------------------------------------------
/src/app/reports/page.tsx:
--------------------------------------------------------------------------------
1 | import { NotImplemented } from "@/app/(ui)/NotImplemented";
2 |
3 | const ReportsPage = () => {
4 | return ;
5 | };
6 |
7 | export default ReportsPage;
8 |
--------------------------------------------------------------------------------
/src/app/saved/page.tsx:
--------------------------------------------------------------------------------
1 | import { apiClient } from "@/app/apiClient";
2 | import { SearchParamLinks } from "@/app/(ui)/SearchParamLinks";
3 | import { Pagination } from "@/app/(ui)/Pagination";
4 | import {
5 | CommentView,
6 | ListingType,
7 | PostView,
8 | SearchType,
9 | SortType,
10 | } from "lemmy-js-client";
11 | import { CombinedPostsAndComments } from "@/app/search/CombinedPostsAndComments";
12 | import { loginPageWithRedirectAction } from "@/app/login/authActions";
13 |
14 | export type SearchPageSearchParams = {
15 | q?: string;
16 | sortType?: SortType;
17 | page?: string;
18 | type?: SearchType;
19 | listingType?: ListingType;
20 | };
21 | const SavedPage = async (props: {
22 | readonly searchParams: SearchPageSearchParams;
23 | }) => {
24 | const { my_user: loggedInUser } = await apiClient.getSite();
25 | if (!loggedInUser) {
26 | await loginPageWithRedirectAction("/saved", true);
27 | return null;
28 | }
29 |
30 | const currentSortType: SortType = props.searchParams.sortType ?? "New";
31 | const currentPage = props.searchParams.page
32 | ? Number(props.searchParams.page)
33 | : 1;
34 |
35 | const availableSortTypes: SortType[] = ["New", "Old", "TopAll"];
36 |
37 | const limit = 20;
38 | const { posts, comments } = await apiClient.getPersonDetails({
39 | person_id: loggedInUser?.local_user_view.person.id,
40 | saved_only: true,
41 | limit,
42 | sort: currentSortType,
43 | });
44 |
45 | const nextPageAvailable =
46 | (comments.length ?? 0) === limit || (posts.length ?? 0) === limit;
47 |
48 | return (
49 |
50 |
{"Saved"}
51 |
52 |
58 |
59 |
60 |
67 |
68 |
1 ? currentPage - 1 : undefined}
71 | />
72 |
73 | );
74 | };
75 |
76 | const PostsAndComments = (props: {
77 | readonly posts: PostView[];
78 | readonly comments: CommentView[];
79 | readonly sortType: SortType;
80 | }) => {
81 | return props.posts.length > 0 || props.comments.length > 0 ? (
82 |
87 | ) : (
88 |
89 | );
90 | };
91 |
92 | const NoResults = () => {
93 | return {"No saved content yet!"}
;
94 | };
95 |
96 | export default SavedPage;
97 |
--------------------------------------------------------------------------------
/src/app/search/CombinedPostsAndComments.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | CommentView,
3 | MyUserInfo,
4 | PostView,
5 | SiteView,
6 | SortType,
7 | } from "lemmy-js-client";
8 | import { Comment } from "@/app/comment/Comment";
9 | import { PostListItem } from "@/app/post/PostListItem";
10 | import { apiClient } from "@/app/apiClient";
11 | import { getVoteConfig } from "@/app/(ui)/vote/getVoteConfig";
12 | import { getMarkdownWithRemoteImagesAction } from "@/app/(ui)/markdown/markdownActions";
13 |
14 | type Props = {
15 | posts: PostView[];
16 | comments: CommentView[];
17 | sortType: SortType;
18 | };
19 | export const CombinedPostsAndComments = async (props: Props) => {
20 | const { site_view: siteView, my_user: loggedInUser } =
21 | await apiClient.getSite();
22 |
23 | return sort(props).map((view) => {
24 | return isComment(view) ? (
25 |
31 | ) : (
32 |
33 | );
34 | });
35 | };
36 |
37 | const CommentListItem = async (props: {
38 | readonly commentView: CommentView;
39 | readonly loggedInUser?: MyUserInfo;
40 | readonly siteView: SiteView;
41 | }) => {
42 | const markdownProps = {
43 | ...(await getMarkdownWithRemoteImagesAction(
44 | props.commentView.comment.content,
45 | `comment-${props.commentView.comment.id}`,
46 | )),
47 | localSiteName: props.siteView.site.name,
48 | };
49 | return (
50 |
59 | );
60 | };
61 |
62 | const sort = (props: Props): Array => {
63 | switch (props.sortType) {
64 | case "New":
65 | return [...props.comments, ...props.posts].sort(sortNew);
66 | case "Old":
67 | return [...props.comments, ...props.posts].sort(sortOld);
68 | default:
69 | return [...props.comments, ...props.posts].sort(sortScore);
70 | }
71 | };
72 |
73 | const sortOld = (
74 | a: PostView | CommentView,
75 | b: PostView | CommentView,
76 | ): number => {
77 | const aPublished = isComment(a) ? a.comment.published : a.post.published;
78 | const bPublished = isComment(b) ? b.comment.published : b.post.published;
79 |
80 | return aPublished > bPublished ? 1 : -1;
81 | };
82 |
83 | const sortNew = (
84 | a: PostView | CommentView,
85 | b: PostView | CommentView,
86 | ): number => {
87 | const aPublished = isComment(a) ? a.comment.published : a.post.published;
88 | const bPublished = isComment(b) ? b.comment.published : b.post.published;
89 |
90 | return aPublished < bPublished ? 1 : -1;
91 | };
92 |
93 | const sortScore = (
94 | a: PostView | CommentView,
95 | b: PostView | CommentView,
96 | ): number => {
97 | const aScore = a.counts.score;
98 | const bScore = b.counts.score;
99 |
100 | return aScore > bScore ? 1 : -1;
101 | };
102 |
103 | const isComment = (input: PostView | CommentView): input is CommentView => {
104 | return (input as CommentView).comment !== undefined;
105 | };
106 |
--------------------------------------------------------------------------------
/src/app/search/searchAction.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { redirect } from "next/navigation";
4 | import { SearchPageSearchParams } from "@/app/search/page";
5 |
6 | export const searchAction = async (
7 | searchParams: SearchPageSearchParams,
8 | data: FormData,
9 | ) => {
10 | const { page: _, q: __, ...oldSearchParams } = searchParams;
11 |
12 | const q = data.get("q")?.toString() ?? undefined;
13 |
14 | let newSearchParamsInput = {
15 | ...oldSearchParams,
16 | } as Record;
17 |
18 | if (q) {
19 | newSearchParamsInput = { ...newSearchParamsInput, q };
20 | }
21 |
22 | const newSearchParams = new URLSearchParams(newSearchParamsInput).toString();
23 | redirect(`/search?${newSearchParams}`);
24 | };
25 |
--------------------------------------------------------------------------------
/src/app/settings/2fa/disable/page.tsx:
--------------------------------------------------------------------------------
1 | import { disable2faAction } from "@/app/settings/userActions";
2 | import { Input } from "@/app/(ui)/form/Input";
3 | import { SubmitButton } from "@/app/(ui)/button/SubmitButton";
4 |
5 | const VerifyEmailPage = async (props: {
6 | readonly params: { token: string };
7 | }) => {
8 | return (
9 |
10 |
{"Disable 2fa"}
11 |
12 |
13 | {
14 | "In order to remove 2fa from your account, please enter a 2fa code from your authenticator."
15 | }
16 |
17 |
31 |
32 |
33 | );
34 | };
35 |
36 | export default VerifyEmailPage;
37 |
--------------------------------------------------------------------------------
/src/app/settings/2fa/enable/page.tsx:
--------------------------------------------------------------------------------
1 | import { apiClient } from "@/app/apiClient";
2 | import { enable2faAction } from "@/app/settings/userActions";
3 | import { Input } from "@/app/(ui)/form/Input";
4 | import { SubmitButton } from "@/app/(ui)/button/SubmitButton";
5 | import * as QRCode from "qrcode";
6 | import { Image } from "@/app/(ui)/Image";
7 |
8 | const VerifyEmailPage = async (props: {
9 | readonly params: { token: string };
10 | }) => {
11 | const { totp_secret_url: qrUrl } = await apiClient.generateTotpSecret();
12 |
13 | const qrDataUrl = await QRCode.toDataURL(qrUrl);
14 |
15 | return (
16 |
17 |
{"Enable 2fa"}
18 |
19 |
20 | {
21 | "In order to remove 2fa from your account, please scan the following QR code with your authenticator, and enter the generated 2fa code below."
22 | }
23 |
24 |
25 |
26 | {
27 | "Note: if you're unable to scan the QR code, you may use the following otp URL to set up your authenticator:"
28 | }
29 |
30 |
{qrUrl}
31 |
45 |
46 |
47 | );
48 | };
49 |
50 | export default VerifyEmailPage;
51 |
--------------------------------------------------------------------------------
/src/app/settings/AuthForm.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { SettingsInputWithLabel } from "@/app/settings/SettingsInputWithLabel";
4 | import { MyUserInfo } from "lemmy-js-client";
5 | import {
6 | changePasswordAction,
7 | updateEmailAction,
8 | } from "@/app/settings/userActions";
9 | import { SubmitButton } from "@/app/(ui)/button/SubmitButton";
10 | import { ButtonLink } from "@/app/(ui)/button/ButtonLink";
11 |
12 | export const AuthForm = (props: { readonly loggedInUser: MyUserInfo }) => {
13 | const totp2faEnabled =
14 | props.loggedInUser.local_user_view.local_user.totp_2fa_enabled;
15 | return (
16 |
17 |
{"Authentication"}
18 |
19 |
32 |
79 |
105 |
106 | );
107 | };
108 |
--------------------------------------------------------------------------------
/src/app/settings/BlocksForm.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { MyUserInfo } from "lemmy-js-client";
4 | import { updateSettingsAction } from "@/app/settings/userActions";
5 |
6 | export const BlocksForm = (props: { readonly loggedInUser: MyUserInfo }) => {
7 | return (
8 |
15 | );
16 | };
17 |
--------------------------------------------------------------------------------
/src/app/settings/ExportImportForm.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { MyUserInfo } from "lemmy-js-client";
4 | import { importSettingsAction } from "@/app/settings/userActions";
5 | import { SubmitButton } from "@/app/(ui)/button/SubmitButton";
6 | import { Input } from "@/app/(ui)/form/Input";
7 | import { ButtonLink } from "@/app/(ui)/button/ButtonLink";
8 |
9 | export const ExportImportForm = (props: {
10 | readonly loggedInUser: MyUserInfo;
11 | }) => {
12 | return (
13 |
42 | );
43 | };
44 |
--------------------------------------------------------------------------------
/src/app/settings/LogoutForm.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { logoutAction } from "@/app/login/authActions";
4 | import { SubmitButton } from "@/app/(ui)/button/SubmitButton";
5 |
6 | export const LogoutForm = () => {
7 | return (
8 |
11 | );
12 | };
13 |
--------------------------------------------------------------------------------
/src/app/settings/ProfileForm.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { SettingsInputWithLabel } from "@/app/settings/SettingsInputWithLabel";
4 | import { MyUserInfo } from "lemmy-js-client";
5 | import { updateProfileAction } from "@/app/settings/userActions";
6 | import { SubmitButton } from "@/app/(ui)/button/SubmitButton";
7 | import { ProfileImageInput } from "@/app/settings/ProfileImageInput";
8 |
9 | export const ProfileForm = (props: { readonly loggedInUser: MyUserInfo }) => {
10 | return (
11 |
49 | );
50 | };
51 |
--------------------------------------------------------------------------------
/src/app/settings/SettingsForm.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { SettingsInputWithLabel } from "@/app/settings/SettingsInputWithLabel";
4 | import { MyUserInfo } from "lemmy-js-client";
5 | import { postListSortOptions } from "@/app/post/postListSortOptions";
6 | import { updateSettingsAction } from "@/app/settings/userActions";
7 | import { SubmitButton } from "@/app/(ui)/button/SubmitButton";
8 |
9 | export const SettingsForm = (props: { readonly loggedInUser: MyUserInfo }) => {
10 | return (
11 |
97 | );
98 | };
99 |
--------------------------------------------------------------------------------
/src/app/settings/SettingsInputWithLabel.tsx:
--------------------------------------------------------------------------------
1 | import classNames from "classnames";
2 | import { Input } from "@/app/(ui)/form/Input";
3 | import { Select } from "@/app/(ui)/form/Select";
4 | import { MarkdownTextArea } from "@/app/(ui)/markdown/MarkdownTextArea";
5 |
6 | type InputWithLabelBaseProps = {
7 | inputId: string;
8 | label: string;
9 | disabled?: boolean;
10 | className?: string;
11 | };
12 |
13 | type InputWithLabelCheckboxProps = InputWithLabelBaseProps & {
14 | type: "checkbox";
15 | defaultChecked?: boolean;
16 | defaultValue?: undefined;
17 | required?: false;
18 | placeholder?: undefined;
19 | };
20 |
21 | type InputWithLabelTextProps = InputWithLabelBaseProps & {
22 | type: "text" | "email" | "textarea" | "password";
23 | required?: boolean;
24 | defaultValue?: string;
25 | defaultChecked?: undefined;
26 | placeholder?: string;
27 | minLength?: number;
28 | maxLength?: number;
29 | autoComplete?: string;
30 | };
31 |
32 | type InputWithLabelSelectProps = InputWithLabelBaseProps & {
33 | type: "select";
34 | defaultValue: string;
35 | options: string[];
36 | required?: false;
37 | defaultChecked?: undefined;
38 | placeholder?: undefined;
39 | };
40 |
41 | export const SettingsInputWithLabel = (
42 | props:
43 | | InputWithLabelTextProps
44 | | InputWithLabelCheckboxProps
45 | | InputWithLabelSelectProps,
46 | ) => {
47 | return (
48 |
57 |
58 |
66 |
67 | {(props.type === "password" ||
68 | props.type === "email" ||
69 | props.type == "text") && (
70 |
83 | )}
84 | {props.type === "checkbox" && (
85 |
96 | )}
97 | {props.type === "select" && (
98 |
109 | )}
110 | {props.type === "textarea" && (
111 |
120 | )}
121 |
122 | );
123 | };
124 |
--------------------------------------------------------------------------------
/src/app/settings/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 | import { LogoutForm } from "@/app/settings/LogoutForm";
3 | import { StyledLink } from "@/app/(ui)/StyledLink";
4 | import { ArrowRightIcon } from "@heroicons/react/16/solid";
5 | import { apiClient } from "@/app/apiClient";
6 | import { ProfileForm } from "@/app/settings/ProfileForm";
7 | import { SettingsForm } from "@/app/settings/SettingsForm";
8 | import { AuthForm } from "@/app/settings/AuthForm";
9 | import { ReactNode } from "react";
10 | import classNames from "classnames";
11 | import { ExportImportForm } from "@/app/settings/ExportImportForm";
12 | import { BlocksForm } from "@/app/settings/BlocksForm";
13 |
14 | const SettingsPage = async () => {
15 | const { my_user: loggedInUser } = await apiClient.getSite();
16 |
17 | if (!loggedInUser) {
18 | redirect("/login");
19 | }
20 |
21 | return (
22 |
23 |
24 |
25 |
{"User settings"}
26 |
27 |
31 | {"View public profile "}
32 |
33 |
34 |
35 |
36 |
37 |
38 |
41 |
44 |
47 |
50 |
53 |
56 |
57 |
58 | );
59 | };
60 |
61 | const Section = (props: {
62 | readonly children: ReactNode;
63 | readonly className?: string;
64 | }) => {
65 | return (
66 |
73 | {props.children}
74 |
75 | );
76 | };
77 |
78 | export default SettingsPage;
79 |
--------------------------------------------------------------------------------
/src/app/settings/userActions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { apiClient } from "@/app/apiClient";
4 | import {
5 | GetReportCountResponse,
6 | GetUnreadCountResponse,
7 | GetUnreadRegistrationApplicationCountResponse,
8 | ListingType,
9 | SortType,
10 | } from "lemmy-js-client";
11 | import { revalidatePath } from "next/cache";
12 | import { getFormBoolean } from "@/app/(utils)/getFormBoolean";
13 | import { redirect } from "next/navigation";
14 |
15 | export type UnreadCounts = {
16 | inbox: GetUnreadCountResponse;
17 | applications: GetUnreadRegistrationApplicationCountResponse | null;
18 | reports: GetReportCountResponse | null;
19 | };
20 |
21 | export const getUnreadCounts = async (
22 | isAdmin: boolean,
23 | isMod: boolean,
24 | applicationsEnabled: boolean,
25 | ): Promise => {
26 | return {
27 | inbox: await apiClient.getUnreadCount(),
28 | applications:
29 | isAdmin && applicationsEnabled
30 | ? await apiClient.getUnreadRegistrationApplicationCount()
31 | : null,
32 | reports: isAdmin || isMod ? await apiClient.getReportCount({}) : null,
33 | };
34 | };
35 |
36 | export const updateProfileAction = async (formData: FormData) => {
37 | await apiClient.saveUserSettings({
38 | display_name: formData.get("display_name")?.toString(),
39 | bio: formData.get("bio")?.toString(),
40 | matrix_user_id: formData.get("matrix")?.toString(),
41 | avatar: formData.get("avatar")?.toString(),
42 | banner: formData.get("banner")?.toString(),
43 | });
44 | revalidatePath("/settings");
45 | };
46 |
47 | export const updateSettingsAction = async (formData: FormData) => {
48 | await apiClient.saveUserSettings({
49 | email: formData.get("email")?.toString(),
50 | default_listing_type: formData.get("filter")?.toString() as
51 | | ListingType
52 | | undefined,
53 | default_sort_type: formData.get("sort")?.toString() as SortType | undefined,
54 | show_nsfw: getFormBoolean(formData, "nsfw_show"),
55 | blur_nsfw: getFormBoolean(formData, "nsfw_blur"),
56 | auto_expand: getFormBoolean(formData, "auto_expand"),
57 | show_scores: getFormBoolean(formData, "show_scores"),
58 | bot_account: getFormBoolean(formData, "is_bot"),
59 | show_bot_accounts: getFormBoolean(formData, "show_bots"),
60 | show_read_posts: getFormBoolean(formData, "show_read_posts"),
61 | send_notifications_to_email: getFormBoolean(
62 | formData,
63 | "notification_emails",
64 | ),
65 | });
66 | revalidatePath("/settings");
67 | revalidatePath("/");
68 | };
69 |
70 | export const updateEmailAction = async (formData: FormData) => {
71 | await apiClient.saveUserSettings({
72 | email: formData.get("email")?.toString(),
73 | });
74 | revalidatePath("/settings");
75 | revalidatePath("/");
76 | };
77 |
78 | export const changePasswordAction = async (formData: FormData) => {
79 | const newPassword = formData.get("password_new")?.toString();
80 | const newPasswordVerify = formData.get("password_verify")?.toString();
81 | const oldPassword = formData.get("password_current")?.toString();
82 |
83 | if (!newPassword || !newPasswordVerify || !oldPassword) {
84 | throw new Error("Form not filled");
85 | }
86 |
87 | await apiClient.changePassword({
88 | new_password: newPassword,
89 | new_password_verify: newPasswordVerify,
90 | old_password: oldPassword,
91 | });
92 | revalidatePath("/settings");
93 | revalidatePath("/");
94 | };
95 |
96 | export const disable2faAction = async (formData: FormData) => {
97 | const totpToken = formData.get("token")?.toString();
98 |
99 | if (!totpToken) {
100 | throw new Error("Missing 2fa token");
101 | }
102 |
103 | await apiClient.updateTotp({
104 | totp_token: totpToken,
105 | enabled: false,
106 | });
107 |
108 | revalidatePath("/settings");
109 | revalidatePath("/");
110 | redirect("/settings");
111 | };
112 |
113 | export const enable2faAction = async (formData: FormData) => {
114 | const totpToken = formData.get("token")?.toString();
115 |
116 | if (!totpToken) {
117 | throw new Error("Missing 2fa token");
118 | }
119 |
120 | await apiClient.updateTotp({
121 | totp_token: totpToken,
122 | enabled: true,
123 | });
124 |
125 | revalidatePath("/settings");
126 | revalidatePath("/");
127 | redirect("/settings");
128 | };
129 |
130 | export const removeUserAvatar = async () => {
131 | await apiClient.saveUserSettings({
132 | avatar: "",
133 | });
134 | revalidatePath("/settings");
135 | revalidatePath("/");
136 | };
137 |
138 | export const removeUserBanner = async () => {
139 | await apiClient.saveUserSettings({
140 | banner: "",
141 | });
142 | revalidatePath("/settings");
143 | revalidatePath("/");
144 | };
145 |
146 | export const importSettingsAction = async (settings: object) => {
147 | await apiClient.importSettings(settings);
148 | };
149 |
--------------------------------------------------------------------------------
/src/app/signup/SignupForm.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Input } from "@/app/(ui)/form/Input";
4 | import { SubmitButton } from "@/app/(ui)/button/SubmitButton";
5 | import { signupAction } from "@/app/signup/signupActions";
6 | import { CaptchaResponse, SiteView } from "lemmy-js-client";
7 | import { TextArea } from "@/app/(ui)/form/TextArea";
8 | import { Markdown } from "@/app/(ui)/markdown/Markdown";
9 |
10 | export const SignupForm = (props: {
11 | readonly siteView: SiteView;
12 | readonly captcha?: CaptchaResponse;
13 | }) => {
14 | return (
15 |
168 | );
169 | };
170 |
--------------------------------------------------------------------------------
/src/app/signup/page.tsx:
--------------------------------------------------------------------------------
1 | import { apiClient } from "@/app/apiClient";
2 | import { Image } from "@/app/(ui)/Image";
3 | import { StyledLink } from "@/app/(ui)/StyledLink";
4 | import { SignupForm } from "@/app/signup/SignupForm";
5 |
6 | const SignupPage = async () => {
7 | const { site_view: siteView } = await apiClient.getSite();
8 | let captcha = undefined;
9 | if (siteView.local_site.captcha_enabled) {
10 | captcha = (await apiClient.getCaptcha()).ok;
11 | }
12 | return (
13 |
18 | {siteView.site.banner && (
19 |
25 | )}
26 |
31 | {"Create an account on "}
32 | {siteView.site.name}
33 |
34 |
35 |
36 |
37 |
38 |
39 | {"Already have an account?"}{" "}
40 |
41 | {"Log in here"}
42 |
43 |
44 |
45 |
46 | );
47 | };
48 |
49 | export default SignupPage;
50 |
--------------------------------------------------------------------------------
/src/app/signup/signupActions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { apiClient } from "@/app/apiClient";
4 | import { redirect } from "next/navigation";
5 | import { getFormBoolean } from "@/app/(utils)/getFormBoolean";
6 | import { setAuthCookie } from "@/app/login/authActions";
7 |
8 | export const signupAction = async (
9 | captchaUuid: string | undefined,
10 | data: FormData,
11 | ) => {
12 | const username = data.get("username")?.toString();
13 | const password = data.get("password")?.toString();
14 | const passwordVerify = data.get("password_verify")?.toString();
15 |
16 | if (!username || !password || !passwordVerify) {
17 | throw new Error("Missing username or password");
18 | }
19 |
20 | const form = {
21 | username,
22 | password,
23 | password_verify: passwordVerify,
24 | show_nsfw: getFormBoolean(data, "show_nsfw"),
25 | email: data.get("email")?.toString(),
26 | captcha_uuid: captchaUuid,
27 | captcha_answer: data.get("captcha_answer")?.toString(),
28 | honeypot: data.get("honey")?.toString(),
29 | answer: data.get("answer")?.toString(),
30 | };
31 |
32 | const response = await apiClient.register(form);
33 |
34 | if (response.jwt) {
35 | await setAuthCookie(response.jwt);
36 | redirect("/");
37 | } else {
38 | redirect("/signup/success");
39 | }
40 | };
41 |
--------------------------------------------------------------------------------
/src/app/signup/success/page.tsx:
--------------------------------------------------------------------------------
1 | import { apiClient } from "@/app/apiClient";
2 | import { StyledLink } from "@/app/(ui)/StyledLink";
3 |
4 | const SignupSuccessPage = async () => {
5 | const { site_view: siteView } = await apiClient.getSite();
6 |
7 | const requireEmailVerification =
8 | siteView.local_site.require_email_verification;
9 | const requireApproval =
10 | siteView.local_site.registration_mode === "RequireApplication";
11 |
12 | return (
13 |
14 |
{"Sign up successful!"}
15 |
16 | {requireEmailVerification && (
17 |
18 | {
19 | "This instance requires you to verify your e-mail. Please check your inbox for a verification link. If you don't see the verification e-mail, make sure to also check your spam folder."
20 | }
21 |
22 | )}
23 | {requireApproval && (
24 |
25 | {
26 | "Before you can log in, an instance admin must approve your registration. You can check out the"
27 | }
28 | {" front page "}
29 | {"while you wait!"}
30 |
31 | )}
32 |
33 |
34 | );
35 | };
36 |
37 | export default SignupSuccessPage;
38 |
--------------------------------------------------------------------------------
/src/app/u/LoggedInUserIcons.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { StyledLink } from "@/app/(ui)/StyledLink";
4 | import {
5 | BookmarkIcon,
6 | EnvelopeIcon,
7 | FlagIcon,
8 | } from "@heroicons/react/16/solid";
9 | import { ClipboardIcon } from "@heroicons/react/20/solid";
10 | import { MyUserInfo } from "lemmy-js-client";
11 | import classNames from "classnames";
12 | import { getUnreadCounts, UnreadCounts } from "@/app/settings/userActions";
13 | import { useInterval } from "usehooks-ts";
14 | import { useState } from "react";
15 |
16 | export const LoggedInUserIcons = (props: {
17 | readonly loggedInUser: MyUserInfo;
18 | readonly applicationsRequired: boolean;
19 | readonly initialCounts?: UnreadCounts;
20 | }) => {
21 | const isAdmin = props.loggedInUser.local_user_view.local_user.admin;
22 | const isMod = props.loggedInUser.moderates.length > 0;
23 | const isModOrAdmin = isAdmin || isMod;
24 |
25 | const [counts, setCounts] = useState(
26 | props.initialCounts,
27 | );
28 |
29 | useInterval(
30 | async () => {
31 | if (!document.hidden) {
32 | setCounts(
33 | await getUnreadCounts(isAdmin, isMod, props.applicationsRequired),
34 | );
35 | }
36 | },
37 | // Delay in milliseconds or null to stop it
38 | 60 * 1000,
39 | );
40 |
41 | const inboxCount =
42 | (counts?.inbox.mentions ?? 0) +
43 | (counts?.inbox.private_messages ?? 0) +
44 | (counts?.inbox.replies ?? 0);
45 |
46 | const reportCount =
47 | (counts?.reports?.comment_reports ?? 0) +
48 | (counts?.reports?.post_reports ?? 0) +
49 | (counts?.reports?.private_message_reports ?? 0);
50 |
51 | const applicationCount = counts?.applications?.registration_applications ?? 0;
52 |
53 | return (
54 | <>
55 |
56 |
57 |
58 |
59 | 0 })}
61 | title={"Inbox"}
62 | />
63 |
64 | {isModOrAdmin && (
65 |
66 | 0,
69 | })}
70 | title={"Reports"}
71 | />
72 |
73 | )}
74 | {isAdmin && props.applicationsRequired && (
75 |
79 | 0,
82 | })}
83 | title={"Applications"}
84 | />
85 |
86 | )}
87 | >
88 | );
89 | };
90 |
--------------------------------------------------------------------------------
/src/app/u/UserLink.tsx:
--------------------------------------------------------------------------------
1 | import { Person } from "lemmy-js-client";
2 | import { formatPersonUsername } from "@/app/u/formatPersonUsername";
3 | import { StyledLink } from "@/app/(ui)/StyledLink";
4 | import { Avatar } from "@/app/(ui)/Avatar";
5 | import { UsernameBadge } from "@/app/u/UsernameBadge";
6 | import { AgeIcon } from "@/app/(ui)/AgeIcon";
7 |
8 | type Props = {
9 | readonly person: Person;
10 | readonly showAdminBadge?: boolean;
11 | readonly showModBadge?: boolean;
12 | readonly showOpBadge?: boolean;
13 | };
14 |
15 | export const UserLink = (props: Props) => {
16 | const creatorFormattedName = formatPersonUsername(props.person);
17 | const creatorUsername = `${props.person.name}@${new URL(props.person.actor_id).host}`;
18 |
19 | return (
20 |
24 |
25 |
26 |
27 | {creatorFormattedName}
28 |
29 | {props.showOpBadge && (
30 |
35 | )}
36 | {props.showAdminBadge && (
37 |
42 | )}
43 | {!props.showAdminBadge && props.showModBadge && (
44 |
49 | )}
50 | {props.person.bot_account && (
51 |
56 | )}
57 | {lemmyMaintainers.includes(props.person.actor_id) && (
58 |
63 | )}
64 | {lemmyUiNextMaintainers.includes(props.person.actor_id) && (
65 |
70 | )}
71 |
72 | );
73 | };
74 |
75 | const lemmyMaintainers = [
76 | "https://lemmy.ml/u/dessalines",
77 | "https://lemmy.ml/u/nutomic",
78 | "https://lemmy.world/u/phiresky",
79 | "https://lemmy.ca/u/dullbananas",
80 | "https://lemmy.ml/u/SleeplessOne1917",
81 | ];
82 |
83 | const lemmyUiNextMaintainers = ["https://lemm.ee/u/sunaurus"];
84 |
--------------------------------------------------------------------------------
/src/app/u/UsernameBadge.tsx:
--------------------------------------------------------------------------------
1 | import classNames from "classnames";
2 |
3 | export const UsernameBadge = (props: {
4 | readonly title: string;
5 | readonly content: string;
6 | readonly className: string;
7 | }) => {
8 | return (
9 |
16 | {props.content}
17 |
18 | );
19 | };
20 |
--------------------------------------------------------------------------------
/src/app/u/formatPersonUsername.ts:
--------------------------------------------------------------------------------
1 | import { Person } from "lemmy-js-client";
2 |
3 | export const formatPersonUsername = (
4 | person: Person,
5 | ignoreDisplayName?: boolean,
6 | ) => {
7 | const name = ignoreDisplayName
8 | ? person.name
9 | : person.display_name ?? person.name;
10 | return `${name}@${new URL(person.actor_id).host}`;
11 | };
12 |
--------------------------------------------------------------------------------
/src/app/verify_email/[token]/page.tsx:
--------------------------------------------------------------------------------
1 | import { apiClient } from "@/app/apiClient";
2 | import { StyledLink } from "@/app/(ui)/StyledLink";
3 |
4 | const VerifyEmailPage = async (props: {
5 | readonly params: { token: string };
6 | }) => {
7 | await apiClient.verifyEmail({ token: props.params.token });
8 |
9 | return (
10 |
11 |
{"Success!"}
12 |
13 |
{"Your e-mail has been verified."}
14 |
{"Return to the front page"}
15 |
16 |
17 | );
18 | };
19 |
20 | export default VerifyEmailPage;
21 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from "next/server";
2 |
3 | export const middleware = (request: NextRequest) => {
4 | const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
5 | const themeHashes = `'sha256-5GuO1HCWHtPxtj8dpBoowHu54oZi+vrJ6qXGSvtwpF4=' 'sha256-+QHRBwWSE/8aH2Jphgji9c73U7aC8Sl3AH2joxD/8m0=' 'sha256-c4sUhkzvod4lhumbNImDD89O/ku8BnXcQ/gOYMBpEtk='`;
6 | // TODO: Remove these once there is a reliable workaround for next/image using inline styles (see @/app/(ui)/Image.tsx)
7 | const nextImageStyleHashes = `'sha256-zlqnbDt84zf1iSefLU/ImC54isoprH/MRiVZGskwexk=' 'sha256-ZDrxqUOB4m/L0JWL/+gS52g1CRH0l/qwMhjTw5Z/Fsc=' 'sha256-hviaKDxYhtiZD+bQBHVEytAzlGZ4afiWmKzkG+0beN8=' 'sha256-n8J8gRN8i9TcT9YXl0FTO+YR7t53ht5jak0CbXJTtOY=' 'sha256-vKQ+CrZTjQhoA/wfOzKCljUqGdbT8Nrhx3SZw3JJKqw='`;
8 | const cspHeader = `
9 | default-src 'self';
10 | ${process.env.NODE_ENV !== "development" ? `script-src 'self' 'nonce-${nonce}' 'strict-dynamic';` : "script-src 'self' 'unsafe-inline' 'unsafe-eval';"}
11 | style-src 'self' 'nonce-${nonce}' 'unsafe-hashes' ${themeHashes} ${nextImageStyleHashes};
12 | font-src 'self';
13 | object-src 'none';
14 | base-uri 'self';
15 | form-action 'self';
16 | frame-ancestors 'none';
17 | frame-src *;
18 | img-src * blob: data:;
19 | media-src *;
20 | ${process.env.NODE_ENV !== "development" ? "block-all-mixed-content; upgrade-insecure-requests;" : ""}
21 | `;
22 | // Replace newline characters and spaces
23 | const contentSecurityPolicyHeaderValue = cspHeader
24 | .replace(/\s{2,}/g, " ")
25 | .trim();
26 |
27 | const requestHeaders = new Headers(request.headers);
28 | requestHeaders.set("x-nonce", nonce);
29 |
30 | requestHeaders.set(
31 | "Content-Security-Policy",
32 | contentSecurityPolicyHeaderValue,
33 | );
34 |
35 | const response = NextResponse.next({
36 | request: {
37 | headers: requestHeaders,
38 | },
39 | });
40 | response.headers.set(
41 | "Content-Security-Policy",
42 | contentSecurityPolicyHeaderValue,
43 | );
44 |
45 | return response;
46 | };
47 |
48 | export const config = {
49 | matcher: [
50 | /*
51 | * Match all request paths except for the ones starting with:
52 | * - api (API routes)
53 | * - _next/static (static files)
54 | * - _next/image (image optimization files)
55 | * - favicon.ico (favicon file)
56 | */
57 | {
58 | source: "/((?!next/api|_next/static|_next/image|favicon.ico).*)",
59 | missing: [
60 | { type: "header", key: "next-router-prefetch" },
61 | { type: "header", key: "purpose", value: "prefetch" },
62 | ],
63 | },
64 | ],
65 | };
66 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 | import TypographyPlugin from "@tailwindcss/typography";
3 | import FormsPlugin from "@tailwindcss/forms";
4 |
5 | const config: Config = {
6 | content: [
7 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
9 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
10 | ],
11 | theme: {
12 | extend: {
13 | colors: {
14 | "primary-50": "rgb(var(--color-primary-50) / )",
15 | "primary-100": "rgb(var(--color-primary-100) / )",
16 | "primary-200": "rgb(var(--color-primary-200) / )",
17 | "primary-300": "rgb(var(--color-primary-300) / )",
18 | "primary-400": "rgb(var(--color-primary-400) / )",
19 | "primary-500": "rgb(var(--color-primary-500) / )",
20 | "primary-600": "rgb(var(--color-primary-600) / )",
21 | "primary-700": "rgb(var(--color-primary-700) / )",
22 | "primary-800": "rgb(var(--color-primary-800) / )",
23 | "primary-900": "rgb(var(--color-primary-900) / )",
24 | "primary-950": "rgb(var(--color-primary-950) / )",
25 | },
26 | },
27 | },
28 | plugins: [TypographyPlugin, FormsPlugin],
29 | future: {
30 | hoverOnlyWhenSupported: true,
31 | },
32 | };
33 | export default config;
34 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./src/*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------