Compare commits

...

2 Commits

Author SHA1 Message Date
Ivan Li
b031797271 feat: article details page. 2021-05-02 20:25:25 +08:00
Ivan Li
305eb667f4 feat: 亮色暗色模式切换。 2021-05-02 20:04:54 +08:00
13 changed files with 349 additions and 49 deletions

1
.env
View File

@ -1,2 +1,3 @@
NEXT_PUBLIC_FIRST_NAME=Ivan
NEXT_PUBLIC_LAST_NAME=Li
BACKEND_URI=http://127.0.0.1:7132/graphql

View File

@ -1,6 +1,98 @@
import { ApolloClient, InMemoryCache } from "@apollo/client";
import { useMemo } from "react";
import { ApolloClient, from, HttpLink, InMemoryCache } from "@apollo/client";
import { concatPagination } from "@apollo/client/utilities";
import { equals, mergeDeepWith } from "ramda";
import { onError } from "@apollo/client/link/error";
export const client = new ApolloClient({
uri: "/api/graphql",
cache: new InMemoryCache(),
});
export const APOLLO_STATE_PROP_NAME = "__APOLLO_STATE__";
let apolloClient: ApolloClient<InMemoryCache>;
function createApolloClient() {
const httpLink = new HttpLink({
uri:
typeof window === "undefined"
? process.env.BACKEND_URI
: "/api/graphql", // Server URL (must be absolute)
credentials: "same-origin", // Additional fetch() options like `credentials` or `headers`
});
const errorLink = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors)
graphQLErrors.forEach(({ message, locations, path }) =>
console.log(
`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`
)
);
if (networkError) console.log(`[Network error]: ${networkError}`);
});
return new ApolloClient({
ssrMode: typeof window === "undefined",
link: from([errorLink, httpLink]),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
allPosts: concatPagination(),
},
},
},
}),
});
}
export function initializeApollo(initialState = null) {
const _apolloClient = apolloClient ?? createApolloClient();
// If your page has Next.js data fetching methods that use Apollo Client, the initial state
// gets hydrated here
if (initialState) {
// Get existing cache, loaded during client side data fetching
const existingCache = _apolloClient.extract();
// Merge the existing cache into data passed from getStaticProps/getServerSideProps
const data = mergeDeepWith(
(a, b) => {
if (Array.isArray(a) && Array.isArray(b)) {
return [
...a,
...b.filter((bi) => a.every((ai) => !equals(ai, bi))),
];
} else {
return b;
}
},
initialState,
existingCache
);
// Restore the cache with the merged data
_apolloClient.cache.restore(data);
}
// For SSG and SSR always create a new Apollo Client
if (typeof window === "undefined") return _apolloClient;
// Create the Apollo Client once in the client
if (!apolloClient) apolloClient = _apolloClient;
return _apolloClient;
}
export function addApolloState(client, pageProps) {
if (pageProps?.props) {
pageProps.props[APOLLO_STATE_PROP_NAME] = client.cache.extract();
}
return pageProps;
}
export function useApollo(pageProps) {
const state = pageProps[APOLLO_STATE_PROP_NAME];
const store = useMemo(() => initializeApollo(state), [state]);
return store;
}

View File

@ -10,3 +10,14 @@ export const ARTICLE_FOR_HOME = gql`
}
}
`;
export const ARTICLE = gql`
query Article($id: String!) {
article(id: $id) {
id
title
content
publishedAt
}
}
`;

56
commons/theme.tsx Normal file
View File

@ -0,0 +1,56 @@
import React, { createContext, FC, useContext, useEffect, useState } from "react";
const getInitialTheme = (): Mode => {
if (typeof window === "undefined") {
return 'auto';
}
const text = window?.localStorage?.getItem('theme');
switch (text) {
case 'dark': return 'dark';
case 'light': return 'light';
default: return 'auto';
}
};
type RawMode = "light" | "dark";
type Mode = RawMode | "auto";
interface Content {
mode: Mode;
setMode: (mode: Mode) => void;
}
export const ThemeContext = createContext<Content>({
mode: 'error' as Mode,
setMode: (_) => {console.log},
});
export const ThemeProvider: FC = ({ children }) => {
const [mode, setMode] = useState<Mode>(() => getInitialTheme());
useEffect(() => {
if (window) {
localStorage.setItem("theme", mode);
let _mode = mode;
if (_mode === "auto") {
_mode =
window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
if (_mode === "dark") {
window.document.documentElement.classList.add("dark");
} else {
window.document.documentElement.classList.remove("dark");
}
}
}, [mode]);
return (
<ThemeContext.Provider value={{ mode, setMode }}>
{children}
</ThemeContext.Provider>
);
};
export function useTheme () {
return useContext(ThemeContext);
}

View File

@ -1,13 +1,12 @@
.wrapper {
@apply bg-green-400 text-white;
@apply bg-green-400 text-white min-h-screen;
:global(.dark) & {
@apply bg-gray-800 text-gray-400;
}
}
.sidebar {
@apply overflow-hidden flex flex-col fixed top-0;
@apply text-center shadow-2xl;
height: 100vh;
@apply text-center shadow-2xl h-screen;
padding-top: 10vh;
}

View File

@ -0,0 +1,10 @@
import { useTheme } from "../commons/theme";
export const SwitchTheme = () => {
const { setMode, mode } = useTheme();
if (mode === "light") {
return <button onClick={() => setMode("dark")}></button>;
}
return <button onClick={() => setMode("light")}></button>;
};

42
package-lock.json generated
View File

@ -13,6 +13,7 @@
"next": "10.2.0",
"postcss-import": "^14.0.1",
"postcss-nested": "^5.0.5",
"ramda": "^0.27.1",
"react": "17.0.2",
"react-dom": "17.0.2"
},
@ -27,6 +28,7 @@
"@types/graphql": "^14.5.0",
"@types/postcss-import": "^12.0.0",
"@types/postcss-nested": "^4.2.3",
"@types/ramda": "^0.27.40",
"@types/react": "^17.0.4",
"@types/tailwindcss": "^2.0.2",
"autoprefixer": "^10.2.5",
@ -2470,6 +2472,15 @@
"integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==",
"dev": true
},
"node_modules/@types/ramda": {
"version": "0.27.40",
"resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.27.40.tgz",
"integrity": "sha512-V99ZfTH2tqVYdLDAlgh2uT+N074HPgqnAsMjALKSBqogYd0HbuuGMqNukJ6fk9Ml/Htaus76fsc4Yh3p7q1VdQ==",
"dev": true,
"dependencies": {
"ts-toolbelt": "^6.15.1"
}
},
"node_modules/@types/react": {
"version": "17.0.4",
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.4.tgz",
@ -7681,6 +7692,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ramda": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.27.1.tgz",
"integrity": "sha512-PgIdVpn5y5Yns8vqb8FzBUEYn98V3xcPgawAkkgj0YJ0qDsnHCiNmZYfOGMgOvoB0eWFLpYbhxUR3mxfDIMvpw=="
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@ -8932,6 +8948,12 @@
}
}
},
"node_modules/ts-toolbelt": {
"version": "6.15.5",
"resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-6.15.5.tgz",
"integrity": "sha512-FZIXf1ksVyLcfr7M317jbB67XFJhOO1YqdTcuGaq9q5jLUoTikukZ+98TPjKiP2jC5CgmYdWWYs0s2nLSU0/1A==",
"dev": true
},
"node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
@ -11517,6 +11539,15 @@
"integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==",
"dev": true
},
"@types/ramda": {
"version": "0.27.40",
"resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.27.40.tgz",
"integrity": "sha512-V99ZfTH2tqVYdLDAlgh2uT+N074HPgqnAsMjALKSBqogYd0HbuuGMqNukJ6fk9Ml/Htaus76fsc4Yh3p7q1VdQ==",
"dev": true,
"requires": {
"ts-toolbelt": "^6.15.1"
}
},
"@types/react": {
"version": "17.0.4",
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.4.tgz",
@ -15684,6 +15715,11 @@
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
"dev": true
},
"ramda": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.27.1.tgz",
"integrity": "sha512-PgIdVpn5y5Yns8vqb8FzBUEYn98V3xcPgawAkkgj0YJ0qDsnHCiNmZYfOGMgOvoB0eWFLpYbhxUR3mxfDIMvpw=="
},
"randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@ -16688,6 +16724,12 @@
"resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz",
"integrity": "sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw=="
},
"ts-toolbelt": {
"version": "6.15.5",
"resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-6.15.5.tgz",
"integrity": "sha512-FZIXf1ksVyLcfr7M317jbB67XFJhOO1YqdTcuGaq9q5jLUoTikukZ+98TPjKiP2jC5CgmYdWWYs0s2nLSU0/1A==",
"dev": true
},
"tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",

View File

@ -16,6 +16,7 @@
"next": "10.2.0",
"postcss-import": "^14.0.1",
"postcss-nested": "^5.0.5",
"ramda": "^0.27.1",
"react": "17.0.2",
"react-dom": "17.0.2"
},
@ -30,6 +31,7 @@
"@types/graphql": "^14.5.0",
"@types/postcss-import": "^12.0.0",
"@types/postcss-nested": "^4.2.3",
"@types/ramda": "^0.27.40",
"@types/react": "^17.0.4",
"@types/tailwindcss": "^2.0.2",
"autoprefixer": "^10.2.5",

View File

@ -11,7 +11,7 @@
@apply bg-gray-700 text-gray-300;
}
.wrapper {
@apply mx-auto max-w-screen-xl;
@apply mx-auto max-w-screen-lg;
}
}
.pageHeader {

View File

@ -4,40 +4,32 @@ import React from "react";
import { GlobalSidebar } from "../components/layouts/global-sidebar";
import styles from "./_app.module.css";
import { ApolloProvider } from "@apollo/client";
import { client } from "../commons/graphql/client";
import { useApollo } from "../commons/graphql/client";
import { ThemeProvider, useTheme } from '../commons/theme';
import { SwitchTheme } from '../components/switch-theme';
function MyApp({ Component, pageProps }) {
const apolloClient = useApollo(pageProps);
return (
<ApolloProvider client={client}>
<div className={styles.page}>
<GlobalSidebar className={styles.sidebar} />
<div className={styles.primary}>
<header className={styles.pageHeader}>
<h1>{"Ivan Li 的个人博客"}</h1>
<div className={styles.actions}>
<button onClick={() => switchTheme("light")}></button>
<button onClick={() => switchTheme("dark")}></button>
<ApolloProvider client={apolloClient}>
<ThemeProvider>
<div className={styles.page}>
<GlobalSidebar className={styles.sidebar} />
<div className={styles.primary}>
<header className={styles.pageHeader}>
<h1>{"Ivan Li 的个人博客"}</h1>
<div className={styles.actions}>
<SwitchTheme />
</div>
</header>
<div className={styles.wrapper}>
<Component {...pageProps} />
</div>
</header>
<div className={styles.wrapper}>
<Component {...pageProps} />
</div>
</div>
</div>
</ThemeProvider>
</ApolloProvider>
);
}
function switchTheme(mode: "light" | "dark" | "auto") {
if (mode === "auto") {
mode = "light";
}
if (mode === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
export default MyApp;

44
pages/articles/[id].tsx Normal file
View File

@ -0,0 +1,44 @@
import { FC } from "react";
import styles from "./article.module.css";
import { GetServerSideProps, GetStaticProps } from "next";
import { addApolloState, initializeApollo } from "../../commons/graphql/client";
import { ARTICLE } from "../../commons/graphql/queries";
import { Article } from "../../commons/graphql/generated";
import { useQuery } from '@apollo/client';
import { useRouter } from 'next/router';
interface Props {
article: Article;
}
const ArticleDetails: FC<Props> = ({ article }) => {
// const router = useRouter()
// const { data } = useQuery<{ article: Article }>(ARTICLE, {
// variables: router.query,
// });
return (
<main className={styles.articleDetails}>
<article className={styles.article}>
<header>
<h1>{article.title}</h1>
<time>{article.publishedAt}</time>
</header>
<div dangerouslySetInnerHTML={{__html: article.content}}></div>
</article>
</main>
);
};
export const getServerSideProps: GetServerSideProps = async ({ params }) => {
const apolloClient = initializeApollo();
const { data } = await apolloClient.query<{ article: Article }>({
query: ARTICLE,
variables: params,
});
return addApolloState(apolloClient, {
props: { article: data.article },
});
};
export default ArticleDetails;

View File

@ -0,0 +1,28 @@
.articleDetail {
}
.article {
@apply bg-gray-50 m-2 overflow-hidden rounded-xl;
:global(.dark) & {
@apply bg-gray-800;
}
& > header {
@apply my-8 mx-4;
h1 {
@apply text-2xl font-medium;
:global(.dark) & {
@apply text-gray-200;
}
}
time {
@apply text-sm text-gray-500;
:global(.dark) & {
@apply text-gray-400;
}
}
}
& > div {
@apply my-4 leading-8 mx-4 lg:mx-6 xl:mx-8 text-justify;
}
}

View File

@ -1,23 +1,46 @@
import { useQuery } from '@apollo/client';
import { Article } from '../commons/graphql/generated';
import { ARTICLE_FOR_HOME } from '../commons/graphql/queries';
import styles from './index.module.css';
import { useQuery } from "@apollo/client";
import { GetServerSideProps } from 'next';
import Link from "next/link";
import React from "react";
import { addApolloState, initializeApollo } from '../commons/graphql/client';
import { Article } from "../commons/graphql/generated";
import { ARTICLE_FOR_HOME } from "../commons/graphql/queries";
import styles from "./index.module.css";
export default function Index() {
const { data, loading } = useQuery<{articles: Article[]}>(ARTICLE_FOR_HOME);
const { data, loading } = useQuery<{ articles: Article[] }>(ARTICLE_FOR_HOME);
return <main className={styles.index}>
<ol>
{
data?.articles?.map(article => <Item article={article} key={article.id} />)
}
</ol>
</main>;
return (
<main className={styles.index}>
<ol>
{data?.articles?.map((article) => (
<Item article={article} key={article.id} />
))}
</ol>
</main>
);
}
function Item({article}: {article: Article}) {
return <li className={styles.item}>
<h2 className={styles.title}>{article.title}</h2>
<p className={styles.description}>{article.content}</p>
</li>
}
function Item({ article }: { article: Article }) {
return (
<Link href={`/articles/${article.id}`}>
<li className={styles.item}>
<h2 className={styles.title}>{article.title}</h2>
<p className={styles.description}>{article.content}</p>
</li>
</Link>
);
}
export const getServerSideProps: GetServerSideProps = async ({ params }) => {
const apolloClient = initializeApollo();
await apolloClient.query({
query: ARTICLE_FOR_HOME,
});
return addApolloState(apolloClient, {
props: {},
});
};