Skip to content
Open
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
14 changes: 14 additions & 0 deletions apps/editor/.env.local.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
APP_NAME=app-template

# Airtable: https://support.airtable.com/docs/creating-and-using-api-keys-and-access-tokens
AIRTABLE_PERSONAL_ACCESS_TOKEN=

# BlueDot Impact Slack, #tech-dev-alerts
ALERTS_SLACK_CHANNEL_ID=C04SFUECECU
# For (local) BlueBot see https://airtable.com/appnNmNoNMB6crg6I/tbllthJ2YSPDsKWCt/viwfjWLvplq6Xw93D/recqurdJTPWzm5y4W
# For (prod) BlueBot see https://api.slack.com/apps/A04PV2GAQRY/install-on-team
# Starts 'xoxb-'
ALERTS_SLACK_BOT_TOKEN=IGNORE_SLACK_ALERTS

WEBSITE_ASSETS_BUCKET_ACCESS_KEY_ID=
WEBSITE_ASSETS_BUCKET_SECRET_ACCESS_KEY=
11 changes: 11 additions & 0 deletions apps/editor/.env.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
APP_NAME=app-template

# Airtable
AIRTABLE_PERSONAL_ACCESS_TOKEN=FAKE_TOKEN

# Slack
ALERTS_SLACK_CHANNEL_ID=C04SFUECECU
ALERTS_SLACK_BOT_TOKEN=FAKE_TOKEN

WEBSITE_ASSETS_BUCKET_ACCESS_KEY_ID=FAKE
WEBSITE_ASSETS_BUCKET_SECRET_ACCESS_KEY=FAKE
31 changes: 31 additions & 0 deletions apps/editor/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# You usually shouldn't need to edit this file.
# If you came here to change environment variables:
# - Add server-side env vars to infra's serviceDefinitions.ts
# - Add client-side env vars to .env.production (you might need to create this)
# If you came here to change the build process, edit the `npm run build` script in package.json instead

FROM node:20-alpine@sha256:7a91aa397f2e2dfbfcdad2e2d72599f374e0b0172be1d86eeb73f1d33f36a4b2 AS base

RUN apk update && apk add --no-cache dumb-init

WORKDIR /app

ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1

# Set the correct permission for prerender cache
RUN mkdir dist
RUN chown node:node dist

ARG APP_NAME
ENV APP_NAME=${APP_NAME}

COPY --chown=node:node dist/standalone ./
COPY --chown=node:node public ./apps/${APP_NAME}/public
COPY --chown=node:node dist/static ./apps/${APP_NAME}/dist/static

USER node

EXPOSE 8080

CMD HOSTNAME="0.0.0.0" PORT="8080" dumb-init node ./apps/${APP_NAME}/server.js
13 changes: 13 additions & 0 deletions apps/editor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# editor

An app for BlueDot staff to edit content: blogs and job postings.

## Developer setup

No special actions needed, just follow [the general developer setup instructions](../../README.md#developer-setup-instructions)

## Deployment

This app is deployed onto the K8s cluster as a standard Next.js app in docker.

To deploy a new version, simply commit to the master branch. GitHub Actions automatically handles CD.
5 changes: 5 additions & 0 deletions apps/editor/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
3 changes: 3 additions & 0 deletions apps/editor/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const { withDefaultBlueDotNextConfig } = require('@bluedot/ui/src/default-config/next');

module.exports = withDefaultBlueDotNextConfig();
57 changes: 57 additions & 0 deletions apps/editor/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"name": "@bluedot/editor",
"version": "1.0.0",
"private": true,
"scripts": {
"postinstall": "shx cp -n .env.local.template .env.local",
"start": "next dev -p 8000",
"start:docker": "docker-scripts start",
"build": "next build",
"lint": "eslint . --report-unused-disable-directives --max-warnings=0",
"lint:fix": "npm run lint -- --fix",
"test": "vitest --run",
"test:watch": "vitest",
"deploy:cd": "docker-scripts deploy"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.803.0",
"@aws-sdk/s3-presigned-post": "^3.803.0",
"@bluedot/ui": "*",
"@mdx-js/mdx": "^3.1.0",
"@syfxlin/tiptap-starter-kit": "^1.1.2",
"@tailwindcss/typography": "^0.5.16",
"@tiptap/pm": "^2.12.0",
"@tiptap/react": "^2.12.0",
"airtable-ts": "^1.5.0",
"airtable-ts-formula": "^1.0.0",
"axios": "^1.9.0",
"axios-hooks": "^5.0.2",
"http-errors": "^2.0.0",
"next": "^15.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.5.0",
"tiptap-markdown": "^0.8.10",
"zod": "^3.24.2"
},
"devDependencies": {
"@bluedot/docker-scripts": "*",
"@bluedot/eslint-config": "*",
"@bluedot/typescript-config": "*",
"@tailwindcss/postcss": "^4.0.6",
"@testing-library/react": "^15.0.2",
"@types/node": "^22.13.2",
"@types/react": "^18.0.22",
"@types/react-dom": "^18.0.7",
"@vitejs/plugin-react": "^4.2.1",
"dotenv": "^16.4.5",
"eslint": "^8.57.0",
"happy-dom": "^14.7.1",
"node-mocks-http": "^1.14.1",
"postcss": "^8.4.38",
"shx": "^0.4.0",
"tailwindcss": "^4.0.6",
"typescript": "^5.8.3",
"vitest": "^1.5.0"
}
}
5 changes: 5 additions & 0 deletions apps/editor/postcss.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
},
};
Empty file added apps/editor/public/.gitkeep
Empty file.
21 changes: 21 additions & 0 deletions apps/editor/src/components/BaseLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { CTALinkOrButton, NewText } from '@bluedot/ui';
import { useRouter } from 'next/router';

export type BaseLayoutProps = {
children?: React.ReactNode;
};

export const BaseLayout: React.FC<BaseLayoutProps> = ({ children }) => {
const router = useRouter();

return (
<div className="section-body gap-4 mt-6">
<NewText.H1>BlueDot Editor</NewText.H1>
<nav className="flex gap-2 mb-4">
<CTALinkOrButton variant={router.pathname.startsWith('/blogs') ? 'primary' : 'secondary'} url="/blogs">Blogs</CTALinkOrButton>
<CTALinkOrButton variant={router.pathname.startsWith('/jobs') ? 'primary' : 'secondary'} url="/jobs">Job postings</CTALinkOrButton>
</nav>
{children}
</div>
);
};
105 changes: 105 additions & 0 deletions apps/editor/src/components/BodyEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {
Auth, CTALinkOrButton,
ProgressDots,
} from '@bluedot/ui';
import axios from 'axios';
import dynamic from 'next/dynamic';
import { useEffect, useState } from 'react';
import { PresignedPostResponse } from '../pages/api/presigned-upload';

const MarkdownEditor = dynamic(() => import('./MarkdownEditor'), { ssr: false, loading: () => <ProgressDots /> });

export type BodyEditorProps = {
auth: Auth;
children?: string;
onSave: (body: string) => Promise<void>;
};

export const BodyEditor: React.FC<BodyEditorProps> = ({ auth, children, onSave }) => {
const [body, setBody] = useState<null | string>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);

useEffect(() => {
if (body === null && children) {
setBody(children);
}
}, [children]);

// Add beforeunload event listener to warn about unsaved changes
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (hasUnsavedChanges) {
e.preventDefault();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider setting e.returnValue explicitly (e.g., e.returnValue = '') in the beforeunload handler to ensure browsers display the unsaved changes warning consistently.

Suggested change
e.preventDefault();
e.returnValue = '';

}
};

window.addEventListener('beforeunload', handleBeforeUnload);

return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [hasUnsavedChanges]);

const [isSaving, setIsSaving] = useState(false);
const save = async () => {
setIsSaving(true);
try {
if (body) {
await onSave(body);
setHasUnsavedChanges(false);
}
} finally {
setIsSaving(false);
}
};

const uploadFile = async (fileData: ArrayBuffer, fileType: string) => {
const blob = new Blob([fileData], { type: fileType });

const presignedResponse = await axios<PresignedPostResponse>({
method: 'POST',
url: '/api/presigned-upload',
data: {
contentType: fileType,
},
headers: {
Authorization: `Bearer ${auth.token}`,
},
});

const formData = new FormData();
Object.entries(presignedResponse.data.fields).forEach(([key, value]) => {
formData.append(key, String(value));
});
formData.append('file', blob);
await axios.post(presignedResponse.data.uploadUrl, formData);

return { url: presignedResponse.data.fileUrl };
};

return (
<div className="max-w-3xl flex flex-col gap-4">
<MarkdownEditor
uploadFile={uploadFile}
onChange={(newBody) => {
setHasUnsavedChanges(true);
setBody(newBody);
}}
>
{children}
</MarkdownEditor>
{body && (
<div className="flex flex-col gap-4">
{hasUnsavedChanges && (
<div className="text-amber-600 text-size-sm">
You have unsaved changes
</div>
)}
<CTALinkOrButton onClick={save}>
{isSaving ? 'Saving...' : 'Save'}
</CTALinkOrButton>
</div>
)}
</div>
);
};
Loading