├── public ├── favicon.ico └── vercel.svg ├── postcss.config.js ├── src ├── styles │ └── globals.css ├── pages │ ├── _app.js │ ├── api │ │ ├── user.js │ │ ├── login.js │ │ └── todo.js │ ├── index.js │ ├── login.js │ └── todos.js ├── lib │ ├── sanity │ │ └── client.js │ └── cookie.js ├── hooks │ └── useAuth.js └── components │ ├── TodoList.js │ ├── Logout.js │ └── Todo.js ├── tailwind.config.js ├── .gitignore ├── package.json └── README.md /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bathrobe/next-magic-sanity-todo/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #eaeaea; 3 | } 4 | 5 | @tailwind base; 6 | @tailwind components; 7 | @tailwind utilities; 8 | -------------------------------------------------------------------------------- /src/pages/_app.js: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css' 2 | 3 | function MyApp({ Component, pageProps }) { 4 | return 5 | } 6 | 7 | export default MyApp 8 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: [ 3 | "./src/pages/**/*.{js,ts,jsx,tsx}", 4 | "./src/components/**/*.{js,ts,jsx,tsx}", 5 | ], 6 | darkMode: false, // or 'media' or 'class' 7 | theme: { 8 | extend: {}, 9 | }, 10 | variants: { 11 | extend: {}, 12 | }, 13 | plugins: [], 14 | }; 15 | -------------------------------------------------------------------------------- /src/lib/sanity/client.js: -------------------------------------------------------------------------------- 1 | import sanityClient from "@sanity/client"; 2 | 3 | const config = { 4 | dataset: process.env.NEXT_PUBLIC_SANITY_DATASET, 5 | projectId: process.env.NEXT_PUBLIC_SANITY_ID, 6 | apiVersion: "2021-04-28", 7 | token: process.env.SANITY_WRITE_KEY, 8 | useCdn: false, 9 | }; 10 | 11 | const client = sanityClient(config); 12 | 13 | export default client; 14 | -------------------------------------------------------------------------------- /src/hooks/useAuth.js: -------------------------------------------------------------------------------- 1 | // hooks/useAuth.js 2 | import useSWR from "swr"; 3 | 4 | function fetcher(route) { 5 | /* our token cookie gets sent with this request */ 6 | return fetch(route) 7 | .then((r) => r.ok && r.json()) 8 | .then((user) => user || null); 9 | } 10 | 11 | export default function useAuth() { 12 | const { data: user, error, mutate } = useSWR("/api/user", fetcher); 13 | const loading = user === undefined; 14 | 15 | return { 16 | user, 17 | loading, 18 | error, 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/pages/api/user.js: -------------------------------------------------------------------------------- 1 | import Iron from "@hapi/iron"; 2 | import CookieService from "../../lib/cookie"; 3 | 4 | export default async (req, res) => { 5 | let user; 6 | try { 7 | user = await Iron.unseal( 8 | CookieService.getAuthToken(req.cookies), 9 | process.env.ENCRYPTION_SECRET, 10 | Iron.defaults 11 | ); 12 | } catch (error) { 13 | res.status(401).end(); 14 | } 15 | 16 | // now we have access to the data inside of user 17 | // and we could make database calls or just send back what we have 18 | // in the token. 19 | 20 | res.json(user); 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/TodoList.js: -------------------------------------------------------------------------------- 1 | import Todo from "./Todo"; 2 | 3 | export default function TodoList({ todoList, handleDelete, user }) { 4 | return ( 5 |
6 | 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "magic-next-td", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@hapi/iron": "^6.0.0", 12 | "@magic-sdk/admin": "^1.3.0", 13 | "@sanity/client": "^2.8.0", 14 | "dayjs": "^1.10.4", 15 | "magic-sdk": "^4.2.1", 16 | "next": "10.2.0", 17 | "react": "17.0.2", 18 | "react-date-picker": "^8.1.1", 19 | "react-dom": "17.0.2", 20 | "react-icons": "^4.2.0", 21 | "swr": "^0.5.5" 22 | }, 23 | "devDependencies": { 24 | "autoprefixer": "^10.2.5", 25 | "postcss": "^8.2.13", 26 | "tailwindcss": "^2.1.2" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Todo App with Next.js, Magic.link, and Sanity 2 | This is a simple todo app set up for use in Sanity Studio. I made it for a tutorial. 3 | Uses: 4 | 5 | - [Next.js](https://nextjs.org/) 6 | - [Magic.link](https://magic.link) 7 | - [Sanity](https://sanity.io) 8 | - [Tailwind](https://tailwindcss.com) 9 | 10 | Auth code already implemented c/o [Eric Adamski](https://vercel.com/blog/simple-auth-with-magic-link-and-nextjs). 11 | Just add your the following values to a `.env.local` file: 12 | 13 | ``` 14 | NEXT_PUBLIC_MAGIC_PUB_KEY=pk_test_12345 15 | MAGIC_SECRET_KEY=sk_test_12345 16 | ENCRYPTION_SECRET=random_encryption_string 17 | NEXT_PUBLIC_SANITY_DATASET=production 18 | NEXT_PUBLIC_SANITY_ID=your_sanity_id 19 | SANITY_WRITE_KEY=your_sanity_write_key 20 | ``` 21 | -------------------------------------------------------------------------------- /src/pages/api/login.js: -------------------------------------------------------------------------------- 1 | import { Magic } from "@magic-sdk/admin"; 2 | import Iron from "@hapi/iron"; 3 | import CookieService from "../../lib/cookie"; 4 | 5 | export default async (req, res) => { 6 | if (req.method !== "POST") return res.status(405).end(); 7 | 8 | // exchange the did from Magic for some user data 9 | const did = req.headers.authorization.split("Bearer").pop().trim(); 10 | const user = await new Magic( 11 | process.env.MAGIC_SECRET_KEY 12 | ).users.getMetadataByToken(did); 13 | 14 | // Author a couple of cookies to persist a user's session 15 | const token = await Iron.seal( 16 | user, 17 | process.env.ENCRYPTION_SECRET, 18 | Iron.defaults 19 | ); 20 | CookieService.setTokenCookie(res, token); 21 | 22 | res.end(); 23 | }; 24 | -------------------------------------------------------------------------------- /src/lib/cookie.js: -------------------------------------------------------------------------------- 1 | import { serialize } from "cookie"; 2 | 3 | const TOKEN_NAME = "api_token"; 4 | const MAX_AGE = 60 * 60 * 8; 5 | 6 | function createCookie(name, data, options = {}) { 7 | return serialize(name, data, { 8 | maxAge: MAX_AGE, 9 | expires: new Date(Date.now() + MAX_AGE * 1000), 10 | secure: process.env.NODE_ENV === "production", 11 | path: "/", 12 | httpOnly: true, 13 | sameSite: "lax", 14 | ...options, 15 | }); 16 | } 17 | 18 | function setTokenCookie(res, token) { 19 | res.setHeader("Set-Cookie", [ 20 | createCookie(TOKEN_NAME, token), 21 | createCookie("authed", true, { httpOnly: false }), 22 | ]); 23 | } 24 | 25 | function removeCookie(res, token) { 26 | res.removeHeader("Set-Cookie"); 27 | } 28 | 29 | function getAuthToken(cookies) { 30 | return cookies[TOKEN_NAME]; 31 | } 32 | 33 | export default { setTokenCookie, getAuthToken, removeCookie }; 34 | -------------------------------------------------------------------------------- /src/components/Logout.js: -------------------------------------------------------------------------------- 1 | import { Magic } from "magic-sdk"; 2 | import { useRouter } from "next/router"; 3 | import { removeCookie } from "../lib/cookie"; 4 | export default function Logout() { 5 | const router = useRouter(); 6 | const logoutUser = async () => { 7 | const m = new Magic(process.env.NEXT_PUBLIC_MAGIC_PUB_KEY); 8 | 9 | try { 10 | await m.user.logout(); 11 | console.log(await m.user.isLoggedIn()); 12 | router.push("/"); 13 | } catch { 14 | (err) => console.error(err); 15 | } 16 | }; 17 | return ( 18 |
19 | 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /src/pages/api/todo.js: -------------------------------------------------------------------------------- 1 | 2 | import client from "../../lib/sanity/client"; 3 | 4 | export default async function handler(req, res) { 5 | switch (req.method) { 6 | case "POST": 7 | //this JSON arrives as a string, 8 | //so we turn it into a JS object with JSON.parse() 9 | const newTodo = await JSON.parse(req.body); 10 | //then use the Sanity client to create a new todo doc 11 | try { 12 | await client 13 | .create({ 14 | _type: "todo", 15 | text: newTodo.text, 16 | isCompleted: false, 17 | createdAt: new Date().toISOString(), 18 | dueDate: newTodo.dueDate, 19 | userEmail: newTodo.user, 20 | }) 21 | .then((res) => { 22 | console.log(`Todo was created, document ID is ${res._id}`); 23 | }); 24 | res 25 | .status(200) 26 | .json({ msg: `Todo was created, document ID is ${res._id}` }); 27 | } catch (err) { 28 | console.error(err); 29 | res.status(500).json({ msg: "Error, check console" }); 30 | } 31 | break; 32 | case "PUT": 33 | const result = await client 34 | .patch(req.body.id) 35 | .set({ 36 | isCompleted: !req.body.isCompleted, 37 | //create new complete date if Todo is marked as done 38 | completedAt: !!req.body.isCompleted ? "" : new Date().toISOString(), 39 | }) 40 | .commit(); 41 | res.status(200).json({ 42 | status: result.isCompleted, 43 | completedAt: result.completedAt, 44 | }); 45 | 46 | break; 47 | case "DELETE": 48 | await client 49 | .delete(req.body) 50 | .then((res) => { 51 | res.body; 52 | }) 53 | .then((res) => console.log(`Todo was deleted`)); 54 | res.status(200).json({ msg: "Success" }); 55 | break; 56 | } 57 | } -------------------------------------------------------------------------------- /src/pages/index.js: -------------------------------------------------------------------------------- 1 | // pages/index.js 2 | 3 | import Link from "next/link"; 4 | export default function Home() { 5 | return ( 6 |
7 |

8 | Sanity-Next.js Todo App Starter 9 |

10 |
11 |
12 |

13 | This tutorial starter comes pre-built with{" "} 14 | 18 | Magic.link 19 | {" "} 20 | for authentication. Aside from the{" "} 21 | 22 | {""} 23 | {" "} 24 | component, the auth code is all taken from{" "} 25 | 29 | "Simple Auth with Magic.link and Next.js" 30 | {" "} 31 | by Eric Adamski at Vercel. 32 |

33 |

34 | There's a Sanity client config in lib/sanity/client.js{" "} 35 | and{" "} 36 | 40 | Tailwind 41 | {" "} 42 | is installed for styling. 43 |

44 | {" "} 45 | 46 | 47 | Go to Login 48 | 49 | 50 |
51 |

52 |
53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/pages/login.js: -------------------------------------------------------------------------------- 1 | // pages/login.js 2 | import { useRouter } from "next/router"; 3 | import { Magic } from "magic-sdk"; 4 | 5 | export default function Login() { 6 | const router = useRouter(); 7 | const handleSubmit = async (event) => { 8 | event.preventDefault(); 9 | 10 | const { elements } = event.target; 11 | 12 | // the Magic code 13 | const did = await new Magic( 14 | process.env.NEXT_PUBLIC_MAGIC_PUB_KEY 15 | ).auth.loginWithMagicLink({ email: elements.email.value }); 16 | 17 | const authRequest = await fetch("/api/login", { 18 | method: "POST", 19 | headers: { Authorization: `Bearer ${did}` }, 20 | }); 21 | 22 | if (authRequest.ok) { 23 | // We successfully logged in, our API 24 | // set authorization cookies and now we 25 | // can redirect to the dashboard! 26 | router.push("/todos"); 27 | } else { 28 | /* handle errors */ 29 | } 30 | }; 31 | 32 | return ( 33 |
34 |

35 | Log in to Sanity-Next Todo 36 |

37 |

With ✨Magic Link✨

38 |
39 | 45 | 50 |
51 |
52 | 55 |
56 |
57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/components/Todo.js: -------------------------------------------------------------------------------- 1 | // src/components/Todo.js 2 | 3 | import { useState, useContext } from "react"; 4 | // import a simple date formatting library 5 | import dayjs from "dayjs"; 6 | // import a trashcan icon for our delete button 7 | import { RiDeleteBin5Line } from "react-icons/ri"; 8 | import { TodoContext } from "../pages/todos" 9 | 10 | export default function Todo({ todo }) { 11 | //with useContext we do not need to pass handleDelete to 12 | const { handleDelete, fetchTodos } = useContext(TodoContext) 13 | //setting states for the isCompleted boolean and a date completed 14 | const [isCompleted, setIsCompleted] = useState(todo.isCompleted); 15 | const [completedTime, setCompletedTime] = useState(todo.completedAt); 16 | 17 | //function that syncs the completed checkbox with Sanity 18 | const handleToggle = async (e) => { 19 | e.preventDefault(); 20 | const result = await fetch("/api/todo", { 21 | method: "PUT", 22 | headers: { 23 | Accept: "application/json", 24 | "Content-Type": "application/json", 25 | }, 26 | body: JSON.stringify({ 27 | id: todo._id, 28 | //passes isCompleted React state to Sanity 29 | isCompleted: isCompleted, 30 | completedAt: todo.completedAt, 31 | }), 32 | }); 33 | 34 | const { status, completedAt } = await result.json(); 35 | await fetchTodos(); 36 | //pass our Sanity results back into React 37 | setIsCompleted(status); 38 | setCompletedTime(completedAt); 39 | 40 | }; 41 | return ( 42 |
  • 47 | 53 | {/*if todo is done, cross it out and turn it gray*/} 54 |

    59 | {todo.text} 60 |

    61 |

    62 | {/*if todo is done, show completedTime 63 | if not done, show due date */} 64 | {todo.isCompleted 65 | ? `Done ${dayjs(completedTime).format("MMM D, YYYY")}` 66 | : `Due ${dayjs(todo.dueDate).format("MMM D, YYYY")}`} 67 |

    68 | 77 |
  • 78 | ); 79 | } -------------------------------------------------------------------------------- /src/pages/todos.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, createContext } from "react"; 2 | //we must import the datepicker's css modules manually 3 | //so it plays nice with Next. 4 | import DatePicker from "react-date-picker/dist/entry.nostyle"; 5 | import "react-date-picker/dist/DatePicker.css"; 6 | import "react-calendar/dist/Calendar.css"; 7 | import useAuth from "../hooks/useAuth"; 8 | import Logout from "../components/Logout"; 9 | import client from "../lib/sanity/client"; 10 | import TodoList from "../components/TodoList" 11 | 12 | export const TodoContext = createContext() 13 | 14 | export default function Todos() { 15 | const { user, loading } = useAuth(); 16 | const [todoList, setTodoList] = useState([]); 17 | //create a state for the text in the todo input form 18 | const [userInput, setUserInput] = useState(""); 19 | //create a state for the due date chosen in the datepicker 20 | const [dueDate, setDueDate] = useState(""); 21 | //set an error message if either input is missing 22 | const [errMessage, setErrMessage] = useState(""); 23 | 24 | //after the useState hooks 25 | const fetchTodos = async () => { 26 | let fetchedTodos; 27 | //make sure the user is loaded 28 | if (!loading) { 29 | //pass userEmail as a query parameter 30 | fetchedTodos = await client.fetch( 31 | `*[_type=="todo" && userEmail==$userEmail] | order(dueDate asc) 32 | {_id, text, createdAt, dueDate, isCompleted, completedAt, userEmail}`, 33 | { 34 | userEmail: user.email, 35 | }); 36 | //insert our response in the todoList state 37 | setTodoList(fetchedTodos); 38 | } 39 | }; 40 | 41 | useEffect( 42 | () => { 43 | //now it will fetch todos on page load... 44 | fetchTodos(); 45 | }, 46 | //this dependecy array tells React to run the 47 | //hook again whenever loading or user values change 48 | [loading, user] 49 | ); 50 | 51 | //FOR THE INPUT FORM: 52 | const handleChange = (e) => { 53 | e.preventDefault(); 54 | setUserInput(e.target.value); 55 | }; 56 | 57 | //FOR THE SUBMIT BUTTON: 58 | const handleSubmit = async (e) => { 59 | e.preventDefault(); 60 | //if either part of the form isn't filled out 61 | //set an error message and exit 62 | if (userInput.length == 0 || dueDate == "") { 63 | setErrMessage("Todo text and due date must be filled out."); 64 | } else { 65 | //otherwise send the todo to our api 66 | // (we'll make this next!) 67 | await fetch("/api/todo", { 68 | method: "POST", 69 | body: JSON.stringify({ 70 | text: userInput, 71 | dueDate: dueDate, 72 | user: user.email, 73 | }), 74 | }); 75 | await fetchTodos(); 76 | // Clear all inputs after the todo is sent to Sanity 77 | setUserInput(""); 78 | setErrMessage(""); 79 | setDueDate(""); 80 | } 81 | }; 82 | const handleDelete = async (selectedTodo) => { 83 | await fetch("/api/todo", { 84 | method: "DELETE", 85 | body: selectedTodo._id, 86 | }); 87 | //todos will refresh after delete, too 88 | fetchTodos(); 89 | }; 90 | 91 | return ( 92 | 93 | {/* all your rendered JSX */} 94 |
    95 | 96 |
    97 |
    98 |

    My To-do List

    99 |

    100 | {loading ? "Loading..." : `Logged in as ${user.email}`} 101 |

    102 |
    103 |
    104 | {/*we flex the text input and datepicker 105 | so they display inline. */} 106 |
    107 | 115 |
    116 | 122 |
    123 |
    {" "} 124 | 132 | {/*error set in handleSubmit*/} 133 |

    {errMessage}

    134 |
    135 |
    136 |

    Your Todos

    138 | {loading ? ( 139 | "loading..." 140 | ) : ( 141 | 145 | )} 146 |
    147 |
    148 |
    149 |
    150 | ); 151 | } 152 | --------------------------------------------------------------------------------