Skip to content

Commit

Permalink
feat(save-playground): support a preference to auto-save the playgrou…
Browse files Browse the repository at this point in the history
…nd directory

Closes #229
Closes epicweb-dev/advanced-react-patterns#132
  • Loading branch information
kentcdodds committed Oct 20, 2024
1 parent e416ace commit 1c6348c
Show file tree
Hide file tree
Showing 11 changed files with 112 additions and 36 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,5 @@ vite.config.ts.timestamp-*
.nx/cache
.nx/workspace-data
*.tsbuildinfo

/example/playgrounds
3 changes: 2 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import {
shouldForceFresh,
} from '@epic-web/workshop-utils/cache.server'
import { getWorkshopConfig } from '@epic-web/workshop-utils/config.server'
import { dayjs } from '@epic-web/workshop-utils/utils.server'
import { z } from 'zod'
import { getHints } from '#app/utils/client-hints.js'
import { getDayjs } from '#app/utils/dayjs.ts'

const EmojiDataSchema = z.union([
z.object({
Expand Down Expand Up @@ -126,8 +126,6 @@ export async function fetchDiscordPosts({ request }: { request: Request }) {

return threadData.map((thread) => ({
...thread,
lastUpdatedDisplay: getDayjs()(thread.lastUpdated)
.tz(hints.timeZone)
.fromNow(),
lastUpdatedDisplay: dayjs(thread.lastUpdated).tz(hints.timeZone).fromNow(),
}))
}
28 changes: 25 additions & 3 deletions packages/workshop-app/app/routes/_app+/preferences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,16 @@ export async function action({ request }: { request: Request }) {
const maxResolution = formData.get('maxResolution')
const fontSize = formData.get('fontSize')
const optOutPresence = formData.get('optOutPresence') === 'on'
const persistPlayground = formData.get('persistPlayground') === 'on'

await setPreferences({
player: {
minResolution: minResolution ? Number(minResolution) : undefined,
maxResolution: maxResolution ? Number(maxResolution) : undefined,
},
fontSize: fontSize ? Number(fontSize) : undefined,
presence: {
optOut: optOutPresence,
},
presence: { optOut: optOutPresence },
playground: { persist: persistPlayground },
})

return redirectWithToast('/preferences', {
Expand All @@ -48,6 +48,7 @@ export default function AccountSettings() {
const playerPreferences = data?.preferences?.player
const fontSizePreference = data?.preferences?.fontSize
const presencePreferences = data?.preferences?.presence
const playgroundPreferences = data?.preferences?.playground
const navigation = useNavigation()

const isSubmitting = navigation.state === 'submitting'
Expand Down Expand Up @@ -128,6 +129,27 @@ export default function AccountSettings() {
</div>
</div>

<div>
<div className="mb-2 flex items-center gap-2">
<h2 className="text-body-xl">Persist Playground</h2>

<SimpleTooltip
content={`When enabled, clicking "Set to Playground" will save the current playground in the "saved-playgrounds" directory.`}
>
<Icon name="Question" tabIndex={0} />
</SimpleTooltip>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="persistPlayground"
name="persistPlayground"
defaultChecked={playgroundPreferences?.persist}
/>
<label htmlFor="persistPlayground">Enable saving playground</label>
</div>
</div>

<div className="h-4" />

<Button
Expand Down
6 changes: 3 additions & 3 deletions packages/workshop-app/app/routes/admin+/version.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import {
makeTimings,
time,
} from '@epic-web/workshop-utils/timing.server'
import { dayjs } from '@epic-web/workshop-utils/utils.server'
import { type SEOHandle } from '@nasa-gcn/remix-seo'
import { type HeadersFunction } from '@remix-run/node'
import { useLoaderData, unstable_data as data } from '@remix-run/react'
import { useWorkshopConfig } from '#app/components/workshop-config.tsx'
import { getDayjs } from '#app/utils/dayjs.ts'
import { getErrorMessage } from '#app/utils/misc.tsx'

export const handle: SEOHandle = {
getSitemapEntries: () => null,
Expand All @@ -21,13 +22,12 @@ export async function loader() {
const timings = makeTimings('versionLoader')
const [commitInfo, latestVersion] = await Promise.all([
time(() => getCommitInfo(), { timings, type: 'getCommitInfo' }),
time(() => getLatestWorkshopAppVersion(), {
time(() => getLatestWorkshopAppVersion().catch((e) => getErrorMessage(e)), {
timings,
type: 'getLatestWorkshopAppVersion',
}),
])

const dayjs = getDayjs()
const uptime = process.uptime() * 1000
const startDate = new Date(Date.now() - uptime)

Expand Down
14 changes: 0 additions & 14 deletions packages/workshop-app/app/utils/dayjs.ts

This file was deleted.

1 change: 0 additions & 1 deletion packages/workshop-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@
"cookie": "^0.7.2",
"cross-env": "^7.0.3",
"cross-spawn": "^7.0.3",
"dayjs": "^1.11.13",
"dotenv": "^16.4.5",
"esbuild": "0.24.0",
"etag": "^1.8.1",
Expand Down
2 changes: 2 additions & 0 deletions packages/workshop-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@
},
"dependencies": {
"@epic-web/cachified": "^5.2.0",
"@epic-web/invariant": "^1.0.0",
"@epic-web/remember": "^1.1.0",
"@kentcdodds/md-temp": "^9.0.1",
"@mdx-js/mdx": "^3.0.1",
Expand All @@ -165,6 +166,7 @@
"close-with-grace": "^2.1.0",
"cookie": "^1.0.1",
"cross-spawn": "^7.0.3",
"dayjs": "^1.11.13",
"execa": "^9.4.0",
"fkill": "^9.0.0",
"fs-extra": "^11.2.0",
Expand Down
69 changes: 59 additions & 10 deletions packages/workshop-utils/src/apps.server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fs from 'node:fs'
import path from 'node:path'
import { type CacheEntry } from '@epic-web/cachified'
import { invariant } from '@epic-web/invariant'
import { remember } from '@epic-web/remember'
import chokidar from 'chokidar'
/// TODO: figure out why this import is necessary (without it tsc seems to not honor the boolean reset 🤷‍♂️)
Expand All @@ -24,6 +25,7 @@ import {
getStackBlitzUrl,
getWorkshopConfig,
} from './config.server.js'
import { getPreferences } from './db.server.js'
import { getEnv, init as initEnv } from './env.server.js'
import { getDirModifiedTime } from './modified-time.server.js'
import {
Expand All @@ -34,6 +36,7 @@ import {
} from './process-manager.server.js'
import { getServerTimeHeader, time, type Timings } from './timing.server.js'
import { getErrorMessage } from './utils.js'
import { dayjs } from './utils.server.js'

declare global {
var __epicshop_apps_initialized__: boolean | undefined
Expand Down Expand Up @@ -1201,19 +1204,65 @@ export async function getAppFromFile(filePath: string) {
return apps.find((app) => filePath.startsWith(app.fullPath))
}

export async function savePlayground() {
const playgroundApp = await getAppByName('playground')
invariant(playgroundApp, 'app with name "playground" does not exist')

invariant(
isPlaygroundApp(playgroundApp),
'app with name "playground" exists, but it is not a playground type app',
)

const playgroundDir = path.join(workshopRoot, 'playground')
const savedPlaygroundsDir = path.join(workshopRoot, 'saved-playgrounds')
await fsExtra.ensureDir(savedPlaygroundsDir)
const now = dayjs()
// note: the format must be filename safe
const timestamp = now.format('YYYY.MM.DD_HH.mm.ss')
const savedPlaygroundDirName = `${timestamp}_${playgroundApp.appName}`

const persistedPlaygroundReadmePath = path.join(
savedPlaygroundsDir,
'README.md',
)
if (!(await exists(persistedPlaygroundReadmePath))) {
await fsExtra.writeFile(
persistedPlaygroundReadmePath,
`
# Saved Playgrounds
This directory stores the playground directory each time you click "Set to
Playground." If you do not wish to do this, go to
[your preferences](http://localhost:5639/preferences) when the app is running
locally and uncheck "Enable saving playground."
`.trim(),
)
}
await fsExtra.copy(
playgroundDir,
path.join(savedPlaygroundsDir, savedPlaygroundDirName),
)
}

export async function setPlayground(
srcDir: string,
{ reset }: { reset?: boolean } = {},
) {
const destDir = path.join(workshopRoot, 'playground')
const isIgnored = await isGitIgnored({ cwd: srcDir })
const preferences = await getPreferences()
const playgroundApp = await getAppByName('playground')
const playgroundDir = path.join(workshopRoot, 'playground')

if (playgroundApp && preferences?.playground?.persist) {
await savePlayground()
}

const isIgnored = await isGitIgnored({ cwd: srcDir })
const playgroundWasRunning = playgroundApp
? isAppRunning(playgroundApp)
: false
if (playgroundApp && reset) {
await closeProcess(playgroundApp.name)
await fsExtra.remove(destDir)
await fsExtra.remove(playgroundDir)
}
const setPlaygroundTimestamp = Date.now()

Expand All @@ -1229,7 +1278,7 @@ export async function setPlayground(

env: {
EPICSHOP_PLAYGROUND_TIMESTAMP: setPlaygroundTimestamp.toString(),
EPICSHOP_PLAYGROUND_DEST_DIR: destDir,
EPICSHOP_PLAYGROUND_DEST_DIR: playgroundDir,
EPICSHOP_PLAYGROUND_SRC_DIR: srcDir,
EPICSHOP_PLAYGROUND_WAS_RUNNING: playgroundWasRunning.toString(),
} as any,
Expand All @@ -1239,9 +1288,9 @@ export async function setPlayground(
const basename = path.basename(srcDir)
// If we don't delete the destination node_modules first then copying the new
// node_modules has issues.
await fsExtra.remove(path.join(destDir, 'node_modules'))
await fsExtra.remove(path.join(playgroundDir, 'node_modules'))
// Copy the contents of the source directory to the destination directory recursively
await fsExtra.copy(srcDir, destDir, {
await fsExtra.copy(srcDir, playgroundDir, {
filter: async (srcFile, destFile) => {
if (
srcFile.includes(`${basename}${path.sep}build`) ||
Expand Down Expand Up @@ -1292,13 +1341,13 @@ export async function setPlayground(

// Remove files from destDir that were in destDir before but are not in srcDir
const srcFiles = await getFiles(srcDir)
const destFiles = await getFiles(destDir)
const destFiles = await getFiles(playgroundDir)
const filesToDelete = destFiles.filter(
(fileName) => !srcFiles.includes(fileName),
)

for (const fileToDelete of filesToDelete) {
await fsExtra.remove(path.join(destDir, fileToDelete))
await fsExtra.remove(path.join(playgroundDir, fileToDelete))
}

const appName = getAppName(srcDir)
Expand All @@ -1323,7 +1372,7 @@ export async function setPlayground(
env: {
EPICSHOP_PLAYGROUND_TIMESTAMP: setPlaygroundTimestamp.toString(),
EPICSHOP_PLAYGROUND_SRC_DIR: srcDir,
EPICSHOP_PLAYGROUND_DEST_DIR: destDir,
EPICSHOP_PLAYGROUND_DEST_DIR: playgroundDir,
EPICSHOP_PLAYGROUND_WAS_RUNNING: playgroundWasRunning.toString(),
EPICSHOP_PLAYGROUND_IS_STILL_RUNNING:
playgroundIsStillRunning.toString(),
Expand All @@ -1333,7 +1382,7 @@ export async function setPlayground(
}

// since we are running without the watcher we need to set the modified time
modifiedTimes.set(destDir, Date.now())
modifiedTimes.set(playgroundDir, Date.now())

if (playgroundApp && restartPlayground) {
await runAppDev(playgroundApp)
Expand Down
5 changes: 5 additions & 0 deletions packages/workshop-utils/src/db.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ const DataSchema = z.object({
.object({
player: PlayerPreferencesSchema,
presence: PresencePreferencesSchema,
playground: z
.object({
persist: z.boolean().default(false),
})
.optional(),
fontSize: z.number().optional(),
})
.optional()
Expand Down
12 changes: 12 additions & 0 deletions packages/workshop-utils/src/utils.server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
import { remember } from '@epic-web/remember'
import dayjsLib from 'dayjs'
import relativeTimePlugin from 'dayjs/plugin/relativeTime.js'
import timeZonePlugin from 'dayjs/plugin/timezone.js'
import utcPlugin from 'dayjs/plugin/utc.js'
import { cachified, connectionCache } from './cache.server.js'
import { type Timings } from './timing.server.js'

export const dayjs = remember('dayjs', () => {
dayjsLib.extend(utcPlugin)
dayjsLib.extend(timeZonePlugin)
dayjsLib.extend(relativeTimePlugin)
return dayjsLib
})

export async function checkConnection() {
try {
const response = await fetch('https://www.cloudflare.com', {
Expand Down

0 comments on commit 1c6348c

Please sign in to comment.