Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

recactor(web): migrate to App Router #5

Merged
merged 2 commits into from
Feb 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ cp .env.example .env
```
S3_AWS_ACCESS_KEY_ID="x"
S3_AWS_SECRET_ACCESS_KEY="x"
S3_AWS_REGION="us-east-1"
S3_BUCKET_NAME="x"
TWITTER_APP_KEY="YOUR_TWITTER_API_KEY"
TWITTER_APP_SECRET="YOUR_TWITTER_API_KEY_SECRET"
Expand Down Expand Up @@ -88,6 +89,7 @@ Example Permission policies:
```shell
S3_AWS_ACCESS_KEY_ID="x"
S3_AWS_SECRET_ACCESS_KEY="x"
S3_AWS_REGION="us-east-1"
S3_BUCKET_NAME="x"
TWITTER_APP_KEY="YOUR_TWITTER_API_KEY"
TWITTER_APP_SECRET="YOUR_TWITTER_API_KEY_SECRET"
Expand All @@ -104,6 +106,7 @@ If you want to get [Bluesky](https://bsky.app/) posts, you can use `yarn run fet
```ts
S3_AWS_ACCESS_KEY_ID="x"
S3_AWS_SECRET_ACCESS_KEY="x"
S3_AWS_REGION="us-east-1"
S3_BUCKET_NAME="x"
BLUESKY_IDENTIFIER="xxx.bsky.social"
BLUESKY_APPPASSWORD="x"
Expand Down Expand Up @@ -192,6 +195,7 @@ This template repository includes [.github/workflows/update.yml](.github/workflo
2. Put following env to Action's secrets
- `S3_AWS_ACCESS_KEY_ID`
- `S3_AWS_SECRET_ACCESS_KEY`
- `S3_AWS_REGION`
- `S3_BUCKET_NAME`
- If you want to fetch tweets
- `TWITTER_APP_KEY`
Expand Down
36 changes: 36 additions & 0 deletions web/app/client/CompositionInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"use client";
import { CSSProperties, useCallback, useState } from "react";

export function CompositionInput(props: { style?: CSSProperties; value: string; onInput: (value: string) => void }) {
const [inputValue, setInputValue] = useState(props.value);
const [isComposing, setIsComposing] = useState(false);
const onInput = useCallback(
(event) => {
const value = event.currentTarget.value;
setInputValue(value);
if (!isComposing) {
props.onInput(value);
}
},
[isComposing]
);
const onCompositionStart = useCallback((e) => {
setIsComposing(true);
}, []);
const onCompositionEnd = useCallback((event) => {
setIsComposing(false);
const value = event.currentTarget.value;
setInputValue(value);
props.onInput(value);
}, []);
return (
<input
type={"text"}
value={inputValue}
onInput={onInput}
style={props.style}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
/>
);
}
58 changes: 58 additions & 0 deletions web/app/client/SearchBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"use client";
import { useDeferredValue, useEffect, useMemo, useState, useTransition } from "react";
import { CompositionInput } from "./CompositionInput";
import { useTypeUrlSearchParams } from "../lib/useTypeUrlSearchParams";
import { HomPageSearchParam } from "../page";
import { useTransitionContext } from "./TransitionContext";

const debounce = (fn: (..._: any[]) => void, delay: number) => {
let timeout: any;
return (...args: any[]) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
fn(...args);
}, delay);
};
};
const useSearch = ({ query }: { query?: string }) => {
const [isSearching, startTransition] = useTransition();
const [inputQuery, setInputQuery] = useState(query ?? "");
const deferredInputValue = useDeferredValue(inputQuery);
const searchParams = useTypeUrlSearchParams<HomPageSearchParam>();
const { setIsLoadingTimeline } = useTransitionContext();
useEffect(() => {
setIsLoadingTimeline(isSearching);
}, [isSearching]);
useEffect(() => {
if (deferredInputValue === query) return;
startTransition(() => {
searchParams.pushParams({
q: deferredInputValue
});
});
}, [deferredInputValue]);
const handlers = useMemo(
() => ({
search: debounce((query: string) => {
setInputQuery(query);
}, 300)
}),
[]
);
return {
inputQuery,
isSearching,
handlers
};
};
export const SearchBox = (props: { query?: string }) => {
const { inputQuery, handlers } = useSearch({ query: props.query });
return (
<div>
<label>
Search:
<CompositionInput value={inputQuery} onInput={handlers.search} style={{ width: "100%" }} />
</label>
</div>
);
};
50 changes: 50 additions & 0 deletions web/app/client/SearchMore.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"use client";
import { LineTweetResponse } from "../server/search";
import { useMemo, useTransition } from "react";
import { useTypeUrlSearchParams } from "../lib/useTypeUrlSearchParams";
import { HomPageSearchParam } from "../page";

export const useSearchMore = (props: { searchResults: LineTweetResponse[] }) => {
const searchParams = useTypeUrlSearchParams<HomPageSearchParam>();
const [isLoadingMore, startTransition] = useTransition();
const handlers = useMemo(() => {
return {
handleMoreTweets: () => {
const lastItemTimeStamp = props.searchResults[props.searchResults.length - 1].timestamp;
if (!lastItemTimeStamp) {
return;
}
startTransition(() => {
searchParams.pushParams({
timestamp: String(lastItemTimeStamp)
});
});
}
};
}, [props.searchResults]);
return {
handlers,
isLoadingMore
} as const;
};
export const SearchMore = (props: { searchResults: LineTweetResponse[] }) => {
const { handlers, isLoadingMore } = useSearchMore(props);
return (
<div
style={{
display: "flex",
flexDirection: "column"
}}
>
<button
onClick={handlers.handleMoreTweets}
disabled={isLoadingMore}
style={{
opacity: isLoadingMore ? 0.5 : 1
}}
>
More Tweets
</button>
</div>
);
};
19 changes: 19 additions & 0 deletions web/app/client/SearchResultContentWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"use client";
import { useTransitionContext } from "./TransitionContext";

export const SearchResultContentWrapper = (props: { children: React.ReactNode }) => {
const { isLoadingTimeline } = useTransitionContext();
return (
<div
style={
isLoadingTimeline
? {
opacity: 0.5
}
: {}
}
>
{props.children}
</div>
);
};
26 changes: 26 additions & 0 deletions web/app/client/TransitionContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"use client";
import { createContext, ReactNode, useContext, useState } from "react";

export type TransitionContext = {
isLoadingTimeline: boolean;
setIsLoadingTimeline: (isLoading: boolean) => void;
};
const TransitionContext = createContext<TransitionContext>({
isLoadingTimeline: false,
setIsLoadingTimeline: () => {}
});
export const TransitionContextProvider = (props: { children: ReactNode }) => {
const [isLoadingTimeline, setIsLoadingTimeline] = useState(false);
return (
<TransitionContext.Provider value={{ isLoadingTimeline, setIsLoadingTimeline }}>
{props.children}
</TransitionContext.Provider>
);
};
export const useTransitionContext = () => {
const context = useContext(TransitionContext);
if (!context) {
throw new Error("useTransitionContext must be used within a TransitionContextProvider");
}
return context;
};
Loading
Loading