Skip to content

Commit

Permalink
Merge pull request #1 from Trugamr/develop
Browse files Browse the repository at this point in the history
Create basic deployment builder form
  • Loading branch information
Trugamr authored Jan 17, 2024
2 parents 1c59c2b + 52fb675 commit de0126f
Show file tree
Hide file tree
Showing 21 changed files with 763 additions and 40 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Self-hosted dashboard to manage docker compose stacks.

<img src="assets/preview.png" />

> [!NOTE]
> [!NOTE]
> This project is still in early development, there will be bugs and missing features.
## Building and Running
Expand Down
44 changes: 44 additions & 0 deletions app/components/client-hits-check.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { getHintUtils } from '@epic-web/client-hints'
import {
clientHint as colorSchemeHint,
subscribeToSchemeChange,
} from '@epic-web/client-hints/color-scheme'
import { clientHint as timeZoneHint } from '@epic-web/client-hints/time-zone'
import { clientHint as reducedMotionHint } from '@epic-web/client-hints/reduced-motion'
import { useRevalidator } from '@remix-run/react'
import { useEffect } from 'react'

const hintsUtils = getHintUtils({
theme: {
...colorSchemeHint,
cookieName: 'color-scheme',
},
timeZone: {
...timeZoneHint,
cookieName: 'time-zone',
},
reducedMotion: {
...reducedMotionHint,
cookieName: 'reduced-motion',
},
})

export const { getHints } = hintsUtils

export function ClientHintCheck() {
const { revalidate } = useRevalidator()

useEffect(() => {
subscribeToSchemeChange(() => {
revalidate()
})
}, [revalidate])

return (
<script
dangerouslySetInnerHTML={{
__html: hintsUtils.getClientHintCheckScript(),
}}
/>
)
}
39 changes: 39 additions & 0 deletions app/components/theme-switcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Button } from './ui/button'
import { THEME_FETCHER_KEY, Theme } from '~/lib/theme'
import { useFetcher } from '@remix-run/react'
import { P, match } from 'ts-pattern'
import { LaptopIcon, MoonStarIcon, SunIcon } from 'lucide-react'
import { useGlobalInfo } from '~/lib/hooks/use-global-info'
import { useOptimisticTheme } from '~/lib/hooks/use-theme'

type ThemeSwitcherOptions = {
className?: string
}

export function ThemeSwitcher({ className }: ThemeSwitcherOptions) {
const { preferences } = useGlobalInfo()

const fetcher = useFetcher({
key: THEME_FETCHER_KEY,
})

const optimistic = useOptimisticTheme()

const next = match(optimistic ?? preferences.theme ?? 'system')
.with('system', () => 'light' as const)
.with(P.union(Theme.Light, 'light'), () => 'dark' as const)
.with(P.union(Theme.Dark, 'dark'), () => 'system' as const)
.exhaustive()

return (
<fetcher.Form className={className} method="POST" action="/api/theme">
<Button name="theme" value={next} size="icon" variant="ghost">
{match(next)
.with('system', () => <LaptopIcon />)
.with(P.union(Theme.Light, 'light'), () => <SunIcon />)
.with(P.union(Theme.Dark, 'dark'), () => <MoonStarIcon />)
.exhaustive()}
</Button>
</fetcher.Form>
)
}
2 changes: 1 addition & 1 deletion app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;

--destructive: 0 62.8% 30.6%;
--destructive: 0 73.71% 41.76%;
--destructive-foreground: 0 0% 98%;

--border: 240 3.7% 15.9%;
Expand Down
52 changes: 52 additions & 0 deletions app/lib/deployment.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Deployment } from '~/routes/deploy/schema'
import {
convertComposeConfigToYaml,
convertDeploymentToComposeConfig,
} from '~/routes/deploy/utils'
import { getManagedStacksDirectory } from './stack.server'
import path from 'node:path'
import fs from 'node:fs/promises'

export async function createNewDeployment(deployment: Deployment) {
let composeConfig: ReturnType<typeof convertDeploymentToComposeConfig>
try {
composeConfig = convertDeploymentToComposeConfig(deployment)
} catch (error) {
// eslint-disable-next-line no-console
console.error(error)
throw new Error('Failed to convert deployment to compose config')
}

let composeYaml: ReturnType<typeof convertComposeConfigToYaml>
try {
composeYaml = convertComposeConfigToYaml(composeConfig)
} catch (error) {
// eslint-disable-next-line no-console
console.error(error)
throw new Error('Failed to convert compose config to yaml')
}

// Create a new directory for the stack
const stackDirectory = path.resolve(getManagedStacksDirectory(), deployment.name)

try {
await fs.mkdir(stackDirectory)
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'EEXIST') {
throw new Error(`Stack with name "${deployment.name}" already exists`)
}
// eslint-disable-next-line no-console
console.error(error)
throw new Error('Failed to create stack directory')
}

// Create a new docker-compose.yml file
const composeYamlPath = path.resolve(stackDirectory, 'docker-compose.yml')
try {
await fs.writeFile(composeYamlPath, composeYaml)
} catch (error) {
// eslint-disable-next-line no-console
console.error(error)
throw new Error('Failed to create docker-compose.yml file')
}
}
2 changes: 1 addition & 1 deletion app/lib/docker/compose.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ import { execa } from 'execa'
export async function getDockerComposeVersion() {
const { stdout } = await execa('docker', ['compose', 'version'])
return stdout
}
}
13 changes: 13 additions & 0 deletions app/lib/hooks/use-global-info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { invariant } from '@epic-web/invariant'
import { useRouteLoaderData } from '@remix-run/react'
import type { loader as rootLoader } from '~/root'

/**
* Get global info from the loader in root
*/
export function useGlobalInfo() {
const data = useRouteLoaderData<typeof rootLoader>('root')
invariant(data?.info, 'No global info found in root loader')

return data.info
}
32 changes: 32 additions & 0 deletions app/lib/hooks/use-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { invariant } from '@epic-web/invariant'
import { THEME_FETCHER_KEY, ThemeFormSchema, isTheme } from '../theme'
import { useGlobalInfo } from './use-global-info'
import { useFetcher } from '@remix-run/react'
import { parse } from '@conform-to/zod'

export function useTheme() {
const { preferences, hints } = useGlobalInfo()
invariant(isTheme(hints.theme), "Hints theme isn't a valid theme")

const optimistic = useOptimisticTheme()
if (optimistic === 'system') {
return hints.theme
}

const theme = optimistic ?? preferences.theme ?? hints.theme
invariant(isTheme(theme), "Theme isn't a valid theme")

return theme
}

export function useOptimisticTheme() {
const themeFetcher = useFetcher({ key: THEME_FETCHER_KEY })

if (themeFetcher.formData) {
const submission = parse(themeFetcher.formData, {
schema: ThemeFormSchema,
})

return submission.value?.theme
}
}
18 changes: 18 additions & 0 deletions app/lib/stack.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,3 +407,21 @@ export async function getStackDetails(stack: Pick<Stack, 'directory' | 'path'>)

return merged
}

/**
* @returns `true` if managed stack with given name doesn't exist
*/
export async function isStackNameUnique(name: string) {
const stacksDirectory = getManagedStacksDirectory()
const stackDirectory = path.join(stacksDirectory, name)

try {
await fs.stat(stackDirectory)
return false
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
return true
}
throw error
}
}
27 changes: 27 additions & 0 deletions app/lib/theme.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { createCookie } from '@remix-run/node'
import { isTheme, ThemeFormSchema } from '~/lib/theme'
import { addYears } from 'date-fns'
import { z } from 'zod'

const themeCookieName = 'theme'

export async function getTheme(request: Request) {
const cookieHeader = request.headers.get('Cookie')
const themeCookie = createCookie(themeCookieName)
const incomingTheme = await themeCookie.parse(cookieHeader)

if (isTheme(incomingTheme)) {
return incomingTheme
}

return null
}

export async function getThemeCookie(theme: z.infer<typeof ThemeFormSchema>['theme']) {
const themeCookie = createCookie(themeCookieName)

return await themeCookie.serialize(theme === 'system' ? null : theme, {
path: '/',
expires: addYears(new Date(), 1),
})
}
21 changes: 21 additions & 0 deletions app/lib/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { z } from 'zod'

export enum Theme {
Light = 'light',
Dark = 'dark',
}

const themes = Object.values<Theme>(Theme)

export const THEME_FETCHER_KEY = 'THEME_FETCHER'

export const ThemeFormSchema = z.object({
theme: z.enum(['light', 'dark', 'system']),
})

/**
* Narrow down the type of a string to a Theme
*/
export function isTheme(value: unknown): value is Theme {
return typeof value === 'string' && themes.includes(value as Theme)
}
63 changes: 38 additions & 25 deletions app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,42 @@ import {
import { PackageIcon, RocketIcon } from 'lucide-react'
import React from 'react'
import { buttonVariants } from './components/ui/button'
import { MetaFunction } from '@remix-run/node'
import { LoaderFunctionArgs, MetaFunction, json } from '@remix-run/node'
import { GlobalNavigationProgress } from './components/global-navigation-progress'
import { useTheme } from './lib/hooks/use-theme'
import { ThemeSwitcher } from './components/theme-switcher'
import { getTheme } from './lib/theme.server'

import 'nprogress/nprogress.css'
import '~/globals.css'
import { ClientHintCheck, getHints } from './components/client-hits-check'

export const meta: MetaFunction = () => {
return [{ title: 'dabba' }]
}

export async function loader({ request }: LoaderFunctionArgs) {
return json({
info: {
hints: getHints(request),
preferences: {
theme: await getTheme(request),
},
},
})
}

type DocumentProps = {
children?: React.ReactNode
}

function Document({ children }: DocumentProps) {
const theme = useTheme()

return (
<html lang="en">
<html lang="en" className={theme}>
<head>
<ClientHintCheck />
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
Expand All @@ -44,17 +62,6 @@ function Document({ children }: DocumentProps) {
)
}

const links = [
{
to: 'stacks',
label: 'Stacks',
},
{
to: 'templates',
label: 'Templates',
},
]

export default function App() {
return (
<Document>
Expand All @@ -67,7 +74,10 @@ export default function App() {
</Link>
<nav className="flex grow items-center justify-between gap-x-8">
<div className="grid grid-flow-col items-center gap-x-4">
{links.map(({ to, label }) => (
{[
{ to: 'stacks', label: 'Stacks' },
{ to: 'templates', label: 'Templates' },
].map(({ to, label }) => (
<NavLink
key={to}
to={to}
Expand All @@ -81,19 +91,22 @@ export default function App() {
))}
</div>

<Link
to="deploy"
className={buttonVariants({
size: 'sm',
className: 'gap-x-1',
})}
>
<RocketIcon className="h-[1em] w-[1em]" />
<span>Deploy</span>
</Link>
<div className="flex items-center gap-x-4">
<Link
to="deploy"
className={buttonVariants({
size: 'sm',
className: 'gap-x-1',
})}
>
<RocketIcon className="h-[1em] w-[1em]" />
<span>Deploy</span>
</Link>
<ThemeSwitcher />
</div>
</nav>
</header>
<div className="h-0 flex-grow">
<div className="h-0 grow">
<Outlet />
</div>
</div>
Expand Down
Loading

0 comments on commit de0126f

Please sign in to comment.