diff --git a/.github/workflows/blog-auto-format.yml b/.github/workflows/blog-auto-format.yml deleted file mode 100644 index 6e3d9850ca..0000000000 --- a/.github/workflows/blog-auto-format.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Blog Auto Format - -on: - push: - branches: - - "blog/**" - -jobs: - auto-format: - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Get changed MDX files - id: changed-files - run: | - git diff --name-only -z HEAD~1 HEAD -- 'apps/web/content/articles/*.mdx' > /tmp/changed-files.txt 2>/dev/null || true - if [ -s /tmp/changed-files.txt ]; then - echo "has_files=true" >> $GITHUB_OUTPUT - else - echo "has_files=false" >> $GITHUB_OUTPUT - fi - - - name: Setup dprint - if: steps.changed-files.outputs.has_files == 'true' - run: | - curl -fsSL https://dprint.dev/install.sh | sh - echo "$HOME/.dprint/bin" >> $GITHUB_PATH - - - name: Run dprint fmt on changed MDX file - if: steps.changed-files.outputs.has_files == 'true' - run: xargs -0 -r dprint fmt < /tmp/changed-files.txt - - - name: Commit and push formatting changes - if: steps.changed-files.outputs.has_files == 'true' - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add -A - if git diff --staged --quiet; then - echo "No formatting changes needed" - else - git commit -m "style: apply dprint formatting" - git push - fi diff --git a/.github/workflows/blog-slack-notify.yml b/.github/workflows/blog-slack-notify.yml deleted file mode 100644 index de6a84c05b..0000000000 --- a/.github/workflows/blog-slack-notify.yml +++ /dev/null @@ -1,229 +0,0 @@ -name: Blog Slack Notifications - -on: - pull_request: - branches: - - main - paths: - - "apps/web/content/articles/**" - -jobs: - notify: - if: startsWith(github.head_ref, 'blog/') - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Get changed files - id: changed-files - run: | - FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD -- 'apps/web/content/articles/*.mdx' | tr '\n' ' ') - echo "files=$FILES" >> $GITHUB_OUTPUT - if [ -z "$FILES" ]; then - echo "has_files=false" >> $GITHUB_OUTPUT - else - echo "has_files=true" >> $GITHUB_OUTPUT - fi - - - name: Extract article info - if: steps.changed-files.outputs.has_files == 'true' - id: article-info - run: | - FILE=$(echo "${{ steps.changed-files.outputs.files }}" | awk '{print $1}') - if [ -f "$FILE" ]; then - RAW_TITLE=$(grep -m1 "^display_title:" "$FILE" | sed 's/display_title:[[:space:]]*"\(.*\)"/\1/' || echo "") - if [ -z "$RAW_TITLE" ]; then - RAW_TITLE=$(grep -m1 "^meta_title:" "$FILE" | sed 's/meta_title:[[:space:]]*"\(.*\)"/\1/' || echo "Untitled") - fi - RAW_AUTHOR=$(grep -m1 "^author:" "$FILE" | sed 's/author:[[:space:]]*"\(.*\)"/\1/' || echo "Unknown") - RAW_DATE=$(grep -m1 "^date:" "$FILE" | sed 's/date:[[:space:]]*"\(.*\)"/\1/' || echo "") - - TITLE=$(echo "$RAW_TITLE" | jq -Rs . | sed 's/^"//;s/"$//') - AUTHOR=$(echo "$RAW_AUTHOR" | jq -Rs . | sed 's/^"//;s/"$//') - DATE=$(echo "$RAW_DATE" | jq -Rs . | sed 's/^"//;s/"$//') - - # Extract slug from filename for preview URL - SLUG=$(basename "$FILE" .mdx) - echo "slug=$SLUG" >> $GITHUB_OUTPUT - else - TITLE="Untitled" - AUTHOR="Unknown" - DATE="" - SLUG="" - fi - echo "title=$TITLE" >> $GITHUB_OUTPUT - echo "author=$AUTHOR" >> $GITHUB_OUTPUT - echo "date=$DATE" >> $GITHUB_OUTPUT - - # Determine if this is a new article or edit (edit branches have timestamp suffix) - BRANCH="${{ github.head_ref }}" - if [[ "$BRANCH" =~ -[0-9]+$ ]]; then - echo "is_edit=true" >> $GITHUB_OUTPUT - else - echo "is_edit=false" >> $GITHUB_OUTPUT - fi - - # Map GitHub username to Slack user ID - GH_USER="${{ github.event.pull_request.user.login }}" - case "$GH_USER" in - "yujonglee") SLACK_USER="<@U0628P6TAPL>" ;; - "ComputelessComputer") SLACK_USER="<@U08PVBSGL31>" ;; - "harshikaalagh-netizen") SLACK_USER="<@U0976J9CAKF>" ;; - *) SLACK_USER="$GH_USER" ;; - esac - echo "slack_user=$SLACK_USER" >> $GITHUB_OUTPUT - - - name: Check ready_for_review status - if: steps.changed-files.outputs.has_files == 'true' - id: review-status - run: | - FILE=$(echo "${{ steps.changed-files.outputs.files }}" | awk '{print $1}') - if [ -f "$FILE" ]; then - READY_FOR_REVIEW=$(grep -m1 "^ready_for_review:" "$FILE" | sed 's/ready_for_review:[[:space:]]*//' || echo "false") - echo "ready_for_review=$READY_FOR_REVIEW" >> $GITHUB_OUTPUT - else - echo "ready_for_review=false" >> $GITHUB_OUTPUT - fi - - - name: Check for unpublish action - if: steps.changed-files.outputs.has_files == 'true' - id: unpublish-check - run: | - FILE=$(echo "${{ steps.changed-files.outputs.files }}" | awk '{print $1}') - # Get current published value - CURRENT_PUBLISHED=$(grep -m1 "^published:" "$FILE" | sed 's/published:[[:space:]]*//' || echo "false") - - # Get previous published value from main branch - PREV_PUBLISHED=$(git show origin/${{ github.base_ref }}:"$FILE" 2>/dev/null | grep -m1 "^published:" | sed 's/published:[[:space:]]*//' || echo "false") - - # Check if this is an unpublish action (was true, now false) - if [ "$PREV_PUBLISHED" = "true" ] && [ "$CURRENT_PUBLISHED" = "false" ]; then - echo "is_unpublish=true" >> $GITHUB_OUTPUT - else - echo "is_unpublish=false" >> $GITHUB_OUTPUT - fi - - - name: Notify Slack (Unpublish) - if: steps.changed-files.outputs.has_files == 'true' && steps.unpublish-check.outputs.is_unpublish == 'true' - uses: slackapi/slack-github-action@v2.0.0 - with: - method: chat.postMessage - token: ${{ secrets.SLACK_BOT_TOKEN }} - payload: | - { - "channel": "${{ secrets.SLACK_BLOG_CHANNEL_ID }}", - "text": "${{ steps.article-info.outputs.slack_user }} wants to unpublish: ${{ steps.article-info.outputs.title }}", - "attachments": [ - { - "color": "#f59e0b", - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "โš ๏ธ ${{ steps.article-info.outputs.slack_user }} wants to unpublish *${{ steps.article-info.outputs.title }}*" - } - }, - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": "๐Ÿ‘ค ${{ steps.article-info.outputs.author }}${{ steps.article-info.outputs.date != '' && format(' โ€ข ๐Ÿ“… {0}', steps.article-info.outputs.date) || '' }}" - } - ] - }, - { - "type": "actions", - "elements": [ - { - "type": "button", - "text": { - "type": "plain_text", - "text": "View PR", - "emoji": true - }, - "url": "${{ github.event.pull_request.html_url }}", - "style": "primary" - } - ] - } - ] - } - ] - } - - - name: Notify Slack (Regular) - if: steps.changed-files.outputs.has_files == 'true' && steps.unpublish-check.outputs.is_unpublish != 'true' && steps.review-status.outputs.ready_for_review == 'true' - uses: slackapi/slack-github-action@v2.0.0 - with: - method: chat.postMessage - token: ${{ secrets.SLACK_BOT_TOKEN }} - payload: | - { - "channel": "${{ secrets.SLACK_BLOG_CHANNEL_ID }}", - "text": "Article submitted for review: ${{ steps.article-info.outputs.title }}", - "attachments": [ - { - "color": "#3b82f6", - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "๐Ÿ‘€ *Article submitted for review*\n<@U0976J9CAKF> please review" - } - }, - { - "type": "divider" - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*${{ steps.article-info.outputs.title }}*" - } - }, - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": "โœ๏ธ ${{ steps.article-info.outputs.author }}${{ steps.article-info.outputs.date != '' && format(' โ€ข ๐Ÿ“… {0}', steps.article-info.outputs.date) || '' }}" - } - ] - }, - { - "type": "actions", - "elements": [ - { - "type": "button", - "text": { - "type": "plain_text", - "text": "Preview", - "emoji": true - }, - "url": "https://deploy-preview-${{ github.event.pull_request.number }}--hyprnote.netlify.app/blog/${{ steps.article-info.outputs.slug }}" - }, - { - "type": "button", - "text": { - "type": "plain_text", - "text": "View PR", - "emoji": true - }, - "url": "${{ github.event.pull_request.html_url }}", - "style": "primary" - } - ] - } - ] - } - ] - } diff --git a/apps/web/src/functions/github-content.ts b/apps/web/src/functions/github-content.ts index 5270f09562..73c9a3c4d1 100644 --- a/apps/web/src/functions/github-content.ts +++ b/apps/web/src/functions/github-content.ts @@ -1002,92 +1002,6 @@ export async function createPullRequest( } } -export async function convertDraftToReady(prNumber: number): Promise<{ - success: boolean; - error?: string; -}> { - if (isDev()) { - return { success: true }; - } - - const credentials = await getGitHubCredentials(); - if (!credentials) { - return { success: false, error: "GitHub token not configured" }; - } - const { token: githubToken } = credentials; - - try { - const prResponse = await fetch( - `https://api.github.com/repos/${GITHUB_REPO}/pulls/${prNumber}`, - { - headers: { - Authorization: `Bearer ${githubToken}`, - Accept: "application/vnd.github.v3+json", - }, - }, - ); - - if (!prResponse.ok) { - const error = await prResponse.json(); - return { - success: false, - error: error.message || `GitHub API error: ${prResponse.status}`, - }; - } - - const prData = await prResponse.json(); - - if (!prData.draft) { - return { success: true }; - } - - const graphqlResponse = await fetch("https://api.github.com/graphql", { - method: "POST", - headers: { - Authorization: `Bearer ${githubToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - query: ` - mutation MarkPullRequestReadyForReview($pullRequestId: ID!) { - markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { - pullRequest { - isDraft - } - } - } - `, - variables: { - pullRequestId: prData.node_id, - }, - }), - }); - - if (!graphqlResponse.ok) { - const error = await graphqlResponse.json(); - return { - success: false, - error: error.message || `GraphQL API error: ${graphqlResponse.status}`, - }; - } - - const graphqlData = await graphqlResponse.json(); - if (graphqlData.errors?.length) { - return { - success: false, - error: graphqlData.errors[0].message, - }; - } - - return { success: true }; - } catch (error) { - return { - success: false, - error: `Failed to convert draft to ready: ${(error as Error).message}`, - }; - } -} - export async function createContentFileOnBranch( folder: string, filename: string, @@ -1511,8 +1425,28 @@ Auto-generated PR from admin panel.`; GITHUB_BRANCH, title, body, - { isDraft: options?.isDraft ?? true }, + { isDraft: options?.isDraft ?? false }, ); + + if (prResult.success && prResult.prNumber) { + try { + await fetch( + `https://api.github.com/repos/${GITHUB_REPO}/pulls/${prResult.prNumber}/requested_reviewers`, + { + method: "POST", + headers: { + Authorization: `Bearer ${githubToken}`, + Accept: "application/vnd.github.v3+json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + reviewers: ["harshikaalagh-netizen"], + }), + }, + ); + } catch {} + } + return { ...prResult, branchName, isExistingPR: false }; } catch (error) { return { diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 7070a3949a..6ccdbbe527 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -129,7 +129,6 @@ import { Route as ApiAdminMediaDeleteRouteImport } from './routes/api/admin/medi import { Route as ApiAdminMediaCreateFolderRouteImport } from './routes/api/admin/media/create-folder' import { Route as ApiAdminImportSaveRouteImport } from './routes/api/admin/import/save' import { Route as ApiAdminImportGoogleDocsRouteImport } from './routes/api/admin/import/google-docs' -import { Route as ApiAdminContentSubmitForReviewRouteImport } from './routes/api/admin/content/submit-for-review' import { Route as ApiAdminContentSaveRouteImport } from './routes/api/admin/content/save' import { Route as ApiAdminContentRenameRouteImport } from './routes/api/admin/content/rename' import { Route as ApiAdminContentPublishRouteImport } from './routes/api/admin/content/publish' @@ -757,12 +756,6 @@ const ApiAdminImportGoogleDocsRoute = path: '/api/admin/import/google-docs', getParentRoute: () => rootRouteImport, } as any) -const ApiAdminContentSubmitForReviewRoute = - ApiAdminContentSubmitForReviewRouteImport.update({ - id: '/api/admin/content/submit-for-review', - path: '/api/admin/content/submit-for-review', - getParentRoute: () => rootRouteImport, - } as any) const ApiAdminContentSaveRoute = ApiAdminContentSaveRouteImport.update({ id: '/api/admin/content/save', path: '/api/admin/content/save', @@ -963,7 +956,6 @@ export interface FileRoutesByFullPath { '/api/admin/content/publish': typeof ApiAdminContentPublishRoute '/api/admin/content/rename': typeof ApiAdminContentRenameRoute '/api/admin/content/save': typeof ApiAdminContentSaveRoute - '/api/admin/content/submit-for-review': typeof ApiAdminContentSubmitForReviewRoute '/api/admin/import/google-docs': typeof ApiAdminImportGoogleDocsRoute '/api/admin/import/save': typeof ApiAdminImportSaveRoute '/api/admin/media/create-folder': typeof ApiAdminMediaCreateFolderRoute @@ -1095,7 +1087,6 @@ export interface FileRoutesByTo { '/api/admin/content/publish': typeof ApiAdminContentPublishRoute '/api/admin/content/rename': typeof ApiAdminContentRenameRoute '/api/admin/content/save': typeof ApiAdminContentSaveRoute - '/api/admin/content/submit-for-review': typeof ApiAdminContentSubmitForReviewRoute '/api/admin/import/google-docs': typeof ApiAdminImportGoogleDocsRoute '/api/admin/import/save': typeof ApiAdminImportSaveRoute '/api/admin/media/create-folder': typeof ApiAdminMediaCreateFolderRoute @@ -1233,7 +1224,6 @@ export interface FileRoutesById { '/api/admin/content/publish': typeof ApiAdminContentPublishRoute '/api/admin/content/rename': typeof ApiAdminContentRenameRoute '/api/admin/content/save': typeof ApiAdminContentSaveRoute - '/api/admin/content/submit-for-review': typeof ApiAdminContentSubmitForReviewRoute '/api/admin/import/google-docs': typeof ApiAdminImportGoogleDocsRoute '/api/admin/import/save': typeof ApiAdminImportSaveRoute '/api/admin/media/create-folder': typeof ApiAdminMediaCreateFolderRoute @@ -1371,7 +1361,6 @@ export interface FileRouteTypes { | '/api/admin/content/publish' | '/api/admin/content/rename' | '/api/admin/content/save' - | '/api/admin/content/submit-for-review' | '/api/admin/import/google-docs' | '/api/admin/import/save' | '/api/admin/media/create-folder' @@ -1503,7 +1492,6 @@ export interface FileRouteTypes { | '/api/admin/content/publish' | '/api/admin/content/rename' | '/api/admin/content/save' - | '/api/admin/content/submit-for-review' | '/api/admin/import/google-docs' | '/api/admin/import/save' | '/api/admin/media/create-folder' @@ -1640,7 +1628,6 @@ export interface FileRouteTypes { | '/api/admin/content/publish' | '/api/admin/content/rename' | '/api/admin/content/save' - | '/api/admin/content/submit-for-review' | '/api/admin/import/google-docs' | '/api/admin/import/save' | '/api/admin/media/create-folder' @@ -1689,7 +1676,6 @@ export interface RootRouteChildren { ApiAdminContentPublishRoute: typeof ApiAdminContentPublishRoute ApiAdminContentRenameRoute: typeof ApiAdminContentRenameRoute ApiAdminContentSaveRoute: typeof ApiAdminContentSaveRoute - ApiAdminContentSubmitForReviewRoute: typeof ApiAdminContentSubmitForReviewRoute ApiAdminImportGoogleDocsRoute: typeof ApiAdminImportGoogleDocsRoute ApiAdminImportSaveRoute: typeof ApiAdminImportSaveRoute ApiAdminMediaCreateFolderRoute: typeof ApiAdminMediaCreateFolderRoute @@ -2544,13 +2530,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiAdminImportGoogleDocsRouteImport parentRoute: typeof rootRouteImport } - '/api/admin/content/submit-for-review': { - id: '/api/admin/content/submit-for-review' - path: '/api/admin/content/submit-for-review' - fullPath: '/api/admin/content/submit-for-review' - preLoaderRoute: typeof ApiAdminContentSubmitForReviewRouteImport - parentRoute: typeof rootRouteImport - } '/api/admin/content/save': { id: '/api/admin/content/save' path: '/api/admin/content/save' @@ -2918,7 +2897,6 @@ const rootRouteChildren: RootRouteChildren = { ApiAdminContentPublishRoute: ApiAdminContentPublishRoute, ApiAdminContentRenameRoute: ApiAdminContentRenameRoute, ApiAdminContentSaveRoute: ApiAdminContentSaveRoute, - ApiAdminContentSubmitForReviewRoute: ApiAdminContentSubmitForReviewRoute, ApiAdminImportGoogleDocsRoute: ApiAdminImportGoogleDocsRoute, ApiAdminImportSaveRoute: ApiAdminImportSaveRoute, ApiAdminMediaCreateFolderRoute: ApiAdminMediaCreateFolderRoute, diff --git a/apps/web/src/routes/admin/README.md b/apps/web/src/routes/admin/README.md index 2ad78c2a8a..8913dbefc8 100644 --- a/apps/web/src/routes/admin/README.md +++ b/apps/web/src/routes/admin/README.md @@ -52,42 +52,23 @@ Complete flow from editing to publication: **1. User Edits a Published Article** - Open `/admin/collections` and select a published article - Make changes in the editor +- Auto-save runs every 60 seconds, or save manually with โŒ˜S / Save button -**2. User Clicks "Save"** +**2. Save Creates a PR** - Creates a new branch `blog/{slug}-{timestamp}` (or uses existing one) -- Commits with `ready_for_review: false` in frontmatter -- Creates/updates PR to `main` +- Creates a non-draft PR to `main`, ready to merge +- Assigns `harshikaalagh-netizen` as reviewer on PR creation +- A banner appears in the editor linking to the PR **3. GitHub Actions Trigger** - `blog-grammar-check.yml` - Runs AI grammar check, posts suggestions as PR comment -- `blog-slack-notify.yml` - No notification sent (waiting for review submission) **4. User Continues Editing (Optional)** -- Each "Save" updates the same PR branch -- Each push triggers workflows again - -**5. User Clicks "Submit for Review"** -- Updates frontmatter to `ready_for_review: true` -- Adds `harshikaalagh-netizen` as PR reviewer - -**6. GitHub Actions Trigger Again** -- Slack notification sent (blue border): - ``` - ๐Ÿ‘€ *Article submitted for review* - @harshika please review - ``` - - Includes "Preview" and "View PR" buttons - - No "Merge" button (merge through GitHub interface) - -**7. Reviewer Merges PR** -- Article goes live on the website - -**Slack Notification Summary:** +- Each save updates the same PR branch +- Each push triggers the grammar check again -| Action | `ready_for_review` | Slack Message | Border | -|--------|-------------------|---------------|--------| -| Save | `false` | No notification | - | -| Submit for Review | `true` | "๐Ÿ‘€ submitted for review" @harshika | Blue | +**5. Reviewer Merges PR** +- Article goes live on the website ## API Endpoints @@ -116,7 +97,7 @@ All API endpoints require admin authentication. - `POST /api/admin/content/save` - Save content (creates PR for published articles) - `POST /api/admin/content/create` - Create new content file - `POST /api/admin/content/publish` - Publish/unpublish an article -- `POST /api/admin/content/submit-for-review` - Submit article for editorial review + - `POST /api/admin/content/rename` - Rename a content file - `POST /api/admin/content/duplicate` - Duplicate a content file - `POST /api/admin/content/delete` - Delete a content file @@ -126,9 +107,8 @@ All API endpoints require admin authentication. The editorial workflow is powered by two GitHub Actions workflows in `.github/workflows/`: - **`blog-grammar-check.yml`** - Runs AI-powered grammar check on article PRs and posts suggestions as comments -- **`blog-slack-notify.yml`** - Sends Slack notifications for article changes with editorial status detection -Both trigger on PRs to `main` that modify `apps/web/content/articles/**` on `blog/` branches. +Triggers on PRs to `main` that modify `apps/web/content/articles/**` on `blog/` branches. ## Environment Variables diff --git a/apps/web/src/routes/admin/collections/index.tsx b/apps/web/src/routes/admin/collections/index.tsx index 703a855534..6fab5b9cba 100644 --- a/apps/web/src/routes/admin/collections/index.tsx +++ b/apps/web/src/routes/admin/collections/index.tsx @@ -24,9 +24,9 @@ import { PinOffIcon, PlusIcon, RefreshCwIcon, + SaveIcon, ScissorsIcon, SearchIcon, - SendHorizontalIcon, SquareArrowOutUpRightIcon, Trash2Icon, XIcon, @@ -39,6 +39,8 @@ import React, { useRef, useState, } from "react"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; import BlogEditor from "@hypr/tiptap/blog-editor"; import "@hypr/tiptap/styles.css"; @@ -84,7 +86,6 @@ interface DraftArticle { author?: string; date?: string; published?: boolean; - ready_for_review?: boolean; } interface CollectionInfo { @@ -134,7 +135,6 @@ interface FileContent { published?: boolean; featured?: boolean; category?: string; - ready_for_review?: boolean; } interface ArticleMetadata { @@ -147,7 +147,6 @@ interface ArticleMetadata { published: boolean; featured: boolean; category: string; - ready_for_review?: boolean; } interface EditorData { @@ -1238,6 +1237,13 @@ function ContentPanel({ } return response.json(); }, + onSuccess: (data) => { + if (data.prNumber) { + queryClient.invalidateQueries({ + queryKey: ["pendingPR", currentTab?.path], + }); + } + }, }); const handleSave = useCallback( @@ -1261,7 +1267,7 @@ function ContentPanel({ [currentTab?.type, currentTab?.path], ); - const { data: pendingPRData } = useQuery({ + useQuery({ queryKey: ["pendingPR", currentTab?.path], queryFn: async () => { const params = new URLSearchParams({ path: currentTab!.path }); @@ -1285,84 +1291,6 @@ function ContentPanel({ const queryClient = useQueryClient(); - const { mutate: submitForReview, isPending: isSubmittingForReview } = - useMutation({ - mutationFn: async (params: { - path: string; - branch: string; - prNumber: number; - prUrl?: string; - }) => { - const response = await fetch("/api/admin/content/submit-for-review", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(params), - }); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || "Failed to submit for review"); - } - const data = await response.json(); - return { ...data, prUrl: params.prUrl }; - }, - onSuccess: (data) => { - queryClient.invalidateQueries({ - queryKey: ["branchFile", currentTab?.path], - }); - queryClient.invalidateQueries({ - queryKey: ["pendingPRFile", currentTab?.path], - }); - if (data.prUrl) { - window.open(data.prUrl, "_blank"); - } - }, - }); - - const handleSubmitForReview = useCallback(async () => { - if (!currentTab || !editorData) return; - - const saveFirst = async () => { - const response = await fetch("/api/admin/content/save", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - path: currentTab.path, - content: editorData.content, - metadata: editorData.metadata, - branch: currentTab.branch, - }), - }); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || "Failed to save"); - } - return response.json(); - }; - - const saveResult = await saveFirst(); - - await queryClient.invalidateQueries({ - queryKey: ["pendingPR", currentTab.path], - }); - - const prData = saveResult.prNumber - ? { - branchName: saveResult.branchName, - prNumber: saveResult.prNumber, - prUrl: saveResult.prUrl, - } - : pendingPRData; - - if (prData?.branchName && prData?.prNumber) { - submitForReview({ - path: `apps/web/content/${currentTab.path}`, - branch: prData.branchName, - prNumber: prData.prNumber, - prUrl: prData.prUrl, - }); - } - }, [currentTab, editorData, pendingPRData, submitForReview, queryClient]); - return (
{currentTab ? ( @@ -1378,11 +1306,9 @@ function ContentPanel({ onReorderTabs={onReorderTabs} isPreviewMode={isPreviewMode} onTogglePreview={() => setIsPreviewMode(!isPreviewMode)} + onSave={handleSave} isSaving={isSaving} isPublished={currentFileContent?.published} - onSubmitForReview={handleSubmitForReview} - isSubmittingForReview={isSubmittingForReview} - hasPendingPR={pendingPRData?.hasPendingPR} onRenameFile={(newSlug) => { const pathParts = currentTab.path.split("/"); pathParts[pathParts.length - 1] = `${newSlug}.mdx`; @@ -1430,11 +1356,9 @@ function EditorHeader({ onReorderTabs, isPreviewMode, onTogglePreview, + onSave, isSaving, isPublished, - onSubmitForReview, - isSubmittingForReview, - hasPendingPR: _hasPendingPR, onRenameFile, hasUnsavedChanges, autoSaveCountdown, @@ -1449,11 +1373,9 @@ function EditorHeader({ onReorderTabs: (tabs: Tab[]) => void; isPreviewMode: boolean; onTogglePreview: () => void; + onSave: () => void; isSaving: boolean; isPublished?: boolean; - onSubmitForReview?: () => void; - isSubmittingForReview?: boolean; - hasPendingPR?: boolean; onRenameFile?: (newSlug: string) => void; hasUnsavedChanges?: boolean; autoSaveCountdown?: number | null; @@ -1581,40 +1503,30 @@ function EditorHeader({ )} - {isSaving ? ( - - - Saving... - - ) : hasUnsavedChanges ? ( - - - Unsaved - {autoSaveCountdown !== null && - autoSaveCountdown !== undefined && ( - ยท {autoSaveCountdown}s - )} - - ) : null} - {onSubmitForReview && ( - - )} +
)} @@ -2185,6 +2097,16 @@ function MetadataPanel({ /> + +
+ handlers.onPublishedChange(e.target.checked)} + className="rounded" + /> +
+
- + {fileContent.mdx ? ( + + ) : ( + {content} + )}
diff --git a/apps/web/src/routes/api/admin/content/save.ts b/apps/web/src/routes/api/admin/content/save.ts index 8da88a6cc8..0dc8ce73bf 100644 --- a/apps/web/src/routes/api/admin/content/save.ts +++ b/apps/web/src/routes/api/admin/content/save.ts @@ -18,7 +18,6 @@ interface ArticleMetadata { published?: boolean; featured?: boolean; category?: string; - ready_for_review?: boolean; } interface SaveRequest { @@ -48,24 +47,21 @@ function buildFrontmatter(metadata: ArticleMetadata): string { if (metadata.author) { lines.push(`author: ${JSON.stringify(metadata.author)}`); } + if (metadata.coverImage) { + lines.push(`coverImage: ${JSON.stringify(metadata.coverImage)}`); + } if (metadata.featured !== undefined) { lines.push(`featured: ${metadata.featured}`); } if (metadata.published !== undefined) { lines.push(`published: ${metadata.published}`); } - if (metadata.ready_for_review !== undefined) { - lines.push(`ready_for_review: ${metadata.ready_for_review}`); - } if (metadata.category) { lines.push(`category: ${JSON.stringify(metadata.category)}`); } if (metadata.date) { lines.push(`date: ${JSON.stringify(metadata.date)}`); } - if (metadata.coverImage) { - lines.push(`coverImage: ${JSON.stringify(metadata.coverImage)}`); - } return `---\n${lines.join("\n")}\n---\n`; } @@ -179,9 +175,9 @@ export const Route = createFileRoute("/api/admin/content/save")({ const frontmatter = buildFrontmatter(metadata); const fullContent = `${frontmatter}\n${processedContent}`; - // If the article is published, create a PR to main (handles branch protection) - // Otherwise, save to the draft branch - const shouldCreatePR = metadata.published === true && !branch; + // If there's no branch, the article is on main, so create a PR (handles branch protection) + // Otherwise, save directly to the draft branch + const shouldCreatePR = !branch; if (shouldCreatePR) { const result = await savePublishedArticleWithPR(path, fullContent, { diff --git a/apps/web/src/routes/api/admin/content/submit-for-review.ts b/apps/web/src/routes/api/admin/content/submit-for-review.ts deleted file mode 100644 index 202969197d..0000000000 --- a/apps/web/src/routes/api/admin/content/submit-for-review.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; - -import { fetchAdminUser } from "@/functions/admin"; -import { - convertDraftToReady, - getFileContentFromBranch, - getGitHubCredentials, - parseMDX, - updateContentFileOnBranch, -} from "@/functions/github-content"; - -const GITHUB_REPO = "fastrepl/hyprnote"; - -interface SubmitForReviewRequest { - path: string; - branch: string; - prNumber: number; -} - -function buildFrontmatter(frontmatter: Record): string { - const lines: string[] = []; - - if (frontmatter.meta_title) { - lines.push(`meta_title: ${JSON.stringify(frontmatter.meta_title)}`); - } - if (frontmatter.display_title) { - lines.push(`display_title: ${JSON.stringify(frontmatter.display_title)}`); - } - if (frontmatter.meta_description) { - lines.push( - `meta_description: ${JSON.stringify(frontmatter.meta_description)}`, - ); - } - if (frontmatter.author) { - lines.push(`author: ${JSON.stringify(frontmatter.author)}`); - } - if (frontmatter.coverImage) { - lines.push(`coverImage: ${JSON.stringify(frontmatter.coverImage)}`); - } - if (frontmatter.featured !== undefined) { - lines.push(`featured: ${frontmatter.featured}`); - } - lines.push(`published: true`); - lines.push(`ready_for_review: true`); - if (frontmatter.category) { - lines.push(`category: ${JSON.stringify(frontmatter.category)}`); - } - if (frontmatter.date) { - lines.push(`date: ${JSON.stringify(frontmatter.date)}`); - } - - return `---\n${lines.join("\n")}\n---\n`; -} - -async function addReviewerToPR( - prNumber: number, - reviewer: string, - token: string, -): Promise<{ success: boolean; error?: string }> { - try { - const response = await fetch( - `https://api.github.com/repos/${GITHUB_REPO}/pulls/${prNumber}/requested_reviewers`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github.v3+json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - reviewers: [reviewer], - }), - }, - ); - - if (!response.ok) { - const error = await response.json(); - return { - success: false, - error: error.message || `GitHub API error: ${response.status}`, - }; - } - - return { success: true }; - } catch (error) { - return { - success: false, - error: `Failed to add reviewer: ${(error as Error).message}`, - }; - } -} - -export const Route = createFileRoute("/api/admin/content/submit-for-review")({ - server: { - handlers: { - POST: async ({ request }) => { - const isDev = process.env.NODE_ENV === "development"; - if (!isDev) { - const user = await fetchAdminUser(); - if (!user?.isAdmin) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 401, - headers: { "Content-Type": "application/json" }, - }); - } - } - - let body: SubmitForReviewRequest; - try { - body = await request.json(); - } catch { - return new Response(JSON.stringify({ error: "Invalid JSON body" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); - } - - const { path, branch, prNumber } = body; - - if (!path || !branch || !prNumber) { - return new Response( - JSON.stringify({ - error: "Missing required fields: path, branch, prNumber", - }), - { status: 400, headers: { "Content-Type": "application/json" } }, - ); - } - - const fileResult = await getFileContentFromBranch(path, branch); - if (!fileResult.success || !fileResult.content) { - return new Response( - JSON.stringify({ error: fileResult.error || "File not found" }), - { status: 404, headers: { "Content-Type": "application/json" } }, - ); - } - - const { frontmatter, content } = parseMDX(fileResult.content); - - const newFrontmatter = buildFrontmatter(frontmatter); - const fullContent = `${newFrontmatter}\n${content}`; - - const updateResult = await updateContentFileOnBranch( - path, - fullContent, - branch, - ); - - if (!updateResult.success) { - return new Response(JSON.stringify({ error: updateResult.error }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }); - } - - if (!isDev) { - const convertResult = await convertDraftToReady(prNumber); - if (!convertResult.success) { - console.warn( - "Failed to convert draft PR to ready:", - convertResult.error, - ); - } - - const credentials = await getGitHubCredentials(); - if (credentials?.token) { - const reviewerResult = await addReviewerToPR( - prNumber, - "harshikaalagh-netizen", - credentials.token, - ); - - if (!reviewerResult.success) { - console.warn("Failed to add reviewer:", reviewerResult.error); - } - } - } - - return new Response( - JSON.stringify({ - success: true, - message: "Article submitted for review", - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ); - }, - }, - }, -});