♻️ 使用 NextJS 重构 #1

Merged
LittleSheep merged 12 commits from refactor/v2 into master 2024-02-24 14:19:52 +00:00
32 changed files with 8441 additions and 194 deletions
Showing only changes of commit 1a9ada9e0e - Show all commits

2
.gitignore vendored
View File

@ -36,3 +36,5 @@ yarn-error.log*
next-env.d.ts next-env.d.ts
bun.lockb bun.lockb
.env

View File

@ -0,0 +1,17 @@
'use client'
/**
* This route is responsible for the built-in authoring environment using Sanity Studio.
* All routes under your studio path is handled by this file using Next.js' catch-all routes:
* https://nextjs.org/docs/routing/dynamic-routes#catch-all-routes
*
* You can learn more about the next-sanity package here:
* https://github.com/sanity-io/next-sanity
*/
import { NextStudio } from 'next-sanity/studio'
import config from '../../../sanity.config'
export default function StudioPage() {
return <NextStudio config={config} />
}

View File

@ -12,10 +12,20 @@ export interface RelatedAccount {
export const SITE_NAME = "Goatshed"; export const SITE_NAME = "Goatshed";
export const SITE_DESCRIPTION = "山羊寒舍,在这里最终智羊工作室的最新动态。"; export const SITE_DESCRIPTION = "山羊寒舍,在这里最终智羊工作室的最新动态。";
export const SITE_URL = "https://smartsheep.studio" export const SITE_URL = "https://smartsheep.studio";
export const RELATED_ACCOUNTS: RelatedAccount[] = [ export const RELATED_ACCOUNTS: RelatedAccount[] = [
{ icon: <GitHubIcon />, platform: "GitHub", accountName: "@smartsheep-hq", link: "https://github.com/smartsheep-hq" }, { icon: <GitHubIcon />, platform: "GitHub", accountName: "@smartsheep-hq", link: "https://github.com/smartsheep-hq" },
{ icon: <TwitterIcon />, platform: "Twitter", accountName: "@littlesheepovo", link: "https://twitter.com/littlesheepovo" }, {
{ icon: <CoffeeIcon />, platform: "Ko-fi", accountName: "@littlesheep2code", link: "https://ko-fi.com/littlesheep2code" }, icon: <TwitterIcon />,
platform: "Twitter",
accountName: "@littlesheepovo",
link: "https://twitter.com/littlesheepovo",
},
{
icon: <CoffeeIcon />,
platform: "Ko-fi",
accountName: "@littlesheep2code",
link: "https://ko-fi.com/littlesheep2code",
},
]; ];

View File

@ -8,7 +8,7 @@ export async function GET() {
description: SITE_DESCRIPTION, description: SITE_DESCRIPTION,
site_url: SITE_URL, site_url: SITE_URL,
feed_url: `${SITE_URL}/feed`, feed_url: `${SITE_URL}/feed`,
language: "zh-CN" language: "zh-CN",
}); });
getSortedPosts().forEach((item) => { getSortedPosts().forEach((item) => {
@ -22,7 +22,7 @@ export async function GET() {
return new Response(feed.xml(), { return new Response(feed.xml(), {
headers: { headers: {
"content-type": "application/xml" "content-type": "application/xml",
} },
}); });
} }

View File

@ -14,6 +14,7 @@ import "@fontsource/roboto/700.css";
import "./globals.css"; import "./globals.css";
import AppShell from "@/components/AppShell"; import AppShell from "@/components/AppShell";
import NextTopLoader from "nextjs-toploader";
export const runtime = "edge"; export const runtime = "edge";
@ -33,6 +34,7 @@ export default function RootLayout({ children }: Readonly<{
<body> <body>
<AppRouterCacheProvider> <AppRouterCacheProvider>
<CssBaseline /> <CssBaseline />
<NextTopLoader showAtBottom color="#4a5099" />
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<AppShell>{children}</AppShell> <AppShell>{children}</AppShell>
</ThemeProvider> </ThemeProvider>

View File

@ -2,14 +2,15 @@ import {
Avatar, Avatar,
Box, Box,
Button, Button,
Card, colors, Card,
colors,
Container, Container,
Grid, Grid,
List, List,
ListItemAvatar, ListItemAvatar,
ListItemButton, ListItemButton,
ListItemText, ListItemText,
Typography Typography,
} from "@mui/material"; } from "@mui/material";
import { RELATED_ACCOUNTS } from "@/app/consts"; import { RELATED_ACCOUNTS } from "@/app/consts";
import Image from "next/image"; import Image from "next/image";
@ -18,19 +19,18 @@ import Link from "next/link";
export default function Home() { export default function Home() {
return ( return (
<Container sx={{ scrollBehavior: "smooth", px: 5 }}> <Container sx={{ scrollBehavior: "smooth", px: 5 }}>
<Grid <Grid container id="introduce" alignItems="center" sx={{ height: "calc(100vh - 64px)" }}>
container
id="introduce"
alignItems="center"
sx={{ height: "calc(100vh - 64px)" }}
>
<Grid item xs={12} sm={6} sx={{ textAlign: { xs: "center", sm: "initial" } }}> <Grid item xs={12} sm={6} sx={{ textAlign: { xs: "center", sm: "initial" } }}>
<Typography variant="h1" gutterBottom> 👋</Typography> <Typography variant="h1" gutterBottom>
👋
</Typography>
<Typography paragraph> <Typography paragraph>
SmartSheep Studio SmartSheep Studio
</Typography> </Typography>
<Button variant="contained" href="#about-us" size="large"></Button> <Button variant="contained" href="#about-us" size="large">
</Button>
</Grid> </Grid>
<Grid <Grid
item item
@ -44,12 +44,7 @@ export default function Home() {
</Grid> </Grid>
</Grid> </Grid>
<Grid <Grid container id="about-us" alignItems="center" sx={{ height: "calc(100vh - 64px)" }}>
container
id="about-us"
alignItems="center"
sx={{ height: "calc(100vh - 64px)" }}
>
<Grid item xs={12} sm={6} sx={{ display: "flex", justifyContent: { xs: "center", sm: "end" } }}> <Grid item xs={12} sm={6} sx={{ display: "flex", justifyContent: { xs: "center", sm: "end" } }}>
<Card sx={{ flexGrow: 1, mr: { xs: 0, sm: 4, md: 8 } }}> <Card sx={{ flexGrow: 1, mr: { xs: 0, sm: 4, md: 8 } }}>
<List sx={{ width: "100%", bgcolor: "background.paper" }}> <List sx={{ width: "100%", bgcolor: "background.paper" }}>
@ -67,7 +62,9 @@ export default function Home() {
</Card> </Card>
</Grid> </Grid>
<Grid item xs={12} sm={6} sx={{ textAlign: { xs: "center", sm: "initial" } }}> <Grid item xs={12} sm={6} sx={{ textAlign: { xs: "center", sm: "initial" } }}>
<Typography variant="h1" gutterBottom></Typography> <Typography variant="h1" gutterBottom>
</Typography>
<Typography paragraph> <Typography paragraph>
2019 2019

View File

@ -1,20 +1,32 @@
import { Box, Card, CardContent, CardMedia, Divider, Typography } from "@mui/material"; import { Box, Card, CardContent, CardMedia, Chip, Divider, Stack, Typography } from "@mui/material";
import { getSinglePost } from "@/content/posts"; import { client } from "@/sanity/lib/client";
import Image from "next/image";
import PostContent from "@/components/posts/PostContent"; import PostContent from "@/components/posts/PostContent";
import Image from "next/image";
export default function PostDetailPage({ params }: { params: { id: string } }) { export default async function PostDetailPage({ params }: { params: { id: string } }) {
const post = getSinglePost(params.id); const post = await client.fetch<any>(`*[_type == "post" && slug.current == $slug][0] {
title, description, slug, body, author, publishedAt,
mainImage {
asset -> {
_id,
url
},
alt
},
"categories": categories[]->title,
"author_name": author->name,
"author_image": author->image
}`, { slug: params.id });
return ( return (
<Card> <Card>
{ {
post.thumbnail && post.mainImage &&
<CardMedia sx={{ height: 360, position: "relative" }} title={post.title}> <CardMedia sx={{ height: 360, position: "relative" }} title={post.mainImage.alt}>
<Image <Image
fill fill
src={post.thumbnail} src={post.mainImage.asset.url}
alt={post.title} alt={post.mainImage.alt}
style={{ objectFit: "cover" }} style={{ objectFit: "cover" }}
/> />
</CardMedia> </CardMedia>
@ -22,16 +34,20 @@ export default function PostDetailPage({ params }: { params: { id: string } }) {
<CardContent sx={{ paddingX: 5, paddingY: 3 }}> <CardContent sx={{ paddingX: 5, paddingY: 3 }}>
<Box> <Box>
<Typography gutterBottom variant="h2"> <Typography variant="h2">
{post.title} {post.title}
</Typography> </Typography>
<Stack direction="row" sx={{ mx: -0.5, mt: 1, mb: 1.2 }}>
{post.categories.map((category: string, idx: number) => <Chip size="small" label={category} key={idx} />)}
</Stack>
<Typography color="text.secondary" variant="body2"> <Typography color="text.secondary" variant="body2">
{post.description ?? "No description yet."} {post.description ?? "No description yet."}
</Typography> </Typography>
</Box> </Box>
<Divider sx={{ my: 5 }} /> <Divider sx={{ my: 2.5, mx: -5 }} />
<Box component="article" className="prose max-w-none" sx={{ minWidth: 0 }}> <Box component="article" className="prose max-w-none" sx={{ minWidth: 0 }}>
<PostContent content={post.content ?? ""} /> <PostContent content={post.body} />
</Box> </Box>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -1,14 +1,14 @@
import { Box, Container } from "@mui/material"; import { Box, Container } from "@mui/material";
import { ReactNode } from "react"; import { ReactNode } from "react";
export default function PostLayout({children}: Readonly<{ export default function PostLayout({
children,
}: Readonly<{
children: ReactNode; children: ReactNode;
}>) { }>) {
return ( return (
<Container sx={{ display: "flex", justifyContent: "center", gap: 4, py: 2 }}> <Container sx={{ display: "flex", justifyContent: "center", gap: 4, py: 2 }}>
<Box sx={{ flexGrow: 1, maxWidth: 720 }}> <Box sx={{ flexGrow: 1, maxWidth: 720 }}>{children}</Box>
{children}
</Box>
</Container> </Container>
) );
} }

View File

@ -1,36 +1,52 @@
import { Button, Card, CardActions, CardContent, CardMedia, Typography } from "@mui/material"; import { Button, Card, CardActions, CardContent, CardMedia, Chip, Stack, Typography } from "@mui/material";
import { getSortedPosts } from "@/content/posts"; import { client } from "@/sanity/lib/client";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
export default function PostList() { export default async function PostList() {
const posts = getSortedPosts(); const posts = await client.fetch<any[]>(`*[_type == "post"] {
title, description, slug, author, publishedAt,
mainImage {
asset -> {
_id,
url
},
alt
},
"categories": categories[]->title,
"author_name": author->name,
"author_image": author->image
}`);
return ( return (
posts.map((post) => ( posts.map((post) => (
<Card key={post.id} sx={{ width: "100%" }}> <Card key={post.slug.current} sx={{ width: "100%" }}>
{ {
post.thumbnail && post.mainImage &&
<CardMedia sx={{ height: 160, position: "relative" }} title={post.title}> <CardMedia sx={{ height: 160, position: "relative" }} title={post.mainImage.alt}>
<Image <Image
fill fill
src={post.thumbnail} src={post.mainImage.asset.url}
alt={post.title} alt={post.mainImage.alt}
style={{ objectFit: "cover" }} style={{ objectFit: "cover" }}
/> />
</CardMedia> </CardMedia>
} }
<CardContent sx={{ paddingX: 5, paddingY: 3 }}> <CardContent sx={{ paddingX: 5, paddingY: 3 }}>
<Typography gutterBottom variant="h3"> <Typography variant="h3">
{post.title} {post.title}
</Typography> </Typography>
<Stack direction="row" sx={{ mx: -0.5, mt: 1, mb: 1.2 }}>
{post.categories.map((category: string, idx: number) => <Chip size="small" label={category} key={idx} />)}
</Stack>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{post.description ?? "No description yet."} {post.description ? post.description : "No description yet."}
</Typography> </Typography>
</CardContent> </CardContent>
<CardActions sx={{ paddingX: 4, paddingBottom: 2 }}> <CardActions sx={{ paddingX: 4, paddingBottom: 2 }}>
<Link href={`/p/${post.id}`} passHref> <Link href={`/p/${post.slug.current}`} passHref>
<Button>Read more</Button> <Button>Read more</Button>
</Link> </Link>
</CardActions> </CardActions>

View File

@ -10,20 +10,20 @@ export default function sitemap(): MetadataRoute.Sitemap {
url: `${SITE_URL}/`, url: `${SITE_URL}/`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 1 priority: 1,
}, },
{ {
url: `${SITE_URL}/posts`, url: `${SITE_URL}/posts`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: "daily", changeFrequency: "daily",
priority: 0.8 priority: 0.8,
}, },
...posts.map((item: Post) => ({ ...posts.map((item: Post) => ({
url: `${SITE_URL}/posts/${item.id}`, url: `${SITE_URL}/posts/${item.id}`,
lastModified: item.date, lastModified: item.date,
changeFrequency: "daily" as any, changeFrequency: "daily" as any,
priority: 0.75 priority: 0.75,
})) })),
]; ];
} }

View File

@ -5,11 +5,11 @@ import { createTheme } from "@mui/material/styles";
export const theme = createTheme({ export const theme = createTheme({
palette: { palette: {
primary: { primary: {
main: "#49509e" main: "#49509e",
}, },
secondary: { secondary: {
main: "#d43630" main: "#d43630",
} },
}, },
typography: { typography: {
h1: { fontSize: "2.5rem" }, h1: { fontSize: "2.5rem" },

View File

@ -8,7 +8,9 @@ import {
AppBarProps as MuiAppBarProps, AppBarProps as MuiAppBarProps,
useScrollTrigger, useScrollTrigger,
IconButton, IconButton,
styled, Box, useMediaQuery styled,
Box,
useMediaQuery,
} from "@mui/material"; } from "@mui/material";
import { ReactElement, ReactNode, useEffect, useState } from "react"; import { ReactElement, ReactNode, useEffect, useState } from "react";
import { SITE_NAME } from "@/app/consts"; import { SITE_NAME } from "@/app/consts";
@ -17,13 +19,10 @@ import MenuIcon from "@mui/icons-material/Menu";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
function HideOnScroll(props: { function HideOnScroll(props: { window?: () => Window; children: ReactElement }) {
window?: () => Window;
children: ReactElement;
}) {
const { children, window } = props; const { children, window } = props;
const trigger = useScrollTrigger({ const trigger = useScrollTrigger({
target: window ? window() : undefined target: window ? window() : undefined,
}); });
return ( return (
@ -38,24 +37,25 @@ interface AppBarProps extends MuiAppBarProps {
} }
const ShellAppBar = styled(MuiAppBar, { const ShellAppBar = styled(MuiAppBar, {
shouldForwardProp: (prop) => prop !== "open" shouldForwardProp: (prop) => prop !== "open",
})<AppBarProps>(({ theme, open }) => { })<AppBarProps>(({ theme, open }) => {
const isMobile = useMediaQuery(isMobileQuery); const isMobile = useMediaQuery(isMobileQuery);
return ({ return {
transition: theme.transitions.create(["margin", "width"], { transition: theme.transitions.create(["margin", "width"], {
easing: theme.transitions.easing.sharp, easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen duration: theme.transitions.duration.leavingScreen,
}), }),
...(!isMobile && open && { ...(!isMobile &&
open && {
width: `calc(100% - ${DRAWER_WIDTH}px)`, width: `calc(100% - ${DRAWER_WIDTH}px)`,
transition: theme.transitions.create(["margin", "width"], { transition: theme.transitions.create(["margin", "width"], {
easing: theme.transitions.easing.easeOut, easing: theme.transitions.easing.easeOut,
duration: theme.transitions.duration.enteringScreen duration: theme.transitions.duration.enteringScreen,
}), }),
marginRight: DRAWER_WIDTH marginRight: DRAWER_WIDTH,
}) }),
}); };
}); });
const AppMain = styled("main", { shouldForwardProp: (prop) => prop !== "open" })<{ const AppMain = styled("main", { shouldForwardProp: (prop) => prop !== "open" })<{
@ -63,28 +63,26 @@ const AppMain = styled("main", { shouldForwardProp: (prop) => prop !== "open" })
}>(({ theme, open }) => { }>(({ theme, open }) => {
const isMobile = useMediaQuery(isMobileQuery); const isMobile = useMediaQuery(isMobileQuery);
return ({ return {
flexGrow: 1, flexGrow: 1,
transition: theme.transitions.create("margin", { transition: theme.transitions.create("margin", {
easing: theme.transitions.easing.sharp, easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen duration: theme.transitions.duration.leavingScreen,
}), }),
marginRight: -DRAWER_WIDTH, marginRight: -DRAWER_WIDTH,
...(!isMobile && open && { ...(!isMobile &&
open && {
transition: theme.transitions.create("margin", { transition: theme.transitions.create("margin", {
easing: theme.transitions.easing.easeOut, easing: theme.transitions.easing.easeOut,
duration: theme.transitions.duration.enteringScreen duration: theme.transitions.duration.enteringScreen,
}), }),
marginRight: 0 marginRight: 0,
}), }),
position: "relative" position: "relative",
}); };
}); });
export default function AppShell({ children }: { children: ReactNode }) {
export default function AppShell({ children }: {
children: ReactNode,
}) {
let documentWindow: Window; let documentWindow: Window;
const isMobile = useMediaQuery(isMobileQuery); const isMobile = useMediaQuery(isMobileQuery);
@ -110,9 +108,7 @@ export default function AppShell({ children }: {
</IconButton> </IconButton>
<Typography variant="h6" component="div" sx={{ flexGrow: 1, fontSize: "1.2rem" }}> <Typography variant="h6" component="div" sx={{ flexGrow: 1, fontSize: "1.2rem" }}>
<Link href="/"> <Link href="/">{SITE_NAME}</Link>
{SITE_NAME}
</Link>
</Typography> </Typography>
<IconButton <IconButton

View File

@ -12,7 +12,7 @@ import {
ListItemIcon, ListItemIcon,
ListItemText, ListItemText,
styled, styled,
useMediaQuery useMediaQuery,
} from "@mui/material"; } from "@mui/material";
import { theme } from "@/app/theme"; import { theme } from "@/app/theme";
import { ReactNode } from "react"; import { ReactNode } from "react";
@ -42,27 +42,25 @@ export const AppNavigationHeader = styled("div")(({ theme }) => ({
padding: theme.spacing(0, 1), padding: theme.spacing(0, 1),
justifyContent: "flex-start", justifyContent: "flex-start",
height: 64, height: 64,
...theme.mixins.toolbar ...theme.mixins.toolbar,
})); }));
export function AppNavigation({ showClose, onClose }: { export function AppNavigation({ showClose, onClose }: { showClose?: boolean; onClose: () => void }) {
showClose?: boolean,
onClose: () => void
}) {
return ( return (
<> <>
<AppNavigationHeader> <AppNavigationHeader>
{ {showClose && (
showClose &&
<IconButton onClick={onClose}> <IconButton onClick={onClose}>
{theme.direction === "rtl" ? <ChevronLeftIcon /> : <ChevronRightIcon />} {theme.direction === "rtl" ? <ChevronLeftIcon /> : <ChevronRightIcon />}
</IconButton> </IconButton>
} )}
</AppNavigationHeader> </AppNavigationHeader>
<Divider /> <Divider />
<List> <List>
{NAVIGATION_ITEMS.map((item, idx) => { {NAVIGATION_ITEMS.map((item, idx) => {
return item.divider ? <Divider key={idx} /> : ( return item.divider ? (
<Divider key={idx} />
) : (
<Link key={idx} href={item.link ?? "/"} passHref> <Link key={idx} href={item.link ?? "/"} passHref>
<ListItemButton> <ListItemButton>
<ListItemIcon>{item.icon}</ListItemIcon> <ListItemIcon>{item.icon}</ListItemIcon>
@ -78,10 +76,7 @@ export function AppNavigation({ showClose, onClose }: {
export const isMobileQuery = theme.breakpoints.down("md"); export const isMobileQuery = theme.breakpoints.down("md");
export default function NavigationDrawer({ open, onClose }: { export default function NavigationDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
open: boolean,
onClose: () => void,
}) {
const isMobile = useMediaQuery(isMobileQuery); const isMobile = useMediaQuery(isMobileQuery);
return isMobile ? ( return isMobile ? (
@ -96,8 +91,8 @@ export default function NavigationDrawer({ open, onClose }: {
sx={{ sx={{
"& .MuiDrawer-paper": { "& .MuiDrawer-paper": {
boxSizing: "border-box", boxSizing: "border-box",
width: DRAWER_WIDTH width: DRAWER_WIDTH,
} },
}} }}
> >
<AppNavigation onClose={onClose} /> <AppNavigation onClose={onClose} />
@ -112,8 +107,8 @@ export default function NavigationDrawer({ open, onClose }: {
width: DRAWER_WIDTH, width: DRAWER_WIDTH,
flexShrink: 0, flexShrink: 0,
"& .MuiDrawer-paper": { "& .MuiDrawer-paper": {
width: DRAWER_WIDTH width: DRAWER_WIDTH,
} },
}} }}
> >
<AppNavigation showClose onClose={onClose} /> <AppNavigation showClose onClose={onClose} />

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import Markdown from "react-markdown"; import { PortableText } from "@portabletext/react";
export default function PostContent({ content }: { content: string }) { export default function PostContent({ content }: { content: any }) {
return <Markdown>{content}</Markdown>; return <PortableText value={content} />;
} }

View File

@ -1,47 +0,0 @@
import fs from "fs";
import path from "path";
import matter from "gray-matter";
const postsDirectory = path.join(process.cwd(), "content", "posts");
export interface Post {
id: string;
title: string;
thumbnail?: string;
description?: string;
content?: string;
date: Date;
}
export function getSortedPosts() {
const fileNames = fs.readdirSync(postsDirectory);
const allPostsData: Post[] = fileNames.map((fileName) => {
const id = fileName.replace(/\.md$/, "");
const fullPath = path.join(postsDirectory, fileName);
const fileContents = fs.readFileSync(fullPath, "utf8");
const matterResult = matter(fileContents);
return {
id,
...matterResult.data
} as Post;
});
return allPostsData.sort((a, b) => {
return a.date < b.date ? 1 : -1;
});
}
export function getSinglePost(id: string) {
const fullPath = path.join(postsDirectory, id + ".md");
const fileContents = fs.readFileSync(fullPath, "utf8");
const matterResult = matter(fileContents);
return {
id,
content: matterResult.content,
...matterResult.data,
} as Post;
}

View File

@ -1,14 +0,0 @@
---
thumbnail: 'https://images.unsplash.com/photo-1707344088547-3cf7cea5ca49?q=80&w=2970&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D'
title: 'Two Forms of Pre-rendering'
date: '2020-01-01'
---
# Woah!
Next.js has two forms of pre-rendering: **Static Generation** and **Server-side Rendering**. The difference is in **when** it generates the HTML for a page.
- **Static Generation** is the pre-rendering method that generates the HTML at **build time**. The pre-rendered HTML is then _reused_ on each request.
- **Server-side Rendering** is the pre-rendering method that generates the HTML on **each request**.
Importantly, Next.js lets you **choose** which pre-rendering form to use for each page. You can create a "hybrid" Next.js app by using Static Generation for most pages and using Server-side Rendering for others.

View File

@ -5,9 +5,9 @@ const nextConfig = {
remotePatterns: [ remotePatterns: [
{ {
protocol: "https", protocol: "https",
hostname: "**" hostname: "**",
} },
] ],
}, },
async rewrites() { async rewrites() {
return [ return [
@ -15,9 +15,9 @@ const nextConfig = {
{ source: "/rss.xml", destination: "/feed.xml" }, { source: "/rss.xml", destination: "/feed.xml" },
{ source: "/feed.xml", destination: "/feed" }, { source: "/feed.xml", destination: "/feed" },
{ source: "/p/:id", destination: "/posts/:id" } { source: "/p/:id", destination: "/posts/:id" },
]; ];
} },
}; };
export default nextConfig; export default nextConfig;

View File

@ -19,17 +19,29 @@
"@mui/material": "^5.15.10", "@mui/material": "^5.15.10",
"@mui/material-nextjs": "^5.15.11", "@mui/material-nextjs": "^5.15.11",
"@next/mdx": "^14.1.0", "@next/mdx": "^14.1.0",
"@portabletext/react": "^3.0.11",
"@sanity/client": "^6.12.4",
"@sanity/icons": "^2.8",
"@sanity/image-url": "1",
"@sanity/types": "^3.25",
"@sanity/ui": "^2.0",
"@sanity/vision": "3",
"@types/mdx": "^2.0.11", "@types/mdx": "^2.0.11",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"next": "14.1.0", "next": "^14.1",
"react": "^18", "next-sanity": "7.1.4",
"nextjs-toploader": "^1.6.6",
"react": "^18.2",
"react-dom": "^18", "react-dom": "^18",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"rss": "^1.2.2" "rss": "^1.2.2",
"sanity": "^3.25",
"styled-components": "^6.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.10", "@tailwindcss/typography": "^0.5.10",
"@types/node": "^20", "@types/node": "^20",
"@types/nprogress": "^0.2.3",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"@types/rss": "^0.0.32", "@types/rss": "^0.0.32",

10
sanity.cli.ts Normal file
View File

@ -0,0 +1,10 @@
/**
* This configuration file lets you run `$ sanity [command]` in this folder
* Go to https://www.sanity.io/docs/cli to learn more.
**/
import { defineCliConfig } from "sanity/cli";
const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID;
const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET;
export default defineCliConfig({ api: { projectId, dataset } });

25
sanity.config.ts Normal file
View File

@ -0,0 +1,25 @@
/**
* This configuration is used to for the Sanity Studio thats mounted on the `/pages/studio/page.tsx` route
*/
import { visionTool } from "@sanity/vision";
import { defineConfig } from "sanity";
import { structureTool } from "sanity/structure";
// Go to https://www.sanity.io/docs/api-versioning to learn how API versioning works
import { apiVersion, dataset, projectId } from "./sanity/env";
import { schema } from "./sanity/schema";
export default defineConfig({
basePath: "/console",
projectId,
dataset,
// Add and edit the content schema in the './sanity/schema' folder
schema,
plugins: [
structureTool(),
// Vision is a tool that lets you query your content with GROQ in the studio
// https://www.sanity.io/docs/the-vision-plugin
visionTool({ defaultApiVersion: apiVersion }),
],
});

21
sanity/env.ts Normal file
View File

@ -0,0 +1,21 @@
export const apiVersion = process.env.NEXT_PUBLIC_SANITY_API_VERSION || "2024-02-24";
export const dataset = assertValue(
process.env.NEXT_PUBLIC_SANITY_DATASET,
"Missing environment variable: NEXT_PUBLIC_SANITY_DATASET",
);
export const projectId = assertValue(
process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
"Missing environment variable: NEXT_PUBLIC_SANITY_PROJECT_ID",
);
export const useCdn = false;
function assertValue<T>(v: T | undefined, errorMessage: string): T {
if (v === undefined) {
throw new Error(errorMessage);
}
return v;
}

10
sanity/lib/client.ts Normal file
View File

@ -0,0 +1,10 @@
import { createClient } from "next-sanity";
import { apiVersion, dataset, projectId, useCdn } from "../env";
export const client = createClient({
apiVersion,
dataset,
projectId,
useCdn,
});

13
sanity/lib/image.ts Normal file
View File

@ -0,0 +1,13 @@
import createImageUrlBuilder from "@sanity/image-url";
import type { Image } from "sanity";
import { dataset, projectId } from "../env";
const imageBuilder = createImageUrlBuilder({
projectId: projectId || "",
dataset: dataset || "",
});
export const urlForImage = (source: Image) => {
return imageBuilder?.image(source).auto("format").fit("max").url();
};

10
sanity/schema.ts Normal file
View File

@ -0,0 +1,10 @@
import { type SchemaTypeDefinition } from "sanity";
import blockContent from "./schemaTypes/blockContent";
import category from "./schemaTypes/category";
import post from "./schemaTypes/post";
import author from "./schemaTypes/author";
export const schema: { types: SchemaTypeDefinition[] } = {
types: [post, author, category, blockContent],
};

View File

@ -0,0 +1,57 @@
import { defineField, defineType } from "sanity";
export default defineType({
name: "author",
title: "Author",
type: "document",
fields: [
defineField({
name: "name",
title: "Name",
type: "string",
}),
defineField({
name: "slug",
title: "Slug",
type: "slug",
options: {
source: "name",
maxLength: 96,
},
}),
defineField({
name: "image",
title: "Image",
type: "image",
options: {
hotspot: true,
},
fields: [
{
name: "alt",
type: "string",
title: "Alternative Text",
},
],
}),
defineField({
name: "bio",
title: "Bio",
type: "array",
of: [
{
title: "Block",
type: "block",
styles: [{ title: "Normal", value: "normal" }],
lists: [],
},
],
}),
],
preview: {
select: {
title: "name",
media: "image",
},
},
});

View File

@ -0,0 +1,75 @@
import { defineType, defineArrayMember } from "sanity";
/**
* This is the schema type for block content used in the post document type
* Importing this type into the studio configuration's `schema` property
* lets you reuse it in other document types with:
* {
* name: 'someName',
* title: 'Some title',
* type: 'blockContent'
* }
*/
export default defineType({
title: "Block Content",
name: "blockContent",
type: "array",
of: [
defineArrayMember({
title: "Block",
type: "block",
// Styles let you define what blocks can be marked up as. The default
// set corresponds with HTML tags, but you can set any title or value
// you want, and decide how you want to deal with it where you want to
// use your content.
styles: [
{ title: "Normal", value: "normal" },
{ title: "H1", value: "h1" },
{ title: "H2", value: "h2" },
{ title: "H3", value: "h3" },
{ title: "H4", value: "h4" },
{ title: "Quote", value: "blockquote" },
],
lists: [{ title: "Bullet", value: "bullet" }],
// Marks let you mark up inline text in the Portable Text Editor
marks: {
// Decorators usually describe a single property e.g. a typographic
// preference or highlighting
decorators: [
{ title: "Strong", value: "strong" },
{ title: "Emphasis", value: "em" },
],
// Annotations can be any object structure e.g. a link or a footnote.
annotations: [
{
title: "URL",
name: "link",
type: "object",
fields: [
{
title: "URL",
name: "href",
type: "url",
},
],
},
],
},
}),
// You can add additional types here. Note that you can't use
// primitive types such as 'string' and 'number' in the same array
// as a block type.
defineArrayMember({
type: "image",
options: { hotspot: true },
fields: [
{
name: "alt",
type: "string",
title: "Alternative Text",
},
],
}),
],
});

View File

@ -0,0 +1,19 @@
import { defineField, defineType } from "sanity";
export default defineType({
name: "category",
title: "Category",
type: "document",
fields: [
defineField({
name: "title",
title: "Title",
type: "string",
}),
defineField({
name: "description",
title: "Description",
type: "text",
}),
],
});

View File

@ -0,0 +1,77 @@
import { defineField, defineType } from "sanity";
export default defineType({
name: "post",
title: "Post",
type: "document",
fields: [
defineField({
name: "title",
title: "Title",
type: "string",
}),
defineField({
name: "description",
title: "Description",
type: "string",
}),
defineField({
name: "slug",
title: "Slug",
type: "slug",
options: {
source: "title",
maxLength: 96,
},
}),
defineField({
name: "author",
title: "Author",
type: "reference",
to: { type: "author" },
}),
defineField({
name: "mainImage",
title: "Main image",
type: "image",
options: {
hotspot: true,
},
fields: [
{
name: "alt",
type: "string",
title: "Alternative Text",
},
],
}),
defineField({
name: "categories",
title: "Categories",
type: "array",
of: [{ type: "reference", to: { type: "category" } }],
}),
defineField({
name: "publishedAt",
title: "Published at",
type: "datetime",
}),
defineField({
name: "body",
title: "Body",
type: "blockContent",
}),
],
preview: {
select: {
title: "title",
author: "author.name",
media: "mainImage",
},
prepare(selection) {
const { author } = selection;
return { ...selection, subtitle: author && `by ${author}` };
},
},
});

View File

@ -12,6 +12,7 @@
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "preserve",
"incremental": true, "incremental": true,
"target": "ES2017",
"plugins": [ "plugins": [
{ {
"name": "next" "name": "next"

7927
yarn.lock Normal file

File diff suppressed because it is too large Load Diff