├── .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 | [![Edit dcard-clone](https://codesandbox.io/static/img/play-codesandbox.svg)](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 | 27 | 匿名 28 | 29 | 33 | 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 | 4 | Close modal 5 | 6 | 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 | 69 | {isOpen && } 70 | 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 |
47 | 65 | 66 | {!hidden && ( 67 | 98 | )} 99 |
100 | 101 | {children} 102 | 103 | 110 | {hidden 111 | ? '已經刪除的內容就像 Dcard 一樣,錯過是無法再相見的!' 112 | : content} 113 | 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 |
    95 | 心情 {likeCount} 96 | 97 | 回應 {commentCount} 98 |
    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 | 37 | {children} 38 | 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 | 10 | 11 | 12 | 16 | 17 | ); 18 | } 19 | 20 | export function BoyIcon(props) { 21 | return ( 22 | 29 | 30 | 31 | 35 | 36 | ); 37 | } 38 | 39 | export function GenderDIcon(props) { 40 | return ( 41 | 42 | 官方 43 | 44 | 45 | 46 | 47 | 48 | 49 | 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 | 10 | 喜歡 11 | 15 | 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 |
    100 | {footer} 101 |
    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 | {props.alt} 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 | {`${forum.name} 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 | 109 | {isOpen && ( 110 | <> 111 | 112 | 113 | 119 | 120 | {prevPost && ( 121 | 127 | )} 128 | {nextPost && ( 129 | 135 | )} 136 | 137 | 138 | )} 139 | 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 | {reaction.name} 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 | 41 |
    47 |

    58 | 這篇文章獲得的心情 59 |

    60 | 61 | 79 |
    80 | 81 |
      92 | {reactions.map((reaction) => ( 93 |
    • 112 | 118 | 119 | 120 | 126 | {reactionsData?.find((item) => item.id === reaction.id)?.name} 127 | 128 | {reaction.count} 129 |
    • 130 | ))} 131 |
    132 |
    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 |
    1. 文章明顯違規,但板主未依照板規執法
    2. 46 |
    3. 板主誤判違規文章,將當事人禁言
    4. 47 |
    5. 板規含有攻擊、歧視,違反全站站規等不適當的內容
    6. 48 |
    7. 板主連續多日未處理看板事務
    8. 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 |
    1. 74 |

      {rule.title}

      75 | {rule.content &&

      {rule.content}

      } 76 |

      違反此板規禁言 {rule.bucketDays} 天。

      77 |
    2. 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 |
    1. 102 |

      {rule.title}

      103 | {rule.content &&

      {rule.content}

      } 104 |

      違反此站規,於此看板禁言 {rule.bucketDays} 天。

      105 |
    2. 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 |
    { 33 | event.preventDefault(); 34 | 35 | if (value) { 36 | router.push({ 37 | pathname: '/search', 38 | query: { 39 | query: value, 40 | ...(searchForumAlias 41 | ? { 42 | forum: searchForumAlias, 43 | } 44 | : {}), 45 | }, 46 | }); 47 | } 48 | }} 49 | > 50 | 53 | 54 | setValue(event.target.value)} 81 | /> 82 | 83 | {!!value && ( 84 | 100 | )} 101 | 102 | 122 |
    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 | 93 | {label} 94 | 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 | 144 | {Object.entries(SORT_LABEL).map(([sortValue, label]) => ( 145 | 153 | ))} 154 | 155 | 156 | 157 | {SINCE_LABEL[since] ?? '不限時間'} 158 | 159 | 160 | {Object.entries(SINCE_LABEL).map(([sinceValue, label]) => ( 161 | 169 | ))} 170 | 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 | 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 | 15 | Play 16 | 21 | 22 | 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 |