├── .babelrc
├── .gitignore
├── LICENSE
├── README.md
├── cypress.json
├── cypress
├── .eslintrc
├── integration
│ └── home.spec.js
├── plugins
│ └── index.js
└── support
│ ├── commands.js
│ └── index.js
├── jsconfig.json
├── next.config.js
├── package.json
├── public
├── all-forums-logo.svg
├── dcard-icon.png
├── forum-hero-placeholder.svg
├── forum-logo-placeholder.svg
├── github-icon.svg
├── landing.png
├── link.svg
├── logo.svg
├── popular-forums-logo.svg
├── reactions
│ └── heart-gray.png
├── reset.svg
├── robots.txt
└── search.svg
├── sandbox.config.json
├── src
├── apis
│ ├── api.js
│ ├── forums.js
│ ├── index.js
│ ├── personas.js
│ ├── posts.js
│ ├── search.js
│ └── topics.js
├── components
│ ├── all-comments.js
│ ├── arrow-icon.js
│ ├── avatar.js
│ ├── check-icon.js
│ ├── close-icon.js
│ ├── comment-modal.js
│ ├── comment.js
│ ├── darsys.js
│ ├── floor.js
│ ├── forum-card.js
│ ├── forum-category.js
│ ├── forum-item.js
│ ├── forum-posts-frequency.js
│ ├── forum.js
│ ├── gender-icons.js
│ ├── head.js
│ ├── highlight.js
│ ├── layout.js
│ ├── like-icon.js
│ ├── link-attachment.js
│ ├── menu.js
│ ├── persona-item.js
│ ├── persona-posts-list.js
│ ├── popular-comments.js
│ ├── popular-forums.js
│ ├── post-arrow-link.js
│ ├── post-content.js
│ ├── post-info.js
│ ├── post-item.js
│ ├── post-label.js
│ ├── post-modal.js
│ ├── post-preview.js
│ ├── post.js
│ ├── posts-list.js
│ ├── reaction.js
│ ├── reactions-list.js
│ ├── reactions-modal.js
│ ├── readable-date-time.js
│ ├── route-dialog.js
│ ├── rule.js
│ ├── search-bar.js
│ ├── search-forums-list.js
│ ├── search-personas-list.js
│ ├── search-posts-filter.js
│ ├── search-posts-list.js
│ ├── search-topics-list.js
│ ├── search.js
│ ├── sort-icons.js
│ ├── tab-list.js
│ ├── topic-item.js
│ ├── topic-posts-list.js
│ ├── topic-tag.js
│ ├── user-info.js
│ ├── video-player.js
│ ├── with-layout.js
│ └── zoomable-image.js
├── hooks
│ ├── use-animate-height.js
│ ├── use-current.js
│ ├── use-forums-query.js
│ ├── use-infinite.js
│ ├── use-modal-parent-location.js
│ ├── use-params.js
│ ├── use-posts-list.js
│ └── use-previous.js
├── pages
│ ├── @
│ │ └── [persona].js
│ ├── _app.js
│ ├── _document.js
│ ├── api
│ │ ├── forums
│ │ │ ├── bulletin.js
│ │ │ ├── categorization
│ │ │ │ ├── category.js
│ │ │ │ └── index.js
│ │ │ ├── index.js
│ │ │ ├── popular-forums.js
│ │ │ └── selected-forums.js
│ │ ├── personas
│ │ │ ├── [persona].js
│ │ │ └── posts.js
│ │ ├── posts
│ │ │ ├── [postID]
│ │ │ │ ├── comment.js
│ │ │ │ ├── comments.js
│ │ │ │ ├── index.js
│ │ │ │ └── preview.js
│ │ │ ├── darsys.js
│ │ │ ├── index.js
│ │ │ ├── link-attachment.js
│ │ │ └── reactions.js
│ │ ├── search
│ │ │ ├── forums.js
│ │ │ ├── personas.js
│ │ │ ├── posts.js
│ │ │ └── topics.js
│ │ └── topics
│ │ │ ├── index.js
│ │ │ └── posts.js
│ ├── f
│ │ ├── [forumAlias]
│ │ │ ├── index.js
│ │ │ ├── p
│ │ │ │ └── [postID]
│ │ │ │ │ ├── b
│ │ │ │ │ └── [floor].js
│ │ │ │ │ └── index.js
│ │ │ └── rule.js
│ │ └── index.js
│ ├── forum
│ │ ├── all.js
│ │ └── popular.js
│ ├── search
│ │ ├── forums.js
│ │ ├── index.js
│ │ ├── personas.js
│ │ ├── posts.js
│ │ └── topics.js
│ └── topics
│ │ └── [topic].js
└── utils
│ ├── custom-scrollbar.js
│ ├── dedupe.js
│ ├── filter-query.js
│ ├── get-scrollbar-width.js
│ └── query-fn.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["next/babel"],
3 | "plugins": [["styled-components", { "ssr": true }]]
4 | }
5 |
--------------------------------------------------------------------------------
/.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 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env.local
29 | .env.development.local
30 | .env.test.local
31 | .env.production.local
32 |
33 | # vercel
34 | .vercel
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Kai Hao
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Dcard Clone
2 |
3 | Unofficial Dcard clone web application.
4 |
5 | > Disclaimer: All of the contents in the app belong to [Dcard.tw](https://dcard.tw). The code is for practice only.
6 |
7 | **Please see the [blog post](https://kaihao.dev/posts/Build-a-Dcard-clone) for more info.**
8 |
9 | [](https://codesandbox.io/s/github/kevin940726/dcard-clone/tree/main/?fontsize=14&hidenavigation=1&theme=dark)
10 |
11 | ## Getting started locally
12 |
13 | ```sh
14 | git clone git@github.com:kevin940726/dcard-clone.git
15 | cd dcard-clone
16 | yarn # Install dependencies
17 | yarn dev # Start the development server
18 | yarn build # Build the production version
19 | yarn start # Start the production server
20 | ```
21 |
--------------------------------------------------------------------------------
/cypress.json:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/cypress/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "plugins": ["eslint-plugin-cypress"],
4 | "extends": ["react-app", "plugin:cypress/recommended"],
5 | "env": {"cypress/globals": true}
6 | }
7 |
--------------------------------------------------------------------------------
/cypress/integration/home.spec.js:
--------------------------------------------------------------------------------
1 | describe('Home', () => {
2 | it('should have the default layout', () => {
3 | cy.visit('/');
4 |
5 | // Should redirect to /f
6 | cy.location('pathname').should('eq', '/f');
7 |
8 | // Should have the Dcard logo
9 | cy.findByRole('img', { name: 'Dcard' })
10 | .should('exist')
11 | .closest('a')
12 | .should('have.attr', 'href', '/f');
13 |
14 | // Should have the "所有看板" and "即時熱門看板" links
15 | cy.findByRole('link', { name: '所有看板' })
16 | .should('exist')
17 | .should('have.attr', 'href', '/forum/all');
18 | cy.findByRole('link', { name: '即時熱門看板' })
19 | .should('exist')
20 | .should('have.attr', 'href', '/forum/popular');
21 |
22 | // Should have the side bars with popular and selected forums
23 | function testForumItem($el) {
24 | const title = $el.attr('title');
25 | const text = $el.text();
26 | const href = $el.attr('href');
27 | expect(text).to.equal(title);
28 | expect(href).to.match(/^\/f\/[\w-_]+/);
29 | }
30 |
31 | cy.findByRole('region', { name: '即時熱門看板' })
32 | .should('exist')
33 | .within(() => {
34 | cy.findAllByRole('listitem')
35 | .should('have.length', 9)
36 | .findAllByRole('link')
37 | .each(($el, index) => {
38 | if (index < 8) {
39 | testForumItem($el);
40 | } else {
41 | expect($el.text()).to.equal('更多');
42 | }
43 | });
44 | });
45 |
46 | cy.findByRole('region', { name: 'Dcard 精選看板' })
47 | .should('exist')
48 | .within(() => {
49 | cy.findAllByRole('listitem')
50 | .should('have.length', 14)
51 | .findAllByRole('link')
52 | .each(testForumItem);
53 | });
54 | });
55 |
56 | it('should load the posts feed', () => {
57 | cy.visit('/');
58 |
59 | // There's a "熱門" tab
60 | cy.findByRole('link', { name: '熱門' })
61 | .should('exist')
62 | .should('have.attr', 'href', '/f');
63 |
64 | // There's a "最新" tab
65 | cy.findByRole('link', { name: '最新' })
66 | .should('exist')
67 | .should('have.attr', 'href', '/f?latest=true');
68 |
69 | // There's a popular posts feed
70 | cy.findByRole('feed')
71 | .should('have.attr', 'aria-busy', 'false')
72 | .within(() => {
73 | cy.findAllByRole('article').each(($el, index) => {
74 | expect($el.attr('aria-posinset')).to.equal(String(index + 1));
75 | expect($el.attr('aria-setsize')).to.equal('30');
76 | const labelledBy = $el.attr('aria-labelledby');
77 | cy.get(`#${labelledBy}`).should('exist').should('not.be.empty');
78 |
79 | if (index === 0) {
80 | cy.get(`#${labelledBy}`).invoke('text').as('firstArticleTitle');
81 | }
82 | });
83 | });
84 |
85 | // Switch from "熱門" to "最新"
86 | cy.findByRole('link', { name: '最新' }).realClick();
87 |
88 | // There should be a latest posts feed
89 | cy.findByRole('feed')
90 | .should('have.attr', 'aria-busy', 'false')
91 | .within(() => {
92 | cy.findAllByRole('article').each(($el, index) => {
93 | expect($el.attr('aria-posinset')).to.equal(String(index + 1));
94 | expect($el.attr('aria-setsize')).to.equal('30');
95 | const labelledBy = $el.attr('aria-labelledby');
96 | cy.get(`#${labelledBy}`).should('exist').should('not.be.empty');
97 |
98 | if (index === 0) {
99 | cy.get(`#${labelledBy}`)
100 | .invoke('text')
101 | .should('not.eq', this.firstArticleTitle);
102 | }
103 | });
104 | });
105 | });
106 | });
107 |
--------------------------------------------------------------------------------
/cypress/plugins/index.js:
--------------------------------------------------------------------------------
1 | ///
2 | // ***********************************************************
3 | // This example plugins/index.js can be used to load plugins
4 | //
5 | // You can change the location of this file or turn off loading
6 | // the plugins file with the 'pluginsFile' configuration option.
7 | //
8 | // You can read more here:
9 | // https://on.cypress.io/plugins-guide
10 | // ***********************************************************
11 |
12 | // This function is called when a project is opened or re-opened (e.g. due to
13 | // the project's config changing)
14 |
15 | /**
16 | * @type {Cypress.PluginConfig}
17 | */
18 | module.exports = (on, config) => {
19 | // `on` is used to hook into various events Cypress emits
20 | // `config` is the resolved Cypress config
21 | return {
22 | ...config,
23 | baseUrl: 'http://localhost:3000',
24 | };
25 | };
26 |
--------------------------------------------------------------------------------
/cypress/support/commands.js:
--------------------------------------------------------------------------------
1 | // ***********************************************
2 | // This example commands.js shows you how to
3 | // create various custom commands and overwrite
4 | // existing commands.
5 | //
6 | // For more comprehensive examples of custom
7 | // commands please read more here:
8 | // https://on.cypress.io/custom-commands
9 | // ***********************************************
10 | //
11 | //
12 | // -- This is a parent command --
13 | // Cypress.Commands.add("login", (email, password) => { ... })
14 | //
15 | //
16 | // -- This is a child command --
17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
18 | //
19 | //
20 | // -- This is a dual command --
21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
22 | //
23 | //
24 | // -- This will overwrite an existing command --
25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
26 | import '@testing-library/cypress/add-commands';
27 |
--------------------------------------------------------------------------------
/cypress/support/index.js:
--------------------------------------------------------------------------------
1 | // ***********************************************************
2 | // This example support/index.js is processed and
3 | // loaded automatically before your test files.
4 | //
5 | // This is a great place to put global configuration and
6 | // behavior that modifies Cypress.
7 | //
8 | // You can change the location of this file or turn off
9 | // automatically serving support files with the
10 | // 'supportFile' configuration option.
11 | //
12 | // You can read more here:
13 | // https://on.cypress.io/configuration
14 | // ***********************************************************
15 |
16 | // Import commands.js using ES2015 syntax:
17 | import './commands';
18 | import 'cypress-real-events/support';
19 |
20 | // Alternatively you can use CommonJS syntax:
21 | // require('./commands')
22 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "@/*": ["src/*"]
6 | }
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | async redirects() {
3 | return [
4 | {
5 | source: '/',
6 | destination: '/f',
7 | permanent: true,
8 | },
9 | {
10 | source: '/@/:persona',
11 | destination: '/@:persona',
12 | permanent: true,
13 | },
14 | ];
15 | },
16 | async rewrites() {
17 | return [
18 | {
19 | source: '/@:persona',
20 | destination: '/@/:persona',
21 | },
22 | ];
23 | },
24 | images: {
25 | domains: [
26 | 'megapx-assets.dcard.tw',
27 | 'i.imgur.com',
28 | 'imgur.com',
29 | 'vivid.dcard.tw',
30 | 'www.dcard.tw',
31 | 'megapx.dcard.tw',
32 | 'imgur.dcard.tw',
33 | 'img.youtube.com',
34 | 'pbs.twimg.com',
35 | ],
36 | },
37 | };
38 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dcard-clone",
3 | "version": "1.0.0",
4 | "scripts": {
5 | "dev": "next",
6 | "build": "next build",
7 | "start": "next start",
8 | "lint": "eslint .",
9 | "post-update": "yarn upgrade --latest"
10 | },
11 | "dependencies": {
12 | "modern-normalize": "^1.0.0",
13 | "next": "^10.0.3",
14 | "next-dynamic-loading-props": "^0.1.1",
15 | "react": "^17.0.1",
16 | "react-dom": "^17.0.1",
17 | "react-query": "^3.3.3",
18 | "reakit": "^1.3.2",
19 | "styled-components": "^5.2.1"
20 | },
21 | "license": "MIT",
22 | "keywords": [],
23 | "description": "",
24 | "devDependencies": {
25 | "@testing-library/cypress": "^7.0.3",
26 | "@typescript-eslint/eslint-plugin": "^4.0.0",
27 | "@typescript-eslint/parser": "^4.0.0",
28 | "babel-eslint": "^10.0.0",
29 | "babel-plugin-styled-components": "^1.12.0",
30 | "cypress": "^6.2.0",
31 | "cypress-real-events": "^1.1.0",
32 | "eslint": "^7.5.0",
33 | "eslint-config-react-app": "^6.0.0",
34 | "eslint-plugin-cypress": "^2.11.2",
35 | "eslint-plugin-flowtype": "^5.2.0",
36 | "eslint-plugin-import": "^2.22.0",
37 | "eslint-plugin-jsx-a11y": "^6.3.1",
38 | "eslint-plugin-react": "^7.20.3",
39 | "eslint-plugin-react-hooks": "^4.0.8",
40 | "prettier": "2.2.1"
41 | },
42 | "eslintConfig": {
43 | "extends": "react-app",
44 | "rules": {
45 | "jsx-a11y/anchor-is-valid": "off",
46 | "no-throw-literal": "off"
47 | }
48 | },
49 | "prettier": {
50 | "singleQuote": true
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/public/all-forums-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/dcard-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevin940726/dcard-clone/622e1218217d0d41ff930c44b2a0e17826703722/public/dcard-icon.png
--------------------------------------------------------------------------------
/public/forum-hero-placeholder.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/forum-logo-placeholder.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/github-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/landing.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevin940726/dcard-clone/622e1218217d0d41ff930c44b2a0e17826703722/public/landing.png
--------------------------------------------------------------------------------
/public/link.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/popular-forums-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/reactions/heart-gray.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevin940726/dcard-clone/622e1218217d0d41ff930c44b2a0e17826703722/public/reactions/heart-gray.png
--------------------------------------------------------------------------------
/public/reset.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Allow: /
3 |
--------------------------------------------------------------------------------
/public/search.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/sandbox.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "infiniteLoopProtection": true,
3 | "hardReloadOnChange": false,
4 | "view": "browser",
5 | "container": {
6 | "node": "14"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/apis/api.js:
--------------------------------------------------------------------------------
1 | import querystring from 'querystring';
2 | import { filterQuery } from '../utils/filter-query';
3 |
4 | export const HOST = 'https://www.dcard.tw';
5 | const API_ENDPOINT = 'https://www.dcard.tw/service/api/v2';
6 |
7 | export async function api(path, { query, headers } = {}) {
8 | let isAbsoluteURL = false;
9 | try {
10 | new URL(path);
11 | isAbsoluteURL = true;
12 | } catch (err) {}
13 |
14 | const endpoint = isAbsoluteURL ? path : `${API_ENDPOINT}/${path}`;
15 | const filteredQuery = query ? filterQuery(query) : {};
16 |
17 | const url = `${encodeURI(endpoint)}${
18 | Object.keys(filteredQuery).length > 0
19 | ? `?${querystring.stringify(filteredQuery)}`
20 | : ''
21 | }`;
22 |
23 | try {
24 | const res = await fetch(url, { headers });
25 | const data = await res.json();
26 |
27 | if (data.error) {
28 | throw data;
29 | }
30 |
31 | return data;
32 | } catch (err) {
33 | console.error(err);
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/apis/forums.js:
--------------------------------------------------------------------------------
1 | import { api, HOST } from './api';
2 |
3 | export const getForums = async ({ nsfw = true } = {}) => {
4 | const forums = await api('forums', { query: { nsfw } });
5 |
6 | const map = {};
7 |
8 | for (const forum of forums) {
9 | map[forum.id] = forum;
10 | }
11 |
12 | return map;
13 | };
14 |
15 | export async function getPopularForums({ pageKey, limit } = {}) {
16 | if (!pageKey) {
17 | const { head } = await api('popularForums/GetHead', {
18 | query: { listKey: 'popularForums' },
19 | });
20 | pageKey = head;
21 | }
22 |
23 | const result = await api('popularForums/GetPage', { query: { pageKey } });
24 |
25 | if (limit) {
26 | return result.items.slice(0, limit);
27 | }
28 |
29 | return result;
30 | }
31 |
32 | export const getSelectedForums = ({
33 | country = 'TW',
34 | sensitiveSelection = true,
35 | } = {}) =>
36 | api(`selections/forums/${country}`, {
37 | query: { sensitiveSelection },
38 | });
39 |
40 | export const getCategorization = ({ country = 'TW', nsfw = true } = {}) =>
41 | api('categorization/countries', {
42 | query: { country, nsfw },
43 | });
44 |
45 | export const getCategory = (categoryID, { nsfw = true } = {}) =>
46 | api(`categorization/categories/${categoryID}`, {
47 | query: { nsfw },
48 | });
49 |
50 | export const getBulletin = (forumID) =>
51 | api(`${HOST}/service/moderator/api/forum/${forumID}/bulletin`);
52 |
--------------------------------------------------------------------------------
/src/apis/index.js:
--------------------------------------------------------------------------------
1 | export * from './forums';
2 | export * from './posts';
3 | export * from './search';
4 | export * from './topics';
5 | export * from './personas';
6 |
--------------------------------------------------------------------------------
/src/apis/personas.js:
--------------------------------------------------------------------------------
1 | import { api } from './api';
2 |
3 | export const getPersonaInfo = (persona) => api(`personas/${persona}`);
4 |
5 | export const getPersonaPosts = (persona, { limit = 30, before } = {}) =>
6 | api(`personas/${persona}/posts`, {
7 | query: { limit, before },
8 | });
9 |
--------------------------------------------------------------------------------
/src/apis/posts.js:
--------------------------------------------------------------------------------
1 | import { api, HOST } from './api';
2 | import { filterQuery } from '../utils/filter-query';
3 |
4 | export const getPosts = ({ popular = true, limit = 30, before } = {}) =>
5 | api('posts', { query: { popular, limit, before } });
6 |
7 | export const getForumPosts = (
8 | forum,
9 | { popular = true, limit = 30, before } = {}
10 | ) =>
11 | api(`forums/${forum}/posts`, {
12 | query: { popular, limit, before },
13 | });
14 |
15 | export const getPost = (postID) => api(`posts/${postID}`);
16 |
17 | export const getInfinityIndex = ({ forum } = {}) =>
18 | api(`posts/infinityIndex`, {
19 | query: { forum },
20 | });
21 |
22 | export const getInfinitePosts = async ({
23 | forum,
24 | popular = true,
25 | limit = 30,
26 | before,
27 | }) => {
28 | const getPostsData = forum ? getForumPosts.bind(null, forum) : getPosts;
29 |
30 | let posts = await getPostsData({ popular, limit, before });
31 |
32 | if (!popular && !posts.length) {
33 | return {
34 | items: posts,
35 | nextQuery: null,
36 | };
37 | }
38 |
39 | let nextQuery = filterQuery({
40 | forum,
41 | popular,
42 | limit,
43 | before: posts[posts.length - 1]?.id,
44 | });
45 |
46 | if (posts.length < limit) {
47 | if (popular) {
48 | const { id: infinityIndex } = await getInfinityIndex({ forum });
49 |
50 | const latestPosts = await getPostsData({
51 | popular: false,
52 | limit: limit - posts.length,
53 | before: infinityIndex,
54 | });
55 |
56 | posts = posts.concat(latestPosts);
57 |
58 | if (posts.length < limit) {
59 | nextQuery = null;
60 | } else {
61 | nextQuery.popular = false;
62 | nextQuery.before = posts[posts.length - 1].id;
63 | }
64 | } else {
65 | nextQuery = null;
66 | }
67 | }
68 |
69 | return {
70 | items: posts,
71 | nextQuery,
72 | };
73 | };
74 |
75 | export const getComments = (postID, { popular, after, limit } = {}) =>
76 | api(`posts/${postID}/comments`, { query: { popular, after, limit } });
77 |
78 | export const getComment = (postID, floor) =>
79 | api(`posts/${postID}/comments`, {
80 | query: { limit: 1, after: floor - 1 },
81 | }).then(([comment]) => comment);
82 |
83 | export const getLinkAttachment = (url) =>
84 | api(`${HOST}/v2/linkAttachment`, {
85 | query: { url },
86 | headers: {
87 | 'request-through-cf': true,
88 | },
89 | });
90 |
91 | export const getDarsys = (postID) => api(`darsys/${postID}`);
92 |
93 | export const getPostPreview = (postID) =>
94 | api('posts', { query: { id: postID } });
95 |
96 | export const getReactions = () => api('reactions');
97 |
--------------------------------------------------------------------------------
/src/apis/search.js:
--------------------------------------------------------------------------------
1 | import { api } from './api';
2 |
3 | export const getSearchPosts = (
4 | query,
5 | { limit = 30, highlight = true, offset, field, sort, since, forum } = {}
6 | ) => {
7 | if (since) {
8 | const now = new Date();
9 | const elapsed = now.setDate(now.getDate() - 1);
10 | since = Math.floor(elapsed / 1000);
11 | }
12 |
13 | return api('search/posts', {
14 | query: { query, limit, highlight, offset, field, sort, since, forum },
15 | headers: {
16 | // Required for images to show up?
17 | 'request-through-cf': true,
18 | },
19 | });
20 | };
21 |
22 | export const getSearchForums = (query, { limit = 30, offset } = {}) =>
23 | api('search/forums', {
24 | query: { query, limit, offset },
25 | headers: {
26 | // Required for images to show up?
27 | 'request-through-cf': true,
28 | },
29 | });
30 |
31 | export const getSearchTopics = (query, { limit = 30, offset } = {}) =>
32 | api('search/topics', {
33 | query: { query, limit, offset },
34 | headers: {
35 | // Required for images to show up?
36 | 'request-through-cf': true,
37 | },
38 | });
39 |
40 | export const getSearchPersonas = (query, { limit = 30, offset } = {}) =>
41 | api('search/personas', {
42 | query: { query, limit, offset },
43 | headers: {
44 | // Required for images to show up?
45 | 'request-through-cf': true,
46 | },
47 | });
48 |
--------------------------------------------------------------------------------
/src/apis/topics.js:
--------------------------------------------------------------------------------
1 | import { api } from './api';
2 |
3 | export const getTopicInfo = (topic) => api(`tagging/topics/${topic}/stat`);
4 |
5 | export const getTopicPosts = (
6 | topic,
7 | { limit = 30, sort = 'like', offset } = {}
8 | ) =>
9 | api('search/posts', {
10 | query: { limit, query: topic, field: 'topics', sort, offset },
11 | headers: {
12 | // Required for images to show up?
13 | 'request-through-cf': true,
14 | },
15 | });
16 |
--------------------------------------------------------------------------------
/src/components/all-comments.js:
--------------------------------------------------------------------------------
1 | import { css } from 'styled-components';
2 | import { useInfiniteQuery } from 'react-query';
3 | import useInfinite from '../hooks/use-infinite';
4 | import Comment from './comment';
5 |
6 | const PAGE_SIZE = 30;
7 |
8 | export default function AllComments({ postID, Wrapper, modalRef }) {
9 | const {
10 | data,
11 | fetchNextPage,
12 | isFetching,
13 | isFetchingNextPage,
14 | hasNextPage,
15 | } = useInfiniteQuery(`posts/${postID}/comments`, {
16 | getNextPageParam: (lastGroup, pages) =>
17 | lastGroup.length < PAGE_SIZE
18 | ? null
19 | : {
20 | after: pages
21 | .map((group) => group.length)
22 | .reduce((sum, cur) => sum + cur, 0),
23 | },
24 | staleTime: 10 * 60 * 1000, // 10 mins
25 | });
26 |
27 | const isLoading = isFetching || isFetchingNextPage;
28 | const anchor = useInfinite(fetchNextPage, isLoading, {
29 | rootRef: modalRef,
30 | });
31 |
32 | if (!data || !data.pages.length) {
33 | // TODO: skeleton?
34 | return null;
35 | }
36 |
37 | const allComments = data.pages.flat();
38 |
39 | return (
40 |
41 |
42 | {allComments.map((comment, index) => (
43 |
56 |
57 |
58 | ))}
59 |
60 | {hasNextPage && anchor}
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/components/arrow-icon.js:
--------------------------------------------------------------------------------
1 | export default function Arrow(props) {
2 | return (
3 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/avatar.js:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 | import styled, { css } from 'styled-components';
3 | import { GirlIcon, BoyIcon, GenderDIcon } from './gender-icons';
4 |
5 | const GENDER_MAP = {
6 | M: BoyIcon,
7 | F: GirlIcon,
8 | D: GenderDIcon,
9 | };
10 |
11 | const StyledImage = styled(Image).attrs((props) => ({
12 | width: props.size,
13 | height: props.size,
14 | }))`
15 | border-radius: 50%;
16 | `;
17 |
18 | function AnonymousIcon(props) {
19 | return (
20 |
34 | );
35 | }
36 |
37 | export default function Avatar({
38 | gender,
39 | nickname,
40 | postAvatar,
41 | size,
42 | hidden,
43 | ...props
44 | }) {
45 | if (hidden) {
46 | return (
47 |
55 | );
56 | } else if (postAvatar) {
57 | return (
58 |
64 | );
65 | } else if (nickname && gender !== 'D') {
66 | return (
67 |
84 | {nickname[0].toUpperCase()}
85 |
86 | );
87 | }
88 |
89 | const SVG = GENDER_MAP[gender] ?? GENDER_MAP.D;
90 |
91 | return ;
92 | }
93 |
--------------------------------------------------------------------------------
/src/components/check-icon.js:
--------------------------------------------------------------------------------
1 | export default function CheckIcon(props) {
2 | return (
3 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/close-icon.js:
--------------------------------------------------------------------------------
1 | export default function CloseIcon(props) {
2 | return (
3 |
7 | );
8 | }
9 |
--------------------------------------------------------------------------------
/src/components/comment-modal.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 | import { css } from 'styled-components';
3 | import { useRouter } from 'next/router';
4 | import { Dialog, useDialogState } from './route-dialog';
5 | import Floor from './floor';
6 |
7 | export default function CommentModal() {
8 | const router = useRouter();
9 | const dialog = useDialogState({ animated: 150 });
10 | const { floor, stepsFromPost } = router.query;
11 | const isOpen = !!floor;
12 | const { show, hide } = dialog;
13 | const closeModalTimeoutRef = useRef();
14 |
15 | useEffect(() => {
16 | if (isOpen) {
17 | show();
18 | } else {
19 | hide();
20 | }
21 | }, [isOpen, show, hide]);
22 |
23 | function closeModal() {
24 | if (dialog.visible) {
25 | dialog.hide();
26 | closeModalTimeoutRef.current = setTimeout(() => {
27 | window.history.go(-parseInt(stepsFromPost ?? 1, 10));
28 | }, 150);
29 | }
30 | }
31 |
32 | useEffect(
33 | () => () => {
34 | clearTimeout(closeModalTimeoutRef.current);
35 | },
36 | []
37 | );
38 |
39 | return (
40 | (dialog.visible || dialog.animating) && (
41 |
71 | )
72 | );
73 | }
74 |
--------------------------------------------------------------------------------
/src/components/comment.js:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { css } from 'styled-components';
3 | import UserInfo from './user-info';
4 | import ReadableDateTime from './readable-date-time';
5 | import LikeIcon from './like-icon';
6 | import PostContent from './post-content';
7 |
8 | function Comment({
9 | id: commentID,
10 | gender,
11 | withNickname,
12 | school,
13 | department,
14 | postAvatar,
15 | floor,
16 | createdAt,
17 | mediaMeta,
18 | content,
19 | likeCount,
20 | hidden,
21 | hiddenByAuthor,
22 | children,
23 | ...props
24 | }) {
25 | return (
26 |
39 |
100 |
101 | {children}
102 |
103 |
114 |
115 | );
116 | }
117 |
118 | export default memo(Comment);
119 |
--------------------------------------------------------------------------------
/src/components/darsys.js:
--------------------------------------------------------------------------------
1 | import { useQuery } from 'react-query';
2 | import { css } from 'styled-components';
3 | import Image from 'next/image';
4 | import Link from 'next/link';
5 | import { useRouter } from 'next/router';
6 |
7 | function Item({
8 | postID,
9 | forumAlias,
10 | title,
11 | likeCount,
12 | commentCount,
13 | image,
14 | isInModal,
15 | }) {
16 | const router = useRouter();
17 |
18 | return (
19 |
47 |
63 |
71 |
81 |
92 | {title}
93 |
94 |
99 |
100 | {image && (
101 |
112 |
113 |
114 | )}
115 |
116 |
117 |
118 | );
119 | }
120 |
121 | export default function Darsys({ Wrapper, postID, isInModal }) {
122 | const { data } = useQuery(['posts/darsys', { postID }], {
123 | staleTime: Infinity,
124 | });
125 |
126 | if (!data) {
127 | return null;
128 | }
129 |
130 | const darsys = data.posts;
131 |
132 | return (
133 |
134 |
144 | {darsys.map((post) => (
145 | -
153 | media.type?.startsWith?.('image/')
154 | )}
155 | isInModal={isInModal}
156 | />
157 | ))}
158 |
159 |
160 | );
161 | }
162 |
--------------------------------------------------------------------------------
/src/components/floor.js:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import { useQuery, useQueryClient } from 'react-query';
3 | import { useRouter } from 'next/router';
4 | import Comment from './comment';
5 |
6 | function Floor({ children }) {
7 | const router = useRouter();
8 | const queryClient = useQueryClient();
9 | const { postID, floor } = router.query;
10 |
11 | const initialData = useMemo(() => {
12 | const popularComments =
13 | queryClient.getQueryData(
14 | [`posts/${postID}/comments`, { popular: true }],
15 | { exact: true }
16 | ) ?? [];
17 | const allComments =
18 | queryClient.getQueryData(`posts/${postID}/comments`, {
19 | exact: true,
20 | }) ?? [];
21 | return popularComments
22 | .concat(...(allComments?.pages ?? []))
23 | .find((comment) => comment.floor === parseInt(floor, 10));
24 | }, [queryClient, postID, floor]);
25 |
26 | const { data: comment } = useQuery([`posts/${postID}/comment`, { floor }], {
27 | initialData,
28 | staleTime: Infinity,
29 | });
30 |
31 | if (!comment) {
32 | return null;
33 | }
34 |
35 | return (
36 |
39 | );
40 | }
41 |
42 | Floor.prefetchQueries = async function prefetchQueries(queryClient, context) {
43 | const { postID, floor } = context.router.query;
44 |
45 | await queryClient.prefetchQuery([`posts/${postID}/comment`, { floor }]);
46 | };
47 |
48 | export default Floor;
49 |
--------------------------------------------------------------------------------
/src/components/forum-card.js:
--------------------------------------------------------------------------------
1 | import { css } from 'styled-components';
2 | import Image from 'next/image';
3 | import Link from 'next/link';
4 | import ForumPostsFrequency from './forum-posts-frequency';
5 |
6 | export default function ForumCard({ forum }) {
7 | if (!forum) {
8 | return null;
9 | }
10 |
11 | return (
12 |
13 |
26 |
27 |
33 |
34 |
44 |
50 |
51 |
62 | {forum.name}
63 |
64 |
76 |
77 |
78 |
79 |
80 | );
81 | }
82 |
--------------------------------------------------------------------------------
/src/components/forum-category.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { css } from 'styled-components';
3 | import { useQuery } from 'react-query';
4 | import { useDisclosureState, Disclosure, DisclosureContent } from 'reakit';
5 | import { useForumsQuery } from '../hooks/use-forums-query';
6 | import useAnimateHeight from '../hooks/use-animate-height';
7 | import ArrowIcon from './arrow-icon';
8 | import ForumItem from './forum-item';
9 |
10 | function CategoryItem({
11 | name,
12 | id,
13 | forums,
14 | expandedCategory,
15 | setExpandedCategory,
16 | }) {
17 | const isActive = id === expandedCategory;
18 | const disclosure = useDisclosureState({ visible: isActive, animated: true });
19 | const { visible, show, hide } = disclosure;
20 |
21 | useEffect(() => {
22 | if (isActive) {
23 | show();
24 | } else {
25 | hide();
26 | }
27 | }, [isActive, show, hide]);
28 |
29 | const { data: category } = useQuery(
30 | ['forums/categorization/category', { categoryID: id }],
31 | { enabled: isActive }
32 | );
33 |
34 | const [style, ref] = useAnimateHeight();
35 |
36 | return (
37 | <>
38 | {
50 | setExpandedCategory(isActive ? null : id);
51 | }}
52 | >
53 |
62 | {name}
63 |
64 |
74 |
75 |
85 | {!!category && (
86 |
93 | {category.forumIds.map((forumID) => (
94 |
95 | ))}
96 |
97 | )}
98 |
99 | >
100 | );
101 | }
102 |
103 | function ForumCategory() {
104 | const { data: forums } = useForumsQuery();
105 | const { data: categorization } = useQuery('forums/categorization', {
106 | staleTime: Infinity,
107 | });
108 |
109 | const [expandedCategory, setExpandedCategory] = useState(null);
110 |
111 | if (!categorization) {
112 | return null;
113 | }
114 |
115 | return (
116 |
121 |
131 | 看板分類
132 |
133 |
141 | {categorization.map((category) => (
142 | -
143 |
150 |
151 | ))}
152 |
153 |
154 | );
155 | }
156 |
157 | ForumCategory.prefetchQueries = async function prefetchQueries(queryClient) {
158 | await queryClient.prefetchQuery('forums/categorization');
159 | };
160 |
161 | export default ForumCategory;
162 |
--------------------------------------------------------------------------------
/src/components/forum-item.js:
--------------------------------------------------------------------------------
1 | import { css } from 'styled-components';
2 | import Image from 'next/image';
3 | import Link from 'next/link';
4 |
5 | export default function ForumItem({ forum }) {
6 | if (!forum) {
7 | return null;
8 | }
9 |
10 | return (
11 |
12 |
25 |
34 |
40 |
41 | {forum.name}
42 |
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/src/components/forum-posts-frequency.js:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 |
3 | export default function ForumPostsFrequency({ forum }) {
4 | const { last30Days } = forum.postCount;
5 | const countFrequency = useMemo(() => {
6 | const countPerWeek = last30Days / 4;
7 |
8 | if (countPerWeek >= 35) {
9 | return `每天有 ${Math.ceil(countPerWeek / 7)} 則貼文`;
10 | } else if (countPerWeek >= 5) {
11 | return `每週有 ${Math.ceil(countPerWeek)} 則貼文`;
12 | } else if (countPerWeek > 0) {
13 | return `每月有 ${last30Days} 則貼文`;
14 | } else {
15 | return `這裡是專屬於${forum.name}的版面。`;
16 | }
17 | }, [last30Days, forum.name]);
18 |
19 | return countFrequency;
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/gender-icons.js:
--------------------------------------------------------------------------------
1 | export function GirlIcon(props) {
2 | return (
3 |
17 | );
18 | }
19 |
20 | export function BoyIcon(props) {
21 | return (
22 |
36 | );
37 | }
38 |
39 | export function GenderDIcon(props) {
40 | return (
41 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/src/components/head.js:
--------------------------------------------------------------------------------
1 | import { Fragment } from 'react';
2 | import NextHead from 'next/head';
3 |
4 | const DEFAULT_DESCRIPTION =
5 | '廣受年輕人喜愛的 Dcard 是台灣最大的匿名交流平台,舉凡時事話題、感情心情、吃喝玩樂、學習工作等,都有卡友陪你聊!';
6 | const DEFAULT_IMAGES = ['/landing.png'];
7 |
8 | export default function Head({
9 | title,
10 | description = DEFAULT_DESCRIPTION,
11 | images = DEFAULT_IMAGES,
12 | children,
13 | }) {
14 | const normalizedTitle = title ? `${title} | Dcard clone` : 'Dcard clone';
15 |
16 | return (
17 |
18 |
19 | {normalizedTitle}
20 |
21 |
26 |
27 |
28 |
33 | {images.map((image) => (
34 |
35 |
36 |
41 |
42 |
43 | ))}
44 | {children}
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/highlight.js:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import styled from 'styled-components';
3 |
4 | const EM_REGEX = /(.+?<\/em>)/;
5 | const EM_CONTENT_REGEX = /(.+?)<\/em>/;
6 |
7 | const Em = styled.em`
8 | color: rgb(51, 151, 207);
9 | font-style: normal;
10 | `;
11 |
12 | function Highlight({ children: text }) {
13 | const splitted = text.split(EM_REGEX);
14 | const content = [];
15 |
16 | splitted.forEach((fragment, index) => {
17 | if (index % 2 === 1) {
18 | const [, highlighted] = fragment.match(EM_CONTENT_REGEX);
19 | content.push({highlighted});
20 | } else {
21 | content.push(fragment);
22 | }
23 | });
24 |
25 | return <>{content}>;
26 | }
27 |
28 | export default memo(Highlight);
29 |
--------------------------------------------------------------------------------
/src/components/like-icon.js:
--------------------------------------------------------------------------------
1 | export default function LikeIcon(props) {
2 | return (
3 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/link-attachment.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { useQuery } from 'react-query';
3 | import styled, { css } from 'styled-components';
4 | import Image from 'next/image';
5 |
6 | export const LinkWrapper = styled.a`
7 | display: flex;
8 | border: 1px solid rgba(0, 0, 0, 0.15);
9 | border-radius: 12px;
10 | background: rgb(255, 255, 255);
11 | height: 92px;
12 | overflow: hidden;
13 | color: rgba(0, 0, 0, 0.35);
14 | `;
15 |
16 | const Description = styled.p`
17 | font-size: 14px;
18 | color: rgba(0, 0, 0, 0.75);
19 | line-height: 20px;
20 | white-space: nowrap;
21 | text-overflow: ellipsis;
22 | overflow: hidden;
23 | margin: 0 16px 0 0;
24 | `;
25 |
26 | export function LinkPreview({
27 | title,
28 | description,
29 | hasBlockQuote,
30 | footer,
31 | image,
32 | defaultImage,
33 | ...props
34 | }) {
35 | const [shouldShowDescription, setShouldShowDescription] = useState(true);
36 |
37 | function titleRefCallback(node) {
38 | if (node && node.offsetHeight > 22) {
39 | setShouldShowDescription(false);
40 | }
41 | }
42 |
43 | return (
44 | <>
45 |
54 |
70 | {title}
71 |
72 | {shouldShowDescription &&
73 | (hasBlockQuote ? (
74 |
82 | {description}
83 |
84 | ) : (
85 | {description}
86 | ))}
87 |
102 |
103 |
104 |
115 | {image ? (
116 |
125 | ) : (
126 | defaultImage
127 | )}
128 |
129 | >
130 | );
131 | }
132 |
133 | export default function LinkAttachment({ src, ...props }) {
134 | const { data: linkAttachment, isLoading } = useQuery(
135 | ['posts/link-attachment', { url: src }],
136 | {
137 | staleTime: Infinity,
138 | }
139 | );
140 |
141 | let content = null;
142 | if (!isLoading) {
143 | const { description, domain, favicon, image, title } = linkAttachment ?? {
144 | title: '無法取得網頁資訊',
145 | domain: new URL(src).hostname,
146 | };
147 |
148 | content = (
149 |
154 | {favicon && (
155 |
{
165 | event.currentTarget.style.display = 'none';
166 | }}
167 | />
168 | )}
169 | {domain}
170 | >
171 | }
172 | image={image?.url}
173 | defaultImage={}
174 | />
175 | );
176 | }
177 |
178 | return (
179 |
185 | {content}
186 |
187 | );
188 | }
189 |
--------------------------------------------------------------------------------
/src/components/menu.js:
--------------------------------------------------------------------------------
1 | import { forwardRef } from 'react';
2 | import { css } from 'styled-components';
3 | import {
4 | useMenuState as useBaseMenuState,
5 | Menu as BaseMenu,
6 | MenuButton as BaseMenuButton,
7 | MenuItem as BaseMenuItem,
8 | MenuArrow,
9 | } from 'reakit';
10 | import CheckIcon from './check-icon';
11 |
12 | function MenuButton({ menu, children, ...props }) {
13 | return (
14 |
23 | {children}
24 |
25 | );
26 | }
27 |
28 | const MenuItem = forwardRef(({ menu, isActive, children, ...props }, ref) => {
29 | return (
30 |
62 | {children}
63 |
64 | {isActive && (
65 |
72 | )}
73 |
74 | );
75 | });
76 |
77 | function Menu({ menu, children, ...props }) {
78 | return (
79 |
93 |
103 |
112 |
113 |
120 | {children}
121 |
122 |
123 |
124 | );
125 | }
126 |
127 | function useMenuState(options = {}) {
128 | return useBaseMenuState({
129 | animated: 150,
130 | // orientation: 'vertical',
131 | ...options,
132 | });
133 | }
134 |
135 | export { useMenuState, MenuButton, MenuItem, Menu };
136 |
--------------------------------------------------------------------------------
/src/components/persona-item.js:
--------------------------------------------------------------------------------
1 | import { css } from 'styled-components';
2 | import Link from 'next/link';
3 | import Avatar from './avatar';
4 |
5 | export default function PersonaItem({ persona }) {
6 | if (!persona) {
7 | return null;
8 | }
9 |
10 | return (
11 |
12 |
20 |
33 |
39 |
40 |
41 |
47 |
56 | {persona.nickname}
57 |
58 |
59 |
67 | @{persona.uid}
68 | ・
69 | {persona.postCount} 篇文章
70 |
71 |
72 |
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/src/components/persona-posts-list.js:
--------------------------------------------------------------------------------
1 | import { useMemo, Fragment } from 'react';
2 | import { useInfiniteQuery } from 'react-query';
3 | import { useRouter } from 'next/router';
4 | import Link from 'next/link';
5 | import { css } from 'styled-components';
6 | import usePostsList from '../hooks/use-posts-list';
7 | import useModalParentLocation from '../hooks/use-modal-parent-location';
8 | import PostItem from './post-item';
9 |
10 | const PAGE_SIZE = 30;
11 |
12 | function PersonaPostsList() {
13 | const router = useRouter();
14 | const { postID } = router.query;
15 | const isModalOpen = !!postID;
16 | const modalParentLocation = useModalParentLocation(isModalOpen);
17 | const { persona } = modalParentLocation.query;
18 |
19 | const {
20 | data,
21 | fetchNextPage,
22 | hasNextPage,
23 | isFetching,
24 | isFetchingNextPage,
25 | } = useInfiniteQuery(['personas/posts', { persona }], {
26 | getNextPageParam: (lastGroup, pages) =>
27 | lastGroup.length < PAGE_SIZE
28 | ? null
29 | : {
30 | before: lastGroup[PAGE_SIZE - 1].id,
31 | },
32 | staleTime: Infinity,
33 | });
34 |
35 | const isLoading = isFetching || isFetchingNextPage;
36 |
37 | const posts = useMemo(() => (data ? data.pages.flat() : []), [data]);
38 |
39 | const [modal, activePostItemRef] = usePostsList(posts, {
40 | fetchNextPage,
41 | hasNextPage,
42 | isFetching,
43 | isFetchingNextPage,
44 | });
45 |
46 | const postsGroupedByMonth = useMemo(() => {
47 | const groups = {};
48 |
49 | posts.forEach((post) => {
50 | const postDate = new Date(post.createdAt);
51 | const month = `${postDate.getFullYear()}年${postDate.getMonth() + 1}月`;
52 | groups[month] = groups[month] ?? [];
53 | groups[month].push(post);
54 | });
55 |
56 | return Object.entries(groups);
57 | }, [posts]);
58 |
59 | return (
60 | <>
61 |
62 | {postsGroupedByMonth.map(([month, groupPosts]) => (
63 |
64 |
73 | {month}
74 |
75 | {groupPosts.map((post, index) => (
76 |
121 |
136 | {
139 | activePostItemRef.current = event.currentTarget;
140 | }}
141 | {...post}
142 | />
143 |
144 |
145 | ))}
146 |
147 | ))}
148 |
149 |
150 | {modal}
151 | >
152 | );
153 | }
154 |
155 | PersonaPostsList.prefetchQueries = async function prefetchQueries(
156 | queryClient,
157 | context
158 | ) {
159 | const { persona } = context.router.query;
160 |
161 | await queryClient.prefetchInfiniteQuery(['personas/posts', { persona }]);
162 | };
163 |
164 | export default PersonaPostsList;
165 |
--------------------------------------------------------------------------------
/src/components/popular-comments.js:
--------------------------------------------------------------------------------
1 | import { css } from 'styled-components';
2 | import { useQuery } from 'react-query';
3 | import Comment from './comment';
4 |
5 | export default function PopularComments({ postID, Wrapper }) {
6 | const { data: popularComments, isFetching } = useQuery(
7 | [`posts/${postID}/comments`, { popular: true }],
8 | {
9 | staleTime: 30 * 60 * 1000, // 30 mins
10 | }
11 | );
12 |
13 | if (!popularComments || !popularComments.length) {
14 | // TODO: skeleton?
15 | return null;
16 | }
17 |
18 | return (
19 |
20 |
21 | {popularComments.map((comment, index) => (
22 |
35 |
36 |
37 | ))}
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/popular-forums.js:
--------------------------------------------------------------------------------
1 | import { css } from 'styled-components';
2 | import { useInfiniteQuery } from 'react-query';
3 | import Image from 'next/image';
4 | import Link from 'next/link';
5 | import useInfinite from '../hooks/use-infinite';
6 | import { useForumsQuery } from '../hooks/use-forums-query';
7 |
8 | function ForumItem({ forumAlias, name, logo, isTop3 }) {
9 | return (
10 |
11 |
12 |
31 |
40 |
46 |
47 |
55 | {name}
56 |
57 |
58 |
59 |
60 | );
61 | }
62 |
63 | function PopularForums() {
64 | const {
65 | data,
66 | fetchNextPage,
67 | hasNextPage,
68 | isFetching,
69 | isFetchingNextPage,
70 | } = useInfiniteQuery('forums/popular-forums', {
71 | getNextPageParam: (lastGroup) =>
72 | lastGroup.nextKey
73 | ? {
74 | pageKey: lastGroup.nextKey,
75 | }
76 | : null,
77 | staleTime: Infinity,
78 | structuralSharing: false,
79 | });
80 | const isLoading = isFetching || isFetchingNextPage;
81 | const anchor = useInfinite(fetchNextPage, isLoading);
82 |
83 | const popularForums = data ? data.pages.flatMap((page) => page.items) : [];
84 |
85 | const { data: forums } = useForumsQuery();
86 |
87 | return (
88 |
96 |
104 | 即時熱門看板
105 |
106 |
107 |
115 | {popularForums.map((forum, index) => (
116 |
123 | ))}
124 |
125 | {hasNextPage && anchor}
126 |
127 |
128 | );
129 | }
130 |
131 | PopularForums.prefetchQueries = async function prefetchQueries(queryClient) {
132 | await queryClient.prefetchInfiniteQuery('forums/popular-forums');
133 | };
134 |
135 | export default PopularForums;
136 |
--------------------------------------------------------------------------------
/src/components/post-arrow-link.js:
--------------------------------------------------------------------------------
1 | import { useState, useLayoutEffect } from 'react';
2 | import Link from 'next/link';
3 | import { useRouter } from 'next/router';
4 | import styled, { css } from 'styled-components';
5 | import PostItem from './post-item';
6 | import getScrollbarWidth from '../utils/get-scrollbar-width';
7 |
8 | const Text = styled.span`
9 | display: inline-flex;
10 | color: rgb(255, 255, 255);
11 | font-size: 14px;
12 | visibility: hidden;
13 | margin-top: 10px;
14 | `;
15 |
16 | const ArrowWrapper = styled.span`
17 | width: 82px;
18 | height: 160px;
19 | display: inline-flex;
20 | flex-direction: column;
21 | align-items: center;
22 | justify-content: center;
23 | `;
24 |
25 | const Article = styled.article`
26 | display: none;
27 | padding: 20px;
28 | width: 640px;
29 | background-color: #fff;
30 | pointer-events: none;
31 | order: ${(props) => (props.direction === 'left' ? 1 : 0)};
32 | `;
33 |
34 | export const PostArrowBackground = styled.div`
35 | position: fixed;
36 | top: 0;
37 | left: 0;
38 | height: 100%;
39 | width: 100%;
40 | background-color: rgba(0, 0, 0, 0.3);
41 | opacity: 0;
42 | transition: opacity 0.3s linear 0s;
43 | pointer-events: none;
44 | z-index: 10;
45 | `;
46 |
47 | export default function PostArrowLink({
48 | post,
49 | postIndex,
50 | activePostItemRef,
51 | direction = 'right',
52 | }) {
53 | const router = useRouter();
54 | const [scrollbarWidth, setScrollbarWidth] = useState(0);
55 | const stepsFromList = parseInt(router.query.stepsFromList ?? 1, 10);
56 |
57 | useLayoutEffect(() => {
58 | setScrollbarWidth(getScrollbarWidth());
59 | }, []);
60 |
61 | return (
62 |
77 | {
110 | if (activePostItemRef.current) {
111 | // Not idiomatic React code but it works
112 | activePostItemRef.current = activePostItemRef.current
113 | .closest('[role="feed"]')
114 | ?.querySelector?.(`[aria-posinset="${postIndex + 1}"] a`);
115 | }
116 | }}
117 | >
118 |
119 |
120 |
121 |
122 |
123 |
145 | {direction === 'right' ? '下一篇' : '上一篇'}
146 |
147 |
148 |
149 | );
150 | }
151 |
--------------------------------------------------------------------------------
/src/components/post-content.js:
--------------------------------------------------------------------------------
1 | import { useMemo, cloneElement } from 'react';
2 | import { useRouter } from 'next/router';
3 | import dynamic from 'next/dynamic';
4 | import Link from 'next/link';
5 | import styled, { css } from 'styled-components';
6 | import ZoomableImage from './zoomable-image';
7 |
8 | const VideoPlayer = dynamic(() => import('./video-player'));
9 | const LinkAttachment = dynamic(() => import('./link-attachment'));
10 | const PostPreview = dynamic(() => import('./post-preview'));
11 |
12 | const FLOOR_REGEX = /\b(b\d+)\b/gim;
13 |
14 | function FloorLink({ floor, children }) {
15 | const router = useRouter();
16 | const { forumAlias, postID, stepsFromPost } = router.query;
17 |
18 | return (
19 |
34 | {children}
35 |
36 | );
37 | }
38 |
39 | const ImageBlock = (props) => (
40 |
47 |
48 | {(!props.height || !props.width) && (
49 |
61 | )}
62 |
63 |
64 | );
65 |
66 | const Paragraph = styled.p`
67 | margin: 0;
68 | `;
69 |
70 | function PostContent({ skipParsing, mediaMeta, children = '', ...props }) {
71 | const contentBlocks = useMemo(() => {
72 | const mediaMetaByURL = {};
73 | if (mediaMeta && mediaMeta.length) {
74 | for (const media of mediaMeta) {
75 | mediaMetaByURL[media.url] = media;
76 | }
77 | }
78 |
79 | const content = [];
80 | const lines = children.split(/(\n|https:\/\/.*)/g);
81 |
82 | lines.forEach((line) => {
83 | if (line in mediaMetaByURL) {
84 | const media = mediaMetaByURL[line];
85 |
86 | let type = media.type;
87 |
88 | if (!type) {
89 | if (line.startsWith('https://i.imgur.com/')) {
90 | type = 'image/imgur';
91 | }
92 | }
93 |
94 | switch (type) {
95 | case 'image/imgur':
96 | case 'image/megapx': {
97 | content.push(
98 |
103 | );
104 | break;
105 | }
106 | case 'video/youtube':
107 | case 'video/vivid': {
108 | content.push(
109 |
114 | );
115 | break;
116 | }
117 | default: {
118 | // TODO: what else?
119 | content.push({
120 | type: Paragraph,
121 | children: line,
122 | });
123 | break;
124 | }
125 | }
126 | } else if (line.match(/^https?:\/\//)) {
127 | if (line.match(/vimeo.com\/\d+/)) {
128 | content.push();
129 | } else if (line.match(/dcard.tw\/f\/\w+\/p\/\d+/)) {
130 | const [, forumAlias, postID] = line.match(
131 | /dcard\.tw\/f\/(\w+)\/p\/(\d+)/
132 | );
133 | content.push(
134 |
141 | );
142 | } else {
143 | content.push(
144 |
150 | );
151 | }
152 | } else {
153 | let lastBlock = content[content.length - 1];
154 |
155 | if (!lastBlock || lastBlock.type !== Paragraph) {
156 | lastBlock = { type: Paragraph, children: '' };
157 | content.push(lastBlock);
158 | }
159 |
160 | lastBlock.children += line;
161 | }
162 | });
163 |
164 | content
165 | .filter((block) => block.type === Paragraph)
166 | .forEach((paragraph) => {
167 | paragraph.children = paragraph.children.split(FLOOR_REGEX);
168 |
169 | for (let index = 1; index < paragraph.children.length; index += 2) {
170 | const floorText = paragraph.children[index];
171 | const floor = parseInt(floorText.slice(1), 10);
172 |
173 | if (floor === 0) {
174 | paragraph.children[index] = {floorText};
175 | } else {
176 | paragraph.children[index] = (
177 |
178 | {floorText}
179 |
180 | );
181 | }
182 | }
183 | });
184 |
185 | return content;
186 | }, [children, mediaMeta]);
187 |
188 | return (
189 |
201 | {skipParsing
202 | ? children
203 | : contentBlocks.map((block, index) =>
204 | block.type === Paragraph ? (
205 |
{block.children}
206 | ) : (
207 | cloneElement(block, { key: index })
208 | )
209 | )}
210 |
211 | );
212 | }
213 |
214 | export default PostContent;
215 |
--------------------------------------------------------------------------------
/src/components/post-info.js:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import { css } from 'styled-components';
3 | import Image from 'next/image';
4 | import { useQuery } from 'react-query';
5 | import ForumPostsFrequency from './forum-posts-frequency';
6 | import UserInfo from './user-info';
7 | import { useForumByAlias } from '../hooks/use-forums-query';
8 |
9 | export default function PostInfo({ forumAlias, persona, ...props }) {
10 | const { data: personaInfo } = useQuery(`personas/${persona}`, {
11 | staleTime: Infinity,
12 | enabled: !!persona,
13 | });
14 | const forum = useForumByAlias(forumAlias);
15 |
16 | if (!forum) {
17 | return null;
18 | }
19 |
20 | const forumInfo = (
21 |
22 |
30 |
36 |
45 |
46 |
47 |
53 |
67 | {forum.name}
68 |
69 |
70 |
79 |
80 |
81 |
82 |
83 |
84 | );
85 |
86 | const userInfo = personaInfo && (
87 |
99 | {personaInfo.postCount} 篇文章
100 | ・
101 | {personaInfo.subscriptionCount} 位粉絲
102 |
103 | );
104 |
105 | return (
106 | <>
107 | {userInfo}
108 | {forumInfo}
109 | >
110 | );
111 | }
112 |
--------------------------------------------------------------------------------
/src/components/post-label.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const PostLabel = styled.span`
4 | display: inline-flex;
5 | background: rgb(163, 163, 163);
6 | color: rgb(255, 255, 255);
7 | font-size: 12px;
8 | line-height: 20px;
9 | padding: 0px 4px;
10 | border-radius: 4px;
11 | flex-shrink: 0;
12 | white-space: nowrap;
13 | text-overflow: ellipsis;
14 | overflow: hidden;
15 | `;
16 |
17 | export default PostLabel;
18 |
--------------------------------------------------------------------------------
/src/components/post-modal.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useCallback, useMemo, useRef } from 'react';
2 | import { css } from 'styled-components';
3 | import { useRouter } from 'next/router';
4 | import dynamic from 'next/dynamic';
5 | import { useDialogState, Dialog, DialogBackdrop } from 'reakit';
6 | import CloseIcon from './close-icon';
7 | import PostArrowLink, { PostArrowBackground } from './post-arrow-link';
8 |
9 | const Post = dynamic(() => import('../components/post'));
10 |
11 | function ScrollPositionManager({ modalRef }) {
12 | const router = useRouter();
13 | const { postID } = router.query;
14 |
15 | useEffect(
16 | function scrollToTopWhenOpeningModal() {
17 | if (postID && modalRef.current) {
18 | modalRef.current.scrollTop = 0;
19 | }
20 | },
21 | [postID, modalRef]
22 | );
23 |
24 | return null;
25 | }
26 |
27 | export default function PostModal({
28 | placeholderData,
29 | children,
30 | prevPost,
31 | prevPostIndex,
32 | nextPost,
33 | nextPostIndex,
34 | activePostItemRef,
35 | }) {
36 | const modalRef = useRef();
37 |
38 | const dialog = useDialogState();
39 | const router = useRouter();
40 | const { postID, stepsFromList } = router.query;
41 | const isOpen = !!postID;
42 |
43 | const closeModal = useCallback(() => {
44 | window.history.go(-parseInt(stepsFromList ?? 1, 10));
45 | }, [stepsFromList]);
46 |
47 | const closeButton = useMemo(
48 | () => (
49 |
69 | ),
70 | [closeModal]
71 | );
72 |
73 | return (
74 |
90 |
140 |
141 | );
142 | }
143 |
--------------------------------------------------------------------------------
/src/components/post-preview.js:
--------------------------------------------------------------------------------
1 | import { useQuery } from 'react-query';
2 | import { css } from 'styled-components';
3 | import Link from 'next/link';
4 | import { useRouter } from 'next/router';
5 | import Image from 'next/image';
6 | import { LinkWrapper, LinkPreview } from './link-attachment';
7 | import ReactionsList from './reactions-list';
8 | import useModalParentLocation from '../hooks/use-modal-parent-location';
9 |
10 | export default function PostPreview({ forumAlias, postID, ...props }) {
11 | const router = useRouter();
12 | const modalParentLocation = useModalParentLocation(!!router.query.postID);
13 | const { stepsFromList = 1 } = router.query;
14 | const { data: post } = useQuery(`posts/${postID}/preview`, {
15 | staleTime: Infinity,
16 | });
17 |
18 | let content = null;
19 | if (post) {
20 | content = (
21 |
35 |
42 |
49 | {post.likeCount}
50 |
51 | 回應 {post.commentCount}
52 |
53 | }
54 | image={post.mediaMeta?.[0]?.url}
55 | defaultImage={
56 |
57 | }
58 | />
59 | );
60 | }
61 |
62 | const asPath = `/f/${forumAlias}/p/${postID}`;
63 |
64 | return (
65 |
81 | {content}
82 |
83 | );
84 | }
85 |
--------------------------------------------------------------------------------
/src/components/posts-list.js:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import { useInfiniteQuery } from 'react-query';
3 | import { useRouter } from 'next/router';
4 | import Link from 'next/link';
5 | import { css } from 'styled-components';
6 | import usePostsList from '../hooks/use-posts-list';
7 | import useModalParentLocation from '../hooks/use-modal-parent-location';
8 | import PostItem from './post-item';
9 | import dedupe from '../utils/dedupe';
10 |
11 | function PostsList() {
12 | const router = useRouter();
13 | const modalParentLocation = useModalParentLocation(!!router.query.postID);
14 | const { forumAlias, latest } = modalParentLocation.query;
15 | const popular = !latest;
16 | const shouldShowForumName = !forumAlias;
17 | const shouldShowDateTime = !popular && !!forumAlias;
18 |
19 | const {
20 | data,
21 | fetchNextPage,
22 | hasNextPage,
23 | isFetching,
24 | isFetchingNextPage,
25 | } = useInfiniteQuery(['posts', { forum: forumAlias, popular }], {
26 | getNextPageParam: (lastGroup) => lastGroup.nextQuery,
27 | staleTime: 5 * 60 * 1000, // 5 mins
28 | });
29 |
30 | const isLoading = isFetching || isFetchingNextPage;
31 |
32 | const posts = useMemo(
33 | () =>
34 | data
35 | ? dedupe(
36 | data.pages.flatMap((page) => page.items),
37 | (post) => post.id
38 | )
39 | : [],
40 | [data]
41 | );
42 |
43 | const [modal, activePostItemRef] = usePostsList(posts, {
44 | fetchNextPage,
45 | hasNextPage,
46 | isFetching,
47 | isFetchingNextPage,
48 | });
49 |
50 | return (
51 | <>
52 |
53 | {posts.map((post, index) => (
54 |
69 |
83 | {
87 | activePostItemRef.current = event.currentTarget;
88 | }}
89 | {...post}
90 | />
91 |
92 |
93 | ))}
94 |
95 |
96 | {modal}
97 | >
98 | );
99 | }
100 |
101 | PostsList.prefetchQueries = async function prefetchQueries(
102 | queryClient,
103 | context
104 | ) {
105 | const { forumAlias, latest } = context.router.query;
106 |
107 | await queryClient.prefetchInfiniteQuery([
108 | 'posts',
109 | { forum: forumAlias, popular: !latest },
110 | ]);
111 | };
112 |
113 | export default PostsList;
114 |
--------------------------------------------------------------------------------
/src/components/reaction.js:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import Image from 'next/image';
3 | import { css } from 'styled-components';
4 | import { useQuery } from 'react-query';
5 |
6 | export default function Reaction({ id, ...props }) {
7 | const { data: reactions } = useQuery('posts/reactions', {
8 | staleTime: Infinity,
9 | });
10 |
11 | const reaction = useMemo(() => reactions?.find((item) => item.id === id), [
12 | reactions,
13 | id,
14 | ]);
15 |
16 | if (!id) {
17 | return (
18 |
27 | );
28 | } else if (!reaction) {
29 | const { width, height, ...rest } = props;
30 | return (
31 |
39 | );
40 | }
41 |
42 | return (
43 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/src/components/reactions-list.js:
--------------------------------------------------------------------------------
1 | import { css } from 'styled-components';
2 | import Reaction from './reaction';
3 |
4 | function ReactionItem({ reactionID, size, ...props }) {
5 | return (
6 |
20 |
21 |
22 | );
23 | }
24 |
25 | export default function ReactionsList({ reactions, size, ...props }) {
26 | return (
27 |
33 | {reactions.map((reaction, index) => (
34 |
42 | ))}
43 |
44 | {!reactions.length && }
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/reactions-modal.js:
--------------------------------------------------------------------------------
1 | import { css } from 'styled-components';
2 | import { useQuery } from 'react-query';
3 | import { useDialogState, DialogDisclosure } from 'reakit';
4 | import { Dialog } from './route-dialog';
5 | import Reaction from './reaction';
6 | import CloseIcon from './close-icon';
7 |
8 | export default function ReactionsModal({ reactions, ...props }) {
9 | const dialog = useDialogState({
10 | animated: true,
11 | });
12 | const { data: reactionsData } = useQuery('posts/reactions');
13 |
14 | return (
15 | <>
16 |
17 |
18 |
133 | >
134 | );
135 | }
136 |
--------------------------------------------------------------------------------
/src/components/readable-date-time.js:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 |
3 | export default function ReadableDateTime({ dateTime, ...props }) {
4 | const createdDate = useMemo(() => new Date(dateTime), [dateTime]);
5 |
6 | const readableDateTime = useMemo(() => {
7 | const dateString = `${
8 | createdDate.getMonth() + 1
9 | }月${createdDate.getDate()}日 ${String(createdDate.getHours()).padStart(
10 | 2,
11 | '0'
12 | )}:${String(createdDate.getMinutes()).padStart(2, '0')}`;
13 |
14 | if (createdDate.getFullYear() === new Date().getFullYear()) {
15 | return dateString;
16 | }
17 |
18 | return `${createdDate.getFullYear()}年${dateString}`;
19 | }, [createdDate]);
20 |
21 | return (
22 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/route-dialog.js:
--------------------------------------------------------------------------------
1 | import { forwardRef } from 'react';
2 | import { css } from 'styled-components';
3 | import {
4 | Dialog as BaseDialog,
5 | useDialogState,
6 | DialogBackdrop as BaseDialogBackdrop,
7 | } from 'reakit';
8 |
9 | const Dialog = forwardRef(
10 | ({ visible, dialog, backdropStyle, children, hide, ...props }, ref) => (
11 | {
32 | if (event.target === event.currentTarget) {
33 | hide();
34 | }
35 | }}
36 | >
37 |
53 | {(visible || (!!dialog.animated && dialog.animating)) && children}
54 |
55 |
56 | )
57 | );
58 |
59 | export { Dialog, useDialogState };
60 |
--------------------------------------------------------------------------------
/src/components/rule.js:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import Link from 'next/link';
3 | import { css } from 'styled-components';
4 | import { useRouter } from 'next/router';
5 | import { useQuery } from 'react-query';
6 | import {
7 | setDehydratedForums,
8 | useForumByAlias,
9 | } from '../hooks/use-forums-query';
10 |
11 | function Moderators({ moderators }) {
12 | if (!moderators.length) {
13 | return null;
14 | }
15 |
16 | const [moderator] = moderators;
17 |
18 | return (
19 |
28 | 管理人員
29 |
30 |
31 | 本看板由板主「
32 |
33 | {moderator.nickname}
34 |
35 | 」負責管理。
36 |
37 |
38 |
39 | 若板主出現以下情形,請來信看板{' '}
40 | 申訴信箱 反應:
41 |
42 |
43 |
44 |
45 | - 文章明顯違規,但板主未依照板規執法
46 | - 板主誤判違規文章,將當事人禁言
47 | - 板規含有攻擊、歧視,違反全站站規等不適當的內容
48 | - 板主連續多日未處理看板事務
49 |
50 |
51 |
52 | );
53 | }
54 |
55 | function ForumRules({ forumName, forumRules }) {
56 | if (!forumRules.length) {
57 | return null;
58 | }
59 |
60 | return (
61 | <>
62 |
63 | {forumName}板板規
64 |
65 | 若貼文內容違反規定,板主將透過管理後臺刪除貼文,貼文刪除的同時,系統會自動禁止發文者繼續於本看板發言。
66 |
67 | 歡迎交流《{forumName}》相關文章。
68 |
69 |
70 |
71 |
72 | {forumRules.map((rule) => (
73 | -
74 |
{rule.title}
75 | {rule.content && {rule.content}
}
76 | 違反此板規禁言 {rule.bucketDays} 天。
77 |
78 | ))}
79 |
80 |
81 | >
82 | );
83 | }
84 |
85 | function GlobalRules({ globalRules }) {
86 | if (!globalRules.length) {
87 | return null;
88 | }
89 |
90 | return (
91 | <>
92 |
93 | 全站站規
94 |
95 | 違反全站站規的貼文,板主刪文後系統會通知官方審核人員,依全站站規於全站停權使用者。
96 |
97 |
98 |
99 |
100 | {globalRules.map((rule) => (
101 | -
102 |
{rule.title}
103 | {rule.content && {rule.content}
}
104 | 違反此站規,於此看板禁言 {rule.bucketDays} 天。
105 |
106 | ))}
107 |
108 |
109 | >
110 | );
111 | }
112 |
113 | function Rule() {
114 | const router = useRouter();
115 | const { forumAlias } = router.query;
116 |
117 | const forum = useForumByAlias(forumAlias);
118 | const forumID = forum?.id;
119 |
120 | const { data: bulletin } = useQuery([`forums/bulletin`, { forumID }], {
121 | staleTime: Infinity,
122 | });
123 |
124 | const version = useMemo(() => {
125 | const date = new Date(bulletin?.lastUpdatedAt);
126 | return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(
127 | 2,
128 | '0'
129 | )}.${String(date.getDate()).padStart(2, '0')}`;
130 | }, [bulletin?.lastUpdatedAt]);
131 |
132 | if (!bulletin || !forum) {
133 | return null;
134 | }
135 |
136 | const { forumRules, globalRules, moderators } = bulletin;
137 |
138 | return (
139 | ol {
174 | color: rgba(0, 0, 0, 0.45);
175 |
176 | > li {
177 | padding-bottom: 8px;
178 | margin-top: 10px;
179 | border-bottom: 1px solid rgb(222, 222, 222);
180 |
181 | &:first-child {
182 | margin-top: 0;
183 | }
184 |
185 | &::marker {
186 | font-size: 20px;
187 | font-weight: 500;
188 | line-height: 1.6;
189 | }
190 | }
191 | }
192 | }
193 | `}
194 | >
195 |
196 |
197 |
198 |
205 |
規範
206 |
211 | 版本:{version}
212 |
213 |
214 |
215 | 於看板發言時請遵守全站站規與本板板規,讓大家都有一個乾淨的討論空間。
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 | );
224 | }
225 |
226 | Rule.prefetchQueries = async function prefetchQueries(queryClient, context) {
227 | const { forumAlias } = context.router.query;
228 | const forums = await queryClient.fetchQuery('forums');
229 | const forum = Object.values(forums).find((f) => f.alias === forumAlias);
230 |
231 | const forumID = forum.id;
232 |
233 | await queryClient.prefetchQuery([`forums/bulletin`, { forumID }]);
234 |
235 | setDehydratedForums(queryClient, {
236 | [forum.id]: forum,
237 | });
238 | };
239 |
240 | export default Rule;
241 |
--------------------------------------------------------------------------------
/src/components/search-bar.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { css } from 'styled-components';
3 | import { VisuallyHidden } from 'reakit';
4 | import { useRouter } from 'next/router';
5 | import Image from 'next/image';
6 | import { useForumByAlias } from '../hooks/use-forums-query';
7 | import useModalParentLocation from '../hooks/use-modal-parent-location';
8 |
9 | export default function SearchBar() {
10 | const router = useRouter();
11 | const [value, setValue] = useState(router.query.query ?? '');
12 | const modalParentLocation = useModalParentLocation(!!router.query.postID);
13 | const { forumAlias, forum } = modalParentLocation.query;
14 | const searchForumAlias = forumAlias ?? forum;
15 | const forumData = useForumByAlias(searchForumAlias);
16 |
17 | return (
18 |
123 | );
124 | }
125 |
--------------------------------------------------------------------------------
/src/components/search-forums-list.js:
--------------------------------------------------------------------------------
1 | import { useInfiniteQuery } from 'react-query';
2 | import { useRouter } from 'next/router';
3 | import { css } from 'styled-components';
4 | import useInfinite from '../hooks/use-infinite';
5 | import ForumItem from './forum-item';
6 |
7 | const PAGE_SIZE = 30;
8 |
9 | function SearchForumsList() {
10 | const router = useRouter();
11 | const { query } = router.query;
12 |
13 | const {
14 | data,
15 | fetchNextPage,
16 | hasNextPage,
17 | isFetching,
18 | isFetchingNextPage,
19 | } = useInfiniteQuery(['search/forums', { query }], {
20 | getNextPageParam: (lastGroup, pages) =>
21 | lastGroup.length < PAGE_SIZE
22 | ? null
23 | : {
24 | offset: pages
25 | .map((page) => page.length)
26 | .reduce((sum, cur) => sum + cur, 0),
27 | },
28 | staleTime: Infinity,
29 | });
30 |
31 | const isLoading = isFetching || isFetchingNextPage;
32 |
33 | const anchor = useInfinite(fetchNextPage, isLoading);
34 |
35 | const forumsList = data ? data.pages.flat() : [];
36 |
37 | return (
38 | <>
39 |
47 | {forumsList.map((forum, index) => (
48 |
54 |
55 |
56 | ))}
57 |
58 | {hasNextPage && anchor}
59 | >
60 | );
61 | }
62 |
63 | SearchForumsList.prefetchQueries = async function prefetchQueries(
64 | queryClient,
65 | context
66 | ) {
67 | const { query } = context.router.query;
68 |
69 | await queryClient.prefetchInfiniteQuery(['search/forums', { query }]);
70 | };
71 |
72 | export default SearchForumsList;
73 |
--------------------------------------------------------------------------------
/src/components/search-personas-list.js:
--------------------------------------------------------------------------------
1 | import { useInfiniteQuery } from 'react-query';
2 | import { useRouter } from 'next/router';
3 | import { css } from 'styled-components';
4 | import useInfinite from '../hooks/use-infinite';
5 | import PersonaItem from './persona-item';
6 |
7 | const PAGE_SIZE = 30;
8 |
9 | function SearchPersonasList() {
10 | const router = useRouter();
11 | const { query } = router.query;
12 |
13 | const {
14 | data,
15 | fetchNextPage,
16 | hasNextPage,
17 | isFetching,
18 | isFetchingNextPage,
19 | } = useInfiniteQuery(['search/personas', { query }], {
20 | getNextPageParam: (lastGroup, pages) =>
21 | lastGroup.length < PAGE_SIZE
22 | ? null
23 | : {
24 | offset: pages
25 | .map((page) => page.length)
26 | .reduce((sum, cur) => sum + cur, 0),
27 | },
28 | staleTime: Infinity,
29 | });
30 |
31 | const isLoading = isFetching || isFetchingNextPage;
32 |
33 | const anchor = useInfinite(fetchNextPage, isLoading);
34 |
35 | const personasList = data ? data.pages.flat() : [];
36 |
37 | return (
38 | <>
39 |
47 | {personasList.map((persona, index) => (
48 |
55 |
56 |
57 | ))}
58 |
59 | {hasNextPage && anchor}
60 | >
61 | );
62 | }
63 |
64 | SearchPersonasList.prefetchQueries = async function prefetchQueries(
65 | queryClient,
66 | context
67 | ) {
68 | const { query } = context.router.query;
69 |
70 | await queryClient.prefetchInfiniteQuery(['search/personas', { query }]);
71 | };
72 |
73 | export default SearchPersonasList;
74 |
--------------------------------------------------------------------------------
/src/components/search-posts-filter.js:
--------------------------------------------------------------------------------
1 | import { forwardRef } from 'react';
2 | import { css } from 'styled-components';
3 | import { useRouter } from 'next/router';
4 | import Link from 'next/link';
5 | import { VisuallyHidden } from 'reakit';
6 | import useModalParentLocation from '../hooks/use-modal-parent-location';
7 | import { useMenuState, MenuButton, Menu, MenuItem } from './menu';
8 | import ArrowIcon from './arrow-icon';
9 |
10 | const SORT_LABEL = {
11 | created: '最新發佈',
12 | like: '心情數',
13 | collection: '收藏數',
14 | relevance: '相關度',
15 | };
16 |
17 | const SINCE_LABEL = {
18 | 0: '不限時間',
19 | 1: '一天內',
20 | 7: '七天內',
21 | 30: '30 天內',
22 | };
23 |
24 | function CheckboxIcon(props) {
25 | return (
26 |
37 | );
38 | }
39 |
40 | function SelectMenuButton({ menu, children, ...props }) {
41 | return (
42 |
66 | {children}
67 |
68 |
69 | );
70 | }
71 |
72 | const SelectMenuItem = forwardRef(
73 | ({ name, value, label, menu, ...props }, ref) => {
74 | const router = useRouter();
75 | return (
76 |
86 |
95 |
96 | );
97 | }
98 | );
99 |
100 | export default function SearchPostsFilter() {
101 | const router = useRouter();
102 | const isModalOpen = !!router.query.postID;
103 | const modalParentLocation = useModalParentLocation(isModalOpen);
104 | const {
105 | sort = 'created',
106 | since = '0',
107 | field = 'all',
108 | } = modalParentLocation.query;
109 |
110 | const sortMenu = useMenuState({ gutter: 10, placement: 'bottom' });
111 | const sinceMenu = useMenuState({ gutter: 10, placement: 'bottom' });
112 |
113 | function handleChangeField(event) {
114 | const field = event.target.checked ? 'all' : 'title';
115 |
116 | router.push({
117 | pathname: router.pathname,
118 | query: {
119 | ...router.query,
120 | field,
121 | },
122 | });
123 | }
124 |
125 | return (
126 |
134 |
140 |
141 | {SORT_LABEL[sort] ?? '最新發佈'}
142 |
143 |
155 |
156 |
157 | {SINCE_LABEL[since] ?? '不限時間'}
158 |
159 |
171 |
172 |
173 |
225 |
226 | );
227 | }
228 |
--------------------------------------------------------------------------------
/src/components/search-posts-list.js:
--------------------------------------------------------------------------------
1 | import { useInfiniteQuery } from 'react-query';
2 | import { useRouter } from 'next/router';
3 | import Link from 'next/link';
4 | import { css } from 'styled-components';
5 | import useModalParentLocation from '../hooks/use-modal-parent-location';
6 | import usePostsList from '../hooks/use-posts-list';
7 | import PostItem from './post-item';
8 |
9 | const PAGE_SIZE = 30;
10 |
11 | function PostArticle({ post, index, totalSize, activePostItemRef }) {
12 | const router = useRouter();
13 |
14 | return (
15 |
30 |
44 | {
47 | activePostItemRef.current = event.currentTarget;
48 | }}
49 | highlight
50 | {...post}
51 | />
52 |
53 |
54 | );
55 | }
56 |
57 | function SearchPostsList({ thirdSlot }) {
58 | const router = useRouter();
59 | const { postID } = router.query;
60 | const isModalOpen = !!postID;
61 | const modalParentLocation = useModalParentLocation(isModalOpen);
62 | const { query, sort, since, field, forum } = modalParentLocation.query;
63 |
64 | const {
65 | data,
66 | fetchNextPage,
67 | hasNextPage,
68 | isFetching,
69 | isFetchingNextPage,
70 | } = useInfiniteQuery(['search/posts', { query, sort, since, field, forum }], {
71 | getNextPageParam: (lastGroup, pages) =>
72 | lastGroup.length < PAGE_SIZE
73 | ? null
74 | : {
75 | offset: pages
76 | .map((page) => page.length)
77 | .reduce((sum, cur) => sum + cur, 0),
78 | },
79 | staleTime: Infinity,
80 | });
81 |
82 | const isLoading = isFetching || isFetchingNextPage;
83 | const posts = data ? data.pages.flat() : [];
84 |
85 | const [modal, activePostItemRef] = usePostsList(posts, {
86 | fetchNextPage,
87 | hasNextPage,
88 | isFetching,
89 | isFetchingNextPage,
90 | });
91 |
92 | return (
93 | <>
94 |
95 | {posts.slice(0, 2).map((post, index) => (
96 |
103 | ))}
104 |
105 | {thirdSlot}
106 |
107 | {posts.slice(2).map((post, index) => (
108 |
115 | ))}
116 |
117 |
118 | {modal}
119 | >
120 | );
121 | }
122 |
123 | SearchPostsList.prefetchQueries = async function prefetchQueries(
124 | queryClient,
125 | context
126 | ) {
127 | const { query, sort, since, field } = context.router.query;
128 |
129 | await queryClient.prefetchInfiniteQuery([
130 | 'search/posts',
131 | { query, sort, since, field },
132 | ]);
133 | };
134 |
135 | export default SearchPostsList;
136 |
--------------------------------------------------------------------------------
/src/components/search-topics-list.js:
--------------------------------------------------------------------------------
1 | import { useInfiniteQuery } from 'react-query';
2 | import { useRouter } from 'next/router';
3 | import { css } from 'styled-components';
4 | import useInfinite from '../hooks/use-infinite';
5 | import TopicItem from './topic-item';
6 |
7 | const PAGE_SIZE = 30;
8 |
9 | function SearchTopicsList() {
10 | const router = useRouter();
11 | const { query } = router.query;
12 |
13 | const {
14 | data,
15 | fetchNextPage,
16 | hasNextPage,
17 | isFetching,
18 | isFetchingNextPage,
19 | } = useInfiniteQuery(['search/topics', { query }], {
20 | getNextPageParam: (lastGroup, pages) =>
21 | lastGroup.length < PAGE_SIZE
22 | ? null
23 | : {
24 | offset: pages
25 | .map((page) => page.length)
26 | .reduce((sum, cur) => sum + cur, 0),
27 | },
28 | staleTime: Infinity,
29 | });
30 |
31 | const isLoading = isFetching || isFetchingNextPage;
32 |
33 | const anchor = useInfinite(fetchNextPage, isLoading);
34 |
35 | const topicsList = data ? data.pages.flat() : [];
36 |
37 | return (
38 | <>
39 |
47 | {topicsList.map((topic, index) => (
48 |
55 |
56 |
57 | ))}
58 |
59 | {hasNextPage && anchor}
60 | >
61 | );
62 | }
63 |
64 | SearchTopicsList.prefetchQueries = async function prefetchQueries(
65 | queryClient,
66 | context
67 | ) {
68 | const { query } = context.router.query;
69 |
70 | await queryClient.prefetchInfiniteQuery(['search/topics', { query }]);
71 | };
72 |
73 | export default SearchTopicsList;
74 |
--------------------------------------------------------------------------------
/src/components/search.js:
--------------------------------------------------------------------------------
1 | import { css } from 'styled-components';
2 | import Link from 'next/link';
3 | import { useRouter } from 'next/router';
4 | import TabList from './tab-list';
5 | import useModalParentLocation from '../hooks/use-modal-parent-location';
6 | import { filterQuery } from '../utils/filter-query';
7 |
8 | const tabs = [
9 | { name: '綜合', pathname: '/search' },
10 | { name: '文章', pathname: '/search/posts' },
11 | { name: '看板', pathname: '/search/forums' },
12 | { name: '話題', pathname: '/search/topics' },
13 | { name: '卡稱', pathname: '/search/personas' },
14 | ];
15 |
16 | function Search({ children }) {
17 | const router = useRouter();
18 | const isModalOpen = !!router.query.postID;
19 | const modalParentLocation = useModalParentLocation(isModalOpen);
20 |
21 | return (
22 | <>
23 |
33 |
34 | {tabs.map((tab) => (
35 |
46 |
49 | {tab.name}
50 |
51 |
52 | ))}
53 |
54 |
55 |
56 |
57 | {children}
58 |
59 | >
60 | );
61 | }
62 |
63 | export default Search;
64 |
--------------------------------------------------------------------------------
/src/components/sort-icons.js:
--------------------------------------------------------------------------------
1 | export function PopularIcon(props) {
2 | return (
3 |
12 | );
13 | }
14 |
15 | export function LatestIcon(props) {
16 | return (
17 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/tab-list.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | // TODO: Use Reakit's Tab component
4 | const Tab = styled.a.attrs({
5 | tabIndex: 0,
6 | })`
7 | color: ${(props) => (props.isActive ? '#000' : 'rgba(0, 0, 0, 0.35)')};
8 | height: 60px;
9 | padding: 0 16px;
10 | line-height: 60px;
11 | font-weight: 500;
12 | font-size: 16px;
13 | position: relative;
14 | cursor: pointer;
15 |
16 | &:hover {
17 | color: #000;
18 | }
19 |
20 | &:after {
21 | content: ${(props) => (props.isActive ? '""' : 'none')};
22 | border-bottom: 2px solid rgb(51, 151, 207);
23 | position: absolute;
24 | left: 0px;
25 | bottom: -1px;
26 | width: 100%;
27 | }
28 |
29 | &:focus:not(:focus-visible) {
30 | outline: none;
31 | }
32 | `;
33 |
34 | const TabList = styled.div`
35 | display: flex;
36 | `;
37 |
38 | TabList.Tab = Tab;
39 |
40 | export default TabList;
41 |
--------------------------------------------------------------------------------
/src/components/topic-item.js:
--------------------------------------------------------------------------------
1 | import { css } from 'styled-components';
2 | import Link from 'next/link';
3 |
4 | export default function TopicItem({ topic }) {
5 | if (!topic) {
6 | return null;
7 | }
8 |
9 | return (
10 |
11 |
19 |
32 |
43 |
44 |
45 |
51 |
60 | {topic.name}
61 |
62 |
63 |
71 | {topic.postCount} 篇文章
72 | ・
73 | {topic.subscriptionCount} 人追蹤
74 |
75 |
76 |
77 |
78 | );
79 | }
80 |
--------------------------------------------------------------------------------
/src/components/topic-posts-list.js:
--------------------------------------------------------------------------------
1 | import { useInfiniteQuery } from 'react-query';
2 | import { useRouter } from 'next/router';
3 | import Link from 'next/link';
4 | import { css } from 'styled-components';
5 | import usePostsList from '../hooks/use-posts-list';
6 | import useModalParentLocation from '../hooks/use-modal-parent-location';
7 | import PostItem from './post-item';
8 |
9 | const PAGE_SIZE = 30;
10 |
11 | function TopicPostsList() {
12 | const router = useRouter();
13 | const { postID } = router.query;
14 | const isModalOpen = !!postID;
15 | const modalParentLocation = useModalParentLocation(isModalOpen);
16 | const { topic, latest } = modalParentLocation.query;
17 | const sort = latest ? 'created' : 'like';
18 |
19 | const {
20 | data,
21 | fetchNextPage,
22 | hasNextPage,
23 | isFetching,
24 | isFetchingNextPage,
25 | } = useInfiniteQuery(['topics/posts', { topic, sort }], {
26 | getNextPageParam: (lastGroup, pages) =>
27 | lastGroup.length < PAGE_SIZE
28 | ? null
29 | : {
30 | offset: pages
31 | .map((page) => page.length)
32 | .reduce((sum, cur) => sum + cur, 0),
33 | },
34 | staleTime: Infinity,
35 | });
36 |
37 | const isLoading = isFetching || isFetchingNextPage;
38 |
39 | const posts = data ? data.pages.flat() : [];
40 |
41 | const [modal, activePostItemRef] = usePostsList(posts, {
42 | fetchNextPage,
43 | hasNextPage,
44 | isFetching,
45 | isFetchingNextPage,
46 | });
47 |
48 | return (
49 | <>
50 |
51 | {posts.map((post, index) => (
52 |
67 |
82 | {
85 | activePostItemRef.current = event.currentTarget;
86 | }}
87 | {...post}
88 | />
89 |
90 |
91 | ))}
92 |
93 |
94 | {modal}
95 | >
96 | );
97 | }
98 |
99 | TopicPostsList.prefetchQueries = async function prefetchQueries(
100 | queryClient,
101 | context
102 | ) {
103 | const { topic, latest } = context.router.query;
104 | const sort = latest ? 'created' : 'like';
105 |
106 | await queryClient.prefetchInfiniteQuery(['topics/posts', { topic, sort }]);
107 | };
108 |
109 | export default TopicPostsList;
110 |
--------------------------------------------------------------------------------
/src/components/topic-tag.js:
--------------------------------------------------------------------------------
1 | import { css } from 'styled-components';
2 | import Link from 'next/link';
3 |
4 | export default function TopicTag({ children, ...props }) {
5 | return (
6 |
7 |
23 | {children}
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/user-info.js:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import styled, { css } from 'styled-components';
3 | import Avatar from './avatar';
4 |
5 | const SecondRow = styled.span`
6 | color: rgba(0, 0, 0, 0.5);
7 | font-weight: 500;
8 | font-size: 12px;
9 | line-height: 17px;
10 | `;
11 |
12 | export default function UserInfo({
13 | gender,
14 | withNickname,
15 | school,
16 | department,
17 | postAvatar,
18 | children,
19 | hidden,
20 | ...props
21 | }) {
22 | const info = (
23 |
30 |
36 |
43 |
44 |
45 |
51 |
65 | {hidden ? (
66 | '這則回應已被刪除'
67 | ) : (
68 | <>
69 | {!school && !department && '匿名'}
70 | {school} {!withNickname && department}
71 | >
72 | )}
73 |
74 |
75 | {children ? (
76 | {children}
77 | ) : (
78 | withNickname && @{department}
79 | )}
80 |
81 |
82 | );
83 |
84 | if (withNickname) {
85 | return (
86 |
87 | {info}
88 |
89 | );
90 | }
91 |
92 | return info;
93 | }
94 |
--------------------------------------------------------------------------------
/src/components/video-player.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import styled, { css } from 'styled-components';
3 | import Image from 'next/image';
4 |
5 | function YouTubePlayIcon(props) {
6 | return (
7 |
23 | );
24 | }
25 |
26 | const PlayerContainer = styled.div`
27 | padding-top: 56.25%;
28 | position: relative;
29 | background: #000;
30 | `;
31 |
32 | const IFrame = styled.iframe`
33 | position: absolute;
34 | top: 0;
35 | left: 0;
36 | width: 100%;
37 | height: 100%;
38 | `;
39 |
40 | function YouTubePlayer({ src, thumbnail, ...props }) {
41 | const [played, setPlayed] = useState(false);
42 | const match = src.match(/\?v=([\w-_]+)/);
43 |
44 | if (!match) {
45 | return null;
46 | }
47 |
48 | const [, id] = match;
49 |
50 | return (
51 |
52 | {played ? (
53 |
62 | ) : (
63 |
86 | )}
87 |
88 | );
89 | }
90 |
91 | function VimeoPlayer({ src, ...props }) {
92 | const match = src.match(/vimeo.com\/(\d+)/);
93 |
94 | if (!match) {
95 | return null;
96 | }
97 |
98 | const [, id] = match;
99 |
100 | return (
101 | <>
102 |
103 |
110 |
111 |
112 | >
113 | );
114 | }
115 |
116 | function HTML5Video({ src, thumbnail, ...props }) {
117 | let normalizedUrl = src;
118 | if (normalizedUrl.startsWith('https://www.dcard.tw/v2/vivid/videos/')) {
119 | const urlObject = new URL(normalizedUrl);
120 | const videoID = urlObject.pathname.slice('/v2/vivid/videos/'.length);
121 | normalizedUrl = `https://vivid.dcard.tw/Public/${videoID}/source`;
122 | }
123 |
124 | return (
125 |
138 | );
139 | }
140 |
141 | export default function VideoPlayer({ type, ...props }) {
142 | switch (type) {
143 | case 'video/youtube': {
144 | return ;
145 | }
146 | case 'video/vimeo': {
147 | return ;
148 | }
149 | case 'video/vivid':
150 | default:
151 | return ;
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/src/components/with-layout.js:
--------------------------------------------------------------------------------
1 | export default function withLayout(getLayout) {
2 | return (Page) => {
3 | const Component = (props) => ;
4 |
5 | Component.getLayout = getLayout;
6 | Component.getInitialProps = Page.getInitialProps;
7 | Component.prefetchQueries = Page.prefetchQueries;
8 |
9 | Component.displayName = `withLayout(${Page.displayName || Page.name})`;
10 |
11 | return Component;
12 | };
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/zoomable-image.js:
--------------------------------------------------------------------------------
1 | import { css } from 'styled-components';
2 | import Image from 'next/image';
3 | import {
4 | useDialogState,
5 | Dialog,
6 | DialogBackdrop,
7 | DialogDisclosure,
8 | VisuallyHidden,
9 | } from 'reakit';
10 |
11 | // TODO: Load the placeholder
12 | export default function ZoomableImage({ children, ...props }) {
13 | const dialog = useDialogState({ animated: true });
14 |
15 | return (
16 | <>
17 | div {
25 | max-height: 60vh;
26 | }
27 | `}
28 | >
29 | {children || }
30 | 放大圖片
31 |
32 |
33 | {(dialog.visible || dialog.animating) && (
34 |
61 |
89 |
90 | )}
91 | >
92 | );
93 | }
94 |
--------------------------------------------------------------------------------
/src/hooks/use-animate-height.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { css } from 'styled-components';
3 |
4 | export default function useAnimateHeight(initialHeight = 0) {
5 | const [scrollHeight, setScrollHeight] = useState(initialHeight);
6 |
7 | function refCallback(node) {
8 | if (node) {
9 | setScrollHeight(node.scrollHeight);
10 | }
11 | }
12 |
13 | return [
14 | css`
15 | height: ${initialHeight}px;
16 |
17 | &[data-enter] {
18 | height: ${scrollHeight}px;
19 | }
20 | `,
21 | refCallback,
22 | ];
23 | }
24 |
--------------------------------------------------------------------------------
/src/hooks/use-current.js:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect } from 'react';
2 |
3 | export default function useCurrent(value) {
4 | const ref = useRef(value);
5 |
6 | useEffect(() => {
7 | ref.current = value;
8 | });
9 |
10 | return ref;
11 | }
12 |
--------------------------------------------------------------------------------
/src/hooks/use-forums-query.js:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import { useQueryClient, useQuery } from 'react-query';
3 |
4 | const QUERY_KEY = '@server:forums';
5 |
6 | export function setDehydratedForums(queryClient, dehydratedForums) {
7 | queryClient.setQueryData(QUERY_KEY, (forumsData = {}) => ({
8 | ...forumsData,
9 | ...dehydratedForums,
10 | }));
11 | }
12 |
13 | export function useForumsQuery() {
14 | const queryClient = useQueryClient();
15 |
16 | const dehydratedForums = queryClient.getQueryData('@server:forums');
17 |
18 | const forumsQuery = useQuery('forums', {
19 | staleTime: Infinity,
20 | placeholderData: dehydratedForums,
21 | });
22 |
23 | return forumsQuery;
24 | }
25 |
26 | export function useForumByAlias(alias) {
27 | const { data: forums } = useForumsQuery();
28 |
29 | return useMemo(
30 | () =>
31 | forums && Object.values(forums).find((forum) => forum.alias === alias),
32 | [forums, alias]
33 | );
34 | }
35 |
36 | export function useForumByID(id) {
37 | const { data: forums } = useForumsQuery();
38 |
39 | return useMemo(() => forums?.[id], [forums, id]);
40 | }
41 |
--------------------------------------------------------------------------------
/src/hooks/use-infinite.js:
--------------------------------------------------------------------------------
1 | export default function useInfinite(fetchMore, isFetching, { rootRef } = {}) {
2 | function refCallback(node) {
3 | if (node) {
4 | function handleIntersect([entry]) {
5 | if (entry.isIntersecting && !isFetching) {
6 | fetchMore();
7 |
8 | observer.disconnect();
9 | }
10 | }
11 |
12 | const observer = new IntersectionObserver(handleIntersect, {
13 | root: rootRef?.current ?? null,
14 | rootMargin: '1000px 0px',
15 | });
16 |
17 | observer.observe(node);
18 | }
19 | }
20 |
21 | return ;
22 | }
23 |
--------------------------------------------------------------------------------
/src/hooks/use-modal-parent-location.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 | import { useRouter } from 'next/router';
3 |
4 | export default function useModalParentLocation(isModalOpen) {
5 | const router = useRouter();
6 | const modalParentLocationRef = useRef(router);
7 |
8 | useEffect(() => {
9 | if (!isModalOpen) {
10 | modalParentLocationRef.current = router;
11 | }
12 | }, [isModalOpen, router]);
13 |
14 | return isModalOpen ? modalParentLocationRef.current : router;
15 | }
16 |
--------------------------------------------------------------------------------
/src/hooks/use-params.js:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import { useRouter } from 'next/router';
3 |
4 | const PARAM_REGEX = /\/\[(\w+)\](?:$|[/?#])/g;
5 |
6 | // There seems to be no official ways in Next.js to distinguish search params from route params.
7 | // e.g. /f/[forumAlias] and /f?forumAlias= both have the forumAlias key in router.query
8 | // Hence, this hook parses the route and returns only the route params.
9 | export default function useParams() {
10 | const router = useRouter();
11 |
12 | return useMemo(() => {
13 | const params = {};
14 |
15 | let match;
16 |
17 | do {
18 | match = PARAM_REGEX.exec(router.route);
19 |
20 | if (match) {
21 | const [, param] = match;
22 | params[param] = router.query[param];
23 | }
24 | } while ((match = PARAM_REGEX.exec(router.route)));
25 |
26 | PARAM_REGEX.lastIndex = 0;
27 |
28 | return params;
29 | }, [router]);
30 | }
31 |
--------------------------------------------------------------------------------
/src/hooks/use-posts-list.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 | import { css } from 'styled-components';
3 | import { useRouter } from 'next/router';
4 | import dynamic from 'next/dynamic';
5 | import useCurrent from './use-current';
6 | import useInfinite from './use-infinite';
7 |
8 | const PostModal = dynamic(() => import('../components/post-modal'), {
9 | ssr: false,
10 | });
11 |
12 | export default function usePostsList(
13 | posts,
14 | { fetchNextPage, hasNextPage, isFetching, isFetchingNextPage }
15 | ) {
16 | const router = useRouter();
17 |
18 | const activePostItemRef = useRef();
19 |
20 | const isLoading = isFetching || isFetchingNextPage;
21 |
22 | const anchor = useInfinite(fetchNextPage, isLoading);
23 | const postsLengthRef = useCurrent(posts.length);
24 | const postIndex = parseInt(router.query.postIndex, 10);
25 |
26 | const prevPost = posts[postIndex - 1];
27 | const nextPost = posts[postIndex + 1];
28 |
29 | useEffect(() => {
30 | if (postIndex === postsLengthRef.current - 1 && hasNextPage) {
31 | fetchNextPage();
32 | }
33 | }, [postIndex, postsLengthRef, fetchNextPage, hasNextPage]);
34 |
35 | const children = (
36 | <>
37 | {hasNextPage && anchor}
38 | {!isFetching && !hasNextPage && (
39 |
50 | 沒有更多文章囉!
51 |
52 | )}
53 |
61 | >
62 | );
63 |
64 | return [children, activePostItemRef];
65 | }
66 |
--------------------------------------------------------------------------------
/src/hooks/use-previous.js:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect } from 'react';
2 |
3 | export default function usePrevious(value) {
4 | const ref = useRef(value);
5 |
6 | useEffect(() => {
7 | ref.current = value;
8 | }, [value]);
9 |
10 | return ref.current;
11 | }
12 |
--------------------------------------------------------------------------------
/src/pages/@/[persona].js:
--------------------------------------------------------------------------------
1 | import { css } from 'styled-components';
2 | import { useRouter } from 'next/router';
3 | import { useQuery } from 'react-query';
4 | import Head from '../../components/head';
5 | import PersonaPostsList from '../../components/persona-posts-list';
6 | import withLayout from '../../components/with-layout';
7 | import Layout from '../../components/layout';
8 | import Avatar from '../../components/avatar';
9 | import useModalParentLocation from '../../hooks/use-modal-parent-location';
10 |
11 | function PersonaInfo() {
12 | const router = useRouter();
13 | const modalParentLocation = useModalParentLocation(!!router.query.postID);
14 | const { persona } = modalParentLocation.query;
15 |
16 | const { data: personaInfo } = useQuery(`personas/${persona}`, {
17 | staleTime: Infinity,
18 | });
19 |
20 | if (!personaInfo) {
21 | return null;
22 | }
23 |
24 | const {
25 | gender,
26 | nickname,
27 | postAvatar,
28 | postCount,
29 | subscriptionCount,
30 | } = personaInfo;
31 |
32 | return (
33 |
43 |
44 |
53 |
59 |
68 | {nickname}
69 |
70 |
79 | @{persona}
80 |
81 |
89 | {postCount} 篇文章・
90 | {subscriptionCount} 位粉絲
91 |
92 |
93 |
94 | );
95 | }
96 |
97 | function PersonaPage() {
98 | return (
99 | <>
100 |
101 |
102 |
103 | >
104 | );
105 | }
106 |
107 | PersonaPage.prefetchQueries = async function prefetchQueries(
108 | queryClient,
109 | context
110 | ) {
111 | const { persona } = context.router.query;
112 |
113 | await Promise.all([
114 | Layout.prefetchQueries(queryClient, context),
115 | // Use fetchQuery to catch the errors
116 | queryClient.fetchQuery(`personas/${persona}`),
117 | PersonaPostsList.prefetchQueries(queryClient, context),
118 | ]);
119 | };
120 |
121 | export default withLayout((children) => (props) => (
122 |
131 | {children}
132 |
133 | ))(PersonaPage);
134 |
--------------------------------------------------------------------------------
/src/pages/_app.js:
--------------------------------------------------------------------------------
1 | import BaseApp from 'next/app';
2 | import Error from 'next/error';
3 | import { createGlobalStyle } from 'styled-components';
4 | import { QueryClientProvider, QueryClient } from 'react-query';
5 | import { Hydrate, dehydrate } from 'react-query/hydration';
6 | import { ReactQueryDevtools } from 'react-query/devtools';
7 | import { Provider } from 'reakit';
8 | import 'modern-normalize';
9 | import queryFn from '../utils/query-fn';
10 |
11 | const GlobalStyle = createGlobalStyle`
12 | html {
13 | line-height: 1.15;
14 | -webkit-text-size-adjust: 100%;
15 | }
16 |
17 | body {
18 | margin: 0;
19 | padding: 0;
20 | font-size: 14px;
21 | -webkit-font-smoothing: antialiased;
22 | -moz-osx-font-smoothing: grayscale;
23 | background-color: rgb(0, 50, 78);
24 | }
25 |
26 | body, button {
27 | font-family: Roboto,Helvetica Neue,Helvetica,Arial,PingFang TC,黑體-繁,Heiti TC,蘋果儷中黑,Apple LiGothic Medium,微軟正黑體,Microsoft JhengHei,sans-serif;
28 | text-rendering: optimizeLegibility;
29 | }
30 |
31 | button {
32 | background: transparent;
33 | border: none;
34 | font-size: 14px;
35 | padding: 0;
36 |
37 | :focus:not(:focus-visible) {
38 | outline: none;
39 | }
40 | }
41 |
42 | ul, ol {
43 | padding: 0;
44 | margin: 0;
45 | }
46 |
47 | a {
48 | text-decoration: none;
49 | color: #3397cf;
50 | }
51 | `;
52 |
53 | const getQueryClientConfig = (req) => ({
54 | defaultOptions: {
55 | queries: {
56 | queryFn: (context) => queryFn({ ...context, req }),
57 | },
58 | },
59 | });
60 |
61 | const queryClient = new QueryClient(getQueryClientConfig());
62 |
63 | function App({ Component, pageProps, dehydratedState, ...rest }) {
64 | if (!dehydratedState) {
65 | return ;
66 | }
67 |
68 | const children = ;
69 | const withLayout = Component.getLayout?.(children)?.(pageProps) ?? children;
70 |
71 | return (
72 |
73 |
74 |
75 | {withLayout}
76 |
77 |
78 |
79 | );
80 | }
81 |
82 | App.getInitialProps = async function getInitialProps(context) {
83 | const queryClient = new QueryClient(getQueryClientConfig(context.ctx.req));
84 |
85 | // Skip prefetching on client-side
86 | if (typeof window === 'undefined') {
87 | try {
88 | await context.Component.prefetchQueries?.(queryClient, context);
89 | } catch (error) {
90 | if (error.statusCode) {
91 | return error;
92 | }
93 | return { statusCode: 404, error };
94 | }
95 | }
96 |
97 | // Don't send the whole forums data to client side on initial page load
98 | queryClient.removeQueries('forums', { exact: true });
99 |
100 | const appProps = await BaseApp.getInitialProps(context);
101 |
102 | return {
103 | ...appProps,
104 | dehydratedState: dehydrate(queryClient),
105 | };
106 | };
107 |
108 | export default App;
109 |
--------------------------------------------------------------------------------
/src/pages/_document.js:
--------------------------------------------------------------------------------
1 | import NextDocument, { Html, Head, Main, NextScript } from 'next/document';
2 | import { ServerStyleSheet } from 'styled-components';
3 |
4 | export default class Document extends NextDocument {
5 | static async getInitialProps(ctx) {
6 | const sheet = new ServerStyleSheet();
7 | const originalRenderPage = ctx.renderPage;
8 |
9 | try {
10 | ctx.renderPage = () =>
11 | originalRenderPage({
12 | enhanceApp: (App) => (props) =>
13 | sheet.collectStyles(),
14 | });
15 |
16 | const initialProps = await NextDocument.getInitialProps(ctx);
17 | return {
18 | ...initialProps,
19 | styles: (
20 | <>
21 | {initialProps.styles}
22 | {sheet.getStyleElement()}
23 | >
24 | ),
25 | };
26 | } finally {
27 | sheet.seal();
28 | }
29 | }
30 |
31 | render() {
32 | return (
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | );
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/pages/api/forums/bulletin.js:
--------------------------------------------------------------------------------
1 | import { getBulletin } from '../../../apis';
2 |
3 | export default async function bulletinHandler(req, res) {
4 | let { forumID } = req.query;
5 |
6 | const bulletin = await getBulletin(forumID);
7 |
8 | res.status(200).json(bulletin);
9 | }
10 |
--------------------------------------------------------------------------------
/src/pages/api/forums/categorization/category.js:
--------------------------------------------------------------------------------
1 | import { getCategory } from '../../../../apis';
2 |
3 | export default async function categoryHandler(req, res) {
4 | const { categoryID, ...query } = req.query;
5 |
6 | const category = await getCategory(categoryID, query);
7 |
8 | res.status(200).json(category);
9 | }
10 |
--------------------------------------------------------------------------------
/src/pages/api/forums/categorization/index.js:
--------------------------------------------------------------------------------
1 | import { getCategorization } from '../../../../apis';
2 |
3 | export default async function categorizationHandler(req, res) {
4 | const categorization = await getCategorization(req.query);
5 |
6 | res.status(200).json(categorization);
7 | }
8 |
--------------------------------------------------------------------------------
/src/pages/api/forums/index.js:
--------------------------------------------------------------------------------
1 | import { getForums } from '../../../apis';
2 |
3 | export default async function forumsHandler(req, res) {
4 | const forums = await getForums(req.query);
5 |
6 | res.status(200).json(forums);
7 | }
8 |
--------------------------------------------------------------------------------
/src/pages/api/forums/popular-forums.js:
--------------------------------------------------------------------------------
1 | import { getPopularForums } from '../../../apis';
2 |
3 | export default async function popularForumsHandler(req, res) {
4 | const popularForums = await getPopularForums(req.query);
5 |
6 | res.status(200).json(popularForums);
7 | }
8 |
--------------------------------------------------------------------------------
/src/pages/api/forums/selected-forums.js:
--------------------------------------------------------------------------------
1 | import { getSelectedForums } from '../../../apis';
2 |
3 | export default async function selectedForumsHandler(req, res) {
4 | const selectedForums = await getSelectedForums(req.query);
5 |
6 | res.status(200).json(selectedForums);
7 | }
8 |
--------------------------------------------------------------------------------
/src/pages/api/personas/[persona].js:
--------------------------------------------------------------------------------
1 | import { getPersonaInfo } from '../../../apis';
2 |
3 | export default async function personaInfoHandler(req, res) {
4 | const { persona } = req.query;
5 | const personaInfo = await getPersonaInfo(persona);
6 |
7 | res.status(200).json(personaInfo);
8 | }
9 |
--------------------------------------------------------------------------------
/src/pages/api/personas/posts.js:
--------------------------------------------------------------------------------
1 | import { getPersonaPosts } from '../../../apis';
2 |
3 | export default async function personaPostsHandler(req, res) {
4 | const { persona, ...queries } = req.query;
5 | const personaPosts = await getPersonaPosts(persona, queries);
6 |
7 | res.status(200).json(personaPosts);
8 | }
9 |
--------------------------------------------------------------------------------
/src/pages/api/posts/[postID]/comment.js:
--------------------------------------------------------------------------------
1 | import { getComment } from '../../../../apis';
2 |
3 | export default async function commentHandler(req, res) {
4 | const { postID, floor } = req.query;
5 |
6 | const comment = await getComment(postID, parseInt(floor, 10));
7 |
8 | res.status(200).json(comment);
9 | }
10 |
--------------------------------------------------------------------------------
/src/pages/api/posts/[postID]/comments.js:
--------------------------------------------------------------------------------
1 | import { getComments } from '../../../../apis';
2 |
3 | export default async function commentsHandler(req, res) {
4 | const { postID, ...query } = req.query;
5 |
6 | const comments = await getComments(postID, query);
7 |
8 | res.status(200).json(comments);
9 | }
10 |
--------------------------------------------------------------------------------
/src/pages/api/posts/[postID]/index.js:
--------------------------------------------------------------------------------
1 | import { getPost } from '../../../../apis';
2 |
3 | export default async function postHandler(req, res) {
4 | const { postID } = req.query;
5 |
6 | const post = await getPost(postID);
7 |
8 | res.status(200).json(post);
9 | }
10 |
--------------------------------------------------------------------------------
/src/pages/api/posts/[postID]/preview.js:
--------------------------------------------------------------------------------
1 | import { getPostPreview } from '../../../../apis';
2 |
3 | export default async function postHandler(req, res) {
4 | const { postID } = req.query;
5 |
6 | const postPreview = await getPostPreview(postID);
7 |
8 | if (!postPreview || !postPreview.length) {
9 | res.status(404).end();
10 | return;
11 | }
12 |
13 | res.status(200).json(postPreview[0]);
14 | }
15 |
--------------------------------------------------------------------------------
/src/pages/api/posts/darsys.js:
--------------------------------------------------------------------------------
1 | import { getDarsys } from '../../../apis';
2 |
3 | export default async function postsHandler(req, res) {
4 | const darsys = await getDarsys(req.query.postID);
5 |
6 | res.status(200).json(darsys);
7 | }
8 |
--------------------------------------------------------------------------------
/src/pages/api/posts/index.js:
--------------------------------------------------------------------------------
1 | import { getInfinitePosts } from '../../../apis';
2 |
3 | export default async function postsHandler(req, res) {
4 | const { popular = 'true', limit = '30', ...queries } = req.query;
5 | const posts = await getInfinitePosts({
6 | popular: popular === 'true',
7 | limit: parseInt(limit, 10),
8 | ...queries,
9 | });
10 |
11 | res.status(200).json(posts);
12 | }
13 |
--------------------------------------------------------------------------------
/src/pages/api/posts/link-attachment.js:
--------------------------------------------------------------------------------
1 | import { getLinkAttachment } from '../../../apis';
2 |
3 | export default async function linkAttachmentHandler(req, res) {
4 | const linkAttachment = await getLinkAttachment(req.query.url);
5 |
6 | if (!linkAttachment) {
7 | res.status(404).end();
8 | return;
9 | }
10 |
11 | res.status(200).json(linkAttachment);
12 | }
13 |
--------------------------------------------------------------------------------
/src/pages/api/posts/reactions.js:
--------------------------------------------------------------------------------
1 | import { getReactions } from '../../../apis';
2 |
3 | export default async function reactionsHandler(req, res) {
4 | const reactions = await getReactions();
5 |
6 | res.status(200).json(reactions);
7 | }
8 |
--------------------------------------------------------------------------------
/src/pages/api/search/forums.js:
--------------------------------------------------------------------------------
1 | import { getSearchForums } from '../../../apis';
2 |
3 | export default async function searchForumsHandler(req, res) {
4 | const { query, ...queries } = req.query;
5 | const searchForums = await getSearchForums(query, queries);
6 |
7 | res.status(200).json(searchForums);
8 | }
9 |
--------------------------------------------------------------------------------
/src/pages/api/search/personas.js:
--------------------------------------------------------------------------------
1 | import { getSearchPersonas } from '../../../apis';
2 |
3 | export default async function searchPersonasHandler(req, res) {
4 | const { query, ...queries } = req.query;
5 | const searchPersonas = await getSearchPersonas(query, queries);
6 |
7 | res.status(200).json(searchPersonas);
8 | }
9 |
--------------------------------------------------------------------------------
/src/pages/api/search/posts.js:
--------------------------------------------------------------------------------
1 | import { getSearchPosts } from '../../../apis';
2 |
3 | export default async function searchPostsHandler(req, res) {
4 | const { query, ...queries } = req.query;
5 | const searchPosts = await getSearchPosts(query, queries);
6 |
7 | res.status(200).json(searchPosts);
8 | }
9 |
--------------------------------------------------------------------------------
/src/pages/api/search/topics.js:
--------------------------------------------------------------------------------
1 | import { getSearchTopics } from '../../../apis';
2 |
3 | export default async function searchTopicsHandler(req, res) {
4 | const { query, ...queries } = req.query;
5 | const searchTopics = await getSearchTopics(query, queries);
6 |
7 | res.status(200).json(searchTopics);
8 | }
9 |
--------------------------------------------------------------------------------
/src/pages/api/topics/index.js:
--------------------------------------------------------------------------------
1 | import { getTopicInfo } from '../../../apis';
2 |
3 | export default async function topicInfoHandler(req, res) {
4 | const { topic } = req.query;
5 | const topicInfo = await getTopicInfo(topic);
6 |
7 | res.status(200).json(topicInfo);
8 | }
9 |
--------------------------------------------------------------------------------
/src/pages/api/topics/posts.js:
--------------------------------------------------------------------------------
1 | import { getTopicPosts } from '../../../apis';
2 |
3 | export default async function topicPostsHandler(req, res) {
4 | const { topic, ...queries } = req.query;
5 | const topicPosts = await getTopicPosts(topic, queries);
6 |
7 | res.status(200).json(topicPosts);
8 | }
9 |
--------------------------------------------------------------------------------
/src/pages/f/[forumAlias]/index.js:
--------------------------------------------------------------------------------
1 | import PostsList from '../../../components/posts-list';
2 | import Layout from '../../../components/layout';
3 | import Forum from '../../../components/forum';
4 | import withLayout from '../../../components/with-layout';
5 |
6 | function ForumPage() {
7 | return ;
8 | }
9 |
10 | ForumPage.prefetchQueries = async function prefetchQueries(
11 | queryClient,
12 | context
13 | ) {
14 | await Promise.all([
15 | Layout.prefetchQueries(queryClient, context),
16 | Forum.prefetchQueries(queryClient, context),
17 | PostsList.prefetchQueries(queryClient, context),
18 | ]);
19 | };
20 |
21 | export default withLayout((children) => (props) => (
22 | } {...props}>
23 | {children}
24 |
25 | ))(ForumPage);
26 |
--------------------------------------------------------------------------------
/src/pages/f/[forumAlias]/p/[postID]/b/[floor].js:
--------------------------------------------------------------------------------
1 | import { css } from 'styled-components';
2 | import { useRouter } from 'next/router';
3 | import { useQuery } from 'react-query';
4 | import Head from '../../../../../../components/head';
5 | import Floor from '../../../../../../components/floor';
6 | import Layout from '../../../../../../components/layout';
7 | import Forum from '../../../../../../components/forum';
8 | import withLayout from '../../../../../../components/with-layout';
9 | import PostPreview from '../../../../../../components/post-preview';
10 |
11 | function PostFloorPage() {
12 | const router = useRouter();
13 | const { forumAlias, postID, floor } = router.query;
14 |
15 | const { data: post } = useQuery(`posts/${postID}`);
16 |
17 | return (
18 |
23 | {post &&
}
24 |
25 |
26 |
34 |
42 | 此回應位於文章 B{floor}
43 |
44 |
51 |
52 |
53 |
54 | );
55 | }
56 |
57 | PostFloorPage.prefetchQueries = async function prefetchQueries(
58 | queryClient,
59 | context
60 | ) {
61 | const { postID } = context.router.query;
62 |
63 | await Promise.all([
64 | Layout.prefetchQueries(queryClient, context),
65 | queryClient.prefetchQuery(`posts/${postID}`),
66 | Floor.prefetchQueries(queryClient, context),
67 | ]);
68 | };
69 |
70 | export default withLayout((children) => (props) => (
71 | } {...props}>
72 | {children}
73 |
74 | ))(PostFloorPage);
75 |
--------------------------------------------------------------------------------
/src/pages/f/[forumAlias]/p/[postID]/index.js:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import Post from '../../../../../components/post';
3 | import Layout from '../../../../../components/layout';
4 | import withLayout from '../../../../../components/with-layout';
5 |
6 | function PostPage() {
7 | const router = useRouter();
8 | const { postID } = router.query;
9 |
10 | return ;
11 | }
12 |
13 | PostPage.prefetchQueries = async function prefetchQueries(
14 | queryClient,
15 | context
16 | ) {
17 | await Promise.all([
18 | Layout.prefetchQueries(queryClient, context),
19 | queryClient.fetchQuery(`posts/${context.router.query.postID}`),
20 | Post.prefetchQueries(queryClient, context),
21 | ]);
22 | };
23 |
24 | export default withLayout((children) => (props) => (
25 | {children}
26 | ))(PostPage);
27 |
--------------------------------------------------------------------------------
/src/pages/f/[forumAlias]/rule.js:
--------------------------------------------------------------------------------
1 | import Layout from '../../../components/layout';
2 | import Forum from '../../../components/forum';
3 | import Rule from '../../../components/rule';
4 | import withLayout from '../../../components/with-layout';
5 | function ForumRulePage() {
6 | return ;
7 | }
8 |
9 | ForumRulePage.prefetchQueries = async function prefetchQueries(
10 | queryClient,
11 | context
12 | ) {
13 | await Promise.all([
14 | Layout.prefetchQueries(queryClient, context),
15 | Forum.prefetchQueries(queryClient, context),
16 | Rule.prefetchQueries(queryClient, context),
17 | ]);
18 | };
19 |
20 | export default withLayout((children) => (props) => (
21 | } {...props}>
22 | {children}
23 |
24 | ))(ForumRulePage);
25 |
--------------------------------------------------------------------------------
/src/pages/f/index.js:
--------------------------------------------------------------------------------
1 | import PostsList from '../../components/posts-list';
2 | import withLayout from '../../components/with-layout';
3 | import Layout from '../../components/layout';
4 | import Forum from '../../components/forum';
5 |
6 | function IndexForumPage() {
7 | return ;
8 | }
9 |
10 | IndexForumPage.prefetchQueries = async function prefetchQueries(
11 | queryClient,
12 | context
13 | ) {
14 | await Promise.all([
15 | Layout.prefetchQueries(queryClient, context),
16 | Forum.prefetchQueries(queryClient, context),
17 | PostsList.prefetchQueries(queryClient, context),
18 | ]);
19 | };
20 |
21 | export default withLayout((children) => (props) => (
22 | } {...props}>
23 | {children}
24 |
25 | ))(IndexForumPage);
26 |
--------------------------------------------------------------------------------
/src/pages/forum/all.js:
--------------------------------------------------------------------------------
1 | import Head from '../../components/head';
2 | import withLayout from '../../components/with-layout';
3 | import Layout from '../../components/layout';
4 | import ForumCategory from '../../components/forum-category';
5 |
6 | function AllForumPage() {
7 | return (
8 | <>
9 |
10 |
11 |
12 | >
13 | );
14 | }
15 |
16 | AllForumPage.prefetchQueries = async function prefetchQueries(
17 | queryClient,
18 | context
19 | ) {
20 | await Promise.all([
21 | Layout.prefetchQueries(queryClient, context),
22 | ForumCategory.prefetchQueries(queryClient, context),
23 | ]);
24 | };
25 |
26 | export default withLayout((children) => (props) => (
27 | {children}
28 | ))(AllForumPage);
29 |
--------------------------------------------------------------------------------
/src/pages/forum/popular.js:
--------------------------------------------------------------------------------
1 | import Head from '../../components/head';
2 | import withLayout from '../../components/with-layout';
3 | import Layout from '../../components/layout';
4 | import PopularForums from '../../components/popular-forums';
5 |
6 | function PopularForumsPage() {
7 | return (
8 | <>
9 |
10 |
11 |
12 | >
13 | );
14 | }
15 |
16 | PopularForumsPage.prefetchQueries = async function prefetchQueries(
17 | queryClient,
18 | context
19 | ) {
20 | await Promise.all([
21 | Layout.prefetchQueries(queryClient, context),
22 | PopularForums.prefetchQueries(queryClient, context),
23 | ]);
24 | };
25 |
26 | export default withLayout((children) => (props) => (
27 | {children}
28 | ))(PopularForumsPage);
29 |
--------------------------------------------------------------------------------
/src/pages/search/forums.js:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import Head from '../../components/head';
3 | import SearchForumsList from '../../components/search-forums-list';
4 | import withLayout from '../../components/with-layout';
5 | import Layout from '../../components/layout';
6 | import Search from '../../components/search';
7 |
8 | function SearchForumsPage() {
9 | const router = useRouter();
10 | const { query } = router.query;
11 |
12 | return (
13 | <>
14 |
15 |
16 |
17 | >
18 | );
19 | }
20 |
21 | SearchForumsPage.prefetchQueries = async function prefetchQueries(
22 | queryClient,
23 | context
24 | ) {
25 | await Promise.all([
26 | Layout.prefetchQueries(queryClient, context),
27 | SearchForumsList.prefetchQueries(queryClient, context),
28 | ]);
29 | };
30 |
31 | export default withLayout((children) => (props) => (
32 |
33 | {children}
34 |
35 | ))(SearchForumsPage);
36 |
--------------------------------------------------------------------------------
/src/pages/search/index.js:
--------------------------------------------------------------------------------
1 | import { css } from 'styled-components';
2 | import Link from 'next/link';
3 | import { useQuery, useQueryClient } from 'react-query';
4 | import { useRouter } from 'next/router';
5 | import Head from '../../components/head';
6 | import SearchPostsList from '../../components/search-posts-list';
7 | import withLayout from '../../components/with-layout';
8 | import Layout from '../../components/layout';
9 | import Search from '../../components/search';
10 | import ForumCard from '../../components/forum-card';
11 | import TopicItem from '../../components/topic-item';
12 | import useModalParentLocation from '../../hooks/use-modal-parent-location';
13 |
14 | // TODO: Show up to 5 forums and a slider.
15 | function ForumsSection({ query }) {
16 | const queryClient = useQueryClient();
17 | const forumsLimit30 = queryClient.getQueryData(['search/forums', { query }]);
18 | const { data: forumsLimit4 = [] } = useQuery(
19 | ['search/forums', { query, limit: 4 }],
20 | { staleTime: Infinity, enabled: !forumsLimit30 }
21 | );
22 |
23 | const forums = (forumsLimit30 ?? forumsLimit4).slice(0, 4);
24 |
25 | if (!forums.length) {
26 | return null;
27 | }
28 |
29 | return (
30 |
38 |
46 | 看板
47 |
48 |
49 |
57 | {forums.map((forum) => (
58 | -
68 |
69 |
70 | ))}
71 |
72 |
73 |
79 | 查看更多看板
80 |
81 |
82 | );
83 | }
84 |
85 | function TopicsSection({ query }) {
86 | const queryClient = useQueryClient();
87 | const topicsLimit30 = queryClient.getQueryData(['search/topics', { query }]);
88 | const { data: topicsLimit3 = [] } = useQuery(
89 | ['search/topics', { query, limit: 3 }],
90 | { staleTime: Infinity, enabled: !topicsLimit30 }
91 | );
92 |
93 | const topics = (topicsLimit30 ?? topicsLimit3).slice(0, 3);
94 |
95 | if (!topics.length) {
96 | return null;
97 | }
98 |
99 | return (
100 |
108 |
116 | 話題
117 |
118 |
119 |
125 | {topics.map((topic) => (
126 |
127 | ))}
128 |
129 |
130 |
136 | 查看更多話題
137 |
138 |
139 | );
140 | }
141 | function SearchPage() {
142 | const router = useRouter();
143 | const isModalOpen = !!router.query.postID;
144 | const modalParentLocation = useModalParentLocation(isModalOpen);
145 | const { query } = modalParentLocation.query;
146 |
147 | return (
148 | <>
149 |