├── .githooks └── pre-commit ├── public ├── favicon.ico ├── img │ └── hatenabookmark │ │ ├── favicon.ico │ │ ├── favicon.icns │ │ ├── favicon-144.png │ │ ├── favicon-192.png │ │ ├── favicon-32.png │ │ ├── favicon-36.png │ │ ├── favicon-48.png │ │ ├── favicon-57.png │ │ ├── favicon-60.png │ │ ├── favicon-72.png │ │ ├── favicon-76.png │ │ ├── favicon-96.png │ │ ├── favicon-72-precomposed.png │ │ ├── favicon-114-precomposed.png │ │ ├── favicon-120-precomposed.png │ │ ├── favicon-152-precomposed.png │ │ ├── favicon-180-precomposed.png │ │ ├── hatenabookmark-logomark.png │ │ ├── manifest.json │ │ └── favicon.svg ├── _redirects └── manifest.json ├── src ├── container │ ├── App.css │ ├── SearchContainer │ │ ├── SearchContainer.css │ │ ├── SearchContainer.tsx │ │ └── SearchContainerStore.ts │ ├── UserFormContainer │ │ ├── UserFormContainer.css │ │ ├── UserFormContainerStore.ts │ │ └── UserFormContainer.tsx │ ├── README.md │ ├── AppStore.ts │ └── App.tsx ├── component │ ├── README.md │ ├── UserForm │ │ ├── UserForm.css │ │ └── UserForm.tsx │ ├── FocusMatcher │ │ └── FocusMatcher.tsx │ ├── HatebuSearchList │ │ ├── HatebuSearchList.css │ │ └── HatebuSearchList.tsx │ └── PageVisibility │ │ └── PageVisibility.tsx ├── infra │ ├── browser │ │ └── browserHistory.ts │ ├── API │ │ └── HatenaBookmarkFetcher.ts │ └── repository │ │ ├── HatebuRepository.ts │ │ ├── StorageManger.ts │ │ └── AppSessionRepository.ts ├── index.css ├── domain │ ├── AppSession │ │ ├── AppSessionFactory.ts │ │ └── AppSession.ts │ └── Hatebu │ │ ├── HatebuFactory.ts │ │ ├── BookmarkItemFactory.ts │ │ ├── BookmarkDate.ts │ │ ├── BookmarkItem.ts │ │ ├── BookmarkSearch.ts │ │ ├── __tests__ │ │ └── BookmarkSearch.test.ts │ │ ├── Hatebu.ts │ │ └── Bookmark.ts ├── use-case │ ├── hatebu-api │ │ ├── FetchHatenaBookmarkPayload.ts │ │ ├── InitializeWithNewHatenaBookmarkUseCase.ts │ │ └── RefreshHatenaBookmarkUseCase.ts │ ├── InitializeSystemUseCase.ts │ ├── CreateHatebuUserUseCase.ts │ ├── RestoreLastSessionUseCase.ts │ └── SwitchCurrentHatebuUserUseCase.ts ├── index.tsx ├── setupProxy.js ├── Context.ts └── react-app-env.d.ts ├── tsconfig.test.json ├── server.js ├── workbox-config.js ├── workers └── filter.ts ├── tools └── package-app.js ├── .github ├── release.yml └── workflows │ └── test.yml ├── tsconfig.json ├── LICENSE ├── README.md ├── index.html ├── .gitignore ├── package.json └── vite.config.ts /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | npx --no-install lint-staged 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/hatebupwa/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/container/App.css: -------------------------------------------------------------------------------- 1 | .App-title { 2 | text-align: center; 3 | font-size: 1.5em; 4 | } 5 | -------------------------------------------------------------------------------- /public/img/hatenabookmark/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/hatebupwa/HEAD/public/img/hatenabookmark/favicon.ico -------------------------------------------------------------------------------- /public/img/hatenabookmark/favicon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/hatebupwa/HEAD/public/img/hatenabookmark/favicon.icns -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } -------------------------------------------------------------------------------- /public/img/hatenabookmark/favicon-144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/hatebupwa/HEAD/public/img/hatenabookmark/favicon-144.png -------------------------------------------------------------------------------- /public/img/hatenabookmark/favicon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/hatebupwa/HEAD/public/img/hatenabookmark/favicon-192.png -------------------------------------------------------------------------------- /public/img/hatenabookmark/favicon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/hatebupwa/HEAD/public/img/hatenabookmark/favicon-32.png -------------------------------------------------------------------------------- /public/img/hatenabookmark/favicon-36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/hatebupwa/HEAD/public/img/hatenabookmark/favicon-36.png -------------------------------------------------------------------------------- /public/img/hatenabookmark/favicon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/hatebupwa/HEAD/public/img/hatenabookmark/favicon-48.png -------------------------------------------------------------------------------- /public/img/hatenabookmark/favicon-57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/hatebupwa/HEAD/public/img/hatenabookmark/favicon-57.png -------------------------------------------------------------------------------- /public/img/hatenabookmark/favicon-60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/hatebupwa/HEAD/public/img/hatenabookmark/favicon-60.png -------------------------------------------------------------------------------- /public/img/hatenabookmark/favicon-72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/hatebupwa/HEAD/public/img/hatenabookmark/favicon-72.png -------------------------------------------------------------------------------- /public/img/hatenabookmark/favicon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/hatebupwa/HEAD/public/img/hatenabookmark/favicon-76.png -------------------------------------------------------------------------------- /public/img/hatenabookmark/favicon-96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/hatebupwa/HEAD/public/img/hatenabookmark/favicon-96.png -------------------------------------------------------------------------------- /src/component/README.md: -------------------------------------------------------------------------------- 1 | # Component 2 | 3 | - It has a single feature 4 | - It should define behavior 5 | - It has not layout style 6 | -------------------------------------------------------------------------------- /public/img/hatenabookmark/favicon-72-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/hatebupwa/HEAD/public/img/hatenabookmark/favicon-72-precomposed.png -------------------------------------------------------------------------------- /src/container/SearchContainer/SearchContainer.css: -------------------------------------------------------------------------------- 1 | .SearchContainer { 2 | margin: auto; 3 | padding: 0 1rem; 4 | max-width: 1000px; 5 | } 6 | -------------------------------------------------------------------------------- /src/infra/browser/browserHistory.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from "history"; 2 | 3 | export const browserHistory = createBrowserHistory(); 4 | -------------------------------------------------------------------------------- /public/img/hatenabookmark/favicon-114-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/hatebupwa/HEAD/public/img/hatenabookmark/favicon-114-precomposed.png -------------------------------------------------------------------------------- /public/img/hatenabookmark/favicon-120-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/hatebupwa/HEAD/public/img/hatenabookmark/favicon-120-precomposed.png -------------------------------------------------------------------------------- /public/img/hatenabookmark/favicon-152-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/hatebupwa/HEAD/public/img/hatenabookmark/favicon-152-precomposed.png -------------------------------------------------------------------------------- /public/img/hatenabookmark/favicon-180-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/hatebupwa/HEAD/public/img/hatenabookmark/favicon-180-precomposed.png -------------------------------------------------------------------------------- /public/img/hatenabookmark/hatenabookmark-logomark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/hatebupwa/HEAD/public/img/hatenabookmark/hatenabookmark-logomark.png -------------------------------------------------------------------------------- /src/container/UserFormContainer/UserFormContainer.css: -------------------------------------------------------------------------------- 1 | .UserFormContainer { 2 | max-width: 1000px; 3 | margin: auto; 4 | padding: 0 1rem; 5 | } 6 | -------------------------------------------------------------------------------- /src/container/README.md: -------------------------------------------------------------------------------- 1 | # Container 2 | 3 | - It includes multiple [components](../component/README.md) 4 | - It defines behavior 5 | - It has layout style 6 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | 7 | @media (max-width: 1200px) { 8 | .github-ribbon { 9 | display: none; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | # Routing 2 | /user/:name /index.html 200 3 | /home/ /index.html 200 4 | # CORS proxy 5 | # https://www.netlify.app/docs/redirects/#proxying 6 | /hatebu/* https://b.hatena.ne.jp/:splat 200 7 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const path = require("path"); 3 | const app = express(); 4 | 5 | app.use(express.static(path.join(__dirname, "build"))); 6 | 7 | app.get("/user/*", function (req, res) { 8 | res.sendFile(path.join(__dirname, "build", "index.html")); 9 | }); 10 | app.listen(9000); 11 | -------------------------------------------------------------------------------- /src/domain/AppSession/AppSessionFactory.ts: -------------------------------------------------------------------------------- 1 | import { AppSession, AppSessionIdentifier } from "./AppSession.js"; 2 | import { ulid } from "ulid"; 3 | 4 | export const createAppSession = () => { 5 | return new AppSession({ 6 | id: new AppSessionIdentifier(ulid()), 7 | hatebuId: undefined 8 | }); 9 | }; 10 | -------------------------------------------------------------------------------- /src/use-case/hatebu-api/FetchHatenaBookmarkPayload.ts: -------------------------------------------------------------------------------- 1 | import { Payload } from "almin"; 2 | 3 | export class StartFetchHatenaBookmarkPayload implements Payload { 4 | type = "StartFetchHatenaBookmarkPayload"; 5 | } 6 | 7 | export class FinishFetchHatenaBookmarkPayload implements Payload { 8 | type = "FinishFetchHatenaBookmarkPayload"; 9 | } 10 | 11 | export class FailToFetchHatenaBookmarkPayload implements Payload { 12 | type = "FailToFetchHatenaBookmarkPayload"; 13 | 14 | constructor(public error: Error) {} 15 | } 16 | -------------------------------------------------------------------------------- /src/domain/Hatebu/HatebuFactory.ts: -------------------------------------------------------------------------------- 1 | import { Hatebu, HatebuIdentifier } from "./Hatebu.js"; 2 | import { Bookmark } from "./Bookmark.js"; 3 | import { BookmarkDate } from "./BookmarkDate.js"; 4 | 5 | export const createHatebu = (userName: string) => { 6 | if (userName.length === 0) { 7 | throw new Error("userName should not be empty"); 8 | } 9 | return new Hatebu({ 10 | id: new HatebuIdentifier(userName), 11 | bookmark: new Bookmark({ 12 | items: [], 13 | lastUpdated: BookmarkDate.createInitialDate() 14 | }) 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /workbox-config.js: -------------------------------------------------------------------------------- 1 | // https://developers.google.com/web/tools/workbox/guides/configure-workbox 2 | module.exports = { 3 | clientsClaim: true, 4 | // For routing 5 | navigateFallback: "/index.html", 6 | globDirectory: "build/", 7 | globPatterns: ["**/*.{json,ico,png,html,js,css}"], 8 | swDest: "build/sw.js", 9 | runtimeCaching: [ 10 | { 11 | // Match any request ends with .png, .jpg, .jpeg or .svg. 12 | urlPattern: /\.(?:png|jpg|jpeg|svg|woff|woff2)$/, 13 | // Apply a cache-first strategy. 14 | handler: "cacheFirst" 15 | } 16 | ] 17 | }; 18 | -------------------------------------------------------------------------------- /src/component/UserForm/UserForm.css: -------------------------------------------------------------------------------- 1 | .UserForm-label { 2 | text-align: center; 3 | } 4 | 5 | .UserForm-body { 6 | display: flex; 7 | align-items: center; 8 | } 9 | 10 | .UserForm-left { 11 | flex: 1; 12 | height: 32px; 13 | } 14 | 15 | .UserForm-right { 16 | flex-shrink: 1; 17 | min-width: 124px; 18 | max-width: 144px; 19 | height: 32px; 20 | /* center */ 21 | display: flex; 22 | align-items: center; 23 | align-content: center; 24 | flex-direction: column; 25 | justify-content: center; 26 | } 27 | 28 | .UserForm-textFieldInput { 29 | /* iOS zoom disabling size*/ 30 | font-size: 18px; 31 | } 32 | -------------------------------------------------------------------------------- /workers/filter.ts: -------------------------------------------------------------------------------- 1 | import { expose, Remote } from "comlink"; 2 | import { HatebuSearchListItem } from "../src/container/SearchContainer/SearchContainerStore"; 3 | import { matchBookmarkItem } from "../src/domain/Hatebu/BookmarkSearch"; 4 | 5 | let currentItems: HatebuSearchListItem[] = []; 6 | const WorkerAPI = { 7 | init(items: HatebuSearchListItem[]) { 8 | currentItems = items; 9 | }, 10 | filter(filterWords: string[]) { 11 | return currentItems.filter((item) => { 12 | return matchBookmarkItem(item, filterWords); 13 | }); 14 | } 15 | }; 16 | export type WorkerAPI = Remote; 17 | expose(WorkerAPI); 18 | -------------------------------------------------------------------------------- /src/domain/Hatebu/BookmarkItemFactory.ts: -------------------------------------------------------------------------------- 1 | import { BookmarkItem } from "./BookmarkItem.js"; 2 | import { BookmarkDate } from "./BookmarkDate.js"; 3 | 4 | export type RawHatenaBookmark = { 5 | title: string; 6 | comment: string; 7 | url: string; 8 | date: Date; 9 | }; 10 | 11 | export const createBookmarkItemFromJSON = (itemJSON: RawHatenaBookmark) => { 12 | return new BookmarkItem({ 13 | title: itemJSON.title, 14 | comment: itemJSON.comment, 15 | url: itemJSON.url, 16 | date: new BookmarkDate(itemJSON.date) 17 | }); 18 | }; 19 | 20 | export const convertItems = (items: RawHatenaBookmark[]) => items.map((item) => createBookmarkItemFromJSON(item)); 21 | -------------------------------------------------------------------------------- /src/component/FocusMatcher/FocusMatcher.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ReactNode } from "react"; 3 | import { browserHistory } from "../../infra/browser/browserHistory"; 4 | import { Route, Router } from "react-routing-resolver"; 5 | 6 | export interface FocusMatcherProps { 7 | matchPath: string; 8 | render: (isFocus: boolean) => ReactNode; 9 | } 10 | 11 | export const FocusMatcher = (props: FocusMatcherProps) => { 12 | return ( 13 | 14 | props.render(true)} /> 15 | props.render(false)} /> 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/domain/Hatebu/BookmarkDate.ts: -------------------------------------------------------------------------------- 1 | export class BookmarkDate { 2 | date: Date; 3 | 4 | constructor(date: Date) { 5 | this.date = date; 6 | } 7 | 8 | static fromUnixTime(value: number) { 9 | return new BookmarkDate(new Date(value)); 10 | } 11 | 12 | /** 13 | * Create oldest Date 14 | * @returns {BookmarkDate} 15 | */ 16 | static createInitialDate() { 17 | return new BookmarkDate(new Date(0)); 18 | } 19 | 20 | get isInitialDate() { 21 | return this.date.getTime() === 0; 22 | } 23 | 24 | get unixTime() { 25 | return this.date.getTime(); 26 | } 27 | 28 | toUTCString() { 29 | return this.date.toUTCString(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom"; 3 | import { initializeIcons } from "@uifabric/icons"; 4 | import { App } from "./container/App"; 5 | import { Provider } from "./Context"; 6 | 7 | const cssFiles = import.meta.glob("./**/*.css"); 8 | const promises: Promise[] = Object.entries(cssFiles).map(([path, loader]) => { 9 | return loader(); 10 | }); 11 | 12 | // Register icons and pull the fonts from the default SharePoint cdn: 13 | 14 | initializeIcons(); 15 | 16 | Promise.all(promises).then(() => { 17 | ReactDOM.render( 18 | 19 | 20 | , 21 | document.getElementById("root") as HTMLElement 22 | ); 23 | }); 24 | -------------------------------------------------------------------------------- /src/setupProxy.js: -------------------------------------------------------------------------------- 1 | import proxy from "http-proxy-middleware"; 2 | 3 | export default function (app) { 4 | app.use( 5 | proxy("/hatebu", { 6 | target: "https://b.hatena.ne.jp", 7 | pathRewrite: { 8 | "^/hatebu": "" 9 | }, 10 | changeOrigin: true 11 | }) 12 | ); 13 | app.use( 14 | proxy("/user", { 15 | target: "http://localhost:3000/", 16 | pathRewrite: { 17 | "^/user/*": "/index.html" 18 | } 19 | }) 20 | ); 21 | app.use( 22 | proxy("/home", { 23 | target: "http://localhost:3000/", 24 | pathRewrite: { 25 | "^/home": "/index.html" 26 | } 27 | }) 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /tools/package-app.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const nativefier = require("nativefier").default; 3 | const projectRoot = path.join(__dirname, ".."); 4 | // possible options, defaults unless specified otherwise 5 | const options = { 6 | name: "HatebuPWA", // will be inferred if not specified 7 | targetUrl: "https://hatebupwa.netlify.app/home/", // required 8 | version: require("../package").version, 9 | out: path.join(projectRoot, "dist"), 10 | overwrite: true, 11 | icon: path.join(projectRoot, "public/img/hatenabookmark/hatenabookmark-logomark.png") 12 | }; 13 | 14 | nativefier(options, function (error, appPath) { 15 | if (error) { 16 | console.error(error); 17 | return; 18 | } 19 | console.log("App has been nativefied to", appPath); 20 | }); 21 | -------------------------------------------------------------------------------- /src/use-case/InitializeSystemUseCase.ts: -------------------------------------------------------------------------------- 1 | import { UseCase } from "almin"; 2 | import { HatebuRepository, hatebuRepository } from "../infra/repository/HatebuRepository.js"; 3 | import { AppSessionRepository, appSessionRepository } from "../infra/repository/AppSessionRepository.js"; 4 | 5 | export const createInitializeSystemUseCase = () => { 6 | return new InitializeSystemUseCase({ 7 | hatebuRepository, 8 | appSessionRepository 9 | }); 10 | }; 11 | 12 | export class InitializeSystemUseCase extends UseCase { 13 | constructor(private repo: { hatebuRepository: HatebuRepository; appSessionRepository: AppSessionRepository }) { 14 | super(); 15 | } 16 | 17 | async execute() { 18 | await this.repo.hatebuRepository.ready(); 19 | await this.repo.appSessionRepository.ready(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /public/img/hatenabookmark/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pollo", 3 | "icons": [ 4 | { 5 | "src": "\/favicon-36.png", 6 | "sizes": "36x36", 7 | "type": "image\/png", 8 | "density": 0.75 9 | }, 10 | { 11 | "src": "\/favicon-48.png", 12 | "sizes": "48x48", 13 | "type": "image\/png", 14 | "density": 1 15 | }, 16 | { 17 | "src": "\/favicon-72.png", 18 | "sizes": "72x72", 19 | "type": "image\/png", 20 | "density": 1.5 21 | }, 22 | { 23 | "src": "\/favicon-96.png", 24 | "sizes": "96x96", 25 | "type": "image\/png", 26 | "density": 2 27 | }, 28 | { 29 | "src": "\/favicon-144.png", 30 | "sizes": "144x144", 31 | "type": "image\/png", 32 | "density": 3 33 | }, 34 | { 35 | "src": "\/favicon-192.png", 36 | "sizes": "192x192", 37 | "type": "image\/png", 38 | "density": 4 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /public/img/hatenabookmark/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - 'Type: Meta' 5 | - 'Type: Question' 6 | - 'Type: Release' 7 | 8 | categories: 9 | - title: Security Fixes 10 | labels: ['Type: Security'] 11 | - title: Breaking Changes 12 | labels: ['Type: Breaking Change'] 13 | - title: Features 14 | labels: ['Type: Feature'] 15 | - title: Bug Fixes 16 | labels: ['Type: Bug'] 17 | - title: Documentation 18 | labels: ['Type: Documentation'] 19 | - title: Refactoring 20 | labels: ['Type: Refactoring'] 21 | - title: Testing 22 | labels: ['Type: Testing'] 23 | - title: Maintenance 24 | labels: ['Type: Maintenance'] 25 | - title: CI 26 | labels: ['Type: CI'] 27 | - title: Dependency Updates 28 | labels: ['Type: Dependencies', "dependencies"] 29 | - title: Other Changes 30 | labels: ['*'] 31 | -------------------------------------------------------------------------------- /src/container/SearchContainer/SearchContainer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { HatebuSearchList } from "../../component/HatebuSearchList/HatebuSearchList"; 3 | import { SearchContainerState } from "./SearchContainerStore"; 4 | import { FocusMatcher } from "../../component/FocusMatcher/FocusMatcher"; 5 | 6 | export interface SearchContainerProps { 7 | searchContainer: SearchContainerState; 8 | } 9 | 10 | export class SearchContainer extends React.PureComponent { 11 | render() { 12 | return ( 13 |
14 | ( 17 | 18 | )} 19 | /> 20 |
21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/use-case/CreateHatebuUserUseCase.ts: -------------------------------------------------------------------------------- 1 | import { UseCase } from "almin"; 2 | import { createHatebu } from "../domain/Hatebu/HatebuFactory.js"; 3 | import { HatebuRepository, hatebuRepository } from "../infra/repository/HatebuRepository.js"; 4 | 5 | export const createCreateHatebuUserUseCase = () => { 6 | return new CreateHatebuUserUseCase({ 7 | hatebuRepository 8 | }); 9 | }; 10 | 11 | export class CreateHatebuUserUseCase extends UseCase { 12 | constructor(private repo: { hatebuRepository: HatebuRepository }) { 13 | super(); 14 | } 15 | 16 | execute(userName: string) { 17 | const existingHatebu = this.repo.hatebuRepository.findByUserName(userName); 18 | if (existingHatebu) { 19 | throw new Error(`Hatebu(${userName}) is already created`); 20 | } 21 | const hatebu = createHatebu(userName); 22 | return this.repo.hatebuRepository.save(hatebu); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/component/HatebuSearchList/HatebuSearchList.css: -------------------------------------------------------------------------------- 1 | .HatebuSearchList-body { 2 | margin-top: 0.3rem; 3 | } 4 | 5 | .HatebuSearchListItem-body { 6 | border: 1px #ddd solid; 7 | display: flex; 8 | flex-direction: row; 9 | padding: 0.5rem 0.3rem; 10 | } 11 | 12 | .HatebuSearchListItem-main { 13 | flex: 7; 14 | word-break: break-all; 15 | border-right: 1px #ddd solid; 16 | /* align vertical */ 17 | display: flex; 18 | flex-direction: column; 19 | flex-wrap: wrap; 20 | } 21 | 22 | .HatebuSearchListItem-title { 23 | padding: 0 0.3rem; 24 | text-align: left; 25 | } 26 | 27 | .HatebuSearchListItem-description { 28 | padding: 0 0.3rem; 29 | text-align: left; 30 | } 31 | 32 | .HatebuSearchListItem-timestamp { 33 | padding: 0 0.3rem; 34 | flex: 1; 35 | text-align: right; 36 | } 37 | 38 | .HatebuSearchList-searchBox { 39 | text-align: center; 40 | } 41 | 42 | .HatebuSearchList-searchBoxInput { 43 | /* iOS zoom disabling size*/ 44 | font-size: 18px; 45 | } 46 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "build/dist", 4 | "module": "esnext", 5 | "target": "ES2022", 6 | "lib": [ 7 | "esnext", 8 | "dom" 9 | ], 10 | "sourceMap": true, 11 | "allowJs": true, 12 | "jsx": "preserve", 13 | "moduleResolution": "NodeNext", 14 | "forceConsistentCasingInFileNames": true, 15 | "noImplicitReturns": true, 16 | "noImplicitThis": true, 17 | "noImplicitAny": true, 18 | "strict": true, 19 | "strictNullChecks": true, 20 | "suppressImplicitAnyIndexErrors": true, 21 | "esModuleInterop": true, 22 | "noUnusedLocals": true, 23 | "skipLibCheck": true, 24 | "allowSyntheticDefaultImports": true, 25 | "resolveJsonModule": true, 26 | "noEmit": true, 27 | "isolatedModules": true 28 | }, 29 | "exclude": [ 30 | "node_modules", 31 | "build", 32 | "scripts", 33 | "acceptance-tests", 34 | "webpack", 35 | "jest", 36 | "src/setupTests.ts" 37 | ], 38 | "include": [ 39 | "src" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 azu 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/domain/Hatebu/BookmarkItem.ts: -------------------------------------------------------------------------------- 1 | import { Serializer, ValueObject } from "ddd-base"; 2 | import { BookmarkDate } from "./BookmarkDate.js"; 3 | 4 | export const BookmarkItemConverter: Serializer = { 5 | fromJSON(json) { 6 | return new BookmarkItem({ 7 | title: json.title, 8 | comment: json.comment, 9 | url: json.url, 10 | date: BookmarkDate.fromUnixTime(json.date) 11 | }); 12 | }, 13 | toJSON(entity) { 14 | return { 15 | title: entity.props.title, 16 | comment: entity.props.comment, 17 | url: entity.props.url, 18 | date: entity.props.date.unixTime 19 | }; 20 | } 21 | }; 22 | 23 | export interface BookmarkItemJSON { 24 | title: string; 25 | comment: string; 26 | url: string; 27 | date: number; 28 | } 29 | 30 | export interface BookmarkItemProps { 31 | title: string; 32 | comment: string; 33 | url: string; 34 | date: BookmarkDate; 35 | } 36 | 37 | export class BookmarkItem extends ValueObject {} 38 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | name: "Test on Node.js ${{ matrix.node-version }}" 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | node-version: [ 20 ] 10 | steps: 11 | - name: checkout 12 | uses: actions/checkout@v4 13 | - name: setup Node.js ${{ matrix.node-version }} 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: ${{ matrix.node-version }} 17 | cache: yarn 18 | - name: Install 19 | run: yarn install 20 | - name: Test 21 | run: yarn test 22 | 23 | build: 24 | name: "Build on Node.js ${{ matrix.node-version }}" 25 | runs-on: ubuntu-latest 26 | strategy: 27 | matrix: 28 | node-version: [ 20 ] 29 | steps: 30 | - name: checkout 31 | uses: actions/checkout@v4 32 | - name: setup Node.js ${{ matrix.node-version }} 33 | uses: actions/setup-node@v4 34 | with: 35 | node-version: ${{ matrix.node-version }} 36 | cache: yarn 37 | - name: Install 38 | run: yarn install 39 | - name: Test 40 | run: yarn run build 41 | -------------------------------------------------------------------------------- /src/Context.ts: -------------------------------------------------------------------------------- 1 | import { Context, StoreGroup } from "almin"; 2 | import { createReactContext } from "@almin/react-context"; 3 | import { UserFormContainerStore } from "./container/UserFormContainer/UserFormContainerStore.js"; 4 | import { SearchContainerStore } from "./container/SearchContainer/SearchContainerStore.js"; 5 | import { AlminLogger } from "almin-logger"; 6 | import { hatebuRepository } from "./infra/repository/HatebuRepository.js"; 7 | import { AppStore } from "./container/AppStore.js"; 8 | 9 | export const AppStoreGroup = new StoreGroup({ 10 | userFormContainer: new UserFormContainerStore({ 11 | hatebuRepository 12 | }), 13 | searchContainer: new SearchContainerStore({ 14 | hatebuRepository 15 | }), 16 | app: new AppStore() 17 | }); 18 | 19 | export const context = new Context({ 20 | store: AppStoreGroup, 21 | options: { 22 | strict: false, 23 | performanceProfile: true 24 | } 25 | }); 26 | 27 | if (process.env.NODE_ENV !== "production") { 28 | const logger = new AlminLogger(); 29 | logger.startLogging(context); 30 | } 31 | 32 | const { Provider, Consumer } = createReactContext(context); 33 | export { Provider, Consumer }; 34 | -------------------------------------------------------------------------------- /src/container/UserFormContainer/UserFormContainerStore.ts: -------------------------------------------------------------------------------- 1 | import { Payload, Store } from "almin"; 2 | import { HatebuRepository } from "../../infra/repository/HatebuRepository.js"; 3 | import { SwitchCurrentHatebuUserUseCasePayload } from "../../use-case/SwitchCurrentHatebuUserUseCase.js"; 4 | 5 | export interface UserFormContainerState { 6 | name?: string; 7 | } 8 | 9 | export interface UserFormContainerStoreArgs { 10 | hatebuRepository: HatebuRepository; 11 | } 12 | 13 | export class UserFormContainerStore extends Store { 14 | state: UserFormContainerState; 15 | 16 | constructor(private args: UserFormContainerStoreArgs) { 17 | super(); 18 | this.state = { 19 | name: undefined 20 | }; 21 | } 22 | 23 | getState(): UserFormContainerState { 24 | return this.state; 25 | } 26 | 27 | receivePayload(payload: Payload): void { 28 | if (payload instanceof SwitchCurrentHatebuUserUseCasePayload) { 29 | const hatebu = this.args.hatebuRepository.findByUserName(payload.userName); 30 | if (!hatebu) { 31 | return; 32 | } 33 | this.setState({ 34 | name: hatebu.name 35 | }); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/domain/Hatebu/BookmarkSearch.ts: -------------------------------------------------------------------------------- 1 | import { BookmarkItem } from "./BookmarkItem.js"; 2 | import memoize from "micro-memoize"; 3 | // @ts-expect-error no type definition 4 | import regexCombiner from "regex-combiner"; 5 | 6 | const stringifyBookmarkItem = (bookmark: BookmarkItem): string => { 7 | return `${bookmark.props.title}\t${bookmark.props.url}\t${ 8 | bookmark.props.comment 9 | }\t${bookmark.props.date.toString()}` 10 | .toString() 11 | .toLowerCase(); 12 | }; 13 | 14 | const memorizedStringifyBookmarkItem = memoize(stringifyBookmarkItem); 15 | const memoriezdRegexCombiner = memoize((searchWord: string[]) => { 16 | const pattern = regexCombiner(searchWord); 17 | return new RegExp(pattern.source, "i"); 18 | }); 19 | export const matchBookmarkItem = (bookmark: BookmarkItem, searchWords: string[]): boolean => { 20 | const text = memorizedStringifyBookmarkItem(bookmark); 21 | const combined = memoriezdRegexCombiner(searchWords); 22 | if (!combined.test(text)) { 23 | return false; 24 | } 25 | if (searchWords.length === 1) { 26 | return true; 27 | } 28 | // multiple words as & search 29 | return searchWords.every((searchWord) => { 30 | return text.indexOf(searchWord.toLowerCase()) !== -1; 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /src/domain/AppSession/AppSession.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Identifier, Serializer } from "ddd-base"; 2 | import { Hatebu, HatebuIdentifier } from "../Hatebu/Hatebu.js"; 3 | 4 | export const AppSessionConverter: Serializer = { 5 | toJSON(entity) { 6 | return { 7 | id: entity.props.id.toValue(), 8 | hatebuId: entity.props.hatebuId ? entity.props.hatebuId.toValue() : undefined 9 | }; 10 | }, 11 | fromJSON(json) { 12 | return new AppSession({ 13 | id: new AppSessionIdentifier(json.id), 14 | hatebuId: json.hatebuId ? new HatebuIdentifier(json.hatebuId) : undefined 15 | }); 16 | } 17 | }; 18 | 19 | export class AppSessionIdentifier extends Identifier {} 20 | 21 | export interface AppSessionJSON { 22 | id: string; 23 | hatebuId?: string; 24 | } 25 | 26 | export interface AppSessionProps { 27 | id: AppSessionIdentifier; 28 | hatebuId?: HatebuIdentifier; 29 | } 30 | 31 | export class AppSession extends Entity { 32 | get currentHatebuId() { 33 | return this.props.hatebuId; 34 | } 35 | 36 | setCurrentUsedHatebu(hatebu: Hatebu) { 37 | return new AppSession({ 38 | ...this.props, 39 | hatebuId: hatebu.props.id 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/container/AppStore.ts: -------------------------------------------------------------------------------- 1 | import { Payload, Store } from "almin"; 2 | import { 3 | FailToFetchHatenaBookmarkPayload, 4 | FinishFetchHatenaBookmarkPayload, 5 | StartFetchHatenaBookmarkPayload 6 | } from "../use-case/hatebu-api/FetchHatenaBookmarkPayload.js"; 7 | 8 | export interface AppState { 9 | isFetching: boolean; 10 | } 11 | 12 | export class AppStore extends Store { 13 | state: AppState; 14 | 15 | constructor() { 16 | super(); 17 | this.state = { 18 | isFetching: false 19 | }; 20 | } 21 | 22 | getState(): AppState { 23 | return this.state; 24 | } 25 | 26 | receivePayload(payload: Payload): void { 27 | if (payload instanceof StartFetchHatenaBookmarkPayload) { 28 | this.setState({ 29 | ...(this.state as AppState), 30 | isFetching: true 31 | }); 32 | } else if (payload instanceof FinishFetchHatenaBookmarkPayload) { 33 | this.setState({ 34 | ...(this.state as AppState), 35 | isFetching: false 36 | }); 37 | } else if (payload instanceof FailToFetchHatenaBookmarkPayload) { 38 | this.setState({ 39 | ...(this.state as AppState), 40 | isFetching: false 41 | }); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "はてブ検索", 3 | "name": "はてなブックマーク検索PWA", 4 | "start_url": "https://hatebupwa.netlify.app/home/", 5 | "icons": [ 6 | { 7 | "src": "./img/hatenabookmark/favicon.svg", 8 | "sizes": "48x48 72x72 96x96 144x144 192x192 512x512 1024x1024", 9 | "type": "image/svg" 10 | }, 11 | { 12 | "src": "favicon.ico", 13 | "sizes": "64x64 32x32 24x24 16x16", 14 | "type": "image/x-icon" 15 | }, 16 | { 17 | "src": "./img/hatenabookmark/favicon-36.png", 18 | "sizes": "36x36", 19 | "type": "image\/png" 20 | }, 21 | { 22 | "src": "./img/hatenabookmark/favicon-48.png", 23 | "sizes": "48x48", 24 | "type": "image\/png" 25 | }, 26 | { 27 | "src": "./img/hatenabookmark/favicon-72.png", 28 | "sizes": "72x72", 29 | "type": "image\/png" 30 | }, 31 | { 32 | "src": "./img/hatenabookmark/favicon-96.png", 33 | "sizes": "96x96", 34 | "type": "image\/png" 35 | }, 36 | { 37 | "src": "./img/hatenabookmark/favicon-144.png", 38 | "sizes": "144x144", 39 | "type": "image\/png" 40 | }, 41 | { 42 | "src": "./img/hatenabookmark/favicon-192.png", 43 | "sizes": "192x192", 44 | "type": "image\/png" 45 | } 46 | ], 47 | "display": "standalone", 48 | "theme_color": "#000000", 49 | "background_color": "#ffffff" 50 | } 51 | -------------------------------------------------------------------------------- /src/use-case/RestoreLastSessionUseCase.ts: -------------------------------------------------------------------------------- 1 | import { UseCase } from "almin"; 2 | import { createSwitchCurrentHatebuUserUseCase } from "./SwitchCurrentHatebuUserUseCase.js"; 3 | import { HatebuRepository, hatebuRepository } from "../infra/repository/HatebuRepository.js"; 4 | import { AppSessionRepository, appSessionRepository } from "../infra/repository/AppSessionRepository.js"; 5 | 6 | import debug0 from "debug"; 7 | 8 | const debug = debug0("hatebupwa"); 9 | 10 | export const createRestoreLastSessionUseCase = () => { 11 | return new RestoreLastSessionUseCase({ 12 | hatebuRepository, 13 | appSessionRepository 14 | }); 15 | }; 16 | 17 | export class RestoreLastSessionUseCase extends UseCase { 18 | constructor(private repo: { hatebuRepository: HatebuRepository; appSessionRepository: AppSessionRepository }) { 19 | super(); 20 | } 21 | 22 | async execute() { 23 | const lastSession = this.repo.appSessionRepository.get(); 24 | debug("last session: %o", lastSession); 25 | if (!lastSession) { 26 | return; 27 | } 28 | if (!lastSession.props.hatebuId) { 29 | return; 30 | } 31 | const hatebu = this.repo.hatebuRepository.findByUserName(lastSession.props.hatebuId.toValue()); 32 | if (!hatebu) { 33 | return; 34 | } 35 | debug("last hatebu: %s", hatebu.name); 36 | await this.context.useCase(createSwitchCurrentHatebuUserUseCase()).execute(hatebu.name); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hatebupwa [![Actions Status: test](https://github.com/azu/hatebupwa/workflows/test/badge.svg)](https://github.com/azu/hatebupwa/actions?query=workflow%3A"test") 2 | 3 | Hatena Bookmark search app. 4 | 5 | ## Feature 6 | 7 | - Search your hatena bookmark 8 | - Fetch difference bookmark automatically 9 | - Support offline search 10 | - Safari 11.3+, Chrome, Firefox and MSEdge etc... 11 | - Work as HomeScreen app 12 | 13 | ## Usage 14 | 15 | 1. Open 16 | 2. Input hatena user name 17 | 3. Search 18 | 19 | 20 | For [asocial-bookmark](https://github.com/azu/asocial-bookmark) user 21 | 22 | 1. Open 23 | 2. Input `https://your-bookmark.example.com/index.json` to hatenagit user name 24 | - Support root index.json file as user name 25 | 3. Search 26 | 27 | ### Install as App 28 | 29 | - iOS: "Add HomesScreen" on 30 | - Android: "Add HomeScreen" on 31 | - mac: Download from 32 | - other platform: Run following commands: 33 | 34 | ``` 35 | git clone https://github.com/azu/hatebupwa 36 | cd hatebupwa 37 | yarn 38 | yarn run pacakge 39 | # generate https://github.com/jiahaog/nativefier based app 40 | ``` 41 | 42 | ## Architecture 43 | 44 | ### Routing 45 | 46 | - `/` 47 | - Start 48 | - `/home/` 49 | - `start_url` of HomeScreen app 50 | - Redirect to last used session 51 | - `/user/:name` 52 | - Set user to session 53 | -------------------------------------------------------------------------------- /src/component/PageVisibility/PageVisibility.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | let hidden: string, visibilityChange: string; 4 | if (typeof document.hidden !== "undefined") { 5 | // Opera 12.10 and Firefox 18 and later support 6 | hidden = "hidden"; 7 | visibilityChange = "visibilitychange"; 8 | } else if (typeof (document as any).msHidden !== "undefined") { 9 | hidden = "msHidden"; 10 | visibilityChange = "msvisibilitychange"; 11 | } else if (typeof (document as any).webkitHidden !== "undefined") { 12 | hidden = "webkitHidden"; 13 | visibilityChange = "webkitvisibilitychange"; 14 | } 15 | 16 | export interface PageVisibilityProps { 17 | onVisible?: () => any; 18 | onHidden?: () => any; 19 | } 20 | 21 | export class PageVisibility extends React.PureComponent { 22 | private visibilityChange = () => { 23 | if (document[hidden]) { 24 | if (this.props.onHidden) { 25 | this.props.onHidden(); 26 | } 27 | } else { 28 | if (this.props.onVisible) { 29 | this.props.onVisible(); 30 | } 31 | } 32 | }; 33 | 34 | constructor(args: PageVisibilityProps) { 35 | super(args); 36 | 37 | document.addEventListener(visibilityChange, this.visibilityChange); 38 | } 39 | 40 | componentWillUnmount() { 41 | document.removeEventListener(visibilityChange, this.visibilityChange); 42 | } 43 | 44 | render() { 45 | return null; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /// 4 | /// 5 | /// 6 | 7 | declare module "*.avif" { 8 | const src: string; 9 | export default src; 10 | } 11 | 12 | declare module "*.bmp" { 13 | const src: string; 14 | export default src; 15 | } 16 | 17 | declare module "*.gif" { 18 | const src: string; 19 | export default src; 20 | } 21 | 22 | declare module "*.jpg" { 23 | const src: string; 24 | export default src; 25 | } 26 | 27 | declare module "*.jpeg" { 28 | const src: string; 29 | export default src; 30 | } 31 | 32 | declare module "*.png" { 33 | const src: string; 34 | export default src; 35 | } 36 | 37 | declare module "*.webp" { 38 | const src: string; 39 | export default src; 40 | } 41 | 42 | declare module "*.svg" { 43 | import * as React from "react"; 44 | 45 | export const ReactComponent: React.FunctionComponent & { title?: string }>; 46 | 47 | const src: string; 48 | export default src; 49 | } 50 | 51 | declare module "*.module.css" { 52 | const classes: { readonly [key: string]: string }; 53 | export default classes; 54 | } 55 | 56 | declare module "*.module.scss" { 57 | const classes: { readonly [key: string]: string }; 58 | export default classes; 59 | } 60 | 61 | declare module "*.module.sass" { 62 | const classes: { readonly [key: string]: string }; 63 | export default classes; 64 | } 65 | -------------------------------------------------------------------------------- /src/domain/Hatebu/__tests__/BookmarkSearch.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | import * as assert from "node:assert"; 3 | import { BookmarkItem } from "../BookmarkItem.js"; 4 | import { matchBookmarkItem } from "../BookmarkSearch.js"; 5 | import { BookmarkDate } from "../BookmarkDate.js"; 6 | 7 | const createExampleBookmark = () => { 8 | return new BookmarkItem({ 9 | title: "title", 10 | url: "http://example.com", 11 | comment: "comment", 12 | date: new BookmarkDate(new Date()) 13 | }); 14 | }; 15 | describe("BookmarkSearch", () => { 16 | describe("matchBookmarkItem", () => { 17 | it("should return true if match the searchWords", () => { 18 | const bookmark = createExampleBookmark(); 19 | assert.ok(matchBookmarkItem(bookmark, ["title"])); 20 | assert.ok(matchBookmarkItem(bookmark, ["comment"])); 21 | assert.ok(matchBookmarkItem(bookmark, ["example.com"])); 22 | }); 23 | it("should accept case-insensitive word", () => { 24 | const bookmark = createExampleBookmark(); 25 | assert.ok(matchBookmarkItem(bookmark, ["TITLE"])); 26 | assert.ok(matchBookmarkItem(bookmark, ["COMMENT"])); 27 | assert.ok(matchBookmarkItem(bookmark, ["EXAMPLE.COM"])); 28 | }); 29 | it("should return false if match the searchWords", () => { 30 | const bookmark = createExampleBookmark(); 31 | assert.strictEqual(matchBookmarkItem(bookmark, ["nomatch"]), false); 32 | assert.strictEqual(matchBookmarkItem(bookmark, ["nomatch", "title"]), false); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/infra/API/HatenaBookmarkFetcher.ts: -------------------------------------------------------------------------------- 1 | import { RawHatenaBookmark } from "../../domain/Hatebu/BookmarkItemFactory.js"; 2 | import { BookmarkDate } from "../../domain/Hatebu/BookmarkDate.js"; 3 | import format from "date-fns/format"; 4 | // @ts-expect-error no types 5 | import { parse } from "hatebu-mydata-parser"; 6 | type AsocialBookmarkItem = import("asocial-bookmark").AsocialBookmarkItem; 7 | export const fetchHatenaBookmark = (userName: string, sinceTime?: BookmarkDate): Promise => { 8 | // Support asocial-bookmark https://github.com/azu/asocial-bookmark 9 | // TODO: undocument ways 10 | const isURL = /^https?:\/\//.test(userName); 11 | if (isURL) { 12 | return fetch(userName) 13 | .then((res) => { 14 | if (!res.ok) { 15 | throw new Error("Can not fetch"); 16 | } 17 | return res.json(); 18 | }) 19 | .then((json: AsocialBookmarkItem[]) => { 20 | return json.map((item) => { 21 | return { 22 | title: item.title, 23 | comment: item.content, 24 | url: item.url, 25 | date: new Date(item.date) 26 | }; 27 | }); 28 | }); 29 | } 30 | const timeStamp = sinceTime ? `timestamp=${format(sinceTime.date, "YYYYMMDDHHmmss")}` : ""; 31 | return fetch(`/hatebu/${encodeURIComponent(userName)}/search.data?${timeStamp}`) 32 | .then((res: any) => { 33 | if (!res.ok) { 34 | return Promise.reject(new Error("Can not fetch")); 35 | } 36 | return res.text(); 37 | }) 38 | .then((text) => { 39 | return parse(text); 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /src/infra/repository/HatebuRepository.ts: -------------------------------------------------------------------------------- 1 | import { NullableRepository } from "ddd-base"; 2 | import { Hatebu, HatebuConverter, HatebuIdentifier, HatebuJSON } from "../../domain/Hatebu/Hatebu.js"; 3 | import { createStorageInstance } from "./StorageManger.js"; 4 | 5 | export class HatebuRepository extends NullableRepository { 6 | private storage: LocalForage; 7 | 8 | constructor() { 9 | super(); 10 | this.storage = createStorageInstance({ 11 | name: "HatebuRepository" 12 | }); 13 | } 14 | 15 | /** 16 | * Please call this before find* API 17 | * @returns {Promise} 18 | */ 19 | async ready(): Promise { 20 | if (this.map.size > 0) { 21 | return Promise.resolve(null); 22 | } 23 | await this.storage.ready(); 24 | const values: HatebuJSON[] = []; 25 | let lastValue: Hatebu | null = null; 26 | await this.storage.iterate((value) => { 27 | values.push(value); 28 | }); 29 | values 30 | .map((json) => { 31 | return HatebuConverter.fromJSON(json); 32 | }) 33 | .forEach((hatebu) => { 34 | this.map.set(hatebu.props.id.toValue(), hatebu); 35 | lastValue = hatebu; 36 | }); 37 | return lastValue; 38 | } 39 | 40 | findByHatebuId(id?: HatebuIdentifier) { 41 | return this.findById(id); 42 | } 43 | 44 | findByUserName(userName: string) { 45 | return this.findById(new HatebuIdentifier(userName)); 46 | } 47 | 48 | save(entity: Hatebu) { 49 | super.save(entity); 50 | return this.storage.setItem(entity.props.id.toValue(), HatebuConverter.toJSON(entity)); 51 | } 52 | } 53 | 54 | export const hatebuRepository = new HatebuRepository(); 55 | -------------------------------------------------------------------------------- /src/infra/repository/StorageManger.ts: -------------------------------------------------------------------------------- 1 | // MIT © 2017 azu 2 | import localForage from "localforage"; 3 | // @ts-expect-error no types 4 | import memoryStorageDriver from "localforage-memoryStorageDriver"; 5 | 6 | export class StorageManger { 7 | currentDriver?: string; 8 | instances: [LocalForage, string][] = []; 9 | 10 | addInstance(instance: LocalForage) { 11 | this.instances.push([instance, instance.driver()]); 12 | } 13 | 14 | async useMemoryDriver() { 15 | await localForage.defineDriver(memoryStorageDriver); 16 | await localForage.setDriver(memoryStorageDriver._driver); 17 | await localForage.ready(); 18 | this.currentDriver = memoryStorageDriver._driver; 19 | const promises = this.instances.map(async ([instance]) => { 20 | await instance.defineDriver(memoryStorageDriver); 21 | await instance.setDriver(memoryStorageDriver._driver); 22 | await instance.ready(); 23 | }); 24 | return Promise.all(promises); 25 | } 26 | 27 | async resetDriver() { 28 | const promises = this.instances.map(([instance, driver]) => { 29 | return instance.setDriver(driver); 30 | }); 31 | this.currentDriver = undefined; 32 | return Promise.all(promises); 33 | } 34 | } 35 | 36 | // singleton 37 | export const storageManger = new StorageManger(); 38 | 39 | /** 40 | * Storage is a wrapper of localForage 41 | * It can change driver at runtime for debugging. 42 | */ 43 | export function createStorageInstance(options: LocalForageOptions): LocalForage { 44 | const defaultOptions = storageManger.currentDriver 45 | ? { 46 | driver: storageManger.currentDriver 47 | } 48 | : {}; 49 | const instance = localForage.createInstance(Object.assign({}, defaultOptions, options)); 50 | storageManger.addInstance(instance); 51 | return instance; 52 | } 53 | -------------------------------------------------------------------------------- /src/infra/repository/AppSessionRepository.ts: -------------------------------------------------------------------------------- 1 | import { NonNullableRepository } from "ddd-base"; 2 | import { AppSession, AppSessionConverter, AppSessionJSON } from "../../domain/AppSession/AppSession.js"; 3 | import { createStorageInstance } from "./StorageManger.js"; 4 | import { createAppSession } from "../../domain/AppSession/AppSessionFactory.js"; 5 | 6 | export class AppSessionRepository extends NonNullableRepository { 7 | private storage: LocalForage; 8 | 9 | constructor(initialEntity: AppSession) { 10 | super(initialEntity); 11 | this.storage = createStorageInstance({ 12 | name: "AppSessionRepository" 13 | }); 14 | } 15 | 16 | /** 17 | * Please call this before find* API 18 | * @returns {Promise} 19 | */ 20 | async ready(): Promise { 21 | if (this.map.size > 0) { 22 | return Promise.resolve(null); 23 | } 24 | await this.storage.ready(); 25 | const values: AppSessionJSON[] = []; 26 | await this.storage.iterate((value) => { 27 | values.push(value); 28 | }); 29 | const entities = values.map((json) => { 30 | return AppSessionConverter.fromJSON(json); 31 | }); 32 | entities.forEach((entity) => { 33 | this.map.set(entity.props.id.toValue(), entity); 34 | }); 35 | // TODO: NonNullableRepository should have set lastUsed Value 36 | if (entities.length === 0) { 37 | return null; 38 | } 39 | this.save(entities[0]); 40 | return entities[0]; 41 | } 42 | 43 | save(entity: AppSession) { 44 | // AppSession can treat a single session 45 | super.save(entity); 46 | return this.storage.setItem(entity.props.id.toValue(), AppSessionConverter.toJSON(entity)); 47 | } 48 | } 49 | 50 | export const appSessionRepository = new AppSessionRepository(createAppSession()); 51 | -------------------------------------------------------------------------------- /src/use-case/hatebu-api/InitializeWithNewHatenaBookmarkUseCase.ts: -------------------------------------------------------------------------------- 1 | import { UseCase } from "almin"; 2 | import { fetchHatenaBookmark } from "../../infra/API/HatenaBookmarkFetcher.js"; 3 | import { HatebuRepository, hatebuRepository } from "../../infra/repository/HatebuRepository.js"; 4 | import { convertItems } from "../../domain/Hatebu/BookmarkItemFactory.js"; 5 | import { 6 | FailToFetchHatenaBookmarkPayload, 7 | FinishFetchHatenaBookmarkPayload, 8 | StartFetchHatenaBookmarkPayload 9 | } from "./FetchHatenaBookmarkPayload.js"; 10 | 11 | import debug0 from "debug"; 12 | 13 | const debug = debug0("hatebupwa"); 14 | export const createFetchInitialHatenaBookmarkUseCase = () => { 15 | return new InitializeWithNewHatenaBookmarkUseCase({ 16 | hatebuRepository 17 | }); 18 | }; 19 | 20 | export class InitializeWithNewHatenaBookmarkUseCase extends UseCase { 21 | constructor(private repo: { hatebuRepository: HatebuRepository }) { 22 | super(); 23 | } 24 | 25 | execute(userName: string) { 26 | const hatebu = this.repo.hatebuRepository.findByUserName(userName); 27 | if (!hatebu) { 28 | throw new Error("Hatebu user should be created before fetch."); 29 | } 30 | debug("start fetching items for initializing"); 31 | this.dispatch(new StartFetchHatenaBookmarkPayload()); 32 | return fetchHatenaBookmark(userName) 33 | .then(async (bookmarkRawItems) => { 34 | debug("finish fetching items(%s) and update list with items", bookmarkRawItems.length); 35 | const updatedHatebu = hatebu.updateBookmarkItems(convertItems(bookmarkRawItems)); 36 | await this.repo.hatebuRepository.save(updatedHatebu); 37 | this.dispatch(new FinishFetchHatenaBookmarkPayload()); 38 | }) 39 | .catch((error) => { 40 | this.dispatch(new FailToFetchHatenaBookmarkPayload(error)); 41 | }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/domain/Hatebu/Hatebu.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Identifier, Serializer } from "ddd-base"; 2 | import { Bookmark, BookmarkConverter, BookmarkJSON } from "./Bookmark.js"; 3 | import { BookmarkItem } from "./BookmarkItem.js"; 4 | 5 | export const HatebuConverter: Serializer = { 6 | fromJSON(json) { 7 | return new Hatebu({ 8 | id: new HatebuIdentifier(json.id), 9 | bookmark: BookmarkConverter.fromJSON(json.bookmark) 10 | }); 11 | }, 12 | toJSON(entity) { 13 | return { 14 | id: entity.props.id.toValue(), 15 | bookmark: BookmarkConverter.toJSON(entity.props.bookmark) 16 | }; 17 | } 18 | }; 19 | 20 | export class HatebuIdentifier extends Identifier {} 21 | 22 | export interface HatebuJSON { 23 | readonly id: string; 24 | readonly bookmark: BookmarkJSON; 25 | } 26 | 27 | export interface HatebuProps { 28 | readonly id: HatebuIdentifier; 29 | readonly bookmark: Bookmark; 30 | } 31 | 32 | export interface Hatebu extends HatebuProps {} 33 | 34 | export class Hatebu extends Entity implements HatebuProps { 35 | constructor(props: HatebuProps) { 36 | super(props); 37 | Object.assign(this, props); 38 | } 39 | 40 | get name() { 41 | return this.props.id.toValue(); 42 | } 43 | 44 | get bookmarkItems() { 45 | return this.props.bookmark.props.items; 46 | } 47 | 48 | get bookmarkTotalCount() { 49 | return this.props.bookmark.totalCount; 50 | } 51 | 52 | addBookmarkItems(bookmarkItems: BookmarkItem[]) { 53 | return new Hatebu({ 54 | ...this.props, 55 | bookmark: this.props.bookmark.addBookmarkItems(bookmarkItems) 56 | }); 57 | } 58 | 59 | updateBookmarkItems(bookmarkItems: BookmarkItem[]) { 60 | return new Hatebu({ 61 | ...this.props, 62 | bookmark: this.props.bookmark.updateBookmarkItems(bookmarkItems) 63 | }); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | 16 | はてなブックマーク検索PWA 17 | 18 | 19 | 20 | Fork me on GitHub 25 | 28 |
29 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /src/use-case/hatebu-api/RefreshHatenaBookmarkUseCase.ts: -------------------------------------------------------------------------------- 1 | import { UseCase } from "almin"; 2 | import { fetchHatenaBookmark } from "../../infra/API/HatenaBookmarkFetcher.js"; 3 | import { HatebuRepository, hatebuRepository } from "../../infra/repository/HatebuRepository.js"; 4 | import { convertItems } from "../../domain/Hatebu/BookmarkItemFactory.js"; 5 | import { 6 | FailToFetchHatenaBookmarkPayload, 7 | FinishFetchHatenaBookmarkPayload, 8 | StartFetchHatenaBookmarkPayload 9 | } from "./FetchHatenaBookmarkPayload.js"; 10 | 11 | import debug0 from "debug"; 12 | 13 | const debug = debug0("hatebupwa"); 14 | export const createRefreshHatenaBookmarkUseCase = () => { 15 | return new RefreshHatenaBookmarkUseCase({ 16 | hatebuRepository 17 | }); 18 | }; 19 | 20 | export class RefreshHatenaBookmarkUseCase extends UseCase { 21 | constructor(private repo: { hatebuRepository: HatebuRepository }) { 22 | super(); 23 | } 24 | 25 | execute(userName: string) { 26 | const hatebu = this.repo.hatebuRepository.findByUserName(userName); 27 | if (!hatebu) { 28 | throw new Error("Hatebu user should be created before fetch."); 29 | } 30 | const lastUpdatedDate = hatebu.bookmark.lastUpdated; 31 | debug( 32 | "start fetching items since %s", 33 | lastUpdatedDate.isInitialDate ? "initial date" : lastUpdatedDate.toUTCString() 34 | ); 35 | this.dispatch(new StartFetchHatenaBookmarkPayload()); 36 | return fetchHatenaBookmark(userName, lastUpdatedDate) 37 | .then(async (bookmarkRawItems) => { 38 | debug("finish fetching items(%s) and add items to list", bookmarkRawItems.length); 39 | const updatedHatebu = hatebu.addBookmarkItems(convertItems(bookmarkRawItems)); 40 | await this.repo.hatebuRepository.save(updatedHatebu); 41 | this.dispatch(new FinishFetchHatenaBookmarkPayload()); 42 | }) 43 | .catch((error) => { 44 | this.dispatch(new FailToFetchHatenaBookmarkPayload(error)); 45 | }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/container/SearchContainer/SearchContainerStore.ts: -------------------------------------------------------------------------------- 1 | import { Payload, Store } from "almin"; 2 | import { HatebuRepository } from "../../infra/repository/HatebuRepository.js"; 3 | import { BookmarkItem } from "../../domain/Hatebu/BookmarkItem.js"; 4 | import { SwitchCurrentHatebuUserUseCasePayload } from "../../use-case/SwitchCurrentHatebuUserUseCase.js"; 5 | 6 | export interface HatebuSearchListItem extends BookmarkItem {} 7 | 8 | export interface SearchContainerState { 9 | name: string | undefined; 10 | items: HatebuSearchListItem[]; 11 | totalCount: number; 12 | } 13 | 14 | export interface SearchContainerStoreArgs { 15 | hatebuRepository: HatebuRepository; 16 | } 17 | 18 | export class SearchContainerStore extends Store { 19 | state: SearchContainerState; 20 | private hatebuRepository: HatebuRepository; 21 | 22 | constructor(args: SearchContainerStoreArgs) { 23 | super(); 24 | this.state = { 25 | name: undefined, 26 | totalCount: 0, 27 | items: [] 28 | }; 29 | this.hatebuRepository = args.hatebuRepository; 30 | this.hatebuRepository.events.onChange(() => { 31 | const hatebu = this.hatebuRepository.get(); 32 | if (!hatebu) { 33 | return; 34 | } 35 | this.setState({ 36 | name: this.state.name, 37 | items: hatebu.bookmarkItems, 38 | totalCount: hatebu.bookmarkTotalCount 39 | }); 40 | }); 41 | } 42 | 43 | getState(): SearchContainerState { 44 | return this.state; 45 | } 46 | 47 | receivePayload(payload: Payload) { 48 | if (payload instanceof SwitchCurrentHatebuUserUseCasePayload) { 49 | const hatebu = this.hatebuRepository.findByUserName(payload.userName); 50 | if (!hatebu) { 51 | return; 52 | } 53 | this.setState({ 54 | name: hatebu.name, 55 | items: hatebu.bookmarkItems, 56 | totalCount: hatebu.bookmarkTotalCount 57 | }); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/use-case/SwitchCurrentHatebuUserUseCase.ts: -------------------------------------------------------------------------------- 1 | import { Payload, UseCase } from "almin"; 2 | import { AppSessionRepository, appSessionRepository } from "../infra/repository/AppSessionRepository.js"; 3 | import { HatebuRepository, hatebuRepository } from "../infra/repository/HatebuRepository.js"; 4 | import { browserHistory } from "../infra/browser/browserHistory.js"; 5 | import { History } from "history"; 6 | import debug0 from "debug"; 7 | const debug = debug0("hatebupwa:SwitchCurrentHatebuUserUseCase"); 8 | 9 | export const createSwitchCurrentHatebuUserUseCase = () => { 10 | return new SwitchCurrentHatebuUserUseCase({ 11 | appSessionRepository, 12 | hatebuRepository, 13 | browserHistory 14 | }); 15 | }; 16 | 17 | export class SwitchCurrentHatebuUserUseCasePayload extends Payload { 18 | type = "SwitchCurrentHatebuUserUseCase"; 19 | 20 | constructor(public userName: string) { 21 | super(); 22 | } 23 | } 24 | 25 | export class SwitchCurrentHatebuUserUseCase extends UseCase { 26 | private browserHistory: History; 27 | 28 | constructor( 29 | private repo: { 30 | appSessionRepository: AppSessionRepository; 31 | hatebuRepository: HatebuRepository; 32 | browserHistory: History; 33 | } 34 | ) { 35 | super(); 36 | this.browserHistory = repo.browserHistory; 37 | } 38 | 39 | async execute(userName: string) { 40 | const hatebu = this.repo.hatebuRepository.findByUserName(userName); 41 | debug("current hatebu: %o", hatebu); 42 | this.dispatch(new SwitchCurrentHatebuUserUseCasePayload(userName)); 43 | // TODO: FIXME history handling 44 | if (this.browserHistory.location.pathname !== `/user/${encodeURIComponent(userName)}`) { 45 | debug("push pathname %s", browserHistory.location.pathname); 46 | this.browserHistory.push(`/user/${encodeURIComponent(userName)}`); 47 | } 48 | if (hatebu) { 49 | const appSession = this.repo.appSessionRepository.get(); 50 | const newSession = appSession.setCurrentUsedHatebu(hatebu); 51 | debug("new session %o", newSession); 52 | await this.repo.appSessionRepository.save(newSession); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | ### https://raw.github.com/github/gitignore/608690d6b9a78c2a003affc792e49a84905b3118/Node.gitignore 23 | 24 | # Logs 25 | logs 26 | *.log 27 | 28 | # Runtime data 29 | pids 30 | *.pid 31 | *.seed 32 | 33 | # Directory for instrumented libs generated by jscoverage/JSCover 34 | lib-cov 35 | 36 | # Coverage directory used by tools like istanbul 37 | coverage 38 | 39 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 40 | .grunt 41 | 42 | # node-waf configuration 43 | .lock-wscript 44 | 45 | # Compiled binary addons (http://nodejs.org/api/addons.html) 46 | build/Release 47 | 48 | # Dependency directory 49 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 50 | node_modules 51 | 52 | # Debug log from npm 53 | npm-debug.log 54 | 55 | 56 | ### https://raw.github.com/github/gitignore/608690d6b9a78c2a003affc792e49a84905b3118/Global/JetBrains.gitignore 57 | 58 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm 59 | 60 | *.iml 61 | 62 | ## Directory-based project format: 63 | .idea/ 64 | # if you remove the above rule, at least ignore the following: 65 | 66 | # User-specific stuff: 67 | # .idea/workspace.xml 68 | # .idea/tasks.xml 69 | # .idea/dictionaries 70 | 71 | # Sensitive or high-churn files: 72 | # .idea/dataSources.ids 73 | # .idea/dataSources.xml 74 | # .idea/sqlDataSources.xml 75 | # .idea/dynamic.xml 76 | # .idea/uiDesigner.xml 77 | 78 | # Gradle: 79 | # .idea/gradle.xml 80 | # .idea/libraries 81 | 82 | # Mongo Explorer plugin: 83 | # .idea/mongoSettings.xml 84 | 85 | ## File-based project format: 86 | *.ipr 87 | *.iws 88 | 89 | ## Plugin-specific files: 90 | 91 | # IntelliJ 92 | out/ 93 | 94 | # mpeltonen/sbt-idea plugin 95 | .idea_modules/ 96 | 97 | # JIRA plugin 98 | atlassian-ide-plugin.xml 99 | 100 | # Crashlytics plugin (for Android Studio and IntelliJ) 101 | com_crashlytics_export_strings.xml 102 | crashlytics.properties 103 | crashlytics-build.properties 104 | 105 | 106 | /lib 107 | /public/workers/ 108 | /dist/ 109 | -------------------------------------------------------------------------------- /src/domain/Hatebu/Bookmark.ts: -------------------------------------------------------------------------------- 1 | import { Identifier, Serializer, ValueObject } from "ddd-base"; 2 | import { BookmarkItem, BookmarkItemConverter, BookmarkItemJSON } from "./BookmarkItem.js"; 3 | import { matchBookmarkItem } from "./BookmarkSearch.js"; 4 | import { BookmarkDate } from "./BookmarkDate.js"; 5 | import uniqBy from "lodash.uniqby"; 6 | 7 | export const BookmarkConverter: Serializer = { 8 | fromJSON(json) { 9 | return new Bookmark({ 10 | items: json.items.map((item) => BookmarkItemConverter.fromJSON(item)), 11 | lastUpdated: BookmarkDate.fromUnixTime(json.lastUpdated) 12 | }); 13 | }, 14 | toJSON(entity) { 15 | return { 16 | items: entity.props.items.map((item) => BookmarkItemConverter.toJSON(item)), 17 | lastUpdated: entity.props.lastUpdated.unixTime 18 | }; 19 | } 20 | }; 21 | 22 | export interface BookmarkJSON { 23 | items: BookmarkItemJSON[]; 24 | lastUpdated: number; 25 | } 26 | 27 | export class BookmarkIdentifier extends Identifier {} 28 | 29 | export interface BookmarkProps { 30 | readonly items: BookmarkItem[]; 31 | readonly lastUpdated: BookmarkDate; 32 | } 33 | 34 | // declaration merging 35 | export interface Bookmark extends BookmarkProps {} 36 | 37 | export class Bookmark extends ValueObject implements Bookmark { 38 | constructor(props: BookmarkProps) { 39 | super(props); 40 | Object.assign(this, props); 41 | } 42 | 43 | get totalCount() { 44 | return this.props.items.length; 45 | } 46 | 47 | /** 48 | * @param {string[]} searchWords searchWord is string-like pattern 49 | * @returns {BookmarkItem[]} 50 | */ 51 | getMatchedItems(searchWords: string[]) { 52 | return this.findItemsByMatch((item) => matchBookmarkItem(item, searchWords)); 53 | } 54 | 55 | findItemsByMatch(predicate: (item: BookmarkItem) => boolean) { 56 | return this.props.items.filter(predicate); 57 | } 58 | 59 | updateBookmarkItems(items: BookmarkItem[], lastUpdated = new Date()) { 60 | return new Bookmark({ 61 | ...this.props, 62 | items: items, 63 | lastUpdated: new BookmarkDate(lastUpdated) 64 | }); 65 | } 66 | 67 | /** 68 | * 69 | * @param {BookmarkItem[]} items new is first 70 | * @param {Date} lastUpdated 71 | * @returns {Bookmark} 72 | */ 73 | addBookmarkItems(items: BookmarkItem[], lastUpdated = new Date()) { 74 | if (items.length === 0) { 75 | return this; 76 | } 77 | const bookmarkItems = items.concat(this.props.items); 78 | return this.updateBookmarkItems( 79 | uniqBy(bookmarkItems, (item) => item.props.url), 80 | lastUpdated 81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/container/UserFormContainer/UserFormContainer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { UserForm } from "../../component/UserForm/UserForm"; 3 | import { context } from "../../Context"; 4 | import { createCreateHatebuUserUseCase } from "../../use-case/CreateHatebuUserUseCase"; 5 | import { UserFormContainerState } from "./UserFormContainerStore"; 6 | import { createFetchInitialHatenaBookmarkUseCase } from "../../use-case/hatebu-api/InitializeWithNewHatenaBookmarkUseCase"; 7 | import { AppState } from "../AppStore"; 8 | import { createSwitchCurrentHatebuUserUseCase } from "../../use-case/SwitchCurrentHatebuUserUseCase"; 9 | import { FocusMatcher } from "../../component/FocusMatcher/FocusMatcher"; 10 | 11 | export interface UserFormContainerProps { 12 | app: AppState; 13 | userFormContainer: UserFormContainerState; 14 | } 15 | 16 | export class UserFormContainer extends React.PureComponent { 17 | private onSubmit = async (userName: string) => { 18 | try { 19 | context 20 | .useCase(createCreateHatebuUserUseCase()) 21 | .executor((useCase) => useCase.execute(userName)) 22 | .then( 23 | () => { 24 | return context.useCase(createSwitchCurrentHatebuUserUseCase()).execute(userName); 25 | }, 26 | async () => { 27 | return context.useCase(createSwitchCurrentHatebuUserUseCase()).execute(userName); 28 | } 29 | ); 30 | } catch (error) { 31 | console.error(error); 32 | } 33 | }; 34 | private onClickRebuild = async (userName: string) => { 35 | try { 36 | await context 37 | .useCase(createCreateHatebuUserUseCase()) 38 | .execute(userName) 39 | .catch((error) => { 40 | console.warn("Already create, but it can be ignored", error); 41 | }); 42 | await context.useCase(createSwitchCurrentHatebuUserUseCase()).execute(userName); 43 | await context.useCase(createFetchInitialHatenaBookmarkUseCase()).execute(userName); 44 | } catch (error) { 45 | console.error(error); 46 | } 47 | }; 48 | 49 | render() { 50 | return ( 51 |
52 | ( 55 | 62 | )} 63 | /> 64 |
65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hatebupwa", 3 | "version": "1.0.0", 4 | "description": "Hatena Bookmark search app.", 5 | "homepage": "https://hatebupwa.netlify.app/", 6 | "private": true, 7 | "type": "module", 8 | "scripts": { 9 | "build": "vite build", 10 | "build:worker": "vite build vite.config.worker.js", 11 | "test": "vitest run", 12 | "sw:generate": "workbox generateSW workbox-config.js", 13 | "package": "node tools/package-app.js", 14 | "format": "prettier --write \"**/*.{js,jsx,ts,tsx,css}\"", 15 | "prepare": "git config --local core.hooksPath .githooks", 16 | "dev": "vite" 17 | }, 18 | "prettier": { 19 | "singleQuote": false, 20 | "printWidth": 120, 21 | "tabWidth": 4, 22 | "trailingComma": "none" 23 | }, 24 | "dependencies": { 25 | "@almin/react-context": "^1.1.1", 26 | "@types/debug": "^0.0.30", 27 | "@types/history": "^4.6.2", 28 | "@types/lodash.uniqby": "^4.5.6", 29 | "@types/prop-types": "^15.5.2", 30 | "@uifabric/icons": "^7.1.1", 31 | "almin": "^0.17.1", 32 | "almin-logger": "^6.2.1", 33 | "asocial-bookmark": "^1.0.3", 34 | "comlink": "^4.0.2", 35 | "date-fns": "^1.29.0", 36 | "ddd-base": "^0.6.0", 37 | "debounce-promise": "^3.1.0", 38 | "debug": "^3.1.0", 39 | "hatebu-mydata-parser": "^1.0.0", 40 | "highlight-words-core": "^1.2.0", 41 | "history": "^4.7.2", 42 | "localforage": "^1.7.1", 43 | "localforage-memoryStorageDriver": "^0.9.2", 44 | "lodash.uniqby": "^4.5.0", 45 | "micro-memoize": "^2.0.1", 46 | "office-ui-fabric-react": "^7.19.1", 47 | "react": "^16.3.2", 48 | "react-dom": "^16.3.2", 49 | "react-highlight-words": "^0.11.0", 50 | "react-routing-resolver": "3.0.0", 51 | "react-scripts": "^3.0.1", 52 | "regex-combiner": "^1.0.1", 53 | "ulid": "^2.3.0" 54 | }, 55 | "devDependencies": { 56 | "@types/jest": "^22.2.2", 57 | "@types/node": "^20.11.5", 58 | "@types/react": "16.8.24", 59 | "@types/react-dom": "16.8.5", 60 | "@vitejs/plugin-react": "^4.2.1", 61 | "express": "^4.16.3", 62 | "http-proxy-middleware": "^0.19.1", 63 | "lint-staged": "^12.1.2", 64 | "nativefier": "^7.6.1", 65 | "npm-run-all": "^4.1.5", 66 | "prettier": "^2.4.1", 67 | "ts-loader": "^6.0.2", 68 | "typescript": "^3.5.1", 69 | "vite": "^5.1.1", 70 | "vite-tsconfig-paths": "^4.3.1", 71 | "vitest": "^1.6.0", 72 | "workbox-cli": "^3.1.0" 73 | }, 74 | "resolutions": { 75 | "@types/react": "16.3.8", 76 | "@types/react-dom": "16.0.5" 77 | }, 78 | "lint-staged": { 79 | "*.{js,jsx,ts,tsx,css}": [ 80 | "prettier --write" 81 | ] 82 | }, 83 | "husky": { 84 | "hooks": { 85 | "post-commit": "git reset", 86 | "pre-commit": "lint-staged" 87 | } 88 | }, 89 | "browserslist": { 90 | "production": [ 91 | ">0.2%", 92 | "not dead", 93 | "not op_mini all" 94 | ], 95 | "development": [ 96 | "last 1 chrome version", 97 | "last 1 firefox version", 98 | "last 1 safari version" 99 | ] 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/component/UserForm/UserForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { FormEvent } from "react"; 3 | import { DefaultButton, Label, Spinner, SpinnerSize, TextField } from "office-ui-fabric-react"; 4 | 5 | export interface UserFormProps { 6 | userName?: string; 7 | autoFocus: boolean; 8 | // lock input and button 9 | isLocked: boolean; 10 | onSubmit: (name: string) => void; 11 | onClickRebuild: (name: string) => void; 12 | } 13 | 14 | export class UserForm extends React.PureComponent { 15 | state = { 16 | value: this.props.userName || "" 17 | }; 18 | private onSubmit = (event: FormEvent) => { 19 | event.preventDefault(); 20 | this.props.onSubmit(this.state.value || ""); 21 | }; 22 | 23 | private onClick = () => { 24 | this.props.onSubmit(this.state.value || ""); 25 | }; 26 | 27 | private onClickRebuild = () => { 28 | this.props.onClickRebuild(this.state.value || ""); 29 | }; 30 | private onChangeTextField = ( 31 | _event: React.FormEvent, 32 | newValue?: string 33 | ) => { 34 | this.setState({ 35 | value: newValue || "" 36 | }); 37 | }; 38 | 39 | render() { 40 | return ( 41 |
42 | 43 |
44 |
45 | 55 |
56 |
57 | {this.props.isLocked ? ( 58 | 59 | ) : ( 60 | 77 | )} 78 |
79 |
80 |
81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/container/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { UserFormContainer } from "./UserFormContainer/UserFormContainer"; 3 | import { SearchContainer } from "./SearchContainer/SearchContainer"; 4 | import { Consumer, context } from "../Context"; 5 | import { createInitializeSystemUseCase } from "../use-case/InitializeSystemUseCase"; 6 | import { Route, Router } from "react-routing-resolver"; 7 | import { createRefreshHatenaBookmarkUseCase } from "../use-case/hatebu-api/RefreshHatenaBookmarkUseCase"; 8 | import { createSwitchCurrentHatebuUserUseCase } from "../use-case/SwitchCurrentHatebuUserUseCase"; 9 | import { createRestoreLastSessionUseCase } from "../use-case/RestoreLastSessionUseCase"; 10 | import { browserHistory } from "../infra/browser/browserHistory"; 11 | import { PageVisibility } from "../component/PageVisibility/PageVisibility"; 12 | import { Link } from "office-ui-fabric-react"; 13 | 14 | export interface AppState { 15 | isInitialized: boolean; 16 | } 17 | 18 | export class App extends React.PureComponent<{}, AppState> { 19 | state = { 20 | isInitialized: false, 21 | routeComponent: null 22 | }; 23 | 24 | private onVisibleUserPage = (args: { name: string }) => { 25 | // refresh on visible 26 | context.useCase(createRefreshHatenaBookmarkUseCase()).executor((useCase) => useCase.execute(args.name)); 27 | }; 28 | 29 | componentDidMount() { 30 | context 31 | .useCase(createInitializeSystemUseCase()) 32 | .executor((useCase) => useCase.execute()) 33 | .then(() => { 34 | this.setState({ 35 | isInitialized: true 36 | }); 37 | }); 38 | } 39 | 40 | private onMatchUser = async (args: { name: string }) => { 41 | const userName = args.name; 42 | try { 43 | await context 44 | .useCase(createSwitchCurrentHatebuUserUseCase()) 45 | .executor((useCase) => useCase.execute(userName)); 46 | await context 47 | .useCase(createRefreshHatenaBookmarkUseCase()) 48 | .executor((useCase) => useCase.execute(userName)); 49 | } catch (error) { 50 | console.error(error); 51 | } 52 | }; 53 | 54 | private onMatchOther = () => {}; 55 | private onMatchHome = async () => { 56 | await context.useCase(createRestoreLastSessionUseCase()).executor((useCase) => useCase.execute()); 57 | }; 58 | 59 | render() { 60 | return ( 61 | <> 62 | {this.state.isInitialized ? ( 63 | 64 | { 68 | return ( 69 | { 71 | this.onVisibleUserPage(args); 72 | }} 73 | /> 74 | ); 75 | }} 76 | /> 77 | 78 | 79 | 80 | ) : null} 81 | 82 |
83 |

84 | はてなブックマーク検索 85 |

86 | 87 | {(state) => { 88 | return ( 89 | <> 90 | 91 | 92 | 93 | ); 94 | }} 95 | 96 |
97 | 98 | ); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/component/HatebuSearchList/HatebuSearchList.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { 3 | FocusZone, 4 | FocusZoneDirection, 5 | FocusZoneTabbableElements, 6 | ITextField, 7 | Link, 8 | List, 9 | TextField 10 | } from "office-ui-fabric-react"; 11 | import * as Comlink from "comlink"; 12 | import { HatebuSearchListItem } from "../../container/SearchContainer/SearchContainerStore"; 13 | import { KeyboardEvent } from "react"; 14 | import format from "date-fns/format"; 15 | // @ts-expect-error: no types 16 | import debouncePromise from "debounce-promise"; 17 | // @ts-expect-error: no types 18 | import Highlighter from "react-highlight-words"; 19 | import FilterWorker from "../../../workers/filter?worker"; 20 | export interface HatebuSearchListProps { 21 | autoFocus: boolean; 22 | items: HatebuSearchListItem[]; 23 | } 24 | 25 | export interface HatebuSearchListState { 26 | filterWords: string[]; 27 | items: HatebuSearchListItem[]; 28 | originalItems: HatebuSearchListItem[]; 29 | refreshFlag: boolean; 30 | } 31 | 32 | export interface HatebuSearchListItemProps extends HatebuSearchListItem { 33 | filterWords: string[]; 34 | } 35 | 36 | export const HatebuSearchListItemComponent = (item: HatebuSearchListItemProps) => { 37 | const onKeyPress = (event: KeyboardEvent) => { 38 | if (event.key === "Enter") { 39 | window.open(item.props.url); 40 | } 41 | }; 42 | 43 | return ( 44 |
45 |
46 |
47 |
48 | 49 | 55 | 56 |
57 |
58 | 64 |
65 |
66 |
{format(item.props.date.date, "YYYY-MM-DD HH:mm")}
67 |
68 |
69 | ); 70 | }; 71 | 72 | export class HatebuSearchList extends React.PureComponent { 73 | state = { 74 | filterWords: [], 75 | items: [], 76 | originalItems: [], 77 | refreshFlag: false 78 | }; 79 | private filterWorker!: Worker; 80 | private textFieldRef = React.createRef(); 81 | private workerAPI!: import("../../../workers/filter").WorkerAPI; 82 | 83 | componentDidMount() { 84 | this.filterWorker = new FilterWorker(); 85 | this.workerAPI = Comlink.wrap(this.filterWorker); 86 | if (this.props.autoFocus) { 87 | this.focus(); 88 | } 89 | } 90 | 91 | static getDerivedStateFromProps(nextProps: HatebuSearchListProps, state: HatebuSearchListState) { 92 | // props.items => originalItems 93 | if (nextProps.items !== state.originalItems) { 94 | return { 95 | refreshFlag: true, 96 | originalItems: nextProps.items 97 | }; 98 | } 99 | return null; 100 | } 101 | 102 | componentDidUpdate(prevProps: HatebuSearchListProps) { 103 | // If no input and got new items, refresh worker 104 | const isEmptyItems = this.state.filterWords.length === 0; 105 | if (this.state.refreshFlag && isEmptyItems) { 106 | this.setState( 107 | { 108 | refreshFlag: false, 109 | items: this.state.originalItems 110 | }, 111 | () => { 112 | return this.workerAPI.init(this.state.originalItems); 113 | } 114 | ); 115 | } 116 | if (this.props.autoFocus !== prevProps.autoFocus) { 117 | this.focus(); 118 | } 119 | } 120 | 121 | public focus = () => { 122 | if (this.textFieldRef.current) { 123 | this.textFieldRef.current.focus(); 124 | } 125 | }; 126 | 127 | render() { 128 | const { originalItems, items } = this.state; 129 | const resultCountText = 130 | items.length === originalItems.length ? "" : ` (${items.length} of ${originalItems.length} shown)`; 131 | 132 | return ( 133 | 138 | 148 | 149 | 150 | ); 151 | } 152 | 153 | private onFilterChange = (event?: any, newValue?: string) => { 154 | if (newValue !== undefined) { 155 | this.onFilterChanged(newValue); 156 | } 157 | }; 158 | private onFilterChanged = debouncePromise((text: string) => { 159 | const filterWords = text.split(/\s/).filter((text) => text.length > 0); 160 | return this.workerAPI 161 | .filter(filterWords) 162 | .then((items: HatebuSearchListItem[]) => { 163 | return new Promise((resolve) => { 164 | this.setState( 165 | { 166 | filterWords: filterWords, 167 | items: items 168 | }, 169 | resolve 170 | ); 171 | }); 172 | }) 173 | .catch((error: Error) => { 174 | console.log(error); 175 | }); 176 | }, 100); 177 | 178 | private onRenderCell = (item: any, index: number | undefined): JSX.Element => { 179 | return ; 180 | }; 181 | } 182 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "node:path"; 2 | import { readFileSync } from "node:fs"; 3 | import { defineConfig, loadEnv, Plugin } from "vite"; 4 | import react from "@vitejs/plugin-react"; 5 | import tsconfigPaths from "vite-tsconfig-paths"; 6 | import setupProxy from "./src/setupProxy"; 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig(({ mode }) => { 10 | setEnv(mode); 11 | return { 12 | plugins: [ 13 | react(), 14 | tsconfigPaths(), 15 | envPlugin(), 16 | devServerPlugin(), 17 | sourcemapPlugin(), 18 | buildPathPlugin(), 19 | basePlugin(), 20 | importPrefixPlugin(), 21 | htmlPlugin(mode), 22 | 23 | setupProxyPlugin() 24 | ] 25 | }; 26 | }); 27 | 28 | function setEnv(mode: string) { 29 | Object.assign(process.env, loadEnv(mode, ".", ["REACT_APP_", "NODE_ENV", "PUBLIC_URL"])); 30 | process.env.NODE_ENV ||= mode; 31 | const { homepage } = JSON.parse(readFileSync("package.json", "utf-8")); 32 | if (process.env.BRANCH !== "master" && process.env.DEPLOY_PRIME_URL) { 33 | process.env.PUBLIC_URL ||= process.env.DEPLOY_PRIME_URL; 34 | } else { 35 | process.env.PUBLIC_URL ||= homepage 36 | ? `${homepage.startsWith("http") || homepage.startsWith("/") ? homepage : `/${homepage}`}`.replace( 37 | /\/$/, 38 | "" 39 | ) 40 | : ""; 41 | } 42 | } 43 | 44 | // Expose `process.env` environment variables to your client code 45 | // Migration guide: Follow the guide below to replace process.env with import.meta.env in your app, you may also need to rename your environment variable to a name that begins with VITE_ instead of REACT_APP_ 46 | // https://vitejs.dev/guide/env-and-mode.html#env-variables 47 | function envPlugin(): Plugin { 48 | return { 49 | name: "env-plugin", 50 | config(_, { mode }) { 51 | const env = loadEnv(mode, ".", ["REACT_APP_", "NODE_ENV", "PUBLIC_URL"]); 52 | return { 53 | define: Object.fromEntries( 54 | Object.entries(env).map(([key, value]) => [`process.env.${key}`, JSON.stringify(value)]) 55 | ) 56 | }; 57 | } 58 | }; 59 | } 60 | 61 | // Setup HOST, SSL, PORT 62 | // Migration guide: Follow the guides below 63 | // https://vitejs.dev/config/server-options.html#server-host 64 | // https://vitejs.dev/config/server-options.html#server-https 65 | // https://vitejs.dev/config/server-options.html#server-port 66 | function devServerPlugin(): Plugin { 67 | return { 68 | name: "dev-server-plugin", 69 | config(_, { mode }) { 70 | const { HOST, PORT, HTTPS, SSL_CRT_FILE, SSL_KEY_FILE } = loadEnv(mode, ".", [ 71 | "HOST", 72 | "PORT", 73 | "HTTPS", 74 | "SSL_CRT_FILE", 75 | "SSL_KEY_FILE" 76 | ]); 77 | const https = HTTPS === "true"; 78 | return { 79 | server: { 80 | host: HOST || "localhost", 81 | port: parseInt(PORT || "3000", 10), 82 | open: true, 83 | ...(https && 84 | SSL_CRT_FILE && 85 | SSL_KEY_FILE && { 86 | https: { 87 | cert: readFileSync(resolve(SSL_CRT_FILE)), 88 | key: readFileSync(resolve(SSL_KEY_FILE)) 89 | } 90 | }) 91 | } 92 | }; 93 | } 94 | }; 95 | } 96 | 97 | // Migration guide: Follow the guide below 98 | // https://vitejs.dev/config/build-options.html#build-sourcemap 99 | function sourcemapPlugin(): Plugin { 100 | return { 101 | name: "sourcemap-plugin", 102 | config(_, { mode }) { 103 | const { GENERATE_SOURCEMAP } = loadEnv(mode, ".", ["GENERATE_SOURCEMAP"]); 104 | return { 105 | build: { 106 | sourcemap: GENERATE_SOURCEMAP === "true" 107 | } 108 | }; 109 | } 110 | }; 111 | } 112 | 113 | // Migration guide: Follow the guide below 114 | // https://vitejs.dev/config/build-options.html#build-outdir 115 | function buildPathPlugin(): Plugin { 116 | return { 117 | name: "build-path-plugin", 118 | config(_, { mode }) { 119 | const { BUILD_PATH } = loadEnv(mode, ".", ["BUILD_PATH"]); 120 | return { 121 | build: { 122 | outDir: BUILD_PATH || "build" 123 | } 124 | }; 125 | } 126 | }; 127 | } 128 | 129 | // Migration guide: Follow the guide below and remove homepage field in package.json 130 | // https://vitejs.dev/config/shared-options.html#base 131 | function basePlugin(): Plugin { 132 | return { 133 | name: "base-plugin", 134 | config(_, { mode }) { 135 | const { PUBLIC_URL } = loadEnv(mode, ".", ["PUBLIC_URL"]); 136 | return { 137 | base: PUBLIC_URL || "" 138 | }; 139 | } 140 | }; 141 | } 142 | 143 | // To resolve modules from node_modules, you can prefix paths with ~ 144 | // https://create-react-app.dev/docs/adding-a-sass-stylesheet 145 | // Migration guide: Follow the guide below 146 | // https://vitejs.dev/config/shared-options.html#resolve-alias 147 | function importPrefixPlugin(): Plugin { 148 | return { 149 | name: "import-prefix-plugin", 150 | config() { 151 | return { 152 | resolve: { 153 | alias: [{ find: /^~([^/])/, replacement: "$1" }] 154 | } 155 | }; 156 | } 157 | }; 158 | } 159 | 160 | // Configuring the Proxy Manually 161 | // https://create-react-app.dev/docs/proxying-api-requests-in-development/#configuring-the-proxy-manually 162 | // https://vitejs.dev/guide/api-plugin.html#configureserver 163 | // Migration guide: Follow the guide below and remove src/setupProxy 164 | // https://vitejs.dev/config/server-options.html#server-proxy 165 | function setupProxyPlugin(): Plugin { 166 | return { 167 | name: "setup-proxy-plugin", 168 | config() { 169 | return { 170 | server: { proxy: {} } 171 | }; 172 | }, 173 | configureServer(server) { 174 | setupProxy(server.middlewares); 175 | } 176 | }; 177 | } 178 | 179 | // Replace %ENV_VARIABLES% in index.html 180 | // https://vitejs.dev/guide/api-plugin.html#transformindexhtml 181 | // Migration guide: Follow the guide below, you may need to rename your environment variable to a name that begins with VITE_ instead of REACT_APP_ 182 | // https://vitejs.dev/guide/env-and-mode.html#html-env-replacement 183 | function htmlPlugin(mode: string): Plugin { 184 | const env = loadEnv(mode, ".", ["REACT_APP_", "NODE_ENV", "PUBLIC_URL"]); 185 | return { 186 | name: "html-plugin", 187 | transformIndexHtml: { 188 | order: "pre", 189 | handler(html) { 190 | return html.replace(/%(.*?)%/g, (match, p1) => env[p1] ?? match); 191 | } 192 | } 193 | }; 194 | } 195 | --------------------------------------------------------------------------------