Skip to content
Draft
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
82 changes: 82 additions & 0 deletions api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,61 @@ paths:
schema:
$ref: "#/components/schemas/Summary"

/webpush/key:
get:
summary: Get the server's public key for Web Push. Note that this API is experimental.
responses:
"200":
description: Public key
content:
application/json:
schema:
$ref: "#/components/schemas/WebPushServerKey"

/webpush/subscription:
post:
summary: Create a subscription. Note that this API is experimental.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/WebPushSubscription"
responses:
"201":
description: Created.
"409":
description: The subscription already exists.
/webpush/subscription/{digest}:
head:
summary: Check a subscription. Note that this API is experimental.
parameters:
- in: path
name: digest
description: Digest of the subscription.
schemas:
type: string
required: true
responses:
"200":
description: Subscription exists.
"404":
description: Subscription not found.
delete:
summary: Delete a subscription. Note that this API is experimental.
parameters:
- in: path
name: digest
description: Digest of the subscription.
schemas:
type: string
required: true
responses:
"204":
description: Deleted.
"404":
description: Subscription not found.

components:
schemas:
ImagePage:
Expand Down Expand Up @@ -656,3 +711,30 @@ components:
description: Set if step failed.
required:
- result

WebPushServerKey:
type: object
properties:
key:
type: string
required:
- key

WebPushSubscription:
type: object
properties:
endpoint:
type: string
keys:
type: object
properties:
p256dh:
type: string
auth:
type: string
required:
- p256dh
- auth
required:
- endpoint
- keys
53 changes: 53 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,59 @@ func NewServer(api *store.Store, hub *events.Hub[worker.Event], processQueue *wo
s.handleJSONResponse(w, r, response, err)
})

s.mux.HandleFunc("GET /api/v1/webpush/key", func(w http.ResponseWriter, r *http.Request) {
_, span := httputil.SpanFromRequest(r)
span.SetAttributes(semconv.HTTPRoute("/api/v1/webpush/key"))

// TODO
response := models.WebPushServerKey{
Key: "1234",
}
s.handleJSONResponse(w, r, response, nil)
})

s.mux.HandleFunc("POST /api/v1/webpush/subscription", func(w http.ResponseWriter, r *http.Request) {
_, span := httputil.SpanFromRequest(r)
span.SetAttributes(semconv.HTTPRoute("/api/v1/webpush/key"))

var subscription models.WebPushSubscription
if err := json.NewDecoder(r.Body).Decode(&subscription); err != nil {
s.handleGenericResponse(w, r, err)
return
}

// TODO
w.WriteHeader(http.StatusCreated)
})

s.mux.HandleFunc("HEAD /api/v1/webpush/subscription/{digest}", func(w http.ResponseWriter, r *http.Request) {
_, span := httputil.SpanFromRequest(r)
span.SetAttributes(semconv.HTTPRoute("/api/v1/webpush/key"))

algorithm, _, ok := strings.Cut(r.PathValue("digest"), "-")
if !ok || algorithm != "sha256" {
s.handleGenericResponse(w, r, ErrBadRequest)
return
}

// TODO
w.WriteHeader(http.StatusOK)
})

s.mux.HandleFunc("DELETE /api/v1/webpush/subscription/{digest}", func(w http.ResponseWriter, r *http.Request) {
_, span := httputil.SpanFromRequest(r)
span.SetAttributes(semconv.HTTPRoute("/api/v1/webpush/key"))

algorithm, _, ok := strings.Cut(r.PathValue("digest"), "-")
if !ok || algorithm != "sha256" {
s.handleGenericResponse(w, r, ErrBadRequest)
return
}

// TODO
w.WriteHeader(http.StatusNoContent)
})

return s
}

Expand Down
12 changes: 12 additions & 0 deletions internal/models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,15 @@ const (
EventTypeImageProcessed EventType = "imageProcessed"
EventTypeImageNewVersionAvailable EventType = "imageNewVersionAvailable"
)

type WebPushServerKey struct {
Key string `json:"key"`
}

type WebPushSubscription struct {
Endpoint string `json:"endpoint"`
Keys struct {
P256DH string `json:"p256dh"`
Auth string `json:"auth"`
} `json:"key"`
}
77 changes: 72 additions & 5 deletions web/App.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { type JSX, useLayoutEffect } from 'react'
import { type JSX, useCallback, useLayoutEffect } from 'react'
import { Link, Route, Routes, useLocation } from 'react-router-dom'

import { EventProvider } from './EventProvider'
import { InfoTooltip } from './components/InfoTooltip'
import { Menu } from './components/Menu'
import { FluentAlert24Regular } from './components/icons/fluent-alert-24-regular'
import { FluentAlertBadge24Regular } from './components/icons/fluent-alert-badge-24-regular'
import { FluentArrowLeft24Regular } from './components/icons/fluent-arrow-left-24-regular'
import { SimpleIconsRss } from './components/icons/simple-icons-rss'
import { FluentOpen16Regular } from './components/icons/fluent-open-16-regular'
import { FluentWarning16Filled } from './components/icons/fluent-warning-16-filled'
import { useWebPushSubscription } from './hooks'
import { DEFAULT_RSS_ENDPOINT } from './lib/api/api-client'
import { Dashboard } from './pages/Dashboard'
import { ImagePage } from './pages/ImagePage'
Expand All @@ -16,6 +22,30 @@ export function App(): JSX.Element {
document.documentElement.scrollTo({ top: 0, left: 0, behavior: 'instant' })
}, [location.pathname, location.search])

const [
webPushSupported,
webPushSubscription,
webPushSynced,
subscribe,
unsubscribe,
] = useWebPushSubscription()

const webPushOk =
webPushSubscription.status !== 'rejected' &&
webPushSynced.status !== 'rejected'

const toggleWebPushSubscription = useCallback(() => {
if (webPushSubscription.status !== 'resolved') {
return
}

if (webPushSubscription.value) {
unsubscribe()
} else {
subscribe()
}
}, [webPushSubscription, subscribe, unsubscribe])

return (
<>
<div className="fixed top-0 left-0 h-[64px] w-full grid grid-cols-3 items-center shadow-sm bg-white/90 dark:bg-[#1e1e1e]/70 z-250 backdrop-blur-md">
Expand All @@ -34,9 +64,46 @@ export function App(): JSX.Element {
</Link>
</div>
<div className="justify-self-end mr-5">
<a target="_blank" href={DEFAULT_RSS_ENDPOINT} rel="noreferrer">
<SimpleIconsRss className="text-orange-400" />
</a>
<Menu
icon={
webPushOk ? (
<FluentAlert24Regular />
) : (
<FluentAlertBadge24Regular />
)
}
>
{webPushSupported && (
<li
className="flex items-center"
onClick={toggleWebPushSubscription}
>
{webPushSubscription.status === 'resolved'
? webPushSubscription.value !== null
? 'Unsubscribe'
: 'Subscribe'
: ''}
{webPushSubscription.status === 'rejected' ? (
<InfoTooltip
icon={<FluentWarning16Filled className="text-red-400" />}
>
{webPushSubscription.error.toString()}
</InfoTooltip>
) : webPushSynced.status === 'rejected' ? (
<InfoTooltip
icon={<FluentWarning16Filled className="text-red-400" />}
>
{webPushSynced.error.toString()}
</InfoTooltip>
) : undefined}
</li>
)}
<a target="_blank" href={DEFAULT_RSS_ENDPOINT} rel="noreferrer">
<li className="flex items-center gap-x-2">
RSS feed <FluentOpen16Regular />
</li>
</a>
</Menu>
</div>
</div>
<main className="pt-[64px]">
Expand Down
50 changes: 50 additions & 0 deletions web/components/Menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {
type JSX,
type PropsWithChildren,
useEffect,
useRef,
useState,
} from 'react'

export type MenuProps = {
icon: JSX.Element
}

export function Menu({
icon,
children,
}: PropsWithChildren<MenuProps>): JSX.Element {
const openRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState<boolean>(false)

useEffect(() => {
if (isOpen) {
const handle = (e: MouseEvent) => {
if (
e.target === openRef.current ||
openRef.current?.contains(e.target as Node)
) {
return
}

setIsOpen(false)
}
document.addEventListener('click', handle)
return () => document.removeEventListener('click', handle)
}
}, [isOpen])

return (
<div>
<button
type="button"
ref={openRef}
onClick={() => setIsOpen(true)}
className="menu-button"
>
{icon}
</button>
{isOpen && <ul className="menu-container">{children}</ul>}
</div>
)
}
21 changes: 21 additions & 0 deletions web/components/icons/fluent-alert-24-regular.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { SVGProps } from 'react'

export function FluentAlert24Regular(props: SVGProps<SVGSVGElement>) {
return (
<svg
role="img"
aria-label="icon"
xmlns="http://www.w3.org/2000/svg"
width="24px"
height="24px"
viewBox="0 0 24 24"
{...props}
>
{/* Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE */}
<path
fill="currentColor"
d="M12 1.996a7.49 7.49 0 0 1 7.496 7.25l.004.25v4.097l1.38 3.156a1.25 1.25 0 0 1-1.145 1.75L15 18.502a3 3 0 0 1-5.995.177L9 18.499H4.275a1.25 1.25 0 0 1-1.147-1.747L4.5 13.594V9.496c0-4.155 3.352-7.5 7.5-7.5M13.5 18.5l-3 .002a1.5 1.5 0 0 0 2.993.145zM12 3.496c-3.32 0-6 2.674-6 6v4.41L4.656 17h14.697L18 13.907V9.509l-.003-.225A5.99 5.99 0 0 0 12 3.496"
/>
</svg>
)
}
21 changes: 21 additions & 0 deletions web/components/icons/fluent-alert-badge-24-regular.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { SVGProps } from 'react'

export function FluentAlertBadge24Regular(props: SVGProps<SVGSVGElement>) {
return (
<svg
role="img"
aria-label="icon"
xmlns="http://www.w3.org/2000/svg"
width="24px"
height="24px"
viewBox="0 0 24 24"
{...props}
>
{/* Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE */}
<path
fill="currentColor"
d="M11.988 1.996c1.447 0 2.8.407 3.947 1.115a3.5 3.5 0 0 0-.767 1.289a6 6 0 0 0-3.18-.904a5.99 5.99 0 0 0-6.009 5.998v4.409l-1.345 3.093H19.35l-1.354-3.092V9.506l-.004-.225a6 6 0 0 0-.02-.323a3.5 3.5 0 0 0 1.499-.098q.015.19.023.382l.004.251v4.096l1.382 3.155a1.25 1.25 0 0 1-1.147 1.75l-4.741.002c0 1.656-1.345 3-3.004 3a3 3 0 0 1-3-2.824l-.005-.178H4.252a1.25 1.25 0 0 1-1.148-1.747l1.373-3.157V9.494a7.493 7.493 0 0 1 7.51-7.498m1.501 16.499l-3.003.002a1.501 1.501 0 0 0 2.997.144zm2.544-13.442A2.5 2.5 0 0 1 18.497 3A2.5 2.5 0 0 1 21 5.499a2.5 2.5 0 0 1-3.218 2.396a2.5 2.5 0 0 1-1.749-2.842"
/>
</svg>
)
}
20 changes: 0 additions & 20 deletions web/components/icons/simple-icons-rss.tsx

This file was deleted.

Loading
Loading