diff --git a/README.md b/README.md index 71e7b39..e4dd969 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,47 @@ -# GitHub GraphQL Demo with Relay +# 🤠 Git Ranch - Wrangle Yer Pull Requests -![GitHub Pull Request Viewer](./example.png) +![Git Ranch - Cattle Roundup](./example.png) -A Vite + React application that authenticates with GitHub using OAuth and displays your open pull requests using the GitHub GraphQL API with Relay. +Yeehaw, partner! Git Ranch is a rootin' tootin' Vite + React application that lets you saddle up with GitHub and wrangle all yer open pull requests using the GitHub GraphQL API with Relay. ## Features -- 🔐 GitHub OAuth authentication -- 📊 GraphQL queries using Relay -- 🔀 View all your open pull requests across repositories -- 📈 See PR status, review decisions, and code changes -- 🎨 Modern UI with Tailwind CSS -- 📱 Responsive design -- 🌙 Dark mode support -- ⚡ Type-safe with TypeScript -- 🚀 Fast development with Vite +- 🐴 **Saddle Up** - GitHub OAuth authentication to join the ranch +- 🐄 **Cattle Roundup** - GraphQL queries using Relay to round up yer PRs +- 🤠 **Wrangle PRs** - View all yer open pull requests across the range +- 🏷️ **Brand Checkin'** - See review status (Branded & Ready, Needs Re-shoein', Needs Inspectin') +- 🎨 **Ranch Style** - Modern UI with Tailwind CSS +- 📱 **Trail Ready** - Responsive design for cowboys on the go +- 🌙 **Night Ridin'** - Dark mode for late night wranglin' +- ⚡ **Fast as Lightnin'** - Type-safe with TypeScript +- 🚀 **Quick Draw** - Fast development with Vite -## Tech Stack +## The Outfit (Tech Stack) -- **Vite** - Build tool and dev server -- **React 18** - UI library -- **React Router** - Client-side routing -- **Relay** - GraphQL client -- **Express** - OAuth token exchange server -- **GitHub GraphQL API** - Data source -- **Tailwind CSS v4** - Styling -- **TypeScript** - Type safety +- **Vite** - Build tool faster than a rattlesnake strike +- **React 18** - UI library for buildin' the ranch house +- **React Router** - Trail markers for client-side routing +- **Relay** - GraphQL wrangler for fetchin' data +- **Express** - OAuth token exchange at the tradin' post +- **GitHub GraphQL API** - The cattle source +- **Tailwind CSS v4** - Ranch stylin' +- **TypeScript** - Type safety like a good fence -## Setup Instructions +## Hitchin' Up Instructions -### 1. Create a GitHub OAuth App +### 1. Register Yer Brand at GitHub -1. Go to [GitHub Developer Settings](https://github.com/settings/developers) +1. Mosey on over to [GitHub Developer Settings](https://github.com/settings/developers) 2. Click "New OAuth App" -3. Fill in the details: - - **Application name**: GraphQL Demo (or any name) +3. Fill in the ranch details: + - **Application name**: Git Ranch (or whatever brand ya fancy) - **Homepage URL**: `http://localhost:3000` - **Authorization callback URL**: `http://localhost:3000/callback` 4. Click "Register application" -5. Copy the **Client ID** -6. Generate a new **Client Secret** and copy it +5. Copy the **Client ID** (yer ranch brand) +6. Generate a new **Client Secret** and copy it (keep it secret, keep it safe) -### 2. Configure Environment Variables +### 2. Set Up the Bunkhouse (.env) 1. Copy the example environment file: @@ -49,98 +49,102 @@ A Vite + React application that authenticates with GitHub using OAuth and displa cp .env.example .env ``` -2. Edit `.env` and add your credentials: +2. Edit `.env` and add yer credentials: ```env - VITE_GITHUB_CLIENT_ID=your_github_client_id - GITHUB_CLIENT_SECRET=your_github_client_secret + VITE_GITHUB_CLIENT_ID=yer_github_client_id + GITHUB_CLIENT_SECRET=yer_github_client_secret VITE_REDIRECT_URI=http://localhost:3000/callback PORT=3001 ``` -### 3. Install Dependencies +### 3. Stock the Barn (Install Dependencies) ```bash npm install ``` -### 4. Compile Relay Queries +### 4. Prep the Lassos (Compile Relay Queries) ```bash npm run relay ``` -### 5. Run the Application +### 5. Open the Ranch Gates -This will start both the OAuth server (port 3001) and Vite dev server (port 3000): +This'll start both the OAuth server (port 3001) and Vite dev server (port 3000): ```bash npm start ``` -Or run them separately: +Or run 'em separately like two cowboys on patrol: ```bash -# Terminal 1 - OAuth server +# Terminal 1 - The Tradin' Post (OAuth server) npm run server -# Terminal 2 - Vite dev server +# Terminal 2 - The Ranch House (Vite dev server) npm run dev ``` -Open [http://localhost:3000](http://localhost:3000) in your browser. +Ride on over to [http://localhost:3000](http://localhost:3000) in yer browser. -## Usage +## How to Wrangle -1. Click "Sign in with GitHub" on the homepage -2. Authorize the application -3. View your open pull requests with details like: - - PR title and number - - Repository name - - Branch information (head → base) - - Review decision status (Approved, Changes Requested, Review Required) - - Draft status - - Code changes (+additions / -deletions) - - Created and updated dates +1. Click "🐴 Saddle Up with GitHub" at the saloon entrance +2. Authorize the ranch to access yer GitHub +3. View yer cattle (pull requests) with all the details: + - 🐮 PR title and number (cattle tag) + - 🏠 Repository name (which pasture) + - 🔀 Branch information (head → base trail) + - 🏷️ Brandin' status (Branded & Ready, Needs Re-shoein', Needs Inspectin') + - 📝 Draft status (Still Ropin') + - ➕➖ Code changes (+additions / -deletions) + - 🌅 When the roundup started and last wrangled -## Project Structure +## Ranch Layout (Project Structure) ``` ├── src/ │ ├── components/ -│ │ ├── PullRequestList.tsx # Pull request list with Relay query -│ │ └── ErrorBoundary.tsx # Error boundary component +│ │ ├── RoundupList.tsx # Cattle list (PRs) with Relay query +│ │ └── Tumbleweed.tsx # Error boundary (when things go sideways) │ ├── pages/ -│ │ ├── HomePage.tsx # Main page with auth logic -│ │ └── CallbackPage.tsx # OAuth callback handler +│ │ ├── Saloon.tsx # Main gathering hall +│ │ └── TrailPost.tsx # OAuth callback checkpoint │ ├── lib/ -│ │ ├── auth.ts # OAuth authentication logic +│ │ ├── auth.ts # Cowboy authentication │ │ └── relay/ │ │ └── environment.ts # Relay environment -│ ├── App.tsx # Root app component -│ ├── main.tsx # App entry point -│ └── index.css # Global styles +│ ├── App.tsx # The whole dang ranch +│ ├── main.tsx # Ranch entrance +│ └── index.css # Ranch dress code ├── __generated__/ # Relay generated files -├── server.js # OAuth token exchange server +├── server.js # OAuth token tradin' post ├── vite.config.ts # Vite configuration ├── relay.config.js # Relay compiler configuration -└── schema.graphql # GitHub GraphQL schema +└── schema.graphql # GitHub GraphQL schema (the brand book) ``` -## Available Scripts +## Ranch Commands -- `npm start` - Start both OAuth server and Vite dev server -- `npm run dev` - Start Vite development server only -- `npm run server` - Start OAuth server only -- `npm run build` - Build for production (includes Relay compilation) -- `npm run preview` - Preview production build -- `npm run relay` - Compile Relay queries -- `npm run lint` - Run ESLint +- `npm start` - Open the ranch (both servers) +- `npm run dev` - Just the ranch house (Vite) +- `npm run server` - Just the tradin' post (OAuth) +- `npm run build` - Prep for the cattle drive (production build) +- `npm run preview` - Preview the finished ranch +- `npm run relay` - Compile them Relay queries +- `npm run lint` - Check for varmints in the code -## Learn More +## Trail Guides & Wisdom -- [Vite Documentation](https://vite.dev/) -- [React Documentation](https://react.dev/) -- [Relay Documentation](https://relay.dev/docs/) -- [React Router Documentation](https://reactrouter.com/) -- [GitHub GraphQL API](https://docs.github.com/en/graphql) -- [Tailwind CSS](https://tailwindcss.com/docs) +- [Vite Documentation](https://vite.dev/) - Fast build tool knowledge +- [React Documentation](https://react.dev/) - React wisdom +- [Relay Documentation](https://relay.dev/docs/) - GraphQL wranglin' guide +- [React Router Documentation](https://reactrouter.com/) - Trail navigation +- [GitHub GraphQL API](https://docs.github.com/en/graphql) - The cattle source docs +- [Tailwind CSS](https://tailwindcss.com/docs) - Ranch stylin' guide + +--- + +_Happy trails, partner!_ 🤠🐴 diff --git a/index.html b/index.html index 3abf974..66b8a1b 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,9 @@ - + - GitHub GraphQL Demo + Git Ranch - Wrangle Yer Pull Requests
diff --git a/package.json b/package.json index b794794..54ba190 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { - "name": "graphql-demo", + "name": "git-ranch", + "description": "🤠 Yeehaw! Wrangle yer GitHub Pull Requests like a true cowpoke", "version": "0.1.0", "private": true, "type": "module", diff --git a/public/desert-tumbleweed-landscape-on-transparent-background-png.png b/public/desert-tumbleweed-landscape-on-transparent-background-png.png new file mode 100644 index 0000000..9eef9f8 Binary files /dev/null and b/public/desert-tumbleweed-landscape-on-transparent-background-png.png differ diff --git a/src/App.tsx b/src/App.tsx index 31a984d..095c16a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,71 +1,76 @@ -import { useState, useEffect, Suspense } from 'react'; -import { RelayEnvironmentProvider } from 'react-relay'; -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; -import { getRelayEnvironment, resetRelayEnvironment } from './lib/relay/environment'; -import { loadAuthState, clearAuthState, type AuthState } from './lib/auth'; -import { ErrorBoundary } from './components/ErrorBoundary'; -import HomePage from './pages/HomePage'; -import CallbackPage from './pages/CallbackPage'; +import { useState, useEffect, Suspense } from "react"; +import { RelayEnvironmentProvider } from "react-relay"; +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import { + getRelayEnvironment, + resetRelayEnvironment, +} from "./lib/relay/environment"; +import { loadAuthState, clearAuthState, type AuthState } from "./lib/auth"; +import { ErrorBoundary } from "./components/ErrorBoundary"; +import { TumbleweedSpawner } from "./components/TumbleweedSpawner"; +import { HomePage } from "./pages/HomePage"; +import { CallbackPage } from "./pages/CallbackPage"; -function App() { - const [authState, setAuthState] = useState(loadAuthState()); +// 🤠 GitRanch - The main app component, yeehaw! +const GitRanch = () => { + const [ranchHand, setRanchHand] = useState(loadAuthState()); const [relayEnvironment, setRelayEnvironment] = useState(() => - authState.accessToken ? getRelayEnvironment(authState.accessToken) : null + ranchHand.accessToken ? getRelayEnvironment(ranchHand.accessToken) : null ); useEffect(() => { - if (authState.accessToken) { - const env = getRelayEnvironment(authState.accessToken); + if (ranchHand.accessToken) { + const env = getRelayEnvironment(ranchHand.accessToken); setRelayEnvironment(env); } else { resetRelayEnvironment(); setRelayEnvironment(null); } - }, [authState.accessToken]); + }, [ranchHand.accessToken]); - const handleLogout = () => { + const hitTheTrail = () => { clearAuthState(); resetRelayEnvironment(); - setAuthState({ accessToken: null, user: null }); + setRanchHand({ accessToken: null, user: null }); }; return ( + -
Loading...
+
🐴 Saddlin' up...
} >
) : ( - + ) } /> } + element={} /> } />
); -} - -export default App; +}; +export default GitRanch; diff --git a/src/components/AddReactionButton.tsx b/src/components/AddReactionButton.tsx new file mode 100644 index 0000000..c3d0fb3 --- /dev/null +++ b/src/components/AddReactionButton.tsx @@ -0,0 +1,126 @@ +import { useState } from "react"; +import { graphql, useFragment, useMutation } from "react-relay"; +import { AddReactionButton_reactable$key } from "./__generated__/AddReactionButton_reactable.graphql"; +import { AddReactionButton_updatable$key } from "./__generated__/AddReactionButton_updatable.graphql"; +import { + REACTION_TYPES, + getReactionEmoji, + ReactionContent, +} from "@/lib/reactions"; + +type Props = { + reactable: AddReactionButton_reactable$key; +}; + +const AddReactionButton = ({ reactable }: Props) => { + const data = useFragment( + graphql` + fragment AddReactionButton_reactable on Reactable { + id + reactionGroups { + content + viewerHasReacted + ...AddReactionButton_updatable + } + } + `, + reactable + ); + + const [showPicker, setShowPicker] = useState(false); + + const [commitAdd, isAddInFlight] = useMutation(graphql` + mutation AddReactionButtonAddReactionMutation($input: AddReactionInput!) { + addReaction(input: $input) { + reaction { + content + reactable { + ...ReactableReactions_reactable + } + } + } + } + `); + + const handleAddReaction = (content: ReactionContent) => { + const reactionGroup = data.reactionGroups?.find( + (group) => group?.content === content + ); + + if (reactionGroup?.viewerHasReacted) { + return; + } + + commitAdd({ + variables: { + input: { + subjectId: data.id, + content: content, + }, + }, + optimisticUpdater: (store) => { + const reactionGroup = data.reactionGroups?.find( + (group) => group?.content === content + ); + if (!reactionGroup || reactionGroup?.viewerHasReacted) { + setShowPicker(false); + return; + } + + const { updatableData } = + store.readUpdatableFragment( + graphql` + fragment AddReactionButton_updatable on ReactionGroup @updatable { + content + viewerHasReacted + reactors { + totalCount + } + } + `, + reactionGroup + ); + + updatableData.viewerHasReacted = true; + updatableData.reactors.totalCount++; + }, + onCompleted: () => setShowPicker(false), + }); + }; + + return ( +
+ + + {showPicker && ( +
+ {REACTION_TYPES.map((type) => ( + + ))} +
+ )} +
+ ); +}; + +export default AddReactionButton; diff --git a/src/components/BranchInfo.tsx b/src/components/BranchInfo.tsx new file mode 100644 index 0000000..9ff9288 --- /dev/null +++ b/src/components/BranchInfo.tsx @@ -0,0 +1,29 @@ +import { graphql, useFragment } from "react-relay"; +import type { BranchInfo_pullRequest$key } from "./__generated__/BranchInfo_pullRequest.graphql"; + +type BranchInfoProps = { + pullRequest: BranchInfo_pullRequest$key; +}; + +// 🌿 BranchInfo - Shows which trails this cattle's takin' +export const BranchInfo = ({ pullRequest }: BranchInfoProps) => { + const data = useFragment( + graphql` + fragment BranchInfo_pullRequest on PullRequest { + headRefName + baseRefName + } + `, + pullRequest + ); + + return ( +
+
+ {data.headRefName} + + {data.baseRefName} +
+
+ ); +}; diff --git a/src/components/DiffStats.tsx b/src/components/DiffStats.tsx new file mode 100644 index 0000000..53f320e --- /dev/null +++ b/src/components/DiffStats.tsx @@ -0,0 +1,38 @@ +import { graphql, useFragment } from "react-relay"; +import type { DiffStats_pullRequest$key } from "./__generated__/DiffStats_pullRequest.graphql"; + +type DiffStatsProps = { + pullRequest: DiffStats_pullRequest$key; +}; + +// 📊 DiffStats - Count the head of cattle bein' moved +export const DiffStats = ({ pullRequest }: DiffStatsProps) => { + const data = useFragment( + graphql` + fragment DiffStats_pullRequest on PullRequest { + additions + deletions + createdAt + updatedAt + } + `, + pullRequest + ); + + return ( +
+
+ + +{data.additions} + + + -{data.deletions} + +
+
🌅 Started {new Date(data.createdAt).toLocaleDateString()}
+
+ 🔄 Last wrangled {new Date(data.updatedAt).toLocaleDateString()} +
+
+ ); +}; diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index d7cae9e..1f1bc38 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -1,7 +1,13 @@ import { Component, ReactNode } from "react"; +interface ErrorFallbackProps { + error?: Error; + resetError: () => void; +} + interface Props { children: ReactNode; + fallback?: ReactNode | ((props: ErrorFallbackProps) => ReactNode); } interface State { @@ -9,39 +15,75 @@ interface State { error?: Error; } +// 🌵 Default fallback - the cowboy-themed error display +const DefaultErrorFallback = ({ error, resetError }: ErrorFallbackProps) => ( +
+
+

+ 🌵 Well, Shucks! +

+

+ {error?.message || + "A tumbleweed done blown through and spooked the horses"} +

+ +
+
+); + +// 🌿 ErrorBoundary - Catches errors like a tumbleweed catches the wind export class ErrorBoundary extends Component { constructor(props: Props) { super(props); - this.state = { hasError: false }; + this.state = { + hasError: false, + }; } static getDerivedStateFromError(error: Error): State { return { hasError: true, error }; } + resetError = () => { + this.setState({ hasError: false, error: undefined }); + }; + render() { if (this.state.hasError) { + const { fallback } = this.props; + + // If fallback is null, render nothing + if (fallback === null) { + return null; + } + + // If fallback is a function, call it with error props + if (typeof fallback === "function") { + return fallback({ + error: this.state.error, + resetError: this.resetError, + }); + } + + // If fallback is a ReactNode, render it + if (fallback !== undefined) { + return fallback; + } + + // Default fallback return ( -
-
-

- Something went wrong -

-

- {this.state.error?.message || "An unexpected error occurred"} -

- -
-
+ ); } return this.props.children; } } - diff --git a/src/components/PullRequestCard.tsx b/src/components/PullRequestCard.tsx new file mode 100644 index 0000000..8e82125 --- /dev/null +++ b/src/components/PullRequestCard.tsx @@ -0,0 +1,85 @@ +import { graphql, useFragment } from "react-relay"; +import type { PullRequestCard_pullRequest$key } from "./__generated__/PullRequestCard_pullRequest.graphql"; +import { ReviewStatusBadge } from "./ReviewStatusBadge"; +import { BranchInfo } from "./BranchInfo"; +import { DiffStats } from "./DiffStats"; +import { Wranglers } from "./Wranglers"; +import ReactableReactions from "./ReactableReactions"; +import { ErrorBoundary } from "./ErrorBoundary"; + +type PullRequestCardProps = { + pullRequest: PullRequestCard_pullRequest$key; +}; + +// 🐮 PullRequestCard - One head of cattle on the range +export const PullRequestCard = ({ pullRequest }: PullRequestCardProps) => { + const data = useFragment( + graphql` + fragment PullRequestCard_pullRequest on PullRequest @throwOnFieldError { + id + number + title + url + isDraft + reviewDecision + repository { + nameWithOwner + } + author { + ... on User { + id + name + } + } + ...BranchInfo_pullRequest + ...DiffStats_pullRequest + ...Wranglers_assignable + ...ReactableReactions_reactable + } + `, + pullRequest + ); + + return ( + +
+
+
+ + 🐮 #{data.number} + + + + {data.repository.nameWithOwner} + +
+

+ {data.title} +

+
+
+ {data.isDraft && ( + + 📝 Still Ropin' + + )} + +
+
+ + + +
+ + + + +
+
+ ); +}; diff --git a/src/components/PullRequestErrorBoundary.tsx b/src/components/PullRequestErrorBoundary.tsx new file mode 100644 index 0000000..5badc06 --- /dev/null +++ b/src/components/PullRequestErrorBoundary.tsx @@ -0,0 +1,54 @@ +import { Component, ReactNode } from "react"; + +interface Props { + children: ReactNode; + prNumber?: number; +} + +interface State { + hasError: boolean; + error?: Error; +} + +export class PullRequestErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error("PullRequest render error:", error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( +
+
+
+

+ Failed to load pull request + {this.props.prNumber && ` #${this.props.prNumber}`} +

+

+ {this.state.error?.message || "An unexpected error occurred"} +

+
+ +
+
+ ); + } + + return this.props.children; + } +} diff --git a/src/components/PullRequestList.tsx b/src/components/PullRequestList.tsx index f8cb065..2dba8e9 100644 --- a/src/components/PullRequestList.tsx +++ b/src/components/PullRequestList.tsx @@ -1,150 +1,106 @@ -import { graphql, useLazyLoadQuery } from "react-relay"; -import type { PullRequestListQuery as PullRequestListQueryType } from "@/__generated__/PullRequestListQuery.graphql"; +import { graphql, useLazyLoadQuery, usePaginationFragment } from "react-relay"; +import type { PullRequestListQuery as PullRequestListQueryType } from "./__generated__/PullRequestListQuery.graphql"; +import type { PullRequestList_viewer$key } from "./__generated__/PullRequestList_viewer.graphql"; +import { PullRequestCard } from "./PullRequestCard"; +import { PullRequestErrorBoundary } from "./PullRequestErrorBoundary"; -const PullRequestListQuery = graphql` - query PullRequestListQuery($first: Int!) { - viewer { - login - pullRequests(first: $first, states: [OPEN], orderBy: { field: UPDATED_AT, direction: DESC }) { - totalCount - nodes { - id - number - title - url - state - isDraft - createdAt - updatedAt - repository { - name - nameWithOwner - } - baseRefName - headRefName - additions - deletions - reviewDecision +// 🐄 PullRequestList - Round up them cattle (PRs) from the range +export const PullRequestList = ({ headCount = 10 }: { headCount?: number }) => { + const queryData = useLazyLoadQuery( + graphql` + query PullRequestListQuery($first: Int!) { + viewer { + ...PullRequestList_viewer @arguments(first: $first) } } - } - } -`; - -interface PullRequestListProps { - count?: number; -} - -type PullRequest = NonNullable< - NonNullable< - PullRequestListQueryType["response"]["viewer"]["pullRequests"]["nodes"] - >[number] ->; - -export default function PullRequestList({ count = 20 }: PullRequestListProps) { - const data = useLazyLoadQuery(PullRequestListQuery, { - first: count, - }); - - const { viewer } = data; - const pullRequests = (viewer.pullRequests.nodes?.filter( - (pr): pr is PullRequest => pr !== null && pr !== undefined - ) ?? []) as PullRequest[]; + `, + { first: headCount } + ); - const getReviewDecisionBadge = (decision: string | null | undefined) => { - if (!decision) return null; + return ; +}; - const badges = { - APPROVED: { text: "Approved", color: "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200" }, - CHANGES_REQUESTED: { text: "Changes Requested", color: "bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200" }, - REVIEW_REQUIRED: { text: "Review Required", color: "bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200" }, - }; +type PullRequestListContentProps = { + viewer: PullRequestList_viewer$key; +}; - const badge = badges[decision as keyof typeof badges]; - if (!badge) return null; +const PullRequestListContent = ({ viewer }: PullRequestListContentProps) => { + const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment( + graphql` + fragment PullRequestList_viewer on User + @refetchable(queryName: "PullRequestListPaginationQuery") + @argumentDefinitions( + first: { type: "Int", defaultValue: 10 } + after: { type: "String" } + ) { + login + pullRequests( + first: $first + after: $after + orderBy: { field: UPDATED_AT, direction: DESC } + ) @connection(key: "PullRequestList_pullRequests") { + totalCount + edges { + node { + id + ...PullRequestCard_pullRequest + } + } + } + } + `, + viewer + ); - return ( - - {badge.text} - - ); - }; + const roundups = + data.pullRequests.edges?.filter( + (edge): edge is NonNullable => + edge !== null && edge?.node !== null + ) ?? []; return (

- {viewer.login}'s Open Pull Requests + 🐄 {data.login}'s Cattle Roundup

- {viewer.pullRequests.totalCount} open pull request{viewer.pullRequests.totalCount !== 1 ? 's' : ''} + {data.pullRequests.totalCount} head of cattle need wranglin'

- {pullRequests.length === 0 ? ( + {roundups.length === 0 ? (
- No open pull requests found + 🌵 No cattle on the range, partner. The herd's all accounted for!
) : ( + )} -
-
- +{pr.additions} - -{pr.deletions} -
-
- Created {new Date(pr.createdAt).toLocaleDateString()} -
-
- Updated {new Date(pr.updatedAt).toLocaleDateString()} -
-
-
- ))} + {hasNext && ( +
+
)}
); -} +}; +export default PullRequestList; diff --git a/src/components/ReactableReactions.tsx b/src/components/ReactableReactions.tsx new file mode 100644 index 0000000..56157f1 --- /dev/null +++ b/src/components/ReactableReactions.tsx @@ -0,0 +1,39 @@ +import { graphql, useFragment } from "react-relay"; +import AddReactionButton from "./AddReactionButton"; +import ReactionGroup from "./ReactionGroup"; +import { ReactableReactions_reactable$key } from "./__generated__/ReactableReactions_reactable.graphql"; + +type Props = { + reactable: ReactableReactions_reactable$key; +}; + +const ReactableReactions = ({ reactable }: Props) => { + const data = useFragment( + graphql` + fragment ReactableReactions_reactable on Reactable { + ...AddReactionButton_reactable + reactionGroups { + content + ...ReactionGroup_group + } + } + `, + reactable + ); + + if (!data.reactionGroups) { + return null; + } + + return ( +
+ {data.reactionGroups.map((group) => ( + + ))} + + +
+ ); +}; + +export default ReactableReactions; diff --git a/src/components/ReactionGroup.tsx b/src/components/ReactionGroup.tsx new file mode 100644 index 0000000..5defa9b --- /dev/null +++ b/src/components/ReactionGroup.tsx @@ -0,0 +1,91 @@ +import { graphql, useFragment, useMutation } from "react-relay"; +import { + ReactionGroup_group$key, + ReactionContent, +} from "./__generated__/ReactionGroup_group.graphql"; +import { getReactionEmoji } from "@/lib/reactions"; + +type Props = { + group: ReactionGroup_group$key; +}; + +const ReactionGroup = ({ group }: Props) => { + const data = useFragment( + graphql` + fragment ReactionGroup_group on ReactionGroup { + # ...ReactionGroup_updatable + content + viewerHasReacted + reactors { + totalCount + } + subject { + id + } + } + `, + group + ); + + const [commitRemove, isRemoveInFlight] = useMutation(graphql` + mutation ReactionGroupRemoveReactionMutation($input: RemoveReactionInput!) { + removeReaction(input: $input) { + reaction { + content + reactable { + ...ReactableReactions_reactable + } + } + } + } + `); + + const handleRemoveReaction = (content: ReactionContent) => { + commitRemove({ + variables: { + input: { + subjectId: data.subject.id, + content: content, + }, + }, + optimisticUpdater: (store) => { + const subject = store.get(data.subject.id); + const groups = subject?.getLinkedRecords("reactionGroups"); + const group = groups?.find( + (g) => g.getValue("content") === data.content + ); + const reactors = group?.getLinkedRecord("reactors"); + const totalCount = Number(reactors?.getValue("totalCount")) ?? 0; + reactors?.setValue(totalCount - 1, "totalCount"); + group?.setValue(false, "viewerHasReacted"); + }, + }); + }; + + if (data.reactors.totalCount === 0) { + return null; + } + + return ( + + ); +}; + +export default ReactionGroup; diff --git a/src/components/ReviewStatusBadge.tsx b/src/components/ReviewStatusBadge.tsx new file mode 100644 index 0000000..abd88fd --- /dev/null +++ b/src/components/ReviewStatusBadge.tsx @@ -0,0 +1,34 @@ +// 🏷️ ReviewStatusBadge - Shows the branding status of this here cattle + +type ReviewStatusBadgeProps = { + decision: string | null | undefined; +}; + +const brands = { + APPROVED: { + text: "🏷️ Branded & Ready", + color: "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200", + }, + CHANGES_REQUESTED: { + text: "🔧 Needs Re-shoein'", + color: "bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200", + }, + REVIEW_REQUIRED: { + text: "👀 Needs Inspectin'", + color: + "bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200", + }, +} as const; + +export const ReviewStatusBadge = ({ decision }: ReviewStatusBadgeProps) => { + if (!decision) return null; + + const brand = brands[decision as keyof typeof brands]; + if (!brand) return null; + + return ( + + {brand.text} + + ); +}; diff --git a/src/components/TumbleweedSpawner.tsx b/src/components/TumbleweedSpawner.tsx new file mode 100644 index 0000000..74edd0f --- /dev/null +++ b/src/components/TumbleweedSpawner.tsx @@ -0,0 +1,85 @@ +import { useEffect, useState, useRef, useCallback } from "react"; + +interface TumbleweedInstance { + id: number; + top: number; +} + +// 🌿 A tumbleweed that randomly rolls across the screen +const RollingTumbleweed = ({ top }: { top: number }) => ( +
+ Tumbleweed +
+); + +// 🌿 TumbleweedSpawner - Spawns tumbleweeds that roll across the range +export const TumbleweedSpawner = () => { + const [activeTumbleweeds, setActiveTumbleweeds] = useState< + TumbleweedInstance[] + >([]); + const [tumbleweedCount, setTumbleweedCount] = useState(1); + const tumbleweedIdCounter = useRef(0); + const timeoutRef = useRef | null>(null); + + const scheduleTumbleweed = useCallback(() => { + const randomDelay = Math.random() * 60000 + 30000; // 30-90 seconds + timeoutRef.current = setTimeout(() => { + // Spawn multiple tumbleweeds based on current count + const newTumbleweeds: TumbleweedInstance[] = []; + for (let i = 0; i < tumbleweedCount; i++) { + tumbleweedIdCounter.current++; + newTumbleweeds.push({ + id: tumbleweedIdCounter.current, + top: Math.random() * 60 + 20, // 20-80% from top + }); + } + + setActiveTumbleweeds((prev) => [...prev, ...newTumbleweeds]); + + // Remove tumbleweeds after animation completes (5 seconds) + setTimeout(() => { + setActiveTumbleweeds((prev) => + prev.filter((t) => !newTumbleweeds.find((nt) => nt.id === t.id)) + ); + // Increase count for next time (max 10 tumbleweeds) + setTumbleweedCount((prev) => Math.min(prev + 1, 10)); + }, 5000); + }, randomDelay); + }, [tumbleweedCount]); + + useEffect(() => { + scheduleTumbleweed(); + + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, [scheduleTumbleweed]); + + // Re-schedule when count changes (after tumbleweeds are removed) + useEffect(() => { + if (activeTumbleweeds.length === 0 && tumbleweedCount > 1) { + scheduleTumbleweed(); + } + }, [activeTumbleweeds.length, tumbleweedCount, scheduleTumbleweed]); + + return ( + <> + {activeTumbleweeds.map((tumbleweed) => ( + + ))} + + ); +}; + +export default TumbleweedSpawner; diff --git a/src/components/Wranglers.tsx b/src/components/Wranglers.tsx new file mode 100644 index 0000000..3f33f28 --- /dev/null +++ b/src/components/Wranglers.tsx @@ -0,0 +1,60 @@ +import { graphql, useFragment } from "react-relay"; +import type { Wranglers_assignable$key } from "./__generated__/Wranglers_assignable.graphql"; + +type WranglersProps = { + cattle: Wranglers_assignable$key; +}; + +// 🤠 Wranglers - Show who's wranglin' this here cattle +export const Wranglers = ({ cattle }: WranglersProps) => { + const data = useFragment( + graphql` + fragment Wranglers_assignable on Assignable @throwOnFieldError { + assignees(first: 5) @connection(key: "Wranglers_assignees") { + edges { + node { + name + avatarUrl(size: 32) + } + } + } + } + `, + cattle + ); + + const assignees = + data.assignees.edges?.filter( + (edge): edge is NonNullable => + edge !== null && edge.node !== null + ) ?? []; + + if (assignees.length === 0) { + return null; + } + + return ( +
+ 🤠 Wranglers +
+ {assignees.map( + (edge) => + edge.node && ( +
+ {edge.node.name + + {edge.node.name} + +
+ ) + )} +
+
+ ); +}; + +export default Wranglers; diff --git a/src/index.css b/src/index.css index 8567d6e..7740a2d 100644 --- a/src/index.css +++ b/src/index.css @@ -20,8 +20,43 @@ body { background: var(--background); color: var(--foreground); - font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Oxygen, Ubuntu, Cantarell, sans-serif; margin: 0; padding: 0; } +/* 🌿 Tumbleweed rolling animation */ +@keyframes tumbleweed-roll { + 0% { + left: -100px; + opacity: 0; + } + 5% { + opacity: 1; + } + 95% { + opacity: 1; + } + 100% { + left: calc(100vw + 100px); + opacity: 0; + } +} + +@keyframes spin-slow { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.animate-tumbleweed { + animation: tumbleweed-roll 5s linear forwards; +} + +.animate-spin-slow { + animation: spin-slow 1s linear infinite; +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 038c1c6..5f9791c 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,4 +1,6 @@ -// GitHub OAuth configuration +// 🤠 Git Ranch - Cowboy Authentication +// GitHub OAuth configuration for saddlin' up with the ranch + const GITHUB_CLIENT_ID = import.meta.env.VITE_GITHUB_CLIENT_ID; const REDIRECT_URI = import.meta.env.VITE_REDIRECT_URI || "http://localhost:3000/callback"; @@ -15,25 +17,22 @@ export interface AuthState { user: GitHubUser | null; } -// Generate a random state for OAuth security -function generateState(): string { +// Generate a random trail token for OAuth security +function generateTrailToken(): string { return ( Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) ); } -// Initiate GitHub OAuth flow +// 🐴 Saddle up and ride to GitHub for authentication export function initiateGitHubLogin(): void { - const state = generateState(); - // Use localStorage instead of sessionStorage for better reliability across redirects - localStorage.setItem("oauth_state", state); - - console.log("Initiating OAuth login with state:", state); - console.log( - "LocalStorage after setting:", - localStorage.getItem("oauth_state") - ); + const state = generateTrailToken(); + // Stash the trail token in the bunkhouse for when we return + localStorage.setItem("trail_token", state); + + console.log("🐴 Headin' out to GitHub with trail token:", state); + console.log("🏠 Bunkhouse storage:", localStorage.getItem("trail_token")); const params = new URLSearchParams({ client_id: GITHUB_CLIENT_ID, @@ -45,31 +44,32 @@ export function initiateGitHubLogin(): void { window.location.href = `https://github.com/login/oauth/authorize?${params.toString()}`; } -// Handle OAuth callback +// 🏤 Check in at the tradin' post with yer authorization papers export async function handleOAuthCallback( code: string, state: string ): Promise { - const savedState = localStorage.getItem("oauth_state"); + const savedState = localStorage.getItem("trail_token"); - console.log("OAuth State Check:", { - receivedState: state, - savedState: savedState, - match: state === savedState, + console.log("🏤 Trail Post Check:", { + receivedPapers: state, + expectedPapers: savedState, + papersMatch: state === savedState, }); if (state !== savedState) { - console.error("State mismatch - possible CSRF attack"); - console.error("Received state:", state); - console.error("Saved state:", savedState); + console.error( + "🚨 Whoa there! Trail papers don't match - possible cattle rustler!" + ); + console.error("Received papers:", state); + console.error("Expected papers:", savedState); return null; } - localStorage.removeItem("oauth_state"); + localStorage.removeItem("trail_token"); try { - // Note: In production, this should go through your backend - // For development, you'll need to set up a simple proxy server + // Trade the authorization code for a ranch pass at the tradin' post const response = await fetch("http://localhost:3001/auth/github/callback", { method: "POST", headers: { @@ -79,18 +79,18 @@ export async function handleOAuthCallback( }); if (!response.ok) { - throw new Error("Failed to exchange code for token"); + throw new Error("🌵 Failed to get yer ranch pass from the tradin' post"); } const data = await response.json(); return data.access_token; } catch (error) { - console.error("Error exchanging code for token:", error); + console.error("🌪️ Dust storm at the tradin' post:", error); return null; } } -// Fetch user info from GitHub +// 🤠 Fetch cowboy info from GitHub export async function fetchGitHubUser( accessToken: string ): Promise { @@ -103,23 +103,23 @@ export async function fetchGitHubUser( }); if (!response.ok) { - throw new Error("Failed to fetch user info"); + throw new Error("🤔 Couldn't find yer cowboy records"); } return await response.json(); } catch (error) { - console.error("Error fetching user info:", error); + console.error("🌵 Error fetchin' cowboy info:", error); return null; } } -// Storage helpers +// 🏠 Bunkhouse storage helpers export function saveAuthState(state: AuthState): void { - localStorage.setItem("auth_state", JSON.stringify(state)); + localStorage.setItem("ranch_hand", JSON.stringify(state)); } export function loadAuthState(): AuthState { - const stored = localStorage.getItem("auth_state"); + const stored = localStorage.getItem("ranch_hand"); if (stored) { try { return JSON.parse(stored); @@ -131,5 +131,5 @@ export function loadAuthState(): AuthState { } export function clearAuthState(): void { - localStorage.removeItem("auth_state"); + localStorage.removeItem("ranch_hand"); } diff --git a/src/lib/reactions.ts b/src/lib/reactions.ts new file mode 100644 index 0000000..dc5d2f5 --- /dev/null +++ b/src/lib/reactions.ts @@ -0,0 +1,37 @@ +import { ReactionContent } from "@/components/__generated__/ReactableReactions_reactable.graphql"; + +export const REACTION_TYPES: ReactionContent[] = [ + "THUMBS_UP", + "THUMBS_DOWN", + "LAUGH", + "HOORAY", + "CONFUSED", + "HEART", + "ROCKET", + "EYES", +]; + +export type { ReactionContent }; + +export const getReactionEmoji = (content: ReactionContent): string => { + switch (content) { + case "CONFUSED": + return "😕"; + case "EYES": + return "👀"; + case "HEART": + return "❤️"; + case "HOORAY": + return "🎉"; + case "LAUGH": + return "😄"; + case "ROCKET": + return "🚀"; + case "THUMBS_DOWN": + return "👎"; + case "THUMBS_UP": + return "👍"; + default: + throw new Error(`Unknown reaction content: ${content}`); + } +}; diff --git a/src/lib/relay/environment.ts b/src/lib/relay/environment.ts index cffd3fd..758f79d 100644 --- a/src/lib/relay/environment.ts +++ b/src/lib/relay/environment.ts @@ -10,6 +10,68 @@ const HTTP_ENDPOINT = "https://api.github.com/graphql"; let clientEnvironment: Environment | null = null; +// Demo flag: set to true to inject fake field errors for every 3rd PR +const DEMO_FIELD_ERRORS = true; + +function injectFieldErrors(response: any): any { + if (!DEMO_FIELD_ERRORS) return response; + + // Check if this is a pull request query response (handles both nodes and edges patterns) + const pullRequestsContainer = response?.data?.viewer?.pullRequests; + const pullRequests = + pullRequestsContainer?.edges ?? pullRequestsContainer?.nodes; + if (!Array.isArray(pullRequests) || pullRequests.length === 0) + return response; + + const errors: any[] = response.errors || []; + const usesEdges = !!pullRequestsContainer?.edges; + + // Only affect the first PR in the list + const firstPr = usesEdges ? pullRequests[0]?.node : pullRequests[0]; + if (firstPr && firstPr.assignees) { + // The Wranglers fragment uses @connection with edges pattern + const assigneesEdges = firstPr.assignees.edges; + if (Array.isArray(assigneesEdges) && assigneesEdges.length > 0) { + const firstAssignee = assigneesEdges[0]?.node; + if (firstAssignee) { + // Build the correct path for the error + const basePath = usesEdges + ? ["viewer", "pullRequests", "edges", 0, "node"] + : ["viewer", "pullRequests", "nodes", 0]; + + // Target the name field of the first assignee to trigger @throwOnFieldError + const errorPath = [ + ...basePath, + "assignees", + "edges", + 0, + "node", + "name", + ]; + + errors.push({ + message: `Demo error: Failed to fetch assignee name for PR #${firstPr.number}`, + path: errorPath, + extensions: { + code: "DEMO_ERROR", + }, + }); + + // Set the name field to null to simulate a field-level error + // This combined with the error path will trigger @throwOnFieldError + firstAssignee.name = null; + } + } + } + + if (errors.length > 0) { + response.errors = errors; + console.log("Injected field errors:", response.errors); + } + + return response; +} + export function createRelayEnvironment(accessToken: string): Environment { const fetchFn: FetchFunction = async (request, variables) => { const resp = await fetch(HTTP_ENDPOINT, { @@ -25,7 +87,10 @@ export function createRelayEnvironment(accessToken: string): Environment { }), }); - return await resp.json(); + const json = await resp.json(); + + // Inject fake field errors for demo purposes + return injectFieldErrors(json); }; return new Environment({ @@ -44,4 +109,3 @@ export function getRelayEnvironment(accessToken: string): Environment { export function resetRelayEnvironment(): void { clientEnvironment = null; } - diff --git a/src/pages/CallbackPage.tsx b/src/pages/CallbackPage.tsx index 25882e4..b784ab9 100644 --- a/src/pages/CallbackPage.tsx +++ b/src/pages/CallbackPage.tsx @@ -1,68 +1,74 @@ -import { useEffect, useState } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; -import { handleOAuthCallback, fetchGitHubUser, saveAuthState, type AuthState } from '../lib/auth'; +import { useEffect, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { + handleOAuthCallback, + fetchGitHubUser, + saveAuthState, + type AuthState, +} from "../lib/auth"; interface CallbackPageProps { - setAuthState: (state: AuthState) => void; + setBrandedCowboy: (state: AuthState) => void; } -export default function CallbackPage({ setAuthState }: CallbackPageProps) { +// 🏤 CallbackPage - The trail post where cowboys check in after OAuth +export const CallbackPage = ({ setBrandedCowboy }: CallbackPageProps) => { const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const [error, setError] = useState(null); + const [trouble, setTrouble] = useState(null); useEffect(() => { - const code = searchParams.get('code'); - const state = searchParams.get('state'); + const code = searchParams.get("code"); + const state = searchParams.get("state"); if (!code || !state) { - setError('Missing authorization code or state'); + setTrouble("🌵 Whoa there partner! Missing yer trail papers"); return; } - async function processCallback() { + async function checkInAtPost() { try { const accessToken = await handleOAuthCallback(code!, state!); - + if (!accessToken) { - setError('Failed to obtain access token'); + setTrouble("🐎 Dagnabbit! Failed to get yer ranch pass"); return; } const user = await fetchGitHubUser(accessToken); - + if (!user) { - setError('Failed to fetch user information'); + setTrouble("🤔 Couldn't find yer cowboy credentials, partner"); return; } const newAuthState = { accessToken, user }; saveAuthState(newAuthState); - setAuthState(newAuthState); - - navigate('/', { replace: true }); + setBrandedCowboy(newAuthState); + + navigate("/", { replace: true }); } catch (err) { - console.error('OAuth callback error:', err); - setError('An error occurred during authentication'); + console.error("Trail post error:", err); + setTrouble("🌪️ A dust storm blew through during authentication"); } } - processCallback(); - }, [searchParams, navigate, setAuthState]); + checkInAtPost(); + }, [searchParams, navigate, setBrandedCowboy]); - if (error) { + if (trouble) { return (

- Authentication Error + 🚫 Trail Trouble

-

{error}

+

{trouble}

@@ -72,10 +78,11 @@ export default function CallbackPage({ setAuthState }: CallbackPageProps) { return (
-
Completing authentication...
+
🐴 Ridin' into the ranch...
); -} +}; +export default CallbackPage; diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 488776f..d89db5f 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,27 +1,44 @@ -import { initiateGitHubLogin, type GitHubUser } from '../lib/auth'; -import PullRequestList from '../components/PullRequestList'; +import { initiateGitHubLogin, type GitHubUser } from "../lib/auth"; +import { PullRequestList } from "@/components/PullRequestList"; +import { useLazyLoadQuery, graphql } from "react-relay"; + +import { HomePageQuery } from "./__generated__/HomePageQuery.graphql"; interface HomePageProps { - user: GitHubUser | null; - onLogout: () => void; + cowboy: GitHubUser | null; + onHitTheTrail: () => void; } -export default function HomePage({ user, onLogout }: HomePageProps) { - if (!user) { +// 🤠 HomePage - The main saloon where cowboys gather +export const HomePage = ({ cowboy, onHitTheTrail }: HomePageProps) => { + const data = useLazyLoadQuery( + graphql` + query HomePageQuery { + viewer { + id + name + } + } + `, + {} + ); + + if (!data.viewer) { return (

- GitHub Pull Request Viewer + 🤠 Welcome to the Git Ranch

- Sign in with GitHub to view your open pull requests + Howdy partner! Saddle up with GitHub to wrangle yer open pull + requests

@@ -34,17 +51,15 @@ export default function HomePage({ user, onLogout }: HomePageProps) {

- Welcome, {user.name || user.login} + 🤠 Howdy, {data.viewer.name || data.viewer.login}!

-

- {user.email} -

+

{cowboy.email}

@@ -52,5 +67,6 @@ export default function HomePage({ user, onLogout }: HomePageProps) { ); -} +}; +export default HomePage;