feat: subscribe to release notes

This commit is contained in:
Jake Turner 2026-01-28 07:21:34 +00:00 committed by Jake Turner
parent c8de767052
commit 8cfe490b57
7 changed files with 137 additions and 15 deletions

View File

@ -1,7 +1,7 @@
import { DockerService } from '#services/docker_service';
import { SystemService } from '#services/system_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 type { HttpContext } from '@adonisjs/core/http'
@ -102,4 +102,10 @@ export default class SystemController {
const logs = this.systemUpdateService.getUpdateLogs();
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.
* It will mark services as not installed if their corresponding containers do not exist, regardless of their running state.
@ -241,7 +269,7 @@ export class SystemService {
const containerExists = serviceStatusList.find(
(s) => s.service_name === service.service_name
)
if (service.installed) {
// If marked as installed but container doesn't exist, mark as not installed
if (!containerExists) {

View File

@ -1,10 +1,20 @@
import vine from '@vinejs/vine'
export const installServiceValidator = vine.compile(vine.object({
service_name: vine.string().trim()
}));
export const affectServiceValidator = vine.compile(vine.object({
export const installServiceValidator = vine.compile(
vine.object({
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;
labelClassName?: string;
inputClassName?: string;
containerClassName?: string;
leftIcon?: React.ReactNode;
error?: boolean;
required?: boolean;
@ -18,6 +19,7 @@ const Input: React.FC<InputProps> = ({
name,
labelClassName,
inputClassName,
containerClassName,
leftIcon,
error,
required,
@ -31,7 +33,7 @@ const Input: React.FC<InputProps> = ({
>
{label}{required ? "*" : ""}
</label>
<div className="mt-1.5">
<div className={classNames("mt-1.5", containerClassName)}>
<div className="relative">
{leftIcon && (
<div className="absolute left-3 top-1/2 transform -translate-y-1/2">

View File

@ -285,6 +285,16 @@ class API {
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()

View File

@ -10,6 +10,9 @@ import { useEffect, useState } from 'react'
import { IconCircleCheck } from '@tabler/icons-react'
import { SystemUpdateStatus } from '../../../types/system'
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: {
system: {
@ -18,11 +21,14 @@ export default function SystemUpdatePage(props: {
currentVersion: string
}
}) {
const { addNotification } = useNotifications()
const [isUpdating, setIsUpdating] = useState(false)
const [updateStatus, setUpdateStatus] = useState<SystemUpdateStatus | null>(null)
const [error, setError] = useState<string | null>(null)
const [showLogs, setShowLogs] = useState(false)
const [logs, setLogs] = useState<string>('')
const [email, setEmail] = useState('')
useEffect(() => {
if (!isUpdating) return
@ -116,10 +122,33 @@ export default function SystemUpdatePage(props: {
if (updateStatus?.stage === 'error')
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 (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" />
}
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 (
<SettingsLayout>
<Head title="System Update" />
@ -128,7 +157,8 @@ export default function SystemUpdatePage(props: {
<div className="mb-8">
<h1 className="text-4xl font-bold text-desert-green mb-2">System Update</h1>
<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>
</div>
@ -161,9 +191,7 @@ export default function SystemUpdatePage(props: {
{!isUpdating && (
<>
<h2 className="text-2xl font-bold text-desert-green mb-2">
{props.system.updateAvailable
? 'Update Available'
: 'System Up to Date'}
{props.system.updateAvailable ? 'Update Available' : 'System Up to Date'}
</h2>
<p className="text-desert-stone-dark mb-6">
{props.system.updateAvailable
@ -305,6 +333,43 @@ export default function SystemUpdatePage(props: {
)}
</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">
<Alert
type="info"

View File

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