├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .editorconfig ├── .example.env ├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── .gitpod.Dockerfile ├── .gitpod.yml ├── .prototools ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── cli.ts ├── client ├── shortcut.ts └── trpc.ts ├── common ├── constants.ts ├── types.ts └── utils.ts ├── components ├── FollowerUsersModal.tsx ├── FollowingUsersModal.tsx ├── LikeUsersModal.tsx ├── Post.tsx ├── Posts.tsx └── Users.tsx ├── deno.json ├── deno.lock ├── dev.ts ├── fresh.config.ts ├── fresh.gen.ts ├── islands ├── AllPosts.tsx ├── DeleteAccount.tsx ├── FollowingPosts.tsx ├── Header.tsx ├── LikePosts.tsx ├── PostEdit.tsx ├── PostNew.tsx ├── PostView.tsx ├── SearchedPosts.tsx └── UserPosts.tsx ├── main.ts ├── misc ├── backup.sh ├── db.sql └── todo.md ├── routes ├── _404.tsx ├── _500.tsx ├── _app.tsx ├── _middleware.ts ├── about.tsx ├── api │ ├── cli │ │ ├── posts │ │ │ └── [postId].ts │ │ └── search.ts │ └── trpc │ │ └── [path].ts ├── auth.ts ├── callback.tsx ├── debug_auth.tsx ├── following.tsx ├── index.tsx ├── likes.tsx ├── notification.tsx ├── posts │ ├── [postId] │ │ ├── edit.tsx │ │ └── index.tsx │ └── new.tsx ├── search.tsx ├── settings.tsx ├── shortcuts.tsx ├── signout.ts ├── sitemap │ ├── [userId].tsx │ └── index.tsx └── users │ └── [userId].tsx ├── server ├── auth.ts ├── database.types.ts ├── db.ts ├── env.ts ├── getTitle.ts ├── markdown.ts ├── query_builder.ts ├── query_builder_test.ts └── trpc │ ├── context.ts │ ├── procedures │ ├── cancelLike.ts │ ├── createComment.ts │ ├── createFollow.ts │ ├── createLike.ts │ ├── createPost.ts │ ├── deleteComment.ts │ ├── deleteFollow.ts │ ├── deletePost.ts │ ├── deleteUser.ts │ ├── getComments.ts │ ├── getFollowInfo.ts │ ├── getFollowerUsers.ts │ ├── getFollowingUsers.ts │ ├── getLikeUsers.ts │ ├── getLikedPosts.ts │ ├── getPosts.ts │ ├── getSession.ts │ ├── isLiked.ts │ ├── md2html.ts │ └── updatePost.ts │ └── router.ts └── static ├── app.css ├── assets └── img │ ├── bell.png │ ├── bell2.png │ ├── bluesky.svg │ ├── btn_google_signin_dark_pressed_web.png │ ├── gear-fill.svg │ ├── heart-fill.svg │ ├── heart-fill.svgZone.Identifier │ ├── heart.svg │ ├── heart.svgZone.Identifier │ ├── icon-192x192.png │ ├── icon-512x512.png │ ├── keyboard-fill.svg │ ├── notification.png │ ├── og.png │ ├── pencil-fill.svg │ ├── question-circle-fill.svg │ ├── trash-fill.svg │ └── twitter.svg ├── btn_google_signin_dark_pressed_web.png ├── favicon.ico ├── google02f544219e20b91a.html ├── manifest.json ├── register_sw.js └── sw.js /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # [Choice] Debian OS version: bullseye, buster 2 | ARG VARIANT=bullseye 3 | FROM --platform=linux/amd64 mcr.microsoft.com/devcontainers/base:0-${VARIANT} 4 | 5 | ENV DENO_INSTALL=/deno 6 | RUN mkdir -p /deno \ 7 | && curl -fsSL https://deno.land/x/install/install.sh | sh \ 8 | && chown -R vscode /deno 9 | RUN wget https://github.com/supabase/cli/releases/download/v1.110.1/supabase_1.110.1_linux_amd64.deb \ 10 | && dpkg -i supabase_1.110.1_linux_amd64.deb 11 | 12 | ENV PATH=${DENO_INSTALL}/bin:${PATH} \ 13 | DENO_DIR=${DENO_INSTALL}/.cache/deno 14 | 15 | # [Optional] Uncomment this section to install additional OS packages. 16 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 17 | # && apt-get -y install --no-install-recommends 18 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Deno", 3 | "build": { 4 | "dockerfile": "Dockerfile" 5 | }, 6 | 7 | // Configure tool-specific properties. 8 | "customizations": { 9 | // Configure properties specific to VS Code. 10 | "vscode": { 11 | // Set *default* container specific settings.json values on container create. 12 | "settings": { 13 | // Enables the project as a Deno project 14 | "deno.enable": true, 15 | // Enables Deno linting for the project 16 | "deno.lint": true, 17 | // Sets Deno as the default formatter for the project 18 | "editor.defaultFormatter": "denoland.vscode-deno" 19 | }, 20 | 21 | // Add the IDs of extensions you want installed when the container is created. 22 | "extensions": [ 23 | "denoland.vscode-deno" 24 | ] 25 | } 26 | }, 27 | 28 | "remoteUser": "vscode" 29 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | indent_size = 2 -------------------------------------------------------------------------------- /.example.env: -------------------------------------------------------------------------------- 1 | LEAVES_AUTH_CLIENT_SECRET= 2 | LEAVES_AUTH_CLIENT_ID= 3 | 4 | SUPABASE_PROJECT_ID= 5 | SUPABASE_HOST= 6 | SUPABASE_SERVICE_ROLE_KEY= 7 | 8 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | on: 3 | push: 4 | branches: [main,develop] 5 | paths: 6 | - "**.tsx?" 7 | - "static/**" 8 | - "!cli.ts" 9 | workflow_dispatch: 10 | # pull_request: 11 | # branches: main 12 | 13 | jobs: 14 | deploy: 15 | name: Deploy 16 | runs-on: ubuntu-latest 17 | 18 | permissions: 19 | id-token: write 20 | contents: read 21 | 22 | steps: 23 | - name: Clone repository 24 | uses: actions/checkout@v3 25 | 26 | - name: Install Deno 27 | uses: denoland/setup-deno@v1 28 | with: 29 | deno-version: v1.x 30 | 31 | - name: Build step 32 | run: "deno task build" 33 | 34 | - name: Upload to Deno Deploy 35 | uses: denoland/deployctl@v1 36 | with: 37 | project: "leaves" 38 | entrypoint: "./main.ts" 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.env 2 | /static/build.txt 3 | *.gz 4 | _fresh/ 5 | -------------------------------------------------------------------------------- /.gitpod.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gitpod/workspace-full 2 | 3 | RUN curl -fsSL https://deno.land/x/install/install.sh | sh 4 | RUN /home/gitpod/.deno/bin/deno completions bash > /home/gitpod/.bashrc.d/90-deno && \ 5 | echo 'export DENO_INSTALL="/home/gitpod/.deno"' >> /home/gitpod/.bashrc.d/90-deno && \ 6 | echo 'export PATH="$DENO_INSTALL/bin:$PATH"' >> /home/gitpod/.bashrc.d/90-deno 7 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | image: 2 | file: .gitpod.Dockerfile 3 | -------------------------------------------------------------------------------- /.prototools: -------------------------------------------------------------------------------- 1 | deno = "1.46.3" 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "denoland.vscode-deno" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "editor.defaultFormatter": "denoland.vscode-deno", 4 | "editor.formatOnSave": true, 5 | "[typescriptreact]": { 6 | "editor.defaultFormatter": "denoland.vscode-deno" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Tomofumi Chiba 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 | ![logo](static/assets/img/icon-192x192.png) 2 | 3 | # Leaves 4 | 5 | [![Made with Fresh](https://fresh.deno.dev/fresh-badge.svg)](https://fresh.deno.dev) 6 | 7 | Source code for https://leaves.chiba.dev/ . 8 | 9 | [Zenn article](https://zenn.dev/chiba/articles/md-sns-deno-fresh) 10 | 11 | ## Leaves CLI 12 | 13 | ### Install 14 | 15 | ``` 16 | deno install -f -n s --allow-net=leaves.chiba.dev https://raw.githubusercontent.com/chibat/leaves/main/cli.ts 17 | ``` 18 | 19 | ## References 20 | 21 | - https://supabase.com/docs/guides/database 22 | - https://app.supabase.com/projects 23 | - https://getbootstrap.com/docs/5.2/getting-started/introduction/ 24 | - https://github.com/cure53/DOMPurify 25 | -------------------------------------------------------------------------------- /cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S deno run --allow-net=localhost,leaves.chiba.dev,leaves--develop.deno.dev 2 | 3 | import { Input } from "https://deno.land/x/cliffy@v1.0.0-rc.3/prompt/input.ts"; 4 | import { Select } from "https://deno.land/x/cliffy@v1.0.0-rc.3/prompt/select.ts"; 5 | import { 6 | clearScreen, 7 | colors, 8 | } from "https://deno.land/x/cliffy@v1.0.0-rc.3/ansi/mod.ts"; 9 | 10 | const COMMAND_OPTIONS = [{ value: -1, name: ":Back" }, { 11 | value: -2, 12 | name: ":Exit", 13 | }]; 14 | const keys = { next: ["j", "down"], previous: ["k", "up"] }; 15 | const API_PATH = Deno.args.at(0) === "dev" 16 | ? "http://localhost:8000/api/cli" 17 | : "https://leaves.chiba.dev/api/cli"; 18 | 19 | let list: Array<{ value: number; name: string }> = []; 20 | let searchSkip = false; 21 | let word = ""; 22 | 23 | while (true) { 24 | console.log(clearScreen); 25 | if (!searchSkip) { 26 | word = await Input.prompt({ message: "Search", suggestions: [word] }); 27 | if (!word) { 28 | Deno.exit(); 29 | } 30 | const url = new URL(`${API_PATH}/search`); 31 | url.searchParams.set("q", word); 32 | const res = await fetch(url); 33 | list = await res.json() as Array<{ name: string; value: number }>; 34 | if (list.length === 0) { 35 | console.log("Not Found"); 36 | Deno.exit(); 37 | } 38 | if (list.length >= 10) { 39 | console.log( 40 | colors.bold.yellow( 41 | "More than 10 posts hits were found. Let's add search words!", 42 | ), 43 | ); 44 | } 45 | } 46 | 47 | const postId: any = await Select.prompt({ // 型推論がおかしい 48 | message: "Select", 49 | maxRows: 12, 50 | options: list.concat(COMMAND_OPTIONS), 51 | keys, 52 | }); 53 | 54 | if (postId === -1) { 55 | searchSkip = false; 56 | continue; 57 | } else if (postId === -2) { 58 | Deno.exit(0); 59 | } 60 | 61 | const res = await fetch(`${API_PATH}/posts/${postId}`); 62 | const json = await res.json(); 63 | console.log( 64 | "---------------------------------------------------------------------------------", 65 | ); 66 | console.log(json.source); 67 | console.log( 68 | "---------------------------------------------------------------------------------", 69 | ); 70 | console.log(`https://leaves.chiba.dev/posts/${postId}`); 71 | const option: any = await Select.prompt({ 72 | message: "Select", 73 | options: COMMAND_OPTIONS, 74 | keys, 75 | }); 76 | if (option === -2) { 77 | Deno.exit(0); 78 | } else if (option === -1) { 79 | searchSkip = true; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /client/shortcut.ts: -------------------------------------------------------------------------------- 1 | import { trpc } from "./trpc.ts"; 2 | import Mousetrap from "mousetrap"; 3 | 4 | export function shortcut() { 5 | Mousetrap.bind("/", () => { 6 | location.href = "/search"; 7 | }); 8 | Mousetrap.bind("n", () => { 9 | location.href = "/posts/new"; 10 | }); 11 | Mousetrap.bind("?", () => { 12 | location.href = "/shortcuts"; 13 | }); 14 | Mousetrap.bind(".", () => { 15 | window.scroll({ top: 0, behavior: "smooth" }); 16 | }); 17 | Mousetrap.bind("g a", () => { 18 | location.href = "/about"; 19 | }); 20 | Mousetrap.bind("g h", () => { 21 | location.href = "/"; 22 | }); 23 | Mousetrap.bind("g n", () => { 24 | location.href = "/notification"; 25 | }); 26 | Mousetrap.bind("g l", () => { 27 | location.href = "/likes"; 28 | }); 29 | Mousetrap.bind("g f", () => { 30 | location.href = "/following"; 31 | }); 32 | Mousetrap.bind("g p", () => { 33 | trpc.getSession.query().then((session) => { 34 | if (session?.user.id) { 35 | location.href = `/users/${session.user.account ?? session.user.id}`; 36 | } 37 | }); 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /client/trpc.ts: -------------------------------------------------------------------------------- 1 | import type { AppRouter } from "~/server/trpc/router.ts"; 2 | import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"; 3 | 4 | export const trpc = createTRPCProxyClient({ 5 | links: [ 6 | httpBatchLink({ 7 | url: "/api/trpc", 8 | }), 9 | ], 10 | }); 11 | -------------------------------------------------------------------------------- /common/constants.ts: -------------------------------------------------------------------------------- 1 | export const PAGE_ROWS = 5; 2 | export const DEFAULT_AVATOR = "default_avator.svg"; 3 | -------------------------------------------------------------------------------- /common/types.ts: -------------------------------------------------------------------------------- 1 | export type ResponsePost = { 2 | id: number; 3 | user_id: number; 4 | source: string; 5 | updated_at: string; 6 | created_at: string; 7 | name?: string; // app_user 8 | picture?: string; // app_user 9 | comments: string; // comment 10 | likes: string; // likes 11 | draft: boolean; 12 | liked: boolean; 13 | }; 14 | -------------------------------------------------------------------------------- /common/utils.ts: -------------------------------------------------------------------------------- 1 | export function defaultString(str: string | null | undefined): string { 2 | return str ? str : ""; 3 | } 4 | -------------------------------------------------------------------------------- /components/FollowerUsersModal.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { useEffect, useState } from "preact/hooks"; 3 | import Users from '~/components/Users.tsx' 4 | import { trpc } from "~/client/trpc.ts"; 5 | import { User } from "~/server/trpc/procedures/getFollowerUsers.ts"; 6 | 7 | export default function FollowerUsersModal(props: { userId: number, setModal: (modal: boolean) => void }) { 8 | 9 | const [users, setUsers] = useState([]); 10 | const [loading, setLoading] = useState(false); 11 | 12 | function closeModal() { 13 | props.setModal(false); 14 | } 15 | 16 | useEffect(() => { 17 | setLoading(true); 18 | trpc.getFollowerUsers.query({ userId: props.userId }).then(results => { 19 | setLoading(false); 20 | setUsers(results); 21 | }); 22 | }, []); 23 | 24 | return ( 25 | ); 48 | } 49 | -------------------------------------------------------------------------------- /components/FollowingUsersModal.tsx: -------------------------------------------------------------------------------- 1 | import Users from '~/components/Users.tsx'; 2 | import { useState, useEffect } from "preact/hooks"; 3 | import { trpc } from "~/client/trpc.ts"; 4 | import { User } from "~/server/trpc/procedures/getFollowerUsers.ts"; 5 | 6 | export default function FollowingUsersModal(props: { userId: number, setModal: (modal: boolean) => void }) { 7 | 8 | const [users, setUsers] = useState([]); 9 | const [loading, setLoading] = useState(false); 10 | 11 | function closeModal() { 12 | props.setModal(false); 13 | } 14 | 15 | useEffect(() => { 16 | setLoading(true); 17 | trpc.getFollowingUsers.query({ userId: props.userId }).then(results => { 18 | setLoading(false); 19 | setUsers(results); 20 | }); 21 | }, []); 22 | 23 | return ( 24 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /components/LikeUsersModal.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "preact/hooks"; 2 | import Users from "~/components/Users.tsx"; 3 | import { User } from "~/server/trpc/procedures/getFollowingUsers.ts"; 4 | import { trpc } from "~/client/trpc.ts"; 5 | 6 | export function LikeUsersModal(props: { postId: number, setModal: (modal: boolean) => void }) { 7 | 8 | const [users, setUsers] = useState([]); 9 | const [loading, setLoading] = useState(false); 10 | 11 | function closeModal() { 12 | props.setModal(false); 13 | } 14 | useEffect(() => { 15 | setLoading(true); 16 | trpc.getLikeUsers.query({ postId: props.postId }).then(a => { 17 | setLoading(false); 18 | setUsers(a); 19 | }); 20 | }, []); 21 | 22 | return ( 23 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /components/Post.tsx: -------------------------------------------------------------------------------- 1 | import { trpc } from "~/client/trpc.ts"; 2 | import { useState } from "preact/hooks"; 3 | import { useSignal } from "@preact/signals"; 4 | import { LikeUsersModal } from "~/components/LikeUsersModal.tsx"; 5 | import { GetPostsOutput } from "~/server/trpc/procedures/getPosts.ts"; 6 | 7 | export default function Post(props: { post: GetPostsOutput; userId?: number }) { 8 | const post = useSignal(props.post); 9 | const now = new Date(); 10 | const createdDate = new Date(post.value.created_at); 11 | const updatedDate = new Date(post.value.updated_at); 12 | 13 | const [modal, setModal] = useState(false); 14 | const [selectedPostId, setSelectedPostId] = useState(); 15 | 16 | async function deletePost(postId: number) { 17 | if (confirm("Delete the post?")) { 18 | await trpc.deletePost.mutate({ postId }); 19 | location.reload(); 20 | } 21 | } 22 | 23 | async function like() { 24 | if (!props.userId) { 25 | location.href = "/auth"; 26 | return; 27 | } 28 | await trpc.createLike.mutate({ postId: post.value.id }); 29 | post.value = { ...post.value, liked: true, likes: post.value.likes + 1 }; 30 | } 31 | 32 | async function cancelLike() { 33 | await trpc.cancelLike.mutate({ postId: post.value.id }); 34 | post.value = { ...post.value, liked: false, likes: post.value.likes - 1 }; 35 | } 36 | 37 | function openModal(postId: number) { 38 | setSelectedPostId(postId); 39 | setModal(true); 40 | } 41 | 42 | return ( 43 | <> 44 |
45 | 72 |
73 | {post.value.draft && 74 | ( 75 | 76 | 🔒 PRIVATE 77 | 78 | )} 79 | 85 | 86 |
87 |
88 | 94 | Comment 95 | 96 | {BigInt(post.value.comments) > 0 && 97 | ( 98 | 99 | {post.value.comments}{" "} 100 | Comment{post.value.comments === 1 ? "" : "s"} 101 | 102 | )} 103 | {props.userId && post.value.liked && 104 | ( 105 | Edit cancelLike()} 111 | class="ms-3" 112 | style={{ cursor: "pointer" }} 113 | /> 114 | )} 115 | {!post.value.liked && 116 | ( 117 | Edit like()} 123 | class="ms-3" 124 | style={{ cursor: "pointer" }} 125 | /> 126 | )} 127 | {BigInt(post.value.likes) > 0n && 128 | ( 129 | openModal(post.value.id)} 132 | style={{ cursor: "pointer" }} 133 | > 134 | {post.value.likes} Like{post.value.likes === 1 ? "" : "s"} 135 | 136 | )} 137 |
138 | {props.userId === post.value.user_id && 139 | ( 140 | 165 | )} 166 |
167 |
168 |
169 | {modal && selectedPostId && ( 170 | 171 | )} 172 | 173 | ); 174 | } 175 | 176 | function formatDate(now: Date, date: Date) { 177 | if ( 178 | now.getFullYear() === date.getFullYear() && 179 | now.getMonth() === date.getMonth() && 180 | now.getDate() === date.getDate() 181 | ) { 182 | return date.toLocaleTimeString(); 183 | } else { 184 | return date.toLocaleDateString(); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /components/Posts.tsx: -------------------------------------------------------------------------------- 1 | import * as hljs from "highlightjs"; 2 | 3 | import { useEffect } from "preact/hooks"; 4 | import { Signal } from "@preact/signals-core"; 5 | import Mousetrap from "mousetrap"; 6 | import { useSignal } from "@preact/signals"; 7 | import Post from "~/components/Post.tsx"; 8 | import { GetPostsOutput } from "~/server/trpc/procedures/getPosts.ts"; 9 | 10 | type Props = { 11 | posts: Signal; 12 | userId?: number; 13 | }; 14 | 15 | export default function Posts(props: Props) { 16 | const selectedIndex = useSignal(0); 17 | 18 | useEffect(() => { 19 | registerJumpElements(document.getElementsByClassName("postCard")); 20 | Mousetrap.bind("o", () => { 21 | location.href = `/posts/${props.posts.value[selectedIndex.value].id}`; 22 | }); 23 | Mousetrap.bind("e", () => { 24 | const post = props.posts.value[selectedIndex.value]; 25 | if (props.userId === post.user_id) { 26 | location.href = `/posts/${post.id}/edit`; 27 | } 28 | }); 29 | }, []); 30 | 31 | useEffect(() => { 32 | (hljs as any).highlightAll(); 33 | }); 34 | 35 | function registerJumpElements(elements: HTMLCollectionOf) { 36 | const KEYCODE_J = "j"; 37 | const KEYCODE_K = "k"; 38 | 39 | let currentIndex = -1; 40 | 41 | const scollElement = (event: KeyboardEvent) => { 42 | if (event.key == ".") { 43 | currentIndex = -1; 44 | } else if (event.key != KEYCODE_J && event.key != KEYCODE_K) { 45 | return; 46 | } 47 | // 次の位置を計算 48 | let nextIndex = currentIndex + ((event.key == KEYCODE_J) ? 1 : -1); 49 | 50 | if (nextIndex < 0) { 51 | nextIndex = 0; 52 | } else if (nextIndex >= elements.length) { 53 | nextIndex = elements.length - 1; 54 | } 55 | 56 | // 要素が表示されるようにスクロール 57 | elements[nextIndex].scrollIntoView(); 58 | currentIndex = nextIndex; 59 | selectedIndex.value = currentIndex; 60 | }; 61 | 62 | if (document.addEventListener) { 63 | document.addEventListener( 64 | "keydown", 65 | scollElement, 66 | false, 67 | ); 68 | } 69 | } 70 | 71 | return ( 72 | <> 73 | {props.posts.value && props.posts.value.map((post) => 74 | 75 | )} 76 | 77 | ); 78 | } 79 | 80 | -------------------------------------------------------------------------------- /components/Users.tsx: -------------------------------------------------------------------------------- 1 | 2 | type Props = { 3 | users: { id: number; name: string; picture?: string }[]; 4 | }; 5 | 6 | export default function Users(props: Props) { 7 | 8 | return ( 9 | <> 10 | {props.users.map(user => 11 |
12 | {user.picture && 13 | mdo 14 | } 15 | {user.name} 16 |
17 | )} 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "lock": false, 3 | "tasks": { 4 | "win": "deno run --watch=static/,routes/ --allow-env --allow-read --allow-write=.,$LOCALAPPDATA/Temp --allow-run=$(where deno),$LOCALAPPDATA/Cache/esbuild/bin/esbuild-windows-64@0.19.4 --allow-net=:8000,deno.land,esm.sh,accounts.google.com,www.googleapis.com,cdn.jsdelivr.net,$SUPABASE_HOST dev.ts", 5 | "arm": "deno run --watch=static/,routes/ --allow-env --allow-read --allow-write=.,/tmp --allow-run=deno,$HOME/.cache/esbuild/bin/esbuild-linux-arm64@0.19.4 --allow-net=:8000,deno.land,esm.sh,accounts.google.com,www.googleapis.com,cdn.jsdelivr.net,$SUPABASE_HOST dev.ts", 6 | "linux": "export $(grep -v ^# .env) && deno run --watch=static/,routes/ --allow-env -R -W=.,$HOME/.cache/fresh,$HOME/.cache/esbuild --allow-run=$HOME/.proto/tools/deno/1.46.3/deno,$HOME/.cache/esbuild/bin/@esbuild-linux-x64@0.19.4 -N=:8000,deno.land,dl.deno.land,esm.sh,accounts.google.com,www.googleapis.com,cdn.jsdelivr.net,$SUPABASE_HOST,registry.npmjs.org dev.ts", 7 | "update": "deno run -A -r https://fresh.deno.dev/update .", 8 | "build": "git show --format='%H' --no-patch > static/build.txt && date --iso-8601='seconds' >> static/build.txt && deno run --allow-env --allow-read --allow-write=.,$HOME/.cache/fresh,$HOME/.cache/esbuild --allow-run=deno,$HOME/.cache/esbuild/bin/@esbuild-linux-x64@0.19.4 --allow-net=deno.land,dl.deno.land,esm.sh,cdn.jsdelivr.net,registry.npmjs.org dev.ts build", 9 | "env": "env", 10 | "preview": "export $(grep -v ^# .env) && deno run -A main.ts", 11 | "sb-gen": "export $(grep -v ^# .env) && supabase gen types typescript --project-id $SUPABASE_PROJECT_ID > server/database.types.ts" 12 | }, 13 | "lint": { 14 | "rules": { 15 | "tags": [ 16 | "fresh", 17 | "recommended" 18 | ] 19 | } 20 | }, 21 | "imports": { 22 | "$std/": "https://deno.land/std@0.193.0/", 23 | "supabase-js": "https://esm.sh/v133/@supabase/supabase-js@2.38.4", 24 | "$fresh/": "https://deno.land/x/fresh@1.6.0/", 25 | "preact": "https://esm.sh/preact@10.19.2", 26 | "preact/": "https://esm.sh/preact@10.19.2/", 27 | "preact-render-to-string": "https://esm.sh/*preact-render-to-string@6.2.2", 28 | "@preact/signals": "https://esm.sh/*@preact/signals@1.2.1", 29 | "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.0", 30 | "@trpc/server": "https://esm.sh/v114/@trpc/server@10.18.0", 31 | "@trpc/server/": "https://esm.sh/v114/@trpc/server@10.18.0/", 32 | "@trpc/client": "https://esm.sh/v114/@trpc/client@10.18.0", 33 | "zod": "https://deno.land/x/zod@v3.20.2/mod.ts", 34 | "highlightjs": "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.7.0/build/highlight.min.js", 35 | "sanitize-html": "https://esm.sh/v114/sanitize-html@2.8.1?target=esnext", 36 | "marked": "https://esm.sh/v114/marked@4.2.12", 37 | "emoji": "https://deno.land/x/emoji@0.2.1/mod.ts", 38 | "mousetrap": "https://esm.sh/v114/mousetrap@1.6.5", 39 | "~/": "./" 40 | }, 41 | "compilerOptions": { 42 | "jsx": "react-jsx", 43 | "jsxImportSource": "preact" 44 | }, 45 | "exclude": [ 46 | "**/_fresh/*" 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /dev.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S deno run -A --watch=static/,routes/ 2 | 3 | import dev from "$fresh/dev.ts"; 4 | import config from "./fresh.config.ts"; 5 | import "$std/dotenv/load.ts"; 6 | 7 | await dev(import.meta.url, "./main.ts", config); 8 | -------------------------------------------------------------------------------- /fresh.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "$fresh/server.ts"; 2 | import { initSupabase } from "~/server/db.ts"; 3 | import { env } from "~/server/env.ts"; 4 | 5 | export default defineConfig({}); 6 | 7 | if (Deno.args.at(0) !== "build") { 8 | env.init(); 9 | initSupabase(); 10 | } 11 | -------------------------------------------------------------------------------- /fresh.gen.ts: -------------------------------------------------------------------------------- 1 | // DO NOT EDIT. This file is generated by Fresh. 2 | // This file SHOULD be checked into source version control. 3 | // This file is automatically updated during development when running `dev.ts`. 4 | 5 | import * as $_404 from "./routes/_404.tsx"; 6 | import * as $_500 from "./routes/_500.tsx"; 7 | import * as $_app from "./routes/_app.tsx"; 8 | import * as $_middleware from "./routes/_middleware.ts"; 9 | import * as $about from "./routes/about.tsx"; 10 | import * as $api_cli_posts_postId_ from "./routes/api/cli/posts/[postId].ts"; 11 | import * as $api_cli_search from "./routes/api/cli/search.ts"; 12 | import * as $api_trpc_path_ from "./routes/api/trpc/[path].ts"; 13 | import * as $auth from "./routes/auth.ts"; 14 | import * as $callback from "./routes/callback.tsx"; 15 | import * as $debug_auth from "./routes/debug_auth.tsx"; 16 | import * as $following from "./routes/following.tsx"; 17 | import * as $index from "./routes/index.tsx"; 18 | import * as $likes from "./routes/likes.tsx"; 19 | import * as $notification from "./routes/notification.tsx"; 20 | import * as $posts_postId_edit from "./routes/posts/[postId]/edit.tsx"; 21 | import * as $posts_postId_index from "./routes/posts/[postId]/index.tsx"; 22 | import * as $posts_new from "./routes/posts/new.tsx"; 23 | import * as $search from "./routes/search.tsx"; 24 | import * as $settings from "./routes/settings.tsx"; 25 | import * as $shortcuts from "./routes/shortcuts.tsx"; 26 | import * as $signout from "./routes/signout.ts"; 27 | import * as $sitemap_userId_ from "./routes/sitemap/[userId].tsx"; 28 | import * as $sitemap_index from "./routes/sitemap/index.tsx"; 29 | import * as $users_userId_ from "./routes/users/[userId].tsx"; 30 | import * as $AllPosts from "./islands/AllPosts.tsx"; 31 | import * as $DeleteAccount from "./islands/DeleteAccount.tsx"; 32 | import * as $FollowingPosts from "./islands/FollowingPosts.tsx"; 33 | import * as $Header from "./islands/Header.tsx"; 34 | import * as $LikePosts from "./islands/LikePosts.tsx"; 35 | import * as $PostEdit from "./islands/PostEdit.tsx"; 36 | import * as $PostNew from "./islands/PostNew.tsx"; 37 | import * as $PostView from "./islands/PostView.tsx"; 38 | import * as $SearchedPosts from "./islands/SearchedPosts.tsx"; 39 | import * as $UserPosts from "./islands/UserPosts.tsx"; 40 | import { type Manifest } from "$fresh/server.ts"; 41 | 42 | const manifest = { 43 | routes: { 44 | "./routes/_404.tsx": $_404, 45 | "./routes/_500.tsx": $_500, 46 | "./routes/_app.tsx": $_app, 47 | "./routes/_middleware.ts": $_middleware, 48 | "./routes/about.tsx": $about, 49 | "./routes/api/cli/posts/[postId].ts": $api_cli_posts_postId_, 50 | "./routes/api/cli/search.ts": $api_cli_search, 51 | "./routes/api/trpc/[path].ts": $api_trpc_path_, 52 | "./routes/auth.ts": $auth, 53 | "./routes/callback.tsx": $callback, 54 | "./routes/debug_auth.tsx": $debug_auth, 55 | "./routes/following.tsx": $following, 56 | "./routes/index.tsx": $index, 57 | "./routes/likes.tsx": $likes, 58 | "./routes/notification.tsx": $notification, 59 | "./routes/posts/[postId]/edit.tsx": $posts_postId_edit, 60 | "./routes/posts/[postId]/index.tsx": $posts_postId_index, 61 | "./routes/posts/new.tsx": $posts_new, 62 | "./routes/search.tsx": $search, 63 | "./routes/settings.tsx": $settings, 64 | "./routes/shortcuts.tsx": $shortcuts, 65 | "./routes/signout.ts": $signout, 66 | "./routes/sitemap/[userId].tsx": $sitemap_userId_, 67 | "./routes/sitemap/index.tsx": $sitemap_index, 68 | "./routes/users/[userId].tsx": $users_userId_, 69 | }, 70 | islands: { 71 | "./islands/AllPosts.tsx": $AllPosts, 72 | "./islands/DeleteAccount.tsx": $DeleteAccount, 73 | "./islands/FollowingPosts.tsx": $FollowingPosts, 74 | "./islands/Header.tsx": $Header, 75 | "./islands/LikePosts.tsx": $LikePosts, 76 | "./islands/PostEdit.tsx": $PostEdit, 77 | "./islands/PostNew.tsx": $PostNew, 78 | "./islands/PostView.tsx": $PostView, 79 | "./islands/SearchedPosts.tsx": $SearchedPosts, 80 | "./islands/UserPosts.tsx": $UserPosts, 81 | }, 82 | baseUrl: import.meta.url, 83 | } satisfies Manifest; 84 | 85 | export default manifest; 86 | -------------------------------------------------------------------------------- /islands/AllPosts.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "preact/hooks"; 2 | import Posts from "~/components/Posts.tsx"; 3 | 4 | import { PAGE_ROWS } from "~/common/constants.ts"; 5 | import { useSignal } from "@preact/signals"; 6 | import { trpc } from "~/client/trpc.ts"; 7 | import { GetPostsOutput } from "~/server/trpc/procedures/getPosts.ts"; 8 | 9 | export default function AllPosts(props: { loginUserId?: number }) { 10 | 11 | const posts = useSignal([]); 12 | const requesting = useSignal(false); 13 | const spinning = useSignal(true); 14 | const allLoaded = useSignal(false); 15 | 16 | useEffect(() => { 17 | const io = new IntersectionObserver(entries => { 18 | if (!requesting.value && entries[0].intersectionRatio !== 0 && !allLoaded.value) { 19 | const postId = posts.value.length === 0 ? null : posts.value[posts.value.length - 1].id; 20 | requesting.value = true; 21 | spinning.value = true; 22 | trpc.getPosts.query({ postId }).then(results => { 23 | if (results.length > 0) { 24 | posts.value = posts.value.concat(results); 25 | } 26 | if (results.length < PAGE_ROWS) { 27 | allLoaded.value = true; 28 | } 29 | requesting.value = false; 30 | spinning.value = false; 31 | }); 32 | } 33 | }); 34 | const bottom = document.getElementById("bottom"); 35 | if (bottom) { 36 | io.observe(bottom); 37 | } 38 | return () => { 39 | if (bottom) { 40 | io.unobserve(bottom) 41 | } 42 | }; 43 | }, []); 44 | 45 | return ( 46 |
47 | 48 |
49 |
50 | {spinning.value && 51 |
52 |
53 | Loading... 54 |
55 |
56 | } 57 |
 
58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /islands/DeleteAccount.tsx: -------------------------------------------------------------------------------- 1 | import { trpc } from "~/client/trpc.ts"; 2 | 3 | export default function DeleteAccount(props: { userName: string }) { 4 | async function onClick() { 5 | if (confirm(`Delete Account "${props.userName}"`)) { 6 | await trpc.deleteUser.mutate(); 7 | location.href = "/signout"; 8 | } 9 | } 10 | return ( 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /islands/FollowingPosts.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "preact/hooks"; 2 | import { PAGE_ROWS } from "~/common/constants.ts"; 3 | import Posts from "~/components/Posts.tsx"; 4 | import { useSignal } from "@preact/signals"; 5 | import { trpc } from "~/client/trpc.ts"; 6 | import { GetPostsOutput } from "~/server/trpc/procedures/getPosts.ts"; 7 | 8 | export default function FollowingPosts(props: { loginUserId?: number }) { 9 | 10 | const posts = useSignal>([]); 11 | const allLoaded = useSignal(false); 12 | const requesting = useSignal(false); 13 | const spinning = useSignal(true); 14 | 15 | useEffect(() => { 16 | const io = new IntersectionObserver(entries => { 17 | if (!allLoaded.value && !requesting.value && entries[0].intersectionRatio !== 0) { 18 | const postId = posts.value.length === 0 ? null : posts.value[posts.value.length - 1].id; 19 | requesting.value = true; 20 | spinning.value = true; 21 | trpc.getPosts.query({ postId, following: true }).then(results => { 22 | if (results.length > 0) { 23 | posts.value = posts.value.concat(results); 24 | } 25 | if (results.length < PAGE_ROWS) { 26 | allLoaded.value = true; 27 | } 28 | requesting.value = false; 29 | spinning.value = false; 30 | }); 31 | } 32 | }); 33 | const bottom = document.getElementById("bottom"); 34 | if (bottom) { 35 | io.observe(bottom); 36 | } 37 | return () => { 38 | if (bottom) { 39 | io.unobserve(bottom) 40 | } 41 | }; 42 | }, []); 43 | 44 | return ( 45 |
46 |

Following

47 | 48 |
49 |
50 | {spinning.value && 51 |
52 |
53 | Loading... 54 |
55 |
56 | } 57 |
 
58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /islands/Header.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "preact/hooks"; 2 | import { shortcut } from "~/client/shortcut.ts"; 3 | import { DEFAULT_AVATOR } from "~/common/constants.ts"; 4 | 5 | export default function Header( 6 | props: { 7 | user?: { 8 | id: number; 9 | picture: string | null; 10 | notification: boolean; 11 | account: string | null; 12 | }; 13 | authUrl?: string; 14 | }, 15 | ) { 16 | useEffect(() => { 17 | shortcut(); 18 | }, []); 19 | 20 | return ( 21 | 244 | ); 245 | } 246 | -------------------------------------------------------------------------------- /islands/LikePosts.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "preact/hooks"; 2 | import { useSignal } from "@preact/signals"; 3 | import Posts from "~/components/Posts.tsx"; 4 | import { PAGE_ROWS } from "~/common/constants.ts"; 5 | import { trpc } from "~/client/trpc.ts"; 6 | import { GetPostsOutput } from "~/server/trpc/procedures/getPosts.ts"; 7 | 8 | export default function LikePosts(props: { loginUserId?: number }) { 9 | 10 | const posts = useSignal>([]); 11 | const spinning = useSignal(true); 12 | const requesting = useSignal(false); 13 | const allLoaded = useSignal(false); 14 | 15 | useEffect(() => { 16 | const io = new IntersectionObserver(entries => { 17 | if (!requesting.value && entries[0].intersectionRatio !== 0 && !allLoaded.value) { 18 | const postId = posts.value.length === 0 ? null : posts.value[posts.value.length - 1].id; 19 | requesting.value = true; 20 | spinning.value = true; 21 | trpc.getLikedPosts.query({ postId }).then(results => { 22 | if (!results) { 23 | return; 24 | } 25 | if (results.length > 0) { 26 | posts.value = posts.value.concat(results); 27 | } 28 | if (results.length < PAGE_ROWS) { 29 | allLoaded.value = true; 30 | } 31 | }).finally(() => { 32 | requesting.value = false; 33 | spinning.value = false; 34 | }); 35 | } 36 | }); 37 | const bottom = document.getElementById("bottom"); 38 | if (bottom) { 39 | io.observe(bottom); 40 | } 41 | return () => { 42 | if (bottom) { 43 | io.unobserve(bottom) 44 | } 45 | }; 46 | }, []); 47 | 48 | return ( 49 |
50 |

51 | Likes 58 | Likes 59 |

60 | 61 |
62 |
63 | {spinning.value && 64 |
65 |
66 | Loading... 67 |
68 |
69 | } 70 |
 
71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /islands/PostEdit.tsx: -------------------------------------------------------------------------------- 1 | import { useSignal } from "@preact/signals"; 2 | import * as hljs from "highlightjs"; 3 | import { useEffect, useState } from "preact/hooks"; 4 | import { PostViewType } from "~/server/db.ts"; 5 | import { trpc } from "~/client/trpc.ts"; 6 | import Mousetrap from "mousetrap"; 7 | import { createRef } from "preact"; 8 | 9 | export default function Edit(props: { post: PostViewType }) { 10 | const postId = props.post.id; 11 | 12 | const preview = useSignal(false); 13 | const text = useSignal(props.post.source); 14 | const draft = useSignal(props.post.draft); 15 | const sanitizedHtml = useSignal(""); 16 | const [loading, setLoading] = useState(false); 17 | const textarea = createRef(); 18 | 19 | function displayEdit() { 20 | preview.value = false; 21 | } 22 | 23 | async function displayPreview() { 24 | sanitizedHtml.value = await trpc.md2html.query({ 25 | source: text.value, 26 | }); 27 | preview.value = true; 28 | } 29 | 30 | useEffect(() => { 31 | (hljs as any).highlightAll(); 32 | }); 33 | 34 | useEffect(() => { 35 | if (textarea.current) { 36 | textarea.current.focus(); 37 | } 38 | }, textarea.current); 39 | 40 | useEffect(() => { 41 | if (preview.value) { 42 | Mousetrap.bind( 43 | "mod+p", 44 | () => { 45 | displayEdit(); 46 | return false; 47 | }, 48 | ); 49 | } else { 50 | Mousetrap(textarea.current).bind( 51 | ["mod+enter", "mod+s"], 52 | () => { 53 | save(); 54 | return false; 55 | }, 56 | ); 57 | Mousetrap(textarea.current).bind( 58 | "mod+p", 59 | () => { 60 | displayPreview(); 61 | return false; 62 | }, 63 | ); 64 | } 65 | }, [preview.value]); 66 | 67 | async function save() { 68 | setLoading(true); 69 | await trpc.updatePost.mutate({ 70 | postId: postId, 71 | source: text.value, 72 | draft: draft.value, 73 | }); 74 | setLoading(false); 75 | location.href = `/posts/${postId}?updated`; 76 | } 77 | 78 | return ( 79 | <> 80 | 81 | Edit - Leaves 82 | 83 |
84 |
85 | 103 | {!preview.value && 104 | ( 105 | 115 | )} 116 | {preview.value && 117 | ( 118 | 124 | 125 | )} 126 |
127 | 168 |
169 | 170 | ); 171 | } 172 | -------------------------------------------------------------------------------- /islands/PostNew.tsx: -------------------------------------------------------------------------------- 1 | import { useSignal } from "@preact/signals"; 2 | import * as hljs from "highlightjs"; 3 | import { createRef } from "preact"; 4 | import Mousetrap from "mousetrap"; 5 | import { useEffect } from "preact/hooks"; 6 | import { trpc } from "~/client/trpc.ts"; 7 | 8 | export default function Post() { 9 | const preview = useSignal(false); 10 | const loading = useSignal(false); 11 | const draft = useSignal(false); 12 | const text = useSignal(""); 13 | const sanitizedHtml = useSignal(""); 14 | const textarea = createRef(); 15 | 16 | function displayEdit() { 17 | preview.value = false; 18 | } 19 | 20 | async function displayPreview() { 21 | sanitizedHtml.value = await trpc.md2html.query({ 22 | source: text.value, 23 | }); 24 | preview.value = true; 25 | } 26 | 27 | useEffect(() => { 28 | (hljs as any).highlightAll(); 29 | }); 30 | 31 | useEffect(() => { 32 | if (textarea.current) { 33 | textarea.current.focus(); 34 | } 35 | }, textarea.current); 36 | 37 | useEffect(() => { 38 | if (preview.value) { 39 | Mousetrap.bind( 40 | "mod+p", 41 | () => { 42 | displayEdit(); 43 | return false; 44 | }, 45 | ); 46 | } else { 47 | Mousetrap(textarea.current).bind( 48 | ["mod+enter", "mod+s"], 49 | () => { 50 | post(); 51 | return false; 52 | }, 53 | ); 54 | Mousetrap(textarea.current).bind( 55 | "mod+p", 56 | () => { 57 | displayPreview(); 58 | return false; 59 | }, 60 | ); 61 | } 62 | }, [preview.value]); 63 | 64 | async function post() { 65 | loading.value = true; 66 | const result = await trpc.createPost.mutate({ 67 | source: text.value, 68 | draft: draft.value, 69 | }); 70 | loading.value = false; 71 | if (result?.postId) { 72 | location.href = `/posts/${result.postId}?posted`; 73 | return; 74 | } 75 | } 76 | 77 | return ( 78 | <> 79 |
80 |
81 | 101 | {!preview.value && 102 | ( 103 | 114 | )} 115 | {preview.value && 116 | ( 117 | 123 | 124 | )} 125 |
126 | 161 |
162 | 163 | ); 164 | } 165 | -------------------------------------------------------------------------------- /islands/PostView.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "preact/hooks"; 2 | import { useSignal } from "@preact/signals"; 3 | import * as hljs from "highlightjs"; 4 | import Mousetrap from "mousetrap"; 5 | import { PostViewType } from "~/server/db.ts"; 6 | import { LikeUsersModal } from "~/components/LikeUsersModal.tsx"; 7 | import { render } from "~/server/markdown.ts"; 8 | import { trpc } from "~/client/trpc.ts"; 9 | import { createRef } from "preact"; 10 | 11 | type Comments = ReturnType extends 12 | Promise ? T : never; 13 | 14 | export default function PostView( 15 | props: { post: PostViewType; postTitle: string; userId?: number }, 16 | ) { 17 | const userId = props.userId; 18 | const post = props.post; 19 | 20 | const preview = useSignal(false); 21 | const text = useSignal(""); 22 | const sanitizedCommentHtml = useSignal(""); 23 | const postSource = render(props.post.source!, {}); 24 | const [likes, setLikes] = useState(0); 25 | const [liked, setLiked] = useState(); 26 | const [comments, setComments] = useState(); 27 | const [loading, setLoading] = useState(false); 28 | const [commentLoading, setCommentLoading] = useState(true); 29 | const [requesting, setRequesting] = useState(false); 30 | const [modal, setModal] = useState(false); 31 | const message = useSignal(""); 32 | const textarea = createRef(); 33 | 34 | function displayEdit() { 35 | preview.value = false; 36 | } 37 | 38 | async function displayPreview() { 39 | sanitizedCommentHtml.value = await trpc.md2html.query({ 40 | source: text.value, 41 | }); 42 | preview.value = true; 43 | } 44 | 45 | async function deletePost() { 46 | if (confirm("Delete the post?")) { 47 | await trpc.deletePost.mutate({ postId: post.id }); 48 | location.href = "/"; 49 | } 50 | } 51 | 52 | async function deleteComment(commentId: number) { 53 | if (confirm("Delete the comment?")) { 54 | await trpc.deleteComment.mutate({ commentId }); 55 | await readComments(); 56 | } 57 | } 58 | 59 | async function readComments() { 60 | setCommentLoading(true); 61 | const results = await trpc.getComments.query({ postId: post.id }); 62 | setCommentLoading(false); 63 | setComments(results); 64 | } 65 | 66 | async function reply() { 67 | setLoading(true); 68 | await trpc.createComment.mutate({ postId: post.id, source: text.value }); 69 | await readComments(); 70 | text.value = ""; 71 | sanitizedCommentHtml.value = ""; 72 | displayEdit(); 73 | setLoading(false); 74 | } 75 | 76 | async function like(postId: number) { 77 | if (!props.userId) { 78 | location.href = "/auth"; 79 | return; 80 | } 81 | setRequesting(true); 82 | await trpc.createLike.mutate({ postId }); 83 | setLiked(true); 84 | setLikes(likes + 1); 85 | setRequesting(false); 86 | } 87 | 88 | async function cancelLike(postId: number) { 89 | setRequesting(true); 90 | await trpc.cancelLike.mutate({ postId }); 91 | setLiked(false); 92 | setLikes(likes - 1); 93 | setRequesting(false); 94 | } 95 | 96 | useEffect(() => { 97 | (hljs as any).highlightAll(); 98 | }); 99 | 100 | useEffect(() => { 101 | if (textarea.current && location.hash === "#comment") { 102 | textarea.current.focus(); 103 | } 104 | }, textarea.current); 105 | 106 | useEffect(() => { 107 | setLikes(post.likes); 108 | (async () => { 109 | const maybeliked = await trpc.isLiked.query({ postId: post.id }); 110 | if (maybeliked) { 111 | setLiked(maybeliked); 112 | } 113 | await readComments(); 114 | })(); 115 | const searchParams = new URLSearchParams(location.search); 116 | if (searchParams.has("posted")) { 117 | message.value = "Posted."; 118 | history.replaceState(null, "", location.pathname); 119 | } else if (searchParams.has("updated")) { 120 | message.value = "Updated."; 121 | history.replaceState(null, "", location.pathname); 122 | } 123 | Mousetrap.bind("e", () => { 124 | if (userId === post.user_id) { 125 | location.href = `/posts/${post.id}/edit`; 126 | } 127 | }); 128 | }, []); 129 | 130 | useEffect(() => { 131 | if (preview.value) { 132 | Mousetrap.bind( 133 | "mod+p", 134 | () => { 135 | displayEdit(); 136 | return false; 137 | }, 138 | ); 139 | } else { 140 | Mousetrap(textarea.current).bind( 141 | "mod+enter", 142 | () => { 143 | reply(); 144 | }, 145 | ); 146 | Mousetrap(textarea.current).bind( 147 | "mod+p", 148 | () => { 149 | displayPreview(); 150 | return false; 151 | }, 152 | ); 153 | } 154 | }, [preview.value]); 155 | 156 | const createdAt = new Date(post.created_at).toLocaleString(); 157 | const updatedAt = new Date(post.updated_at).toLocaleString(); 158 | 159 | function tweet() { 160 | const url = "https://twitter.com/intent/tweet?text=" + 161 | encodeURIComponent(props.postTitle + "\n" + location.href); 162 | globalThis.open(url); 163 | } 164 | 165 | function bluesky() { 166 | const url = "https://bsky.app/intent/compose?text=" + 167 | encodeURIComponent(props.postTitle + "\n" + location.href); 168 | globalThis.open(url); 169 | } 170 | 171 | return ( 172 |
173 | {post && 174 | ( 175 | <> 176 | {message.value && 177 | ( 178 | 191 | )} 192 |
193 |
194 |
195 | mdo 203 | 207 | {post.name} 208 | 209 |
210 |
213 | {updatedAt} 214 |
215 |
216 |
217 | {post.draft && 218 | ( 219 | 220 | 🔒 PRIVATE 221 | 222 | )} 223 |
224 | 228 | 229 |
230 |
231 | Tweet 239 | Bluesky 248 | {requesting && 249 | ( 250 |
254 | Loading... 255 |
256 | )} 257 | {userId && !requesting && liked && 258 | ( 259 | Unlike cancelLike(post.id)} 266 | class="ms-3" 267 | style={{ cursor: "pointer" }} 268 | /> 269 | )} 270 | {!requesting && !liked && 271 | ( 272 | Like like(post.id)} 279 | class="ms-3" 280 | style={{ cursor: "pointer" }} 281 | /> 282 | )} 283 | {Number(likes) > 0 && 284 | ( 285 | setModal(true)} 289 | > 290 | {likes} Like{likes === 1 ? "" : "s"} 291 | 292 | )} 293 |
294 |
295 | {userId === post.user_id && 296 | ( 297 | <> 298 | 304 | Delete 310 | 311 | 312 | 313 | Edit 319 | 320 | 321 | 322 | )} 323 |
324 |
325 |
326 |
327 | 469 | {modal && 470 | } 471 |
472 | 473 | )} 474 |
475 | ); 476 | } 477 | -------------------------------------------------------------------------------- /islands/SearchedPosts.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "preact/hooks"; 2 | import Posts from "~/components/Posts.tsx"; 3 | import { PAGE_ROWS } from "~/common/constants.ts"; 4 | import { useSignal } from "@preact/signals"; 5 | import { trpc } from "~/client/trpc.ts"; 6 | import { GetPostsOutput } from "~/server/trpc/procedures/getPosts.ts"; 7 | 8 | export default function SearchedPosts(props: { searchWord: string, loginUserId?: number }) { 9 | 10 | const posts = useSignal>([]); 11 | const requesting = useSignal(false); 12 | const spinning = useSignal(true); 13 | const allLoaded = useSignal(false); 14 | const notFound = useSignal(false); 15 | 16 | useEffect(() => { 17 | const io = new IntersectionObserver(entries => { 18 | if (!requesting.value && entries[0].intersectionRatio !== 0 && !allLoaded.value) { 19 | const postId = posts.value.length === 0 ? null : posts.value[posts.value.length - 1].id; 20 | requesting.value = true; 21 | spinning.value = true; 22 | trpc.getPosts.query({ postId, searchWord: props.searchWord }).then(results => { 23 | if (results.length > 0) { 24 | posts.value = posts.value.concat(results); 25 | } 26 | if (results.length < PAGE_ROWS) { 27 | allLoaded.value = true; 28 | } 29 | requesting.value = false; 30 | spinning.value = false; 31 | if (!postId && results.length === 0) { 32 | notFound.value = true; 33 | } 34 | }); 35 | } 36 | }); 37 | const bottom = document.getElementById("bottom"); 38 | if (bottom) { 39 | io.observe(bottom); 40 | } 41 | return () => { 42 | if (bottom) { 43 | io.unobserve(bottom) 44 | } 45 | }; 46 | }, []); 47 | 48 | return ( 49 |
50 | {notFound.value && Not Found} 51 | 52 |
53 |
54 | {spinning.value && 55 |
56 |
57 | Loading... 58 |
59 |
60 | } 61 |
 
62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /islands/UserPosts.tsx: -------------------------------------------------------------------------------- 1 | import Posts from "~/components/Posts.tsx"; 2 | import { PAGE_ROWS } from "~/common/constants.ts"; 3 | import FollowingUsersModal from "~/components/FollowingUsersModal.tsx"; 4 | import FollowerUsersModal from "~/components/FollowerUsersModal.tsx"; 5 | 6 | import { useEffect, useState } from "preact/hooks"; 7 | import { useSignal } from "@preact/signals"; 8 | import { trpc } from "~/client/trpc.ts"; 9 | import { GetPostsOutput } from "~/server/trpc/procedures/getPosts.ts"; 10 | 11 | export default function UserPosts( 12 | props: { pageUserId: number; loginUserId?: number }, 13 | ) { 14 | const loginUserId = props.loginUserId; 15 | const userId = props.pageUserId; 16 | const allLoaded = useSignal(false); 17 | 18 | const posts = useSignal>([]); 19 | const requesting = useSignal(false); 20 | const spinning = useSignal(true); 21 | const [followLoading, setFollowLoading] = useState(false); 22 | const [following, setFollowing] = useState(""); 23 | const [followers, setFollowers] = useState(""); 24 | const [isFollowing, setIsFollowing] = useState(); 25 | const [followingModal, setFollowingModal] = useState(false); 26 | const [followerModal, setFollowerModal] = useState(false); 27 | 28 | useEffect(() => { 29 | trpc.getFollowInfo.query({ userId }).then((result) => { 30 | setFollowing(result.following); 31 | setFollowers(result.followers); 32 | setIsFollowing(result.isFollowing); 33 | }); 34 | 35 | const io = new IntersectionObserver((entries) => { 36 | if ( 37 | !allLoaded.value && !requesting.value && 38 | entries[0].intersectionRatio !== 0 39 | ) { 40 | const postId = posts.value.length === 0 41 | ? null 42 | : posts.value[posts.value.length - 1].id; 43 | requesting.value = true; 44 | spinning.value = true; 45 | trpc.getPosts.query({ postId, userId }).then((results) => { 46 | if (results.length > 0) { 47 | posts.value = posts.value.concat(results); 48 | } 49 | if (results.length < PAGE_ROWS) { 50 | allLoaded.value = true; 51 | } 52 | requesting.value = false; 53 | spinning.value = false; 54 | }); 55 | } 56 | }); 57 | const bottom = document.getElementById("bottom"); 58 | if (bottom) { 59 | io.observe(bottom); 60 | } 61 | return () => { 62 | if (bottom) { 63 | io.unobserve(bottom); 64 | } 65 | }; 66 | }, []); 67 | 68 | async function follow() { 69 | setFollowLoading(true); 70 | await trpc.createFollow.mutate({ followingUserId: props.pageUserId }); 71 | setFollowers((Number(followers) + 1).toString()); 72 | setIsFollowing(!isFollowing); 73 | setFollowLoading(false); 74 | } 75 | 76 | async function unfollow() { 77 | setFollowLoading(true); 78 | await trpc.deleteFollow.mutate({ followingUserId: props.pageUserId }); 79 | const _followers = Number(followers) - 1; 80 | setFollowers((_followers < 0 ? 0 : _followers).toString()); 81 | setIsFollowing(!isFollowing); 82 | setFollowLoading(false); 83 | } 84 | 85 | function displayFollowingUsers() { 86 | setFollowingModal(true); 87 | } 88 | 89 | function displayFollowerUsers() { 90 | setFollowerModal(true); 91 | } 92 | 93 | return ( 94 |
95 | {(loginUserId && props.pageUserId !== loginUserId) && 96 | ( 97 | <> 98 | {isFollowing === false && 99 | ( 100 | 117 | )} 118 | {isFollowing === true && 119 | ( 120 | <> 121 | Following 122 | 139 | 140 | )} 141 | 142 | )} 143 |
144 | {following && 145 | (following === "0" ? 0 Following : ( 146 | 151 | {following} Following 152 | 153 | ))} 154 | {followers && 155 | (followers === "0" ? 0 Followers : ( 156 | 161 | {followers} Follower{followers === "1" ? "" : "s"} 162 | 163 | ))} 164 | {(loginUserId && props.pageUserId === loginUserId) && 165 | Likes} 166 |
167 | {followingModal && 168 | } 169 | {followerModal && 170 | } 171 | 172 |
173 |
174 | {spinning.value && 175 | ( 176 |
177 |
178 | Loading... 179 |
180 |
181 | )} 182 |
 
183 |
184 | ); 185 | } 186 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | /// 5 | /// 6 | 7 | import { start } from "$fresh/server.ts"; 8 | import manifest from "~/fresh.gen.ts"; 9 | import config from "./fresh.config.ts"; 10 | 11 | await start(manifest, config); 12 | -------------------------------------------------------------------------------- /misc/backup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export $(cat ../.env | grep -v ^#) 4 | 5 | DATE=$(date +%Y%m%d) 6 | pg_dump --file=db-${DATE}.sql && gzip db-${DATE}.sql 7 | ls -lh db-*.sql.gz 8 | 9 | -------------------------------------------------------------------------------- /misc/db.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE app_user ( 2 | id SERIAL PRIMARY KEY, 3 | google_id TEXT, 4 | account TEXT, 5 | name TEXT, 6 | picture TEXT, 7 | notification BOOLEAN DEFAULT false, 8 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 9 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 10 | CONSTRAINT app_user_google_id UNIQUE(google_id) 11 | ); 12 | 13 | CREATE TABLE post ( 14 | id SERIAL PRIMARY KEY, 15 | user_id INTEGER REFERENCES app_user(id) ON DELETE CASCADE, 16 | source TEXT, 17 | draft BOOLEAN DEFAULT false, 18 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 19 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP 20 | ); 21 | CREATE INDEX post_user_id ON post(user_id); 22 | CREATE INDEX post_source_index ON post USING pgroonga (source); 23 | ALTER TABLE post ADD column draft BOOLEAN DEFAULT false; 24 | 25 | CREATE TABLE revision ( 26 | id SERIAL PRIMARY KEY, 27 | post_id INTEGER, 28 | source TEXT, 29 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 30 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP 31 | ); 32 | CREATE INDEX revision_post_id ON revision(post_id); 33 | 34 | CREATE OR REPLACE FUNCTION insert_revision() RETURNS TRIGGER AS $insert_revision$ 35 | BEGIN 36 | IF (TG_OP = 'DELETE' OR TG_OP = 'UPDATE') THEN 37 | INSERT INTO revision(post_id, source, created_at) SELECT OLD.id, OLD.source, OLD.created_at; 38 | RETURN OLD; 39 | END IF; 40 | RETURN NULL; 41 | END; 42 | $insert_revision$ LANGUAGE plpgsql; 43 | 44 | CREATE TRIGGER insert_revision_trigger 45 | AFTER UPDATE OR DELETE ON post 46 | FOR EACH ROW EXECUTE PROCEDURE insert_revision(); 47 | 48 | CREATE TABLE app_session ( 49 | id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), 50 | user_id INTEGER NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, 51 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 52 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP 53 | ); 54 | CREATE INDEX app_session_user_id ON app_session(user_id); 55 | 56 | CREATE TABLE comment ( 57 | id SERIAL PRIMARY KEY, 58 | post_id INTEGER REFERENCES post(id) ON DELETE CASCADE, 59 | user_id INTEGER REFERENCES app_user(id) ON DELETE CASCADE, 60 | source TEXT, 61 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 62 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP 63 | ); 64 | CREATE INDEX comment_post_id ON comment(post_id); 65 | 66 | CREATE TABLE follow ( 67 | user_id INTEGER REFERENCES app_user(id) ON DELETE CASCADE, 68 | following_user_id INTEGER, 69 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 70 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 71 | PRIMARY KEY (user_id, following_user_id) 72 | ); 73 | 74 | CREATE TYPE notification_type AS ENUM ('follow', 'like', 'comment'); 75 | 76 | CREATE TABLE notification ( 77 | id SERIAL PRIMARY KEY, 78 | user_id INTEGER REFERENCES app_user(id) ON DELETE CASCADE, 79 | type notification_type NOT NULL, 80 | action_user_id INTEGER REFERENCES app_user(id) ON DELETE CASCADE, 81 | post_id INTEGER REFERENCES post(id) ON DELETE CASCADE, 82 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 83 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP 84 | ); 85 | CREATE INDEX notification_user_id ON notification(user_id); 86 | 87 | CREATE TABLE likes ( 88 | user_id INTEGER REFERENCES app_user(id) ON DELETE CASCADE, 89 | post_id INTEGER REFERENCES post(id) ON DELETE CASCADE, 90 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 91 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 92 | PRIMARY KEY (user_id, post_id) 93 | ); 94 | 95 | 96 | ---- 97 | 98 | select * from select_posts_by_word('todo', null, null); 99 | 100 | CREATE OR REPLACE FUNCTION select_posts_by_word(search_word TEXT, login_user_id INTEGER , post_id INTEGER) 101 | RETURNS TABLE( 102 | id integer, 103 | user_id integer, 104 | source text, 105 | updated_at timestamp with time zone, 106 | created_at timestamp with time zone, 107 | draft boolean, 108 | name text, 109 | picture text, 110 | comments int8, 111 | likes int8 112 | ) AS 113 | $$ 114 | BEGIN 115 | RETURN QUERY ( 116 | SELECT 117 | v.id, 118 | v.user_id, 119 | v.source, 120 | v.updated_at, 121 | v.created_at, 122 | v.draft, 123 | v.name, 124 | v.picture, 125 | v.comments, 126 | v.likes 127 | FROM post_view v 128 | WHERE v.source &@~ search_word 129 | AND (v.draft = false 130 | OR (login_user_id IS NOT NULL AND v.user_id = login_user_id) 131 | ) 132 | AND (v.id < post_id OR post_id IS NULL) 133 | ORDER BY v.id DESC LIMIT 10 134 | ); 135 | END; 136 | $$ LANGUAGE plpgsql; 137 | 138 | CREATE OR REPLACE FUNCTION select_following_users_posts(login_user_id INTEGER , post_id INTEGER) 139 | RETURNS TABLE( 140 | id integer, 141 | user_id integer, 142 | source text, 143 | updated_at timestamp with time zone, 144 | created_at timestamp with time zone, 145 | draft boolean, 146 | name text, 147 | picture text, 148 | comments int8, 149 | likes int8 150 | ) AS 151 | $$ 152 | BEGIN 153 | RETURN QUERY ( 154 | SELECT 155 | v.id, 156 | v.user_id, 157 | v.source, 158 | v.updated_at, 159 | v.created_at, 160 | v.draft, 161 | v.name, 162 | v.picture, 163 | v.comments, 164 | v.likes 165 | FROM post_view v 166 | WHERE v.draft = false AND v.user_id IN (SELECT f.following_user_id FROM follow f WHERE f.user_id = login_user_id) 167 | AND (post_id IS NULL OR v.id < post_id) 168 | ORDER BY v.id DESC LIMIT 10 169 | ); 170 | END; 171 | $$ LANGUAGE plpgsql; 172 | 173 | CREATE OR REPLACE FUNCTION select_liked_posts(login_user_id INTEGER , post_id INTEGER) 174 | RETURNS TABLE( 175 | id integer, 176 | user_id integer, 177 | source text, 178 | updated_at timestamp with time zone, 179 | created_at timestamp with time zone, 180 | draft boolean, 181 | name text, 182 | picture text, 183 | comments int8, 184 | likes int8 185 | ) AS 186 | $$ 187 | BEGIN 188 | RETURN QUERY ( 189 | SELECT 190 | v.id, 191 | v.user_id, 192 | v.source, 193 | v.updated_at, 194 | v.created_at, 195 | v.draft, 196 | v.name, 197 | v.picture, 198 | v.comments, 199 | v.likes 200 | FROM post_view v 201 | WHERE v.draft = false AND v.id IN (SELECT l.post_id FROM likes l WHERE l.user_id = login_user_id) 202 | AND (post_id IS NULL OR v.id < post_id) 203 | ORDER BY v.id DESC LIMIT 10 204 | ); 205 | END; 206 | $$ LANGUAGE plpgsql; 207 | 208 | create view user_view 209 | as 210 | SELECT user_id, max(updated_at) as updated_at FROM post GROUP BY user_id ORDER BY user_id 211 | ; 212 | 213 | 214 | 215 | 216 | 217 | CREATE OR REPLACE FUNCTION get_notification_for_comment(p_post_id integer, p_user_id integer) 218 | RETURNS TABLE("user_id" integer, "type" notification_type, "post_id" integer, "action_user_id" integer) 219 | LANGUAGE plpgsql 220 | AS $$ 221 | BEGIN 222 | RETURN QUERY ( 223 | SELECT p.user_id, 'comment'::notification_type, p_post_id, p_user_id 224 | FROM post p 225 | WHERE p.id=p_post_id AND p.user_id != p_user_id 226 | UNION 227 | SELECT DISTINCT c.user_id, 'comment'::notification_type, p_post_id, p_user_id 228 | FROM comment c 229 | WHERE c.post_id=p_post_id AND c.user_id != p_user_id 230 | ); 231 | END 232 | $$; 233 | 234 | CREATE OR REPLACE FUNCTION insert_notification_for_comment(p_post_id integer, p_user_id integer) 235 | RETURNS void 236 | AS $$ 237 | BEGIN 238 | INSERT INTO notification ("user_id", "type", "post_id", "action_user_id") 239 | SELECT "user_id", "type", "post_id", "action_user_id" FROM get_notification_for_comment(p_post_id, p_user_id) 240 | ; 241 | -- TODO I want to store the result of insert "user_id" in a variable and use it in update. 242 | UPDATE app_user SET NOTIFICATION = true WHERE id IN (SELECT user_id FROM get_notification_for_comment(p_post_id, p_user_id)) 243 | ; 244 | END; 245 | $$ LANGUAGE plpgsql; 246 | 247 | CREATE OR REPLACE FUNCTION insert_notification_for_like(p_post_id integer, p_user_id integer) 248 | RETURNS void 249 | AS $$ 250 | BEGIN 251 | INSERT INTO notification ("user_id", "type", "post_id", "action_user_id") 252 | SELECT user_id, 'like', p_post_id, p_user_id 253 | FROM post 254 | WHERE id= p_post_id AND user_id != p_user_id 255 | ; 256 | 257 | -- TODO I want to store the result of insert "user_id" in a variable and use it in update. 258 | UPDATE app_user 259 | SET NOTIFICATION = true 260 | WHERE id IN (select user_id FROM post WHERE id= p_post_id AND user_id != p_user_id) 261 | ; 262 | END; 263 | $$ LANGUAGE plpgsql; 264 | 265 | -------------------------------------------------------------------------------- /misc/todo.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | - 全文検索 4 | - bookmark 5 | - tag 6 | - sort の仕様を変える 7 | - link は別 tab で開く 8 | - 日付の表示の仕方 9 | - J, K key で移動 10 | - x モダールの外をクリックしたら閉じる。難しそう。。 11 | -------------------------------------------------------------------------------- /routes/_404.tsx: -------------------------------------------------------------------------------- 1 | import { defineRoute } from "$fresh/server.ts"; 2 | import { Head } from "$fresh/runtime.ts"; 3 | import Header from "~/islands/Header.tsx"; 4 | import { getAuthUrl, getSession } from "~/server/auth.ts"; 5 | 6 | export default defineRoute(async (req, _ctx) => { 7 | const session = await getSession(req); 8 | const authUrl = session ? undefined : getAuthUrl(req.url); 9 | return ( 10 | <> 11 | 12 | Not Found - Leaves 13 | 14 |
15 |
16 |

404 Not Found

17 |
18 | 19 | ); 20 | }); 21 | -------------------------------------------------------------------------------- /routes/_500.tsx: -------------------------------------------------------------------------------- 1 | import { PageProps } from "$fresh/server.ts"; 2 | import { Head } from "$fresh/runtime.ts"; 3 | 4 | export default function Error500Page({ error }: PageProps) { 5 | return ( 6 | <> 7 | 8 | 500 Internal Server Error - Leaves 9 | 10 |
11 | 500 Internal Server Error: {(error as Error).message} 12 |
13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /routes/_app.tsx: -------------------------------------------------------------------------------- 1 | import { PageProps } from "$fresh/server.ts"; 2 | 3 | export default function App({ Component }: PageProps) { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 14 | 20 | 24 | 25 | 26 | 34 | */ 41 | } 42 | 43 | 44 | 45 | 67 | 73 | 74 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /routes/_middleware.ts: -------------------------------------------------------------------------------- 1 | import { FreshContext } from "$fresh/server.ts"; 2 | import { selectPostIds, selectUsers } from "~/server/db.ts"; 3 | 4 | export async function handler( 5 | _req: Request, 6 | ctx: FreshContext, 7 | ) { 8 | const resp = await ctx.next(); 9 | if (resp.status === 500) { 10 | const contentType = resp.headers.get("content-type") || "text/plain"; 11 | return new Response("503 Service Unavailable", { 12 | status: 503, 13 | headers: { "content-type": contentType }, 14 | }); 15 | } 16 | const url = new URL(_req.url); 17 | if (url.hostname === "leaves.deno.dev") { 18 | url.hostname = "leaves.chiba.dev"; 19 | return Response.redirect(url, 301); // Moved Permanently 20 | } 21 | if (url.pathname === "/sitemap.xml") { 22 | const users = await selectUsers(); 23 | const posts = await selectPostIds(); 24 | const baseUrl = `${url.protocol}//${url.hostname}${ 25 | url.hostname === "localhost" ? (":" + url.port) : "" 26 | }`; 27 | 28 | return new Response( 29 | `${ 30 | users.map((user) => 31 | `${baseUrl}/users/${ 32 | user.account ?? user.user_id 33 | }${ 34 | new Date(user.updated_at).toISOString() 35 | }` 36 | ).join("") + posts.map((post) => 37 | `${baseUrl}/posts/${post.id}${ 38 | new Date(post.updated_at).toISOString() 39 | }` 40 | ).join("") 41 | }`, 42 | { 43 | headers: { "content-type": 'application/xml; charset="UTF-8"' }, 44 | }, 45 | ); 46 | } 47 | return resp; 48 | } 49 | -------------------------------------------------------------------------------- /routes/about.tsx: -------------------------------------------------------------------------------- 1 | import { Head } from "$fresh/runtime.ts"; 2 | import { defineRoute } from "$fresh/server.ts"; 3 | import Header from "~/islands/Header.tsx"; 4 | import { getAuthUrl, getSession } from "~/server/auth.ts"; 5 | 6 | export default defineRoute(async (req, _ctx) => { 7 | const session = await getSession(req); 8 | const authUrl = session ? undefined : getAuthUrl(req.url); 9 | return ( 10 | <> 11 | 12 | About - Leaves 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 |
24 |
25 |

About

26 | 121 |
122 | 123 | ); 124 | }); 125 | -------------------------------------------------------------------------------- /routes/api/cli/posts/[postId].ts: -------------------------------------------------------------------------------- 1 | import { defineRoute } from "$fresh/server.ts"; 2 | import { selectPost } from "~/server/db.ts"; 3 | 4 | export default defineRoute(async (_req, ctx) => { 5 | const postId = Number(ctx.params.postId); 6 | const post = await selectPost(postId); 7 | if (!post) { 8 | return Response.json([]); 9 | } 10 | return Response.json({ source: post.source }); 11 | }); 12 | -------------------------------------------------------------------------------- /routes/api/cli/search.ts: -------------------------------------------------------------------------------- 1 | import { defineRoute } from "$fresh/server.ts"; 2 | import { selectPostsBySearchWord } from "~/server/db.ts"; 3 | import { getTitle } from "~/server/getTitle.ts"; 4 | 5 | export default defineRoute(async (req, _ctx) => { 6 | const searchWord = new URL(req.url).searchParams.get("q"); 7 | if (!searchWord) { 8 | return Response.json([]); 9 | } 10 | const rows = await selectPostsBySearchWord({ 11 | searchWord, 12 | postId: null, 13 | loginUserId: null, 14 | }); 15 | const body = rows.map((row) => { 16 | return { value: row.id, name: "* " + getTitle(row.source) }; 17 | }); 18 | return Response.json(body); 19 | }); 20 | -------------------------------------------------------------------------------- /routes/api/trpc/[path].ts: -------------------------------------------------------------------------------- 1 | import { FreshContext } from "$fresh/server.ts"; 2 | import { appRouter } from "~/server/trpc/router.ts"; 3 | import { createContext } from "~/server/trpc/context.ts"; 4 | 5 | import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; 6 | 7 | export const handler = async ( 8 | req: Request, 9 | _ctx: FreshContext, 10 | ): Promise => { 11 | const trpcRes = await fetchRequestHandler({ 12 | endpoint: "/api/trpc", 13 | req, 14 | router: appRouter, 15 | createContext, 16 | }); 17 | 18 | return new Response(trpcRes.body, { 19 | headers: trpcRes.headers, 20 | status: trpcRes.status, 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /routes/auth.ts: -------------------------------------------------------------------------------- 1 | import { Handlers } from "$fresh/server.ts"; 2 | import { getAuthUrl, getSession } from "~/server/auth.ts"; 3 | 4 | export const handler: Handlers = { 5 | async GET(request) { 6 | 7 | const session = await getSession(request); 8 | if (session) { 9 | return new Response("", { 10 | status: 307, 11 | headers: { Location: "/" }, 12 | }); 13 | } 14 | 15 | const authUrl = getAuthUrl(request.url); 16 | return Response.redirect(authUrl); 17 | }, 18 | } 19 | 20 | -------------------------------------------------------------------------------- /routes/callback.tsx: -------------------------------------------------------------------------------- 1 | import { Handlers } from "$fresh/server.ts"; 2 | import { env } from "~/server/env.ts"; 3 | import { createSession, getCallbackUrl } from "~/server/auth.ts"; 4 | import { selectUserByGoogleId, upsertUser } from "~/server/db.ts"; 5 | 6 | export type Token = { access_token: string; refresh_token: string }; 7 | 8 | export type GoogleUser = { 9 | id: string; 10 | name: string; 11 | given_name: string; 12 | family_name: string; 13 | picture: string; 14 | locale: string; 15 | }; 16 | 17 | export const handler: Handlers = { 18 | async GET(req, ctx) { 19 | const searchParams = new URL(req.url).searchParams; 20 | const code = searchParams.get("code"); 21 | const res = await ctx.render(); 22 | 23 | if (code) { 24 | const redirectUri = getCallbackUrl(req.url); 25 | 26 | const tokenResponse = await fetch( 27 | "https://accounts.google.com/o/oauth2/token", 28 | { 29 | method: "POST", 30 | headers: { "Content-Type": "application/x-www-form-urlencoded" }, 31 | body: new URLSearchParams([ 32 | ["client_id", env.clientId], 33 | ["client_secret", env.clientSecret], 34 | ["redirect_uri", redirectUri], 35 | ["grant_type", "authorization_code"], 36 | ["code", code], 37 | ]), 38 | }, 39 | ); 40 | const { access_token } = await tokenResponse.json() as Token; 41 | 42 | const userResponse = await fetch( 43 | "https://www.googleapis.com/oauth2/v1/userinfo?" + 44 | new URLSearchParams([["access_token", access_token]]), 45 | ); 46 | const googleUser: GoogleUser = await userResponse.json(); 47 | if (userResponse.status !== 200) { 48 | throw new Error(JSON.stringify(googleUser)); 49 | } 50 | 51 | await fetch( 52 | "https://accounts.google.com/o/oauth2/revoke?" + 53 | new URLSearchParams([["token", access_token]]), 54 | ); 55 | 56 | let user = await selectUserByGoogleId(googleUser.id); 57 | if (user) { 58 | if ( 59 | user.name === googleUser.name && user.picture === googleUser.picture 60 | ) { 61 | await createSession(res, user.id); 62 | return res; 63 | } 64 | } 65 | 66 | user = await upsertUser({ 67 | googleId: googleUser.id, 68 | name: googleUser.name, 69 | picture: googleUser.picture, 70 | }); 71 | 72 | await createSession(res, user.id); 73 | } 74 | return res; 75 | } 76 | } 77 | 78 | export default function Callback() { 79 | // response header での redirect だと cookie が送信されない 80 | return ( 81 | 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /routes/debug_auth.tsx: -------------------------------------------------------------------------------- 1 | import { Handlers } from "$fresh/server.ts"; 2 | import { createSession } from "~/server/auth.ts"; 3 | 4 | const debug = Deno.env.get("HOSTNAME")?.startsWith("codespaces-"); 5 | 6 | export const handler: Handlers = { 7 | async GET(_req, ctx) { 8 | if (!debug) { 9 | return ctx.renderNotFound(); 10 | } 11 | const res = await ctx.render(); 12 | await createSession(res, 1); 13 | return res; 14 | }, 15 | }; 16 | 17 | export default function Page() { 18 | // response header での redirect だと cookie が送信されない 19 | return ( 20 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /routes/following.tsx: -------------------------------------------------------------------------------- 1 | import { Head } from "$fresh/runtime.ts"; 2 | import { defineRoute } from "$fresh/server.ts"; 3 | import FollowingPosts from "~/islands/FollowingPosts.tsx"; 4 | import Header from "~/islands/Header.tsx"; 5 | import { getSession } from "~/server/auth.ts"; 6 | 7 | export default defineRoute(async (req, _ctx) => { 8 | const session = await getSession(req); 9 | if (!session) { 10 | return new Response("", { 11 | status: 307, 12 | headers: { Location: "/" }, 13 | }); 14 | } 15 | return ( 16 | <> 17 | 18 | Following - Leaves 19 | 20 |
21 |
22 | 23 |
24 | 25 | ); 26 | }); 27 | 28 | 29 | -------------------------------------------------------------------------------- /routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { Head } from "$fresh/runtime.ts"; 2 | import { defineRoute } from "$fresh/server.ts"; 3 | import { getAuthUrl, getSession } from "~/server/auth.ts"; 4 | import Header from "~/islands/Header.tsx"; 5 | import AllPosts from "~/islands/AllPosts.tsx"; 6 | 7 | export default defineRoute(async (req, _ctx) => { 8 | const session = await getSession(req); 9 | const authUrl = session ? undefined : getAuthUrl(req.url); 10 | const title = "Leaves - Microblog with Markdown"; 11 | return ( 12 | <> 13 | 14 | {title} 15 | 16 | 17 | 18 | 19 | 20 | 21 | 25 | 26 |
27 |
28 | {!session?.user && authUrl && 29 | ( 30 |
31 |
32 |
33 | Leaves 39 |

Leaves

40 |
41 |

42 | This website is a Microblog that allows you to post in 43 | {" "} 44 | Markdown.
Sign in with your Google account and 45 | use it. 46 |

47 |
48 |
49 | 50 | 54 | 55 |
56 |
57 |
58 |
59 |
60 |
61 | )} 62 |
63 |
64 | {session?.user?.picture && 65 | ( 66 | mdo 74 | )} 75 | 76 | 84 | 85 | 86 |
87 |
88 | 89 |
90 | 91 | ); 92 | }); 93 | -------------------------------------------------------------------------------- /routes/likes.tsx: -------------------------------------------------------------------------------- 1 | import { Head } from "$fresh/runtime.ts"; 2 | import { defineRoute } from "$fresh/server.ts"; 3 | import Header from "~/islands/Header.tsx"; 4 | import { getSession } from "~/server/auth.ts"; 5 | import LikePosts from "~/islands/LikePosts.tsx"; 6 | 7 | export default defineRoute(async (req, _ctx) => { 8 | const session = await getSession(req); 9 | if (!session) { 10 | return new Response("", { 11 | status: 307, 12 | headers: { Location: "/" }, 13 | }); 14 | } 15 | return ( 16 | <> 17 | 18 | Likes - Leaves 19 | 20 |
21 |
22 | 23 |
24 | 25 | ); 26 | }); 27 | 28 | -------------------------------------------------------------------------------- /routes/notification.tsx: -------------------------------------------------------------------------------- 1 | import { defineRoute } from "$fresh/server.ts"; 2 | import { Head } from "$fresh/runtime.ts"; 3 | import { getSession } from "~/server/auth.ts"; 4 | import { selectNotificationsWithUpdate } from "~/server/db.ts"; 5 | import Header from "~/islands/Header.tsx"; 6 | 7 | export default defineRoute(async (req, _ctx) => { 8 | const session = await getSession(req); 9 | if (!session) { 10 | return new Response("", { 11 | status: 307, 12 | headers: { Location: "/" }, 13 | }); 14 | } 15 | const notifications = await selectNotificationsWithUpdate(session.user.id); 16 | // let locale: Intl.Locale; 17 | // try { 18 | // locale = new Intl.Locale(req.headers.get("accept-language")?.split(",").at(0) ?? "en"); 19 | // } catch (error) { 20 | // locale = new Intl.Locale("en"); 21 | // } 22 | return ( 23 | <> 24 | 25 | Notification - Leaves 26 | 27 |
28 |
29 |

Notification

30 | {notifications.map(notification => 31 |
32 | {new Date(notification.created_at).toISOString()} 33 | {notification.type === "follow" && {notification.action_user?.name} followed you. 34 | } 35 | {notification.type === "like" && {notification.action_user?.name} liked your post. 36 | } 37 | {notification.type === "comment" && {notification.action_user?.name} commented on the post you are related to. 38 | } 39 |
40 | )} 41 |
42 |
43 |
44 | 45 | ); 46 | }); 47 | -------------------------------------------------------------------------------- /routes/posts/[postId]/edit.tsx: -------------------------------------------------------------------------------- 1 | import { Head } from "$fresh/runtime.ts"; 2 | import { defineRoute } from "$fresh/server.ts"; 3 | import { selectPost } from "~/server/db.ts"; 4 | import Header from "~/islands/Header.tsx"; 5 | import { getAuthUrl, getSession } from "~/server/auth.ts"; 6 | import PostEdit from "~/islands/PostEdit.tsx"; 7 | 8 | export default defineRoute(async (req, ctx) => { 9 | const session = await getSession(req); 10 | if (!session) { 11 | return new Response("", { 12 | status: 307, 13 | headers: { Location: "/" }, 14 | }); 15 | } 16 | const authUrl = session ? undefined : getAuthUrl(req.url); 17 | 18 | const postId = Number(ctx.params.postId); 19 | const post = await selectPost(postId); 20 | if (!post) { 21 | return ctx.renderNotFound(); 22 | } 23 | if (session.user.id !== post.user_id) { 24 | return new Response("", { 25 | status: 307, 26 | headers: { Location: "/" }, 27 | }); 28 | } 29 | 30 | const user = session.user; 31 | return ( 32 | <> 33 | 34 | Edit - Leaves 35 | 36 |
37 |
38 |

Edit Post

39 | 40 |
41 | 42 | ); 43 | }); 44 | -------------------------------------------------------------------------------- /routes/posts/[postId]/index.tsx: -------------------------------------------------------------------------------- 1 | import { Head } from "$fresh/runtime.ts"; 2 | import { defineRoute } from "$fresh/server.ts"; 3 | import Header from "~/islands/Header.tsx"; 4 | import { getAuthUrl, getSession } from "~/server/auth.ts"; 5 | import { selectPost } from "~/server/db.ts"; 6 | import PostView from "~/islands/PostView.tsx"; 7 | import { getTitle } from "~/server/getTitle.ts"; 8 | 9 | export default defineRoute(async (req, ctx) => { 10 | const postId = Number(ctx.params.postId); 11 | if (Number.isNaN(postId)) { 12 | return ctx.renderNotFound(); 13 | } 14 | const post = await selectPost(postId); 15 | if (!post) { 16 | return ctx.renderNotFound(); 17 | } 18 | 19 | const session = await getSession(req); 20 | if (post.draft && post.user_id !== session?.user.id) { 21 | return new Response("", { 22 | status: 307, 23 | headers: { Location: "/" }, 24 | }); 25 | } 26 | const authUrl = session ? undefined : getAuthUrl(req.url); 27 | const user = session?.user; 28 | const title = getTitle(post.source) + " | Leaves"; 29 | return ( 30 | <> 31 | 32 | {title} 33 | 34 | 35 | 36 | 41 | 42 | 43 | 44 | 45 | 46 |
47 |
48 | 49 |
50 | 51 | ); 52 | }); 53 | -------------------------------------------------------------------------------- /routes/posts/new.tsx: -------------------------------------------------------------------------------- 1 | import { Head } from "$fresh/runtime.ts"; 2 | import { defineRoute } from "$fresh/server.ts"; 3 | import Header from "~/islands/Header.tsx"; 4 | import { getAuthUrl, getSession } from "~/server/auth.ts"; 5 | import PostNew from "~/islands/PostNew.tsx"; 6 | 7 | export default defineRoute(async (req, _ctx) => { 8 | const session = await getSession(req); 9 | if (!session) { 10 | // return new Response("Unauthorized", { status: 401 }); 11 | const authUrl = getAuthUrl(req.url); 12 | return Response.redirect(authUrl); 13 | } 14 | return ( 15 | <> 16 | 17 | New - Leaves 18 | 19 |
20 |
21 |

New Post

22 | 23 |
24 | 25 | ); 26 | }); 27 | -------------------------------------------------------------------------------- /routes/search.tsx: -------------------------------------------------------------------------------- 1 | import { Head } from "$fresh/runtime.ts"; 2 | import { defineRoute } from "$fresh/server.ts"; 3 | import Header from "~/islands/Header.tsx"; 4 | import { getAuthUrl, getSession } from "~/server/auth.ts"; 5 | import SearchedPosts from "~/islands/SearchedPosts.tsx"; 6 | 7 | export default defineRoute(async (req, ctx) => { 8 | const session = await getSession(req); 9 | const authUrl = session ? undefined : getAuthUrl(req.url); 10 | const searchParams = ctx.url.searchParams.get("value") || ""; 11 | return ( 12 | <> 13 | 14 | Search:{searchParams} - Leaves 15 | 16 | 17 | 21 | 22 | 23 | 24 | 28 | 29 |
30 |
31 |

Search

32 |
33 | 41 |
42 | {searchParams && 43 | ( 44 | 48 | )} 49 |
50 | 51 | ); 52 | }); 53 | -------------------------------------------------------------------------------- /routes/settings.tsx: -------------------------------------------------------------------------------- 1 | import { defineRoute } from "$fresh/server.ts"; 2 | import { getAuthUrl, getSession } from "~/server/auth.ts"; 3 | import Header from "~/islands/Header.tsx"; 4 | import DeleteAccount from "~/islands/DeleteAccount.tsx"; 5 | import { Head } from "$fresh/runtime.ts"; 6 | 7 | export default defineRoute(async (req, _ctx) => { 8 | const session = await getSession(req); 9 | if (!session) { 10 | return new Response("", { 11 | status: 307, 12 | headers: { Location: "/" }, 13 | }); 14 | } 15 | const authUrl = getAuthUrl(req.url); 16 | return ( 17 | <> 18 | 19 | Settings - Leaves 20 | 21 | 22 | 23 | 24 | 25 | 29 | 30 |
31 |
32 |

33 | LikesSettings 40 |

41 | { 42 | /*

Name

43 | TODO: move to islands 44 | 45 | */ 46 | } 47 | 48 |
49 | 50 | ); 51 | }); 52 | -------------------------------------------------------------------------------- /routes/shortcuts.tsx: -------------------------------------------------------------------------------- 1 | import { Head } from "$fresh/runtime.ts"; 2 | import { defineRoute } from "$fresh/server.ts"; 3 | import Header from "~/islands/Header.tsx"; 4 | import { getAuthUrl, getSession } from "~/server/auth.ts"; 5 | 6 | export default defineRoute(async (req, _ctx) => { 7 | const session = await getSession(req); 8 | const authUrl = session ? undefined : getAuthUrl(req.url); 9 | return ( 10 | <> 11 | 12 | Keyboard Shortcuts - Leaves 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 |
24 |
25 |

26 | Edit 33 | 34 | Keyboard Shortcuts 35 |

36 |
37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 |
ActionMappings
Keyboard Shortcuts?
Search/
New Postn
Scroll to top.
Next Postj
Previous Postk
Open Posto
Edit Poste
Aboutga
Homegh
Notificationgn
Likedgl
Followinggf
Profilegp
Send/Save Post(CTRL|⌘)+Enter, (CTRL|⌘)+s
Switch between Preview and Edit(CTRL|⌘)+p
111 |
112 |
113 | 114 | ); 115 | }); 116 | -------------------------------------------------------------------------------- /routes/signout.ts: -------------------------------------------------------------------------------- 1 | import { Handlers } from "$fresh/server.ts"; 2 | import { deleteCookie } from "$std/http/cookie.ts"; 3 | import { getSession } from "~/server/auth.ts"; 4 | import { deleteSession } from "~/server/db.ts"; 5 | 6 | export const handler: Handlers = { 7 | async GET(request) { 8 | const session = await getSession(request); 9 | if (session) { 10 | await deleteSession(session); 11 | } 12 | const response = new Response("", { 13 | status: 307, 14 | headers: { Location: "/" }, 15 | }); 16 | deleteCookie(response.headers, "session"); 17 | return response; 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /routes/sitemap/[userId].tsx: -------------------------------------------------------------------------------- 1 | import { defineRoute } from "$fresh/server.ts"; 2 | import { Head } from "$fresh/runtime.ts"; 3 | import { selectUserPostIds } from "~/server/db.ts"; 4 | 5 | export default defineRoute(async (_req, ctx) => { 6 | const userId = Number(ctx.params.userId); 7 | const postIds = await selectUserPostIds(userId); 8 | return ( 9 | <> 10 | 11 | Sitemap Posts - Leaves 12 | 13 |
14 |

Sitemap Posts

15 | {postIds.map((postId) => ( 16 | <> 17 | 18 | {postId} 19 |   20 | 21 | ))} 22 |
23 | 24 | ); 25 | }); 26 | -------------------------------------------------------------------------------- /routes/sitemap/index.tsx: -------------------------------------------------------------------------------- 1 | import { defineRoute } from "$fresh/server.ts"; 2 | import { Head } from "$fresh/runtime.ts"; 3 | import { selectUsers } from "~/server/db.ts"; 4 | 5 | export default defineRoute(async (_req, _ctx) => { 6 | const users = await selectUsers(); 7 | 8 | return ( 9 | <> 10 | 11 | Sitemap Users - Leaves 12 | 13 |
14 |

Sitemap Users

15 | {users.map((user) => ( 16 | <> 17 | {user.user_id}  18 | 19 | ))} 20 |
21 | 22 | ); 23 | }); 24 | -------------------------------------------------------------------------------- /routes/users/[userId].tsx: -------------------------------------------------------------------------------- 1 | import { Head } from "$fresh/runtime.ts"; 2 | import { defineRoute } from "$fresh/server.ts"; 3 | import { getAuthUrl, getSession } from "~/server/auth.ts"; 4 | import Header from "~/islands/Header.tsx"; 5 | import { selectUser } from "~/server/db.ts"; 6 | import UserPosts from "~/islands/UserPosts.tsx"; 7 | 8 | export default defineRoute(async (req, ctx) => { 9 | const session = await getSession(req); 10 | const authUrl = session ? undefined : getAuthUrl(req.url); 11 | const pageUser = await selectUser(ctx.params.userId); 12 | if (!pageUser) { 13 | return ctx.renderNotFound(); 14 | } 15 | const picture = pageUser.picture ?? undefined; 16 | const title = `${pageUser.name} - Leaves`; 17 | return ( 18 | <> 19 | 20 | {title} 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |
32 |

33 | {" "} 39 | {pageUser.name} 40 |

41 | 42 |
43 | 44 | ); 45 | }); 46 | -------------------------------------------------------------------------------- /server/auth.ts: -------------------------------------------------------------------------------- 1 | import { getCookies, setCookie } from "$std/http/cookie.ts"; 2 | import { AppUser, insertSession, selectSession } from "~/server/db.ts"; 3 | import { env } from "~/server/env.ts"; 4 | 5 | export type SessionType = { id: string; user: AppUser }; 6 | export async function getSession(req: Request) { 7 | const cookies = getCookies(req.headers); 8 | const sessionId = cookies["session"]; 9 | if (!sessionId) { 10 | return undefined; 11 | } 12 | const user = await selectSession(sessionId); 13 | if (!user) { 14 | return undefined; 15 | } 16 | return { id: sessionId, user }; 17 | } 18 | 19 | export async function createSession(response: Response, userId: number) { 20 | const sessionId = await insertSession(userId); 21 | const expires = new Date(); 22 | expires.setMonth(expires.getMonth() + 1); 23 | setCookie(response.headers, { 24 | name: "session", 25 | value: sessionId, 26 | expires, 27 | sameSite: "Lax", 28 | httpOnly: true, 29 | secure: true, 30 | path: "/", 31 | }); 32 | } 33 | 34 | export function getCallbackUrl(requestUrl: string) { 35 | const url = new URL(requestUrl); 36 | return (url.hostname === "localhost" ? "http" : "https") + "://" + url.host + 37 | "/callback"; 38 | } 39 | 40 | export function getAuthUrl(requestUrl: string): string { 41 | const redirectUri = getCallbackUrl(requestUrl); 42 | if (!env.clientId) { 43 | throw new Error("clientId is undefined"); 44 | } 45 | return "https://accounts.google.com/o/oauth2/auth?" + 46 | new URLSearchParams([ 47 | ["client_id", env.clientId], 48 | ["redirect_uri", redirectUri], 49 | ["scope", "https://www.googleapis.com/auth/userinfo.profile"], 50 | ["access_type", "offline"], 51 | ["response_type", "code"], 52 | ]).toString(); 53 | } 54 | -------------------------------------------------------------------------------- /server/database.types.ts: -------------------------------------------------------------------------------- 1 | export type Json = 2 | | string 3 | | number 4 | | boolean 5 | | null 6 | | { [key: string]: Json | undefined } 7 | | Json[] 8 | 9 | export type Database = { 10 | public: { 11 | Tables: { 12 | app_session: { 13 | Row: { 14 | created_at: string | null 15 | id: string 16 | updated_at: string | null 17 | user_id: number 18 | } 19 | Insert: { 20 | created_at?: string | null 21 | id?: string 22 | updated_at?: string | null 23 | user_id: number 24 | } 25 | Update: { 26 | created_at?: string | null 27 | id?: string 28 | updated_at?: string | null 29 | user_id?: number 30 | } 31 | Relationships: [ 32 | { 33 | foreignKeyName: "app_session_user_id_fkey" 34 | columns: ["user_id"] 35 | isOneToOne: false 36 | referencedRelation: "app_user" 37 | referencedColumns: ["id"] 38 | } 39 | ] 40 | } 41 | app_user: { 42 | Row: { 43 | account: string | null 44 | created_at: string | null 45 | google_id: string | null 46 | id: number 47 | name: string 48 | notification: boolean 49 | picture: string | null 50 | updated_at: string | null 51 | } 52 | Insert: { 53 | account?: string | null 54 | created_at?: string | null 55 | google_id?: string | null 56 | id?: number 57 | name: string 58 | notification?: boolean 59 | picture?: string | null 60 | updated_at?: string | null 61 | } 62 | Update: { 63 | account?: string | null 64 | created_at?: string | null 65 | google_id?: string | null 66 | id?: number 67 | name?: string 68 | notification?: boolean 69 | picture?: string | null 70 | updated_at?: string | null 71 | } 72 | Relationships: [] 73 | } 74 | comment: { 75 | Row: { 76 | created_at: string 77 | id: number 78 | post_id: number | null 79 | source: string 80 | updated_at: string 81 | user_id: number | null 82 | } 83 | Insert: { 84 | created_at?: string 85 | id?: number 86 | post_id?: number | null 87 | source: string 88 | updated_at?: string 89 | user_id?: number | null 90 | } 91 | Update: { 92 | created_at?: string 93 | id?: number 94 | post_id?: number | null 95 | source?: string 96 | updated_at?: string 97 | user_id?: number | null 98 | } 99 | Relationships: [ 100 | { 101 | foreignKeyName: "comment_post_id_fkey" 102 | columns: ["post_id"] 103 | isOneToOne: false 104 | referencedRelation: "post" 105 | referencedColumns: ["id"] 106 | }, 107 | { 108 | foreignKeyName: "comment_post_id_fkey" 109 | columns: ["post_id"] 110 | isOneToOne: false 111 | referencedRelation: "post_view" 112 | referencedColumns: ["id"] 113 | }, 114 | { 115 | foreignKeyName: "comment_user_id_fkey" 116 | columns: ["user_id"] 117 | isOneToOne: false 118 | referencedRelation: "app_user" 119 | referencedColumns: ["id"] 120 | } 121 | ] 122 | } 123 | follow: { 124 | Row: { 125 | created_at: string | null 126 | following_user_id: number 127 | updated_at: string | null 128 | user_id: number 129 | } 130 | Insert: { 131 | created_at?: string | null 132 | following_user_id: number 133 | updated_at?: string | null 134 | user_id: number 135 | } 136 | Update: { 137 | created_at?: string | null 138 | following_user_id?: number 139 | updated_at?: string | null 140 | user_id?: number 141 | } 142 | Relationships: [ 143 | { 144 | foreignKeyName: "follow_following_user_id_fkey" 145 | columns: ["following_user_id"] 146 | isOneToOne: false 147 | referencedRelation: "app_user" 148 | referencedColumns: ["id"] 149 | }, 150 | { 151 | foreignKeyName: "follow_user_id_fkey" 152 | columns: ["user_id"] 153 | isOneToOne: false 154 | referencedRelation: "app_user" 155 | referencedColumns: ["id"] 156 | } 157 | ] 158 | } 159 | likes: { 160 | Row: { 161 | created_at: string | null 162 | post_id: number 163 | updated_at: string | null 164 | user_id: number 165 | } 166 | Insert: { 167 | created_at?: string | null 168 | post_id: number 169 | updated_at?: string | null 170 | user_id: number 171 | } 172 | Update: { 173 | created_at?: string | null 174 | post_id?: number 175 | updated_at?: string | null 176 | user_id?: number 177 | } 178 | Relationships: [ 179 | { 180 | foreignKeyName: "likes_post_id_fkey" 181 | columns: ["post_id"] 182 | isOneToOne: false 183 | referencedRelation: "post" 184 | referencedColumns: ["id"] 185 | }, 186 | { 187 | foreignKeyName: "likes_post_id_fkey" 188 | columns: ["post_id"] 189 | isOneToOne: false 190 | referencedRelation: "post_view" 191 | referencedColumns: ["id"] 192 | }, 193 | { 194 | foreignKeyName: "likes_user_id_fkey" 195 | columns: ["user_id"] 196 | isOneToOne: false 197 | referencedRelation: "app_user" 198 | referencedColumns: ["id"] 199 | } 200 | ] 201 | } 202 | memos: { 203 | Row: { 204 | content: string | null 205 | id: number | null 206 | } 207 | Insert: { 208 | content?: string | null 209 | id?: number | null 210 | } 211 | Update: { 212 | content?: string | null 213 | id?: number | null 214 | } 215 | Relationships: [] 216 | } 217 | notification: { 218 | Row: { 219 | action_user_id: number | null 220 | created_at: string 221 | id: number 222 | post_id: number | null 223 | type: Database["public"]["Enums"]["notification_type"] 224 | updated_at: string 225 | user_id: number | null 226 | } 227 | Insert: { 228 | action_user_id?: number | null 229 | created_at?: string 230 | id?: number 231 | post_id?: number | null 232 | type: Database["public"]["Enums"]["notification_type"] 233 | updated_at?: string 234 | user_id?: number | null 235 | } 236 | Update: { 237 | action_user_id?: number | null 238 | created_at?: string 239 | id?: number 240 | post_id?: number | null 241 | type?: Database["public"]["Enums"]["notification_type"] 242 | updated_at?: string 243 | user_id?: number | null 244 | } 245 | Relationships: [ 246 | { 247 | foreignKeyName: "notification_action_user_id_fkey" 248 | columns: ["action_user_id"] 249 | isOneToOne: false 250 | referencedRelation: "app_user" 251 | referencedColumns: ["id"] 252 | }, 253 | { 254 | foreignKeyName: "notification_post_id_fkey" 255 | columns: ["post_id"] 256 | isOneToOne: false 257 | referencedRelation: "post" 258 | referencedColumns: ["id"] 259 | }, 260 | { 261 | foreignKeyName: "notification_post_id_fkey" 262 | columns: ["post_id"] 263 | isOneToOne: false 264 | referencedRelation: "post_view" 265 | referencedColumns: ["id"] 266 | }, 267 | { 268 | foreignKeyName: "notification_user_id_fkey" 269 | columns: ["user_id"] 270 | isOneToOne: false 271 | referencedRelation: "app_user" 272 | referencedColumns: ["id"] 273 | } 274 | ] 275 | } 276 | post: { 277 | Row: { 278 | created_at: string 279 | draft: boolean | null 280 | id: number 281 | source: string | null 282 | updated_at: string 283 | user_id: number | null 284 | } 285 | Insert: { 286 | created_at?: string 287 | draft?: boolean | null 288 | id?: number 289 | source?: string | null 290 | updated_at?: string 291 | user_id?: number | null 292 | } 293 | Update: { 294 | created_at?: string 295 | draft?: boolean | null 296 | id?: number 297 | source?: string | null 298 | updated_at?: string 299 | user_id?: number | null 300 | } 301 | Relationships: [ 302 | { 303 | foreignKeyName: "post_user_id_fkey" 304 | columns: ["user_id"] 305 | isOneToOne: false 306 | referencedRelation: "app_user" 307 | referencedColumns: ["id"] 308 | } 309 | ] 310 | } 311 | revision: { 312 | Row: { 313 | created_at: string | null 314 | id: number 315 | post_id: number | null 316 | source: string | null 317 | updated_at: string | null 318 | } 319 | Insert: { 320 | created_at?: string | null 321 | id?: number 322 | post_id?: number | null 323 | source?: string | null 324 | updated_at?: string | null 325 | } 326 | Update: { 327 | created_at?: string | null 328 | id?: number 329 | post_id?: number | null 330 | source?: string | null 331 | updated_at?: string | null 332 | } 333 | Relationships: [] 334 | } 335 | } 336 | Views: { 337 | post_view: { 338 | Row: { 339 | account: string | null 340 | comments: number | null 341 | created_at: string | null 342 | draft: boolean | null 343 | id: number | null 344 | likes: number | null 345 | name: string | null 346 | picture: string | null 347 | source: string | null 348 | updated_at: string | null 349 | user_id: number | null 350 | } 351 | Relationships: [ 352 | { 353 | foreignKeyName: "post_user_id_fkey" 354 | columns: ["user_id"] 355 | isOneToOne: false 356 | referencedRelation: "app_user" 357 | referencedColumns: ["id"] 358 | } 359 | ] 360 | } 361 | user_view: { 362 | Row: { 363 | account: string | null 364 | updated_at: string | null 365 | user_id: number | null 366 | } 367 | Relationships: [ 368 | { 369 | foreignKeyName: "post_user_id_fkey" 370 | columns: ["user_id"] 371 | isOneToOne: false 372 | referencedRelation: "app_user" 373 | referencedColumns: ["id"] 374 | } 375 | ] 376 | } 377 | } 378 | Functions: { 379 | get_notification_for_comment: { 380 | Args: { 381 | p_post_id: number 382 | p_user_id: number 383 | } 384 | Returns: { 385 | user_id: number 386 | type: Database["public"]["Enums"]["notification_type"] 387 | post_id: number 388 | action_user_id: number 389 | }[] 390 | } 391 | insert_notification_for_comment: { 392 | Args: { 393 | p_post_id: number 394 | p_user_id: number 395 | } 396 | Returns: undefined 397 | } 398 | insert_notification_for_like: { 399 | Args: { 400 | p_post_id: number 401 | p_user_id: number 402 | } 403 | Returns: undefined 404 | } 405 | select_following_users_posts: { 406 | Args: { 407 | login_user_id: number 408 | post_id: number 409 | } 410 | Returns: { 411 | id: number 412 | user_id: number 413 | source: string 414 | updated_at: string 415 | created_at: string 416 | draft: boolean 417 | name: string 418 | picture: string 419 | comments: number 420 | likes: number 421 | account: string 422 | }[] 423 | } 424 | select_liked_posts: { 425 | Args: { 426 | login_user_id: number 427 | post_id: number 428 | } 429 | Returns: { 430 | id: number 431 | user_id: number 432 | source: string 433 | updated_at: string 434 | created_at: string 435 | draft: boolean 436 | name: string 437 | picture: string 438 | comments: number 439 | likes: number 440 | account: string 441 | }[] 442 | } 443 | select_posts_by_word: { 444 | Args: { 445 | search_word: string 446 | login_user_id: number 447 | post_id: number 448 | } 449 | Returns: { 450 | id: number 451 | user_id: number 452 | source: string 453 | updated_at: string 454 | created_at: string 455 | draft: boolean 456 | name: string 457 | picture: string 458 | comments: number 459 | likes: number 460 | account: string 461 | }[] 462 | } 463 | } 464 | Enums: { 465 | notification_type: "follow" | "like" | "comment" 466 | } 467 | CompositeTypes: { 468 | [_ in never]: never 469 | } 470 | } 471 | } 472 | 473 | export type Tables< 474 | PublicTableNameOrOptions extends 475 | | keyof (Database["public"]["Tables"] & Database["public"]["Views"]) 476 | | { schema: keyof Database }, 477 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 478 | ? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] & 479 | Database[PublicTableNameOrOptions["schema"]]["Views"]) 480 | : never = never 481 | > = PublicTableNameOrOptions extends { schema: keyof Database } 482 | ? (Database[PublicTableNameOrOptions["schema"]]["Tables"] & 483 | Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends { 484 | Row: infer R 485 | } 486 | ? R 487 | : never 488 | : PublicTableNameOrOptions extends keyof (Database["public"]["Tables"] & 489 | Database["public"]["Views"]) 490 | ? (Database["public"]["Tables"] & 491 | Database["public"]["Views"])[PublicTableNameOrOptions] extends { 492 | Row: infer R 493 | } 494 | ? R 495 | : never 496 | : never 497 | 498 | export type TablesInsert< 499 | PublicTableNameOrOptions extends 500 | | keyof Database["public"]["Tables"] 501 | | { schema: keyof Database }, 502 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 503 | ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] 504 | : never = never 505 | > = PublicTableNameOrOptions extends { schema: keyof Database } 506 | ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { 507 | Insert: infer I 508 | } 509 | ? I 510 | : never 511 | : PublicTableNameOrOptions extends keyof Database["public"]["Tables"] 512 | ? Database["public"]["Tables"][PublicTableNameOrOptions] extends { 513 | Insert: infer I 514 | } 515 | ? I 516 | : never 517 | : never 518 | 519 | export type TablesUpdate< 520 | PublicTableNameOrOptions extends 521 | | keyof Database["public"]["Tables"] 522 | | { schema: keyof Database }, 523 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 524 | ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] 525 | : never = never 526 | > = PublicTableNameOrOptions extends { schema: keyof Database } 527 | ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { 528 | Update: infer U 529 | } 530 | ? U 531 | : never 532 | : PublicTableNameOrOptions extends keyof Database["public"]["Tables"] 533 | ? Database["public"]["Tables"][PublicTableNameOrOptions] extends { 534 | Update: infer U 535 | } 536 | ? U 537 | : never 538 | : never 539 | 540 | export type Enums< 541 | PublicEnumNameOrOptions extends 542 | | keyof Database["public"]["Enums"] 543 | | { schema: keyof Database }, 544 | EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database } 545 | ? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"] 546 | : never = never 547 | > = PublicEnumNameOrOptions extends { schema: keyof Database } 548 | ? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName] 549 | : PublicEnumNameOrOptions extends keyof Database["public"]["Enums"] 550 | ? Database["public"]["Enums"][PublicEnumNameOrOptions] 551 | : never 552 | -------------------------------------------------------------------------------- /server/db.ts: -------------------------------------------------------------------------------- 1 | import { createClient, SupabaseClient } from "supabase-js"; 2 | import * as uuid from "$std/uuid/mod.ts"; 3 | import { PAGE_ROWS } from "~/common/constants.ts"; 4 | import * as env from "~/server/env.ts"; 5 | import { Database } from "~/server/database.types.ts"; 6 | 7 | export type AppUser = Database["public"]["Tables"]["app_user"]["Row"]; 8 | export type PostViewTypeA = Database["public"]["Views"]["post_view"]["Row"]; 9 | 10 | // View の型定義の自動生成はフィールドが全部 null 許可になってしまうので明示的に定義 11 | export type PostViewType = { 12 | comments: number; 13 | created_at: string; 14 | draft: boolean; 15 | id: number; 16 | likes: number; 17 | name: string; 18 | picture: string | null; 19 | source: string; 20 | updated_at: string; 21 | user_id: number; 22 | account: string | null; 23 | }; 24 | 25 | export type UserViewType = { 26 | user_id: number; 27 | updated_at: string; 28 | account: string | null; 29 | }; 30 | 31 | let supabase: SupabaseClient; 32 | 33 | export function initSupabase() { 34 | const url = "https://" + env.get("SUPABASE_HOST"); 35 | const serviceRoleKey = env.get("SUPABASE_SERVICE_ROLE_KEY"); 36 | supabase = createClient(url, serviceRoleKey); 37 | } 38 | 39 | export async function selectUserByGoogleId(googleId: string) { 40 | const { data, error } = await supabase.from("app_user").select( 41 | "id,name,picture", 42 | ).eq( 43 | "google_id", 44 | googleId, 45 | ).maybeSingle(); 46 | if (error) { 47 | throw error; 48 | } 49 | return data; 50 | } 51 | 52 | export async function upsertUser( 53 | params: { googleId: string; name: string; picture: string }, 54 | ) { 55 | const { data, error } = await supabase.from("app_user").upsert({ 56 | google_id: params.googleId, 57 | name: params.name, 58 | picture: params.picture, 59 | }, { onConflict: "google_id" }).select("id,google_id,name,picture") 60 | .maybeSingle(); 61 | if (error) { 62 | throw error; 63 | } 64 | return data!; 65 | } 66 | 67 | export async function deleteUser( 68 | userId: number, 69 | ) { 70 | const { error } = await supabase.from("app_user").delete().eq("id", userId); 71 | if (error) { 72 | throw error; 73 | } 74 | } 75 | 76 | export async function selectUser(userKey: string) { 77 | const id = Number(userKey); 78 | const select = supabase.from("app_user").select( 79 | "id,name,picture,notification", 80 | ); 81 | const { data, error } = 82 | await (isNaN(id) ? select.eq("account", userKey) : select.eq("id", id)) 83 | .maybeSingle(); 84 | if (error) { 85 | throw error; 86 | } 87 | return data; 88 | } 89 | 90 | export async function selectUsers() { 91 | const { data, error } = await supabase.from("user_view").select( 92 | "user_id,updated_at,account", 93 | ).returns(); 94 | if (error) { 95 | throw error; 96 | } 97 | return data ?? []; 98 | } 99 | 100 | export async function insertPost( 101 | params: { userId: number; source: string; draft: boolean }, 102 | ) { 103 | const { data, error } = await supabase.from("post").insert({ 104 | user_id: params.userId, 105 | source: params.source, 106 | draft: params.draft, 107 | }).select("id").maybeSingle(); 108 | if (error) { 109 | throw error; 110 | } 111 | return data?.id!; 112 | } 113 | 114 | export async function updatePost( 115 | params: { postId: number; userId: number; source: string; draft: boolean }, 116 | ) { 117 | const { error } = await supabase.from("post") 118 | .update({ 119 | id: params.postId, 120 | source: params.source, 121 | draft: params.draft, 122 | updated_at: new Date().toISOString(), 123 | }).match({ "id": params.postId, "user_id": params.userId }); 124 | if (error) { 125 | throw error; 126 | } 127 | } 128 | 129 | export async function deletePost( 130 | params: { id: number; userId: number }, 131 | ) { 132 | const { error } = await supabase.from("post").delete().match({ 133 | "id": params.id, 134 | "user_id": params.userId, 135 | }); 136 | if (error) { 137 | throw error; 138 | } 139 | } 140 | 141 | export async function selectPost(id: number) { 142 | const { data, error } = await supabase.from("post_view").select("*").eq( 143 | "id", 144 | id, 145 | ) 146 | .returns() 147 | .maybeSingle(); 148 | if (error) { 149 | throw error; 150 | } 151 | return data; 152 | } 153 | 154 | export async function selectPostIds() { 155 | const { data, error } = await supabase.from("post").select("id,updated_at") 156 | .eq( 157 | "draft", 158 | false, 159 | ).order( 160 | "id", 161 | { ascending: false }, 162 | ).limit(1000); 163 | if (error) { 164 | throw error; 165 | } 166 | return data ?? []; 167 | } 168 | 169 | export async function selectPosts(ltId: number | null) { 170 | const builder = supabase.from("post_view").select("*").eq("draft", false); 171 | if (ltId) { 172 | builder.lt("id", ltId); 173 | } 174 | const { data, error } = await builder.order("id", { ascending: false }).limit( 175 | PAGE_ROWS, 176 | ).returns(); 177 | if (error) { 178 | throw error; 179 | } 180 | return data ?? []; 181 | } 182 | 183 | export async function selectUserPost( 184 | params: { userId: number; self: boolean; ltId: number | null }, 185 | ) { 186 | const builder = supabase.from("post_view").select("*").eq( 187 | "user_id", 188 | params.userId, 189 | ); 190 | if (!params.self) { 191 | builder.eq("draft", false); 192 | } 193 | if (params.ltId) { 194 | builder.lt("id", params.ltId); 195 | } 196 | const { data, error } = await builder.order("id", { ascending: false }).limit( 197 | PAGE_ROWS, 198 | ).returns(); 199 | if (error) { 200 | throw error; 201 | } 202 | return data ?? []; 203 | } 204 | 205 | export async function selectUserPostIds(userId: number) { 206 | const { data, error } = await supabase.from("post").select("id").eq( 207 | "user_id", 208 | userId, 209 | ).eq( 210 | "draft", 211 | false, 212 | ).order( 213 | "id", 214 | { ascending: false }, 215 | ).limit(1000); 216 | if (error) { 217 | throw error; 218 | } 219 | return data.map((row) => row.id) ?? []; 220 | } 221 | 222 | export async function selectFollowingUsersPosts( 223 | params: { userId: number; ltId: number | null }, 224 | ) { 225 | const { data, error } = await supabase.rpc("select_following_users_posts", { 226 | login_user_id: params.userId, 227 | post_id: params.ltId!, 228 | }); 229 | if (error) { 230 | throw error; 231 | } 232 | return data ?? []; 233 | } 234 | 235 | export async function selectLikedPosts( 236 | params: { userId: number; ltId: number | null }, 237 | ) { 238 | const { data, error } = await supabase.rpc("select_liked_posts", { 239 | login_user_id: params.userId, 240 | post_id: params.ltId!, 241 | }); 242 | if (error) { 243 | throw error; 244 | } 245 | return data ?? []; 246 | } 247 | 248 | export async function selectPostsBySearchWord( 249 | params: { 250 | searchWord: string; 251 | postId: number | null; 252 | loginUserId: number | null; 253 | }, 254 | ) { 255 | const searchWord = params.searchWord.trim(); 256 | if (searchWord.trim().length === 0) { 257 | return []; 258 | } 259 | const { data, error } = await supabase.rpc("select_posts_by_word", { 260 | search_word: searchWord, 261 | login_user_id: params.loginUserId!, 262 | post_id: params.postId!, 263 | }); 264 | if (error) { 265 | throw error; 266 | } 267 | return data ?? []; 268 | } 269 | 270 | export async function insertComment( 271 | params: { postId: number; userId: number; source: string }, 272 | ) { 273 | const { error } = await supabase.from("comment").insert({ 274 | "post_id": params.postId, 275 | "user_id": params.userId, 276 | "source": params.source, 277 | }); 278 | if (error) { 279 | throw error; 280 | } 281 | 282 | supabase.rpc("insert_notification_for_comment", { 283 | p_post_id: params.postId, 284 | p_user_id: params.userId, 285 | }).then(({ error }) => { 286 | if (error) { 287 | console.error(error); 288 | } 289 | }); 290 | } 291 | 292 | export async function selectComments( 293 | postId: number, 294 | ) { 295 | const { data, error } = await supabase.from("comment").select( 296 | "id,user_id,source,updated_at,app_user(name,picture)", 297 | ).eq("post_id", postId).order("id").limit(100); 298 | if (error) { 299 | throw error; 300 | } 301 | return data ?? []; 302 | } 303 | 304 | export async function deleteComment(params: { id: number; userId: number }) { 305 | const { error } = await supabase.from("comment").delete().match({ 306 | id: params.id, 307 | user_id: params.userId, 308 | }); 309 | if (error) { 310 | throw error; 311 | } 312 | } 313 | 314 | export async function insertFollow( 315 | params: { userId: number; followingUserId: number }, 316 | ) { 317 | const { error } = await supabase.from("follow").insert({ 318 | "user_id": params.userId, 319 | "following_user_id": params.followingUserId, 320 | }); 321 | if (error) { 322 | throw error; 323 | } 324 | 325 | supabase.from("notification").insert({ 326 | "user_id": params.followingUserId, 327 | "type": "follow", 328 | action_user_id: params.userId, 329 | }).then((error) => { 330 | if (error) { 331 | console.error(error); 332 | } else { 333 | supabase.from("app_user").update({ notification: true }).eq( 334 | "id", 335 | params.followingUserId, 336 | ).then(({ error }) => { 337 | if (error) { 338 | console.error(error); 339 | } 340 | }); 341 | } 342 | }); 343 | } 344 | 345 | export async function deleteFollow( 346 | params: { userId: number; followingUserId: number }, 347 | ) { 348 | const { error } = await supabase.from("follow").delete().match({ 349 | "user_id": params.userId, 350 | "following_user_id": params.followingUserId, 351 | }); 352 | if (error) { 353 | throw error; 354 | } 355 | } 356 | 357 | export async function selectFollowingUsers(userId: number) { 358 | const { data, error } = await supabase.from("follow").select( 359 | "user:following_user_id(id,name,picture)", 360 | ).eq( 361 | "user_id", 362 | userId, 363 | ) 364 | .order("created_at", { ascending: false }).returns< 365 | { user: { id: number; name: string; picture: string } }[] 366 | >(); 367 | if (error) { 368 | throw error; 369 | } 370 | return data!.map((row) => row.user); 371 | } 372 | 373 | export async function selectFollowerUsers(followingUserId: number) { 374 | const { data, error } = await supabase.from("follow").select( 375 | "user:user_id(id,name,picture)", 376 | ) 377 | .eq( 378 | "following_user_id", 379 | followingUserId, 380 | ) 381 | .order("created_at", { ascending: false }).returns< 382 | { user: { id: number; name: string; picture: string } }[] 383 | >(); 384 | if (error) { 385 | throw error; 386 | } 387 | return data!.map((row) => row.user); 388 | } 389 | 390 | export async function selectCountFollowing(userId: number) { 391 | const { count, error } = await supabase.from("follow").select("user_id", { 392 | count: "exact", 393 | }).eq("user_id", userId); 394 | if (error) { 395 | throw error; 396 | } 397 | return "" + count; 398 | } 399 | 400 | export async function selectCountFollower(followingUserId: number) { 401 | const { count, error } = await supabase.from("follow").select("user_id", { 402 | count: "exact", 403 | }).eq("following_user_id", followingUserId); 404 | if (error) { 405 | throw error; 406 | } 407 | return "" + count; 408 | } 409 | 410 | export async function judgeFollowing( 411 | params: { userId: number; followingUserId: number }, 412 | ) { 413 | const { data, error } = await supabase.from("follow").select("user_id").match( 414 | { 415 | "user_id": params.userId, 416 | "following_user_id": params.followingUserId, 417 | }, 418 | ); 419 | if (error) { 420 | throw error; 421 | } 422 | return data?.length === 1; 423 | } 424 | 425 | export async function selectNotificationsWithUpdate(userId: number) { 426 | const { data, error } = await supabase.from("notification").select( 427 | "id,type,action_user_id,post_id,created_at,action_user:action_user_id(name)", 428 | ).eq("user_id", userId).order("created_at", { ascending: false }).limit(10) 429 | .returns< 430 | { 431 | id: number; 432 | type: string; 433 | post_id: number; 434 | action_user_id: number; 435 | created_at: string; 436 | action_user: { name: string }; 437 | }[] 438 | >(); 439 | if (error) { 440 | throw error; 441 | } 442 | 443 | supabase.from("app_user").update({ "notification": false }).eq( 444 | "id", 445 | userId, 446 | ).then(({ error }) => { 447 | if (error) { 448 | console.error(error); 449 | } 450 | }); 451 | 452 | return data ?? []; 453 | } 454 | 455 | export async function insertLike( 456 | params: { userId: number; postId: number }, 457 | ) { 458 | const { error } = await supabase.from("likes").insert({ 459 | "user_id": params.userId, 460 | "post_id": params.postId, 461 | }); 462 | if (error) { 463 | throw error; 464 | } 465 | 466 | supabase.rpc("insert_notification_for_like", { 467 | p_post_id: params.postId, 468 | p_user_id: params.userId, 469 | }).then(({ error }) => { 470 | if (error) { 471 | console.error(error); 472 | } 473 | }); 474 | } 475 | 476 | export async function deleteLike(params: { userId: number; postId: number }) { 477 | const { error } = await supabase.from("likes").delete().match({ 478 | "post_id": params.postId, 479 | "user_id": params.userId, 480 | }); 481 | if (error) { 482 | throw error; 483 | } 484 | } 485 | 486 | export async function selectLikes( 487 | { userId, postIds }: { userId: number; postIds: number[] }, 488 | ) { 489 | const { data, error } = await supabase.from("likes").select("post_id").eq( 490 | "user_id", 491 | userId, 492 | ).in("post_id", postIds); 493 | if (error) { 494 | throw error; 495 | } 496 | return data?.map((row) => row.post_id) ?? []; 497 | } 498 | 499 | export async function selectLikeUsers(postId: number) { 500 | const { data, error } = await supabase.from("likes").select( 501 | "app_user(id,name,picture)", 502 | ).eq( 503 | "post_id", 504 | postId, 505 | ).order("created_at", { ascending: false }); 506 | if (error) { 507 | throw error; 508 | } 509 | if (!data) { 510 | return []; 511 | } 512 | return data.map((row) => row.app_user!); 513 | } 514 | 515 | export async function selectSession(sessionId: string) { 516 | if (!uuid.validate(sessionId)) { 517 | return undefined; 518 | } 519 | const { data, error } = await supabase.from("app_session").select( 520 | "app_user(id,name,picture,notification,account)", 521 | ).eq( 522 | "id", 523 | sessionId, 524 | ).maybeSingle(); 525 | if (error) { 526 | throw error; 527 | } 528 | if (!data) { 529 | return undefined; 530 | } 531 | return data.app_user; 532 | } 533 | 534 | export async function insertSession(userId: number) { 535 | const { data, error } = await supabase.from("app_session").insert({ 536 | user_id: userId, 537 | }) 538 | .select("id").maybeSingle(); 539 | if (error) { 540 | throw error; 541 | } 542 | const sessionId = data?.id; 543 | deleteExpiredSession(userId); 544 | return sessionId!; 545 | } 546 | 547 | export async function deleteSession( 548 | session: { id: string; user: { id: number } }, 549 | ) { 550 | const { error } = await supabase.from("app_session").delete().match({ 551 | id: session.id, 552 | }); 553 | if (error) { 554 | throw error; 555 | } 556 | deleteExpiredSession(session.user.id); 557 | } 558 | 559 | /** 560 | * 作成後、1ヶ月経過しているセッションを削除する 561 | * TODO セッション取得時に updated_at を更新するか? 562 | * 563 | * @param userId 564 | */ 565 | function deleteExpiredSession(userId: number) { 566 | const date = new Date(); 567 | date.setMonth(date.getMonth() - 1); 568 | supabase.from("app_session").delete().eq("user_id", userId).lt( 569 | "updated_at", 570 | date.toISOString(), 571 | ).then(({ error }) => { 572 | if (error) { 573 | console.error(error); 574 | } 575 | }); 576 | } 577 | -------------------------------------------------------------------------------- /server/env.ts: -------------------------------------------------------------------------------- 1 | export function get(name: string) { 2 | const value = Deno.env.get(name); 3 | if (!value) { 4 | console.error("Cannot get the environment variable: " + name); 5 | Deno.exit(1); 6 | } 7 | return value; 8 | } 9 | 10 | class Env { 11 | #clientId?: string; 12 | #clientSecret?: string; 13 | init() { 14 | // build 時に処理が動かないように初期化を遅延させる 15 | this.#clientId = get("LEAVES_AUTH_CLIENT_ID"); 16 | this.#clientSecret = get("LEAVES_AUTH_CLIENT_SECRET"); 17 | } 18 | get clientId() { 19 | return this.#clientId!; 20 | } 21 | get clientSecret() { 22 | return this.#clientSecret!; 23 | } 24 | } 25 | 26 | export const env = new Env(); 27 | -------------------------------------------------------------------------------- /server/getTitle.ts: -------------------------------------------------------------------------------- 1 | const startRegex = /[\s#]/; 2 | const endRegex = /[\r\n]/; 3 | const MAX = 256; 4 | 5 | export function getTitle(text: string) { 6 | let start: number | null = null; 7 | let end: number | null = null; 8 | for (let index = 0; index < text.length && index < MAX; index++) { 9 | const c = text[index]; 10 | if (start == null && !startRegex.test(c)) { 11 | start = index; 12 | } else if (start != null && endRegex.test(c)) { 13 | end = index; 14 | break; 15 | } 16 | } 17 | if (start === null) { 18 | start = 0; 19 | } 20 | if (end === null) { 21 | end = text.length < MAX ? text.length : MAX; 22 | } 23 | return text.substring(start, end); 24 | } 25 | -------------------------------------------------------------------------------- /server/markdown.ts: -------------------------------------------------------------------------------- 1 | // Original: https://github.com/denoland/deno-gfm/blob/6f4b8ae149b1f044037986929f096434b06bd726/mod.ts 2 | import { emojify } from "emoji"; 3 | import * as Marked from "marked"; 4 | //import { default as Prism } from "prismjs"; 5 | import { default as sanitizeHtml } from "sanitize-html"; 6 | //import { escape as htmlEscape } from "he"; 7 | // import "https://esm.sh/prismjs@1.29.0/components/prism-jsx?no-check&pin=v57"; 8 | // import "https://esm.sh/prismjs@1.29.0/components/prism-typescript?no-check&pin=v57"; 9 | // import "https://esm.sh/prismjs@1.29.0/components/prism-tsx?no-check&pin=v57"; 10 | // import "https://esm.sh/prismjs@1.29.0/components/prism-bash?no-check&pin=v57"; 11 | // import "https://esm.sh/prismjs@1.29.0/components/prism-powershell?no-check&pin=v57"; 12 | // import "https://esm.sh/prismjs@1.29.0/components/prism-json?no-check&pin=v57"; 13 | // import "https://esm.sh/prismjs@1.29.0/components/prism-diff?no-check&pin=v57"; 14 | 15 | class Renderer extends Marked.Renderer { 16 | // heading( 17 | // text: string, 18 | // level: 1 | 2 | 3 | 4 | 5 | 6, 19 | // raw: string, 20 | // slugger: Marked.Slugger, 21 | // ): string { 22 | // const slug = slugger.slug(raw); 23 | // return `${text}`; 24 | // } 25 | 26 | // image(src: string, title: string | null, alt: string | null) { 27 | // return `${alt ?? `; 28 | // } 29 | 30 | // code(code: string, language?: string) { 31 | // // a language of `ts, ignore` should really be `ts` 32 | // // and it should be lowercase to ensure it has parity with regular github markdown 33 | // language = language?.split(",")?.[0].toLocaleLowerCase(); 34 | // 35 | // // transform math code blocks into HTML+MathML 36 | // // https://github.blog/changelog/2022-06-28-fenced-block-syntax-for-mathematical-expressions/ 37 | // const grammar = 38 | // language && Object.hasOwnProperty.call(Prism.languages, language) 39 | // ? Prism.languages[language] 40 | // : undefined; 41 | // if (grammar === undefined) { 42 | // return `
${htmlEscape(code)}
`; 43 | // } 44 | // const html = Prism.highlight(code, grammar, language!); 45 | // return `
${html}
`; 46 | // } 47 | 48 | link(href: string, title: string, text: string) { 49 | if (href.startsWith("#")) { 50 | return `${text}`; 51 | } 52 | if (this.options.baseUrl) { 53 | href = new URL(href, this.options.baseUrl).href; 54 | } 55 | if (title === undefined && href === text) { 56 | const youtubeTag = youtube(href); 57 | if (youtubeTag) { 58 | return youtubeTag; 59 | } 60 | } 61 | if (text === "_preview_large") { 62 | return ``; 65 | } else if (text === "_preview_small") { 66 | return ``; 69 | } 70 | return `${text}`; 71 | } 72 | } 73 | 74 | export interface RenderOptions { 75 | baseUrl?: string; 76 | mediaBaseUrl?: string; 77 | inline?: boolean; 78 | allowMath?: boolean; 79 | disableHtmlSanitization?: boolean; 80 | } 81 | 82 | export function render(markdown: string, opts: RenderOptions = {}): string { 83 | opts.mediaBaseUrl ??= opts.baseUrl; 84 | markdown = emojify(markdown); 85 | 86 | const marked_opts = { 87 | baseUrl: opts.baseUrl, 88 | gfm: true, 89 | renderer: new Renderer(), 90 | breaks: true, 91 | }; 92 | 93 | const html = opts.inline 94 | ? Marked.marked.parseInline(markdown, marked_opts) 95 | : Marked.marked.parse(markdown, marked_opts); 96 | 97 | if (opts.disableHtmlSanitization) { 98 | return html; 99 | } 100 | 101 | let allowedTags = sanitizeHtml.defaults.allowedTags.concat([ 102 | "img", 103 | "video", 104 | "svg", 105 | "path", 106 | "circle", 107 | "figure", 108 | "figcaption", 109 | "del", 110 | "details", 111 | "summary", 112 | "iframe", 113 | "input", 114 | ]); 115 | if (opts.allowMath) { 116 | allowedTags = allowedTags.concat([ 117 | "math", 118 | "maction", 119 | "annotation", 120 | "annotation-xml", 121 | "menclose", 122 | "merror", 123 | "mfenced", 124 | "mfrac", 125 | "mi", 126 | "mmultiscripts", 127 | "mn", 128 | "mo", 129 | "mover", 130 | "mpadded", 131 | "mphantom", 132 | "mprescripts", 133 | "mroot", 134 | "mrow", 135 | "ms", 136 | "semantics", 137 | "mspace", 138 | "msqrt", 139 | "mstyle", 140 | "msub", 141 | "msup", 142 | "msubsup", 143 | "mtable", 144 | "mtd", 145 | "mtext", 146 | "mtr", 147 | ]); 148 | } 149 | 150 | function transformMedia(tagName: string, attribs: sanitizeHtml.Attributes) { 151 | if (opts.mediaBaseUrl && attribs.src) { 152 | try { 153 | attribs.src = new URL(attribs.src, opts.mediaBaseUrl).href; 154 | } catch { 155 | delete attribs.src; 156 | } 157 | } 158 | return { tagName, attribs }; 159 | } 160 | 161 | return sanitizeHtml(html, { 162 | allowedIframeDomains: ["youtu.be", "www.youtube.com", "ogp.deno.dev"], 163 | transformTags: { 164 | img: transformMedia, 165 | video: transformMedia, 166 | }, 167 | allowedTags, 168 | allowedAttributes: { 169 | ...sanitizeHtml.defaults.allowedAttributes, 170 | img: ["src", "alt", "height", "width", "align"], 171 | video: [ 172 | "src", 173 | "alt", 174 | "height", 175 | "width", 176 | "autoplay", 177 | "muted", 178 | "loop", 179 | "playsinline", 180 | ], 181 | a: ["id", "aria-hidden", "href", "tabindex", "rel", "target"], 182 | svg: ["viewbox", "width", "height", "aria-hidden", "background"], 183 | path: ["fill-rule", "d"], 184 | circle: ["cx", "cy", "r", "stroke", "stroke-width", "fill", "alpha"], 185 | span: opts.allowMath ? ["aria-hidden", "style"] : [], 186 | h1: ["id"], 187 | h2: ["id"], 188 | h3: ["id"], 189 | h4: ["id"], 190 | h5: ["id"], 191 | h6: ["id"], 192 | // 193 | iframe: ["src", "width", "height", "style"], // Only used when iframe tags are allowed in the first place. 194 | math: ["xmlns"], // Only enabled when math is enabled 195 | annotation: ["encoding"], // Only enabled when math is enabled 196 | input: ["type", "checked", "disabled"], 197 | code: ["class"], 198 | }, 199 | allowedClasses: { 200 | div: ["highlight", "highlight-source-*", "notranslate"], 201 | span: [ 202 | "token", 203 | "keyword", 204 | "operator", 205 | "number", 206 | "boolean", 207 | "function", 208 | "string", 209 | "comment", 210 | "class-name", 211 | "regex", 212 | "regex-delimiter", 213 | "tag", 214 | "attr-name", 215 | "punctuation", 216 | "script-punctuation", 217 | "script", 218 | "plain-text", 219 | "property", 220 | "prefix", 221 | "line", 222 | "deleted", 223 | "inserted", 224 | ], 225 | a: ["anchor"], 226 | svg: ["octicon", "octicon-link"], 227 | }, 228 | allowProtocolRelative: false, 229 | }); 230 | } 231 | 232 | function youtube(url: string) { 233 | let id: string | null = null; 234 | if (url.startsWith("https://www.youtube.com/watch")) { 235 | id = new URL(url).searchParams.get("v"); 236 | } else if (url.startsWith("https://youtu.be/")) { 237 | id = new URL(url).pathname.substring(1); 238 | } 239 | if (id) { 240 | const embedUrl = new URL("https://www.youtube.com"); 241 | embedUrl.pathname = `/embed/${id}`; 242 | const src = embedUrl.toString(); 243 | return ``; 244 | } 245 | return null; 246 | } 247 | -------------------------------------------------------------------------------- /server/query_builder.ts: -------------------------------------------------------------------------------- 1 | export class QueryBuilder { 2 | #index = 1; 3 | #query = ""; 4 | readonly #args: unknown[] = []; 5 | 6 | get query() { 7 | return this.#query; 8 | } 9 | 10 | get args() { 11 | return this.#args; 12 | } 13 | 14 | append(query: TemplateStringsArray | string, ...args: unknown[]) { 15 | if (this.#query !== "") { 16 | this.#query += " "; 17 | } 18 | if (typeof query === "string") { 19 | // query: string 20 | this.#query += query; 21 | return this; 22 | } 23 | // query: TemplateStringsArray 24 | query.forEach((value, index) => { 25 | this.#query += value + 26 | ((index < query.length - 1) ? "$" + (this.#index++) : ""); 27 | }); 28 | this.#args.push(...args); 29 | return this; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /server/query_builder_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "$std/testing/asserts.ts"; 2 | import { QueryBuilder } from "~/server/query_builder.ts"; 3 | 4 | const LIMIT = 10; 5 | 6 | function build({ deptId, age }: { deptId: number; age?: number }) { 7 | const builder = new QueryBuilder() 8 | .append`SELECT * FROM emp WHERE dept_id = ${deptId}`; // (A) 9 | if (age) { 10 | builder.append`AND age > ${age}`; // (B) 11 | } 12 | builder.append(`ORDER BY DESC salary LIMIT ${LIMIT}`); // (C) 13 | return builder; 14 | } 15 | 16 | Deno.test("with age", () => { 17 | const builder = build({ deptId: 1, age: 50 }); 18 | assertEquals( 19 | builder.query, 20 | "SELECT * FROM emp WHERE dept_id = $1 AND age > $2 ORDER BY DESC salary LIMIT 10", 21 | ); 22 | assertEquals(builder.args, [1, 50]); 23 | }); 24 | 25 | Deno.test("without age", () => { 26 | const builder = build({ deptId: 1 }); 27 | assertEquals( 28 | builder.query, 29 | "SELECT * FROM emp WHERE dept_id = $1 ORDER BY DESC salary LIMIT 10", 30 | ); 31 | assertEquals(builder.args, [1]); 32 | }); 33 | -------------------------------------------------------------------------------- /server/trpc/context.ts: -------------------------------------------------------------------------------- 1 | import { inferAsyncReturnType } from "@trpc/server"; 2 | import { initTRPC } from "@trpc/server"; 3 | import { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch"; 4 | 5 | export function createContext({ 6 | req, 7 | resHeaders, 8 | }: FetchCreateContextFnOptions) { 9 | const user = { name: req.headers.get("username") ?? "anonymous" }; 10 | return { req, resHeaders, user }; 11 | } 12 | 13 | export type Context = inferAsyncReturnType; 14 | const trpc = initTRPC.context().create(); 15 | export const publicProcedure = trpc.procedure; 16 | export const router = trpc.router; 17 | -------------------------------------------------------------------------------- /server/trpc/procedures/cancelLike.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { deleteLike } from "~/server/db.ts"; 3 | import { getSession } from "~/server/auth.ts"; 4 | import { publicProcedure } from "~/server/trpc/context.ts"; 5 | 6 | export const cancelLike = publicProcedure.input( 7 | z.object({ postId: z.number() }), 8 | ).mutation(async ({ input, ctx }) => { 9 | const session = await getSession(ctx.req); 10 | if (!session) { 11 | return null; // TODO 12 | } 13 | await deleteLike({ 14 | userId: session.user.id, 15 | postId: input.postId, 16 | }); 17 | return {}; 18 | }); 19 | -------------------------------------------------------------------------------- /server/trpc/procedures/createComment.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { insertComment } from "~/server/db.ts"; 3 | import { getSession } from "~/server/auth.ts"; 4 | import { publicProcedure } from "~/server/trpc/context.ts"; 5 | 6 | export type RequestType = { postId: number; source: string }; 7 | 8 | export const createComment = publicProcedure.input( 9 | z.object({ postId: z.number(), source: z.string() }), 10 | ).mutation(async ({ input, ctx }) => { 11 | const session = await getSession(ctx.req); 12 | if (!session) { 13 | return; 14 | } 15 | if (input.source.length > 5000) { 16 | return; 17 | } 18 | await insertComment({ 19 | postId: input.postId, 20 | userId: session.user.id, 21 | source: input.source, 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /server/trpc/procedures/createFollow.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { publicProcedure } from "~/server/trpc/context.ts"; 3 | import { insertFollow } from "~/server/db.ts"; 4 | import { getSession } from "~/server/auth.ts"; 5 | 6 | export const createFollow = publicProcedure.input( 7 | z.object({ followingUserId: z.number() }), 8 | ).mutation(async ({ input, ctx }) => { 9 | const session = await getSession(ctx.req); 10 | if (!session) { 11 | return null; 12 | } 13 | await insertFollow({ 14 | userId: session.user.id, 15 | followingUserId: input.followingUserId, 16 | }); 17 | return {}; 18 | }); 19 | -------------------------------------------------------------------------------- /server/trpc/procedures/createLike.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { publicProcedure } from "~/server/trpc/context.ts"; 3 | import { insertLike } from "~/server/db.ts"; 4 | import { getSession } from "~/server/auth.ts"; 5 | 6 | export const createLike = publicProcedure.input( 7 | z.object({ postId: z.number() }), 8 | ).mutation(async ({ input, ctx }) => { 9 | const session = await getSession(ctx.req); 10 | if (!session) { 11 | return null; // TODO 12 | } 13 | await insertLike({ 14 | userId: session.user.id, 15 | postId: input.postId, 16 | }); 17 | return {}; 18 | }); 19 | -------------------------------------------------------------------------------- /server/trpc/procedures/createPost.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { publicProcedure } from "~/server/trpc/context.ts"; 3 | import { insertPost } from "~/server/db.ts"; 4 | import { getSession } from "~/server/auth.ts"; 5 | 6 | export const createPost = publicProcedure.input( 7 | z.object({ source: z.string().max(10000), draft: z.boolean() }), 8 | ).mutation(async ({ input, ctx }) => { 9 | const session = await getSession(ctx.req); 10 | if (!session) { 11 | return null; 12 | } 13 | const userId = session.user.id; 14 | const postId = await insertPost({ 15 | userId, 16 | source: input.source, 17 | draft: input.draft, 18 | }); 19 | return { postId }; 20 | }); 21 | -------------------------------------------------------------------------------- /server/trpc/procedures/deleteComment.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import * as db from "~/server/db.ts"; 3 | import { getSession } from "~/server/auth.ts"; 4 | import { publicProcedure } from "~/server/trpc/context.ts"; 5 | 6 | export const deleteComment = publicProcedure.input( 7 | z.object({ commentId: z.number() }), 8 | ).mutation(async ({ input, ctx }) => { 9 | const session = await getSession(ctx.req); 10 | if (!session) { 11 | return null; // TODO 12 | } 13 | await db.deleteComment({ 14 | id: input.commentId, 15 | userId: session.user.id, 16 | }); 17 | return {}; 18 | }); 19 | -------------------------------------------------------------------------------- /server/trpc/procedures/deleteFollow.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import * as db from "~/server/db.ts"; 3 | import { getSession } from "~/server/auth.ts"; 4 | import { publicProcedure } from "~/server/trpc/context.ts"; 5 | 6 | export const deleteFollow = publicProcedure.input( 7 | z.object({ followingUserId: z.number() }), 8 | ).mutation(async ({ input, ctx }) => { 9 | const session = await getSession(ctx.req); 10 | if (!session) { 11 | return null; // TODO 12 | } 13 | await db.deleteFollow({ 14 | userId: session.user.id, 15 | followingUserId: input.followingUserId, 16 | }); 17 | return {}; 18 | }); 19 | -------------------------------------------------------------------------------- /server/trpc/procedures/deletePost.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { publicProcedure } from "~/server/trpc/context.ts"; 3 | import * as db from "~/server/db.ts"; 4 | 5 | import { getSession } from "~/server/auth.ts"; 6 | 7 | export const deletePost = publicProcedure.input( 8 | z.object({ postId: z.number() }), 9 | ).mutation(async ({ input, ctx }) => { 10 | const session = await getSession(ctx.req); 11 | if (!session) { 12 | return null; 13 | } 14 | await db.deletePost({ id: input.postId, userId: session.user.id }); 15 | return { postId: input.postId }; 16 | }); 17 | -------------------------------------------------------------------------------- /server/trpc/procedures/deleteUser.ts: -------------------------------------------------------------------------------- 1 | import { publicProcedure } from "~/server/trpc/context.ts"; 2 | import * as db from "~/server/db.ts"; 3 | 4 | import { getSession } from "~/server/auth.ts"; 5 | 6 | export const deleteUser = publicProcedure.mutation(async ({ ctx }) => { 7 | const session = await getSession(ctx.req); 8 | if (!session) { 9 | return null; 10 | } 11 | await db.deleteUser(session.user.id); 12 | await db.deleteSession(session); 13 | return { userId: session.user.id }; 14 | }); 15 | -------------------------------------------------------------------------------- /server/trpc/procedures/getComments.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { selectComments } from "~/server/db.ts"; 3 | import { publicProcedure } from "~/server/trpc/context.ts"; 4 | import { render } from "~/server/markdown.ts"; 5 | 6 | export const getComments = publicProcedure.input( 7 | z.object({ postId: z.number() }), 8 | ).query(async ({ input }) => { 9 | const postId = input.postId; 10 | const rows = await selectComments(postId); 11 | return rows.map((row) => { 12 | return { ...row, source: render(row.source) }; 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /server/trpc/procedures/getFollowInfo.ts: -------------------------------------------------------------------------------- 1 | import { 2 | judgeFollowing, 3 | selectCountFollower, 4 | selectCountFollowing, 5 | } from "~/server/db.ts"; 6 | import { getSession } from "~/server/auth.ts"; 7 | import { publicProcedure } from "~/server/trpc/context.ts"; 8 | import { z } from "zod"; 9 | 10 | export const getFollowInfo = publicProcedure.input( 11 | z.object({ userId: z.number() }), 12 | ).query(async ({ input, ctx }) => { 13 | const following = await selectCountFollowing(input.userId); 14 | const followers = await selectCountFollower(input.userId); 15 | const session = await getSession(ctx.req); 16 | let isFollowing = false; 17 | if (session) { 18 | isFollowing = await judgeFollowing({ 19 | userId: session.user.id, 20 | followingUserId: input.userId, 21 | }); 22 | } 23 | return { following, followers, isFollowing }; 24 | }); 25 | -------------------------------------------------------------------------------- /server/trpc/procedures/getFollowerUsers.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { selectFollowerUsers } from "~/server/db.ts"; 3 | import { defaultString } from "~/common/utils.ts"; 4 | import { publicProcedure } from "~/server/trpc/context.ts"; 5 | 6 | export type User = { id: number; name: string; picture: string }; 7 | 8 | export const getFollowerUsers = publicProcedure.input( 9 | z.object({ userId: z.number() }), 10 | ).query(async ({ input }) => { 11 | return (await selectFollowerUsers(input.userId)).map( 12 | (row) => { 13 | return { 14 | id: row.id, 15 | name: defaultString(row.name), 16 | picture: defaultString(row.picture), 17 | } as User; 18 | }, 19 | ); 20 | }); 21 | -------------------------------------------------------------------------------- /server/trpc/procedures/getFollowingUsers.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { selectFollowingUsers } from "~/server/db.ts"; 3 | import { defaultString } from "~/common/utils.ts"; 4 | import { publicProcedure } from "~/server/trpc/context.ts"; 5 | 6 | export type User = { id: number; name: string; picture: string }; 7 | 8 | export const getFollowingUsers = publicProcedure.input( 9 | z.object({ userId: z.number() }), 10 | ).query(async ({ input }) => { 11 | return (await selectFollowingUsers(input.userId)).map( 12 | (row) => { 13 | return { 14 | id: row.id, 15 | name: defaultString(row.name), 16 | picture: defaultString(row.picture), 17 | }; 18 | }, 19 | ); 20 | }); 21 | -------------------------------------------------------------------------------- /server/trpc/procedures/getLikeUsers.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { selectLikeUsers } from "~/server/db.ts"; 3 | import { defaultString } from "~/common/utils.ts"; 4 | import { publicProcedure } from "~/server/trpc/context.ts"; 5 | 6 | export type User = { id: number; name: string; picture: string }; 7 | 8 | export const getLikeUsers = publicProcedure.input( 9 | z.object({ postId: z.number() }), 10 | ).query(async ({ input }) => { 11 | return (await selectLikeUsers(input.postId)).map( 12 | (appUser) => { 13 | return { 14 | id: appUser.id, 15 | name: defaultString(appUser.name), 16 | picture: defaultString(appUser.picture), 17 | } as User; 18 | }, 19 | ); 20 | }); 21 | -------------------------------------------------------------------------------- /server/trpc/procedures/getLikedPosts.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { getSession } from "~/server/auth.ts"; 3 | import { selectLikedPosts } from "~/server/db.ts"; 4 | import { publicProcedure } from "~/server/trpc/context.ts"; 5 | import { render } from "~/server/markdown.ts"; 6 | 7 | export const getLikedPosts = publicProcedure.input( 8 | z.object({ 9 | postId: z.number().nullable(), 10 | }), 11 | ).query(async ({ input, ctx }) => { 12 | const session = await getSession(ctx.req); 13 | if (!session) { 14 | return []; 15 | } 16 | const user = session.user; 17 | 18 | const posts = (await selectLikedPosts({ 19 | userId: user.id, 20 | ltId: input.postId, 21 | })).map((post) => { 22 | return { ...post, source: render(post.source) }; 23 | }); 24 | 25 | return posts.map((p) => { 26 | return { 27 | id: p.id, 28 | user_id: p.user_id, 29 | source: p.source, 30 | updated_at: p.updated_at, 31 | created_at: p.created_at, 32 | comments: p.comments, 33 | name: p.name, 34 | picture: p.picture, 35 | likes: p.likes, 36 | liked: true, 37 | draft: p.draft, 38 | account: p.account, 39 | }; 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /server/trpc/procedures/getPosts.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { inferProcedureOutput } from "@trpc/server"; 3 | import { 4 | PostViewType, 5 | selectFollowingUsersPosts, 6 | selectLikes, 7 | selectPosts, 8 | selectPostsBySearchWord, 9 | selectUserPost, 10 | } from "~/server/db.ts"; 11 | import { publicProcedure } from "~/server/trpc/context.ts"; 12 | import { getSession } from "~/server/auth.ts"; 13 | import { render } from "~/server/markdown.ts"; 14 | 15 | export const getPosts = publicProcedure.input( 16 | z.object({ 17 | postId: z.number().nullable(), 18 | userId: z.number().optional(), 19 | following: z.boolean().optional(), 20 | searchWord: z.string().optional(), 21 | }), 22 | ).query(async ({ input, ctx }) => { 23 | const session = await getSession(ctx.req); 24 | 25 | let posts: PostViewType[] = []; 26 | if (input.userId) { 27 | // specified user only 28 | posts = await selectUserPost({ 29 | userId: input.userId, 30 | self: input.userId === session?.user.id, 31 | ltId: input.postId, 32 | }); 33 | } else if (input.following && session) { 34 | // following user only 35 | const userId = session.user.id; 36 | posts = await selectFollowingUsersPosts({ userId, ltId: input.postId }); 37 | } else if (input.searchWord) { 38 | posts = await selectPostsBySearchWord({ 39 | searchWord: input.searchWord, 40 | postId: input.postId, 41 | loginUserId: session ? session.user.id : null, 42 | }); 43 | } else { 44 | // all user 45 | posts = await selectPosts(input.postId); 46 | } 47 | 48 | const likedPostIds = session 49 | ? await selectLikes({ 50 | userId: session.user.id, 51 | postIds: posts.map((post) => post.id), 52 | }) 53 | : []; 54 | 55 | return posts.map((p) => { 56 | return { 57 | id: p.id, 58 | user_id: p.user_id, 59 | source: render(p.source), 60 | updated_at: p.updated_at, 61 | created_at: p.created_at, 62 | comments: p.comments, 63 | name: p.name, 64 | picture: p.picture, 65 | likes: p.likes, 66 | liked: likedPostIds.includes(p.id), 67 | draft: p.draft, 68 | account: p.account, 69 | }; 70 | }); 71 | }); 72 | 73 | export type GetPostsOutput = (inferProcedureOutput)[number]; 74 | -------------------------------------------------------------------------------- /server/trpc/procedures/getSession.ts: -------------------------------------------------------------------------------- 1 | import * as auth from "~/server/auth.ts"; 2 | import { publicProcedure } from "~/server/trpc/context.ts"; 3 | 4 | export const getSession = publicProcedure.query(({ ctx }) => { 5 | return auth.getSession(ctx.req); 6 | }); 7 | -------------------------------------------------------------------------------- /server/trpc/procedures/isLiked.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { getSession } from "~/server/auth.ts"; 3 | import { selectLikes } from "~/server/db.ts"; 4 | import { publicProcedure } from "~/server/trpc/context.ts"; 5 | 6 | export const isLiked = publicProcedure.input( 7 | z.object({ postId: z.number() }), 8 | ).query(async ({ input, ctx }) => { 9 | const session = await getSession(ctx.req); 10 | if (!session) { 11 | return null; 12 | } 13 | const results = await selectLikes({ 14 | userId: session.user.id, 15 | postIds: [input.postId], 16 | }); // TODO: to one postId 17 | return results.length === 1; 18 | }); 19 | -------------------------------------------------------------------------------- /server/trpc/procedures/md2html.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { publicProcedure } from "~/server/trpc/context.ts"; 3 | import { render } from "~/server/markdown.ts"; 4 | 5 | export const md2html = publicProcedure.input( 6 | z.object({ source: z.string() }), 7 | ).query(({ input }) => { 8 | return render(input.source, {}); 9 | }); 10 | -------------------------------------------------------------------------------- /server/trpc/procedures/updatePost.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { publicProcedure } from "~/server/trpc/context.ts"; 3 | import * as db from "~/server/db.ts"; 4 | import { getSession } from "~/server/auth.ts"; 5 | 6 | export const updatePost = publicProcedure.input( 7 | z.object({ 8 | postId: z.number(), 9 | source: z.string().max(10000), 10 | draft: z.boolean(), 11 | }), 12 | ).mutation(async ({ input, ctx }) => { 13 | const session = await getSession(ctx.req); 14 | if (!session) { 15 | return null; // TODO 16 | } 17 | await db.updatePost({ 18 | postId: input.postId, 19 | userId: session.user.id, 20 | source: input.source, 21 | draft: input.draft, 22 | }); 23 | return {}; 24 | }); 25 | -------------------------------------------------------------------------------- /server/trpc/router.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { publicProcedure, router } from "./context.ts"; 3 | import { getComments } from "~/server/trpc/procedures/getComments.ts"; 4 | import { createComment } from "~/server/trpc/procedures/createComment.ts"; 5 | import { getPosts } from "~/server/trpc/procedures/getPosts.ts"; 6 | import { getFollowInfo } from "~/server/trpc/procedures/getFollowInfo.ts"; 7 | import { getFollowerUsers } from "~/server/trpc/procedures/getFollowerUsers.ts"; 8 | import { getLikeUsers } from "~/server/trpc/procedures/getLikeUsers.ts"; 9 | import { isLiked } from "~/server/trpc/procedures/isLiked.ts"; 10 | import { getLikedPosts } from "~/server/trpc/procedures/getLikedPosts.ts"; 11 | import { getSession } from "~/server/trpc/procedures/getSession.ts"; 12 | import { createLike } from "~/server/trpc/procedures/createLike.ts"; 13 | import { createFollow } from "~/server/trpc/procedures/createFollow.ts"; 14 | import { createPost } from "~/server/trpc/procedures/createPost.ts"; 15 | import { cancelLike } from "~/server/trpc/procedures/cancelLike.ts"; 16 | import { deleteComment } from "~/server/trpc/procedures/deleteComment.ts"; 17 | import { deletePost } from "~/server/trpc/procedures/deletePost.ts"; 18 | import { deleteFollow } from "~/server/trpc/procedures/deleteFollow.ts"; 19 | import { updatePost } from "~/server/trpc/procedures/updatePost.ts"; 20 | import { md2html } from "~/server/trpc/procedures/md2html.ts"; 21 | import { getFollowingUsers } from "~/server/trpc/procedures/getFollowingUsers.ts"; 22 | import { deleteUser } from "~/server/trpc/procedures/deleteUser.ts"; 23 | 24 | const posts = [{ name: "first post" }]; 25 | 26 | export const appRouter = router({ 27 | createComment, 28 | createFollow, 29 | createLike, 30 | createPost, 31 | cancelLike, 32 | deleteComment, 33 | deletePost, 34 | deleteUser, 35 | deleteFollow, 36 | getComments, 37 | getPosts, 38 | getFollowInfo, 39 | getFollowerUsers, 40 | getFollowingUsers, 41 | getLikeUsers, 42 | getSession, 43 | isLiked, 44 | getLikedPosts, 45 | updatePost, 46 | md2html, 47 | postGet: publicProcedure.query(() => posts), 48 | postCreate: publicProcedure.input(z.object({ 49 | name: z.string(), 50 | })).mutation((req) => { 51 | posts.push(req.input); 52 | return req.input; 53 | }), 54 | }); 55 | 56 | export type AppRouter = typeof appRouter; 57 | -------------------------------------------------------------------------------- /static/app.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --nc-bg-2: #F6F8FA; 3 | --nc-bg-3: #E5E7EB; 4 | } 5 | 6 | pre { 7 | color: #ccc; 8 | background-color: #2d2d2d; 9 | } 10 | 11 | pre code { 12 | display: block; 13 | overflow-x: auto; 14 | padding: 1em; 15 | } 16 | 17 | code { 18 | color: black; 19 | background-color: rgba(0,0,0,.08); 20 | border-radius: 4px; 21 | padding: 1px 4px; 22 | } 23 | 24 | a { 25 | color: blue; 26 | text-decoration: none; 27 | } 28 | a:hover { 29 | /* text-decoration: underline; */ 30 | } 31 | 32 | a.noDecoration { 33 | color: black; 34 | text-decoration: none; 35 | } 36 | 37 | a.noDecoration:hover { 38 | color: black; 39 | /* text-decoration: underline; */ 40 | } 41 | 42 | a.doc { 43 | color: blue; 44 | text-decoration: none; 45 | } 46 | 47 | body { 48 | --bs-bg-opacity: 1; 49 | background-color: rgba(var(--bs-light-rgb), var(--bs-bg-opacity)) !important; 50 | font-family: "YakuHanJPs","-apple-system","BlinkMacSystemFont","Segoe UI","Hiragino Sans","Hiragino Kaku Gothic ProN","Meiryo",sans-serif; 51 | } 52 | 53 | img { 54 | max-width: 100%; 55 | height: auto; 56 | } 57 | 58 | iframe { 59 | max-width: 100%; 60 | } 61 | 62 | .post table { 63 | /* border-collapse sets the table's elements to share borders, rather than floating as separate "boxes". */ 64 | border-collapse: collapse; 65 | width: 100% 66 | } 67 | 68 | .post td,th { 69 | border: 1px solid var(--nc-bg-3); 70 | text-align: left; 71 | padding: .5rem; 72 | } 73 | 74 | .post th { 75 | background: var(--nc-bg-2); 76 | } 77 | 78 | .post tr:nth-child(even) { 79 | /* Set every other cell slightly darker. Improves readability. */ 80 | background: var(--nc-bg-2); 81 | } 82 | 83 | .post table caption { 84 | font-weight: bold; 85 | margin-bottom: .5rem; 86 | } 87 | 88 | .post blockquote { 89 | padding: 1.5rem; 90 | background: var(--nc-bg-2); 91 | border-left: 5px solid var(--nc-bg-3); 92 | } 93 | 94 | .post blockquote *:last-child { 95 | padding-bottom: 0; 96 | margin-bottom: 0; 97 | } 98 | 99 | .post h1, h2 { 100 | margin-bottom: 16px; 101 | } 102 | 103 | -------------------------------------------------------------------------------- /static/assets/img/bell.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chibat/leaves/5d7ba0692c0b1c49dfd278a314742c072c992fce/static/assets/img/bell.png -------------------------------------------------------------------------------- /static/assets/img/bell2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chibat/leaves/5d7ba0692c0b1c49dfd278a314742c072c992fce/static/assets/img/bell2.png -------------------------------------------------------------------------------- /static/assets/img/bluesky.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/assets/img/btn_google_signin_dark_pressed_web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chibat/leaves/5d7ba0692c0b1c49dfd278a314742c072c992fce/static/assets/img/btn_google_signin_dark_pressed_web.png -------------------------------------------------------------------------------- /static/assets/img/gear-fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /static/assets/img/heart-fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /static/assets/img/heart-fill.svgZone.Identifier: -------------------------------------------------------------------------------- 1 | [ZoneTransfer] 2 | ZoneId=3 3 | ReferrerUrl=https://icons.getbootstrap.com/icons/heart-fill/ 4 | HostUrl=https://icons.getbootstrap.com/assets/icons/heart-fill.svg 5 | -------------------------------------------------------------------------------- /static/assets/img/heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /static/assets/img/heart.svgZone.Identifier: -------------------------------------------------------------------------------- 1 | [ZoneTransfer] 2 | ZoneId=3 3 | ReferrerUrl=https://icons.getbootstrap.com/icons/heart/ 4 | HostUrl=https://icons.getbootstrap.com/assets/icons/heart.svg 5 | -------------------------------------------------------------------------------- /static/assets/img/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chibat/leaves/5d7ba0692c0b1c49dfd278a314742c072c992fce/static/assets/img/icon-192x192.png -------------------------------------------------------------------------------- /static/assets/img/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chibat/leaves/5d7ba0692c0b1c49dfd278a314742c072c992fce/static/assets/img/icon-512x512.png -------------------------------------------------------------------------------- /static/assets/img/keyboard-fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /static/assets/img/notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chibat/leaves/5d7ba0692c0b1c49dfd278a314742c072c992fce/static/assets/img/notification.png -------------------------------------------------------------------------------- /static/assets/img/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chibat/leaves/5d7ba0692c0b1c49dfd278a314742c072c992fce/static/assets/img/og.png -------------------------------------------------------------------------------- /static/assets/img/pencil-fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /static/assets/img/question-circle-fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /static/assets/img/trash-fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /static/assets/img/twitter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /static/btn_google_signin_dark_pressed_web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chibat/leaves/5d7ba0692c0b1c49dfd278a314742c072c992fce/static/btn_google_signin_dark_pressed_web.png -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chibat/leaves/5d7ba0692c0b1c49dfd278a314742c072c992fce/static/favicon.ico -------------------------------------------------------------------------------- /static/google02f544219e20b91a.html: -------------------------------------------------------------------------------- 1 | google-site-verification: google02f544219e20b91a.html -------------------------------------------------------------------------------- /static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Leaves", 3 | "short_name": "Leaves", 4 | "theme_color": "#1fbc92", 5 | "background_color": "#999999", 6 | "display": "standalone", 7 | "scope": "/", 8 | "start_url": "/", 9 | "icons": [ 10 | { 11 | "src": "/assets/img/icon-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/assets/img/icon-192x192.png", 17 | "sizes": "192x192", 18 | "type": "image/png" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /static/register_sw.js: -------------------------------------------------------------------------------- 1 | if ("serviceWorker" in navigator) { 2 | navigator.serviceWorker.register("/sw.js"); 3 | } 4 | -------------------------------------------------------------------------------- /static/sw.js: -------------------------------------------------------------------------------- 1 | self.addEventListener("fetch", function (_event) {}); 2 | --------------------------------------------------------------------------------