feat: subscribe to release notes

This commit is contained in:
Jake Turner 2026-01-28 07:21:34 +00:00
parent e7336f2a8e
commit 2ecf6aca70
7 changed files with 137 additions and 15 deletions

View File

@ -1,7 +1,7 @@
import { DockerService } from '#services/docker_service'; import { DockerService } from '#services/docker_service';
import { SystemService } from '#services/system_service' import { SystemService } from '#services/system_service'
import { SystemUpdateService } from '#services/system_update_service' import { SystemUpdateService } from '#services/system_update_service'
import { affectServiceValidator, installServiceValidator } from '#validators/system'; import { affectServiceValidator, installServiceValidator, subscribeToReleaseNotesValidator } from '#validators/system';
import { inject } from '@adonisjs/core' import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http' import type { HttpContext } from '@adonisjs/core/http'
@ -102,4 +102,10 @@ export default class SystemController {
const logs = this.systemUpdateService.getUpdateLogs(); const logs = this.systemUpdateService.getUpdateLogs();
response.send({ logs }); response.send({ logs });
} }
async subscribeToReleaseNotes({ request }: HttpContext) {
const reqData = await request.validateUsing(subscribeToReleaseNotesValidator);
return await this.systemService.subscribeToReleaseNotes(reqData.email);
}
} }

View File

@ -226,6 +226,34 @@ export class SystemService {
} }
} }
async subscribeToReleaseNotes(email: string): Promise<{ success: boolean; message: string }> {
try {
const response = await axios.post(
'https://api.projectnomad.us/api/v1/lists/release-notes/subscribe',
{ email },
{ timeout: 5000 }
)
if (response.status === 200) {
return {
success: true,
message: 'Successfully subscribed to release notes',
}
}
return {
success: false,
message: `Failed to subscribe: ${response.statusText}`,
}
} catch (error) {
logger.error('Error subscribing to release notes:', error)
return {
success: false,
message: `Failed to subscribe: ${error instanceof Error ? error.message : error}`,
}
}
}
/** /**
* Checks the current state of Docker containers against the database records and updates the database accordingly. * Checks the current state of Docker containers against the database records and updates the database accordingly.
* It will mark services as not installed if their corresponding containers do not exist, regardless of their running state. * It will mark services as not installed if their corresponding containers do not exist, regardless of their running state.

View File

@ -1,10 +1,20 @@
import vine from '@vinejs/vine' import vine from '@vinejs/vine'
export const installServiceValidator = vine.compile(vine.object({ export const installServiceValidator = vine.compile(
service_name: vine.string().trim() vine.object({
}));
export const affectServiceValidator = vine.compile(vine.object({
service_name: vine.string().trim(), service_name: vine.string().trim(),
action: vine.enum(['start', 'stop', 'restart']) })
})); )
export const affectServiceValidator = vine.compile(
vine.object({
service_name: vine.string().trim(),
action: vine.enum(['start', 'stop', 'restart']),
})
)
export const subscribeToReleaseNotesValidator = vine.compile(
vine.object({
email: vine.string().email().trim(),
})
)

View File

@ -7,6 +7,7 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
className?: string; className?: string;
labelClassName?: string; labelClassName?: string;
inputClassName?: string; inputClassName?: string;
containerClassName?: string;
leftIcon?: React.ReactNode; leftIcon?: React.ReactNode;
error?: boolean; error?: boolean;
required?: boolean; required?: boolean;
@ -18,6 +19,7 @@ const Input: React.FC<InputProps> = ({
name, name,
labelClassName, labelClassName,
inputClassName, inputClassName,
containerClassName,
leftIcon, leftIcon,
error, error,
required, required,
@ -31,7 +33,7 @@ const Input: React.FC<InputProps> = ({
> >
{label}{required ? "*" : ""} {label}{required ? "*" : ""}
</label> </label>
<div className="mt-1.5"> <div className={classNames("mt-1.5", containerClassName)}>
<div className="relative"> <div className="relative">
{leftIcon && ( {leftIcon && (
<div className="absolute left-3 top-1/2 transform -translate-y-1/2"> <div className="absolute left-3 top-1/2 transform -translate-y-1/2">

View File

@ -285,6 +285,16 @@ class API {
return response.data return response.data
})() })()
} }
async subscribeToReleaseNotes(email: string) {
return catchInternal(async () => {
const response = await this.client.post<{ success: boolean; message: string }>(
'/system/subscribe-release-notes',
{ email }
)
return response.data
})()
}
} }
export default new API() export default new API()

View File

@ -10,6 +10,9 @@ import { useEffect, useState } from 'react'
import { IconCircleCheck } from '@tabler/icons-react' import { IconCircleCheck } from '@tabler/icons-react'
import { SystemUpdateStatus } from '../../../types/system' import { SystemUpdateStatus } from '../../../types/system'
import api from '~/lib/api' import api from '~/lib/api'
import Input from '~/components/inputs/Input'
import { useMutation } from '@tanstack/react-query'
import { useNotifications } from '~/context/NotificationContext'
export default function SystemUpdatePage(props: { export default function SystemUpdatePage(props: {
system: { system: {
@ -18,11 +21,14 @@ export default function SystemUpdatePage(props: {
currentVersion: string currentVersion: string
} }
}) { }) {
const { addNotification } = useNotifications()
const [isUpdating, setIsUpdating] = useState(false) const [isUpdating, setIsUpdating] = useState(false)
const [updateStatus, setUpdateStatus] = useState<SystemUpdateStatus | null>(null) const [updateStatus, setUpdateStatus] = useState<SystemUpdateStatus | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [showLogs, setShowLogs] = useState(false) const [showLogs, setShowLogs] = useState(false)
const [logs, setLogs] = useState<string>('') const [logs, setLogs] = useState<string>('')
const [email, setEmail] = useState('')
useEffect(() => { useEffect(() => {
if (!isUpdating) return if (!isUpdating) return
@ -116,10 +122,33 @@ export default function SystemUpdatePage(props: {
if (updateStatus?.stage === 'error') if (updateStatus?.stage === 'error')
return <IconAlertCircle className="h-12 w-12 text-desert-red" /> return <IconAlertCircle className="h-12 w-12 text-desert-red" />
if (isUpdating) return <IconRefresh className="h-12 w-12 text-desert-green animate-spin" /> if (isUpdating) return <IconRefresh className="h-12 w-12 text-desert-green animate-spin" />
if (props.system.updateAvailable) return <IconArrowBigUpLines className="h-16 w-16 text-desert-green" /> if (props.system.updateAvailable)
return <IconArrowBigUpLines className="h-16 w-16 text-desert-green" />
return <IconCircleCheck className="h-16 w-16 text-desert-olive" /> return <IconCircleCheck className="h-16 w-16 text-desert-olive" />
} }
const subscribeToReleaseNotesMutation = useMutation({
mutationKey: ['subscribeToReleaseNotes'],
mutationFn: (email: string) => api.subscribeToReleaseNotes(email),
onSuccess: (data) => {
if (data && data.success) {
addNotification({ type: 'success', message: 'Successfully subscribed to release notes!' })
setEmail('')
} else {
addNotification({
type: 'error',
message: `Failed to subscribe: ${data?.message || 'Unknown error'}`,
})
}
},
onError: (error: any) => {
addNotification({
type: 'error',
message: `Error subscribing to release notes: ${error.message || 'Unknown error'}`,
})
},
})
return ( return (
<SettingsLayout> <SettingsLayout>
<Head title="System Update" /> <Head title="System Update" />
@ -128,7 +157,8 @@ export default function SystemUpdatePage(props: {
<div className="mb-8"> <div className="mb-8">
<h1 className="text-4xl font-bold text-desert-green mb-2">System Update</h1> <h1 className="text-4xl font-bold text-desert-green mb-2">System Update</h1>
<p className="text-desert-stone-dark"> <p className="text-desert-stone-dark">
Keep your Project N.O.M.A.D. instance up to date with the latest features and improvements. Keep your Project N.O.M.A.D. instance up to date with the latest features and
improvements.
</p> </p>
</div> </div>
@ -161,9 +191,7 @@ export default function SystemUpdatePage(props: {
{!isUpdating && ( {!isUpdating && (
<> <>
<h2 className="text-2xl font-bold text-desert-green mb-2"> <h2 className="text-2xl font-bold text-desert-green mb-2">
{props.system.updateAvailable {props.system.updateAvailable ? 'Update Available' : 'System Up to Date'}
? 'Update Available'
: 'System Up to Date'}
</h2> </h2>
<p className="text-desert-stone-dark mb-6"> <p className="text-desert-stone-dark mb-6">
{props.system.updateAvailable {props.system.updateAvailable
@ -305,6 +333,43 @@ export default function SystemUpdatePage(props: {
)} )}
</div> </div>
</div> </div>
<div className="bg-white rounded-lg border shadow-md overflow-hidden py-6 mt-6">
<div className="flex flex-col md:flex-row justify-between items-center p-8 gap-y-8 md:gap-y-0 gap-x-8">
<div>
<h2 className="max-w-xl text-lg font-bold text-desert-green sm:text-xl lg:col-span-7">
Want to stay updated with the latest from Project N.O.M.A.D.? Subscribe to receive
release notes directly to your inbox. Unsubscribe anytime.
</h2>
</div>
<div className="flex flex-col">
<div className="flex gap-x-3">
<Input
name="email"
label=""
type="email"
placeholder="Your email address"
disabled={false}
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full"
containerClassName="!mt-0"
/>
<StyledButton
variant="primary"
disabled={!email}
onClick={() => subscribeToReleaseNotesMutation.mutateAsync(email)}
loading={subscribeToReleaseNotesMutation.isPending}
>
Subscribe
</StyledButton>
</div>
<p className="mt-2 text-sm text-desert-stone-dark">
We care about your privacy. Project N.O.M.A.D. will never share your email with
third parties or send you spam.
</p>
</div>
</div>
</div>
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<Alert <Alert
type="info" type="info"

View File

@ -105,6 +105,7 @@ router
router.post('/services/affect', [SystemController, 'affectService']) router.post('/services/affect', [SystemController, 'affectService'])
router.post('/services/install', [SystemController, 'installService']) router.post('/services/install', [SystemController, 'installService'])
router.post('/services/force-reinstall', [SystemController, 'forceReinstallService']) router.post('/services/force-reinstall', [SystemController, 'forceReinstallService'])
router.post('/subscribe-release-notes', [SystemController, 'subscribeToReleaseNotes'])
router.get('/latest-version', [SystemController, 'checkLatestVersion']) router.get('/latest-version', [SystemController, 'checkLatestVersion'])
router.post('/update', [SystemController, 'requestSystemUpdate']) router.post('/update', [SystemController, 'requestSystemUpdate'])
router.get('/update/status', [SystemController, 'getSystemUpdateStatus']) router.get('/update/status', [SystemController, 'getSystemUpdateStatus'])