wip
This commit is contained in:
24
.devcontainer/devcontainer.json
Normal file
24
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,24 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/debian
|
||||
{
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/go:1": {
|
||||
"version": "1.26",
|
||||
"golangciLintVersion": "2"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "lts"
|
||||
}
|
||||
},
|
||||
"name": "Debian",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/base:trixie"
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
5
.editorconfig
Normal file
5
.editorconfig
Normal file
@@ -0,0 +1,5 @@
|
||||
root = true
|
||||
|
||||
[*.json]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.env*
|
||||
!.env.default
|
||||
41
cctv.code-workspace
Normal file
41
cctv.code-workspace
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"name": "/",
|
||||
"path": "."
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"path": "server"
|
||||
},
|
||||
{
|
||||
"name": "web",
|
||||
"path": "web"
|
||||
}
|
||||
],
|
||||
"extensions": {
|
||||
"recommendations": [
|
||||
"golang.go",
|
||||
"esbenp.prettier-vscode",
|
||||
"editorconfig.editorconfig",
|
||||
"svelte.svelte-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss"
|
||||
},
|
||||
"tailwindCSS.classAttributes": [
|
||||
"class",
|
||||
"className",
|
||||
"ngClass",
|
||||
"class:list",
|
||||
"classes",
|
||||
".*Class",
|
||||
".*Classes"
|
||||
],
|
||||
"tailwindCSS.classFunctions": ["tw"]
|
||||
}
|
||||
}
|
||||
4
server/.env.default
Normal file
4
server/.env.default
Normal file
@@ -0,0 +1,4 @@
|
||||
ENVIRONMENT=development
|
||||
CCTV_PB_ENCRYPTION_KEY=_CCTV_POCKETBASE_ENCRYPTION_KEY_ # Note: length 32 as required
|
||||
CCTV_PB_DATA_DIR=./pb_data
|
||||
CCTV_EXTERNAL_WEB_APP=http://localhost:5173
|
||||
2
server/.gitignore
vendored
Normal file
2
server/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
pb_data/
|
||||
recordings/
|
||||
39
server/.golangci.yml
Normal file
39
server/.golangci.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
version: "2"
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- asasalint
|
||||
- errcheck
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
- errorlint
|
||||
- exhaustive
|
||||
- forbidigo
|
||||
- forcetypeassert
|
||||
- gochecknoglobals
|
||||
- gosec
|
||||
- nilerr
|
||||
- nilnesserr
|
||||
- nilnil
|
||||
- predeclared
|
||||
- reassign
|
||||
- revive
|
||||
- sloglint
|
||||
- tagliatelle
|
||||
- usestdlibvars
|
||||
settings:
|
||||
revive:
|
||||
enable-default-rules: true
|
||||
rules:
|
||||
- name: exported
|
||||
disabled: true
|
||||
- name: package-comments
|
||||
disabled: true
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- goimports
|
||||
severity:
|
||||
default: error
|
||||
29
server/.vscode/settings.json
vendored
Normal file
29
server/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"[go.mod]": {
|
||||
"editor.defaultFormatter": "golang.go",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "always"
|
||||
}
|
||||
},
|
||||
"[go]": {
|
||||
"editor.defaultFormatter": "golang.go",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "always"
|
||||
}
|
||||
},
|
||||
"go.lintTool": "golangci-lint-v2",
|
||||
"go.lintFlags": [
|
||||
"--path-mode=abs",
|
||||
"--fast-only"
|
||||
],
|
||||
"go.formatTool": "custom",
|
||||
"go.alternateTools": {
|
||||
"customFormatter": "golangci-lint-v2"
|
||||
},
|
||||
"go.formatFlags": [
|
||||
"fmt",
|
||||
"--stdin"
|
||||
]
|
||||
}
|
||||
41
server/api.go
Normal file
41
server/api.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func registerAPI(se *core.ServeEvent) {
|
||||
group := se.Router.Group("/api/cctv")
|
||||
|
||||
group.GET("/thumb/{streamId}", func(e *core.RequestEvent) error {
|
||||
streamId := e.Request.PathValue("streamId")
|
||||
if streamId == "" {
|
||||
return e.BadRequestError("Missing stream ID", nil)
|
||||
}
|
||||
img, err := ingestService.GetThumbnail(e.Request.Context(), streamId)
|
||||
if err != nil {
|
||||
return e.InternalServerError("Failed to get thumbnail", err)
|
||||
}
|
||||
if img == nil {
|
||||
return e.NotFoundError("Thumbnail not found", nil)
|
||||
}
|
||||
e.Response.Header().Set("Content-Type", "image/jpeg")
|
||||
e.Response.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
_, err = io.Copy(e.Response, img)
|
||||
if err != nil {
|
||||
return e.InternalServerError("Failed to write thumbnail", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
group.GET("/live/{streamId}", func(e *core.RequestEvent) error {
|
||||
streamId := e.Request.PathValue("streamId")
|
||||
if streamId == "" {
|
||||
return e.BadRequestError("Missing stream ID", nil)
|
||||
}
|
||||
ingestService.SubscribeLive(e.Request.Context(), streamId)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
968
server/collections.json
Normal file
968
server/collections.json
Normal file
@@ -0,0 +1,968 @@
|
||||
[
|
||||
{
|
||||
"id": "pbc_2279338944",
|
||||
"listRule": "@request.auth.id != '' \u0026\u0026 recordRef = @request.auth.id \u0026\u0026 collectionRef = @request.auth.collectionId",
|
||||
"viewRule": "@request.auth.id != '' \u0026\u0026 recordRef = @request.auth.id \u0026\u0026 collectionRef = @request.auth.collectionId",
|
||||
"createRule": null,
|
||||
"updateRule": null,
|
||||
"deleteRule": null,
|
||||
"name": "_mfas",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text455797646",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "collectionRef",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text127846527",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "recordRef",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text1582905952",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "method",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate2990389176",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": true,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate3332085495",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": true,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
"CREATE INDEX `idx_mfas_collectionRef_recordRef` ON `_mfas` (collectionRef,recordRef)"
|
||||
],
|
||||
"created": "2026-04-03 11:46:26.426Z",
|
||||
"updated": "2026-04-03 11:46:26.426Z",
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "pbc_1638494021",
|
||||
"listRule": "@request.auth.id != '' \u0026\u0026 recordRef = @request.auth.id \u0026\u0026 collectionRef = @request.auth.collectionId",
|
||||
"viewRule": "@request.auth.id != '' \u0026\u0026 recordRef = @request.auth.id \u0026\u0026 collectionRef = @request.auth.collectionId",
|
||||
"createRule": null,
|
||||
"updateRule": null,
|
||||
"deleteRule": null,
|
||||
"name": "_otps",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text455797646",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "collectionRef",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text127846527",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "recordRef",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cost": 8,
|
||||
"hidden": true,
|
||||
"id": "password901924565",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "password",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "password"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": true,
|
||||
"id": "text3866985172",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "sentTo",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate2990389176",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": true,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate3332085495",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": true,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
"CREATE INDEX `idx_otps_collectionRef_recordRef` ON `_otps` (collectionRef, recordRef)"
|
||||
],
|
||||
"created": "2026-04-03 11:46:26.429Z",
|
||||
"updated": "2026-04-03 11:46:26.429Z",
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "pbc_2281828961",
|
||||
"listRule": "@request.auth.id != '' \u0026\u0026 recordRef = @request.auth.id \u0026\u0026 collectionRef = @request.auth.collectionId",
|
||||
"viewRule": "@request.auth.id != '' \u0026\u0026 recordRef = @request.auth.id \u0026\u0026 collectionRef = @request.auth.collectionId",
|
||||
"createRule": null,
|
||||
"updateRule": null,
|
||||
"deleteRule": "@request.auth.id != '' \u0026\u0026 recordRef = @request.auth.id \u0026\u0026 collectionRef = @request.auth.collectionId",
|
||||
"name": "_externalAuths",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text455797646",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "collectionRef",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text127846527",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "recordRef",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text2462348188",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "provider",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text1044722854",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "providerId",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate2990389176",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": true,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate3332085495",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": true,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
"CREATE UNIQUE INDEX `idx_externalAuths_record_provider` ON `_externalAuths` (collectionRef, recordRef, provider)",
|
||||
"CREATE UNIQUE INDEX `idx_externalAuths_collection_provider` ON `_externalAuths` (collectionRef, provider, providerId)"
|
||||
],
|
||||
"created": "2026-04-03 11:46:26.431Z",
|
||||
"updated": "2026-04-03 11:46:26.431Z",
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "pbc_4275539003",
|
||||
"listRule": "@request.auth.id != '' \u0026\u0026 recordRef = @request.auth.id \u0026\u0026 collectionRef = @request.auth.collectionId",
|
||||
"viewRule": "@request.auth.id != '' \u0026\u0026 recordRef = @request.auth.id \u0026\u0026 collectionRef = @request.auth.collectionId",
|
||||
"createRule": null,
|
||||
"updateRule": null,
|
||||
"deleteRule": "@request.auth.id != '' \u0026\u0026 recordRef = @request.auth.id \u0026\u0026 collectionRef = @request.auth.collectionId",
|
||||
"name": "_authOrigins",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text455797646",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "collectionRef",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text127846527",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "recordRef",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text4228609354",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "fingerprint",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate2990389176",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": true,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate3332085495",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": true,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
"CREATE UNIQUE INDEX `idx_authOrigins_unique_pairs` ON `_authOrigins` (collectionRef, recordRef, fingerprint)"
|
||||
],
|
||||
"created": "2026-04-03 11:46:26.435Z",
|
||||
"updated": "2026-04-03 11:46:26.435Z",
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "pbc_3142635823",
|
||||
"listRule": null,
|
||||
"viewRule": null,
|
||||
"createRule": null,
|
||||
"updateRule": null,
|
||||
"deleteRule": null,
|
||||
"name": "_superusers",
|
||||
"type": "auth",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cost": 0,
|
||||
"hidden": true,
|
||||
"id": "password901924565",
|
||||
"max": 0,
|
||||
"min": 8,
|
||||
"name": "password",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "password"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "[a-zA-Z0-9]{50}",
|
||||
"hidden": true,
|
||||
"id": "text2504183744",
|
||||
"max": 60,
|
||||
"min": 30,
|
||||
"name": "tokenKey",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"exceptDomains": null,
|
||||
"hidden": false,
|
||||
"id": "email3885137012",
|
||||
"name": "email",
|
||||
"onlyDomains": null,
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "email"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "bool1547992806",
|
||||
"name": "emailVisibility",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": true,
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "bool256245529",
|
||||
"name": "verified",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": true,
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate2990389176",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": true,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate3332085495",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": true,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
"CREATE UNIQUE INDEX `idx_tokenKey_pbc_3142635823` ON `_superusers` (`tokenKey`)",
|
||||
"CREATE UNIQUE INDEX `idx_email_pbc_3142635823` ON `_superusers` (`email`) WHERE `email` != ''"
|
||||
],
|
||||
"created": "2026-04-03 11:46:26.437Z",
|
||||
"updated": "2026-04-03 11:46:26.437Z",
|
||||
"system": true,
|
||||
"authRule": "",
|
||||
"manageRule": null,
|
||||
"authAlert": {
|
||||
"enabled": true,
|
||||
"emailTemplate": {
|
||||
"subject": "Login from a new location",
|
||||
"body": "\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eWe noticed a login to your {APP_NAME} account from a new location:\u003c/p\u003e\n\u003cp\u003e\u003cem\u003e{ALERT_INFO}\u003c/em\u003e\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eIf this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eIf this was you, you may disregard this email.\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e"
|
||||
}
|
||||
},
|
||||
"oauth2": {
|
||||
"providers": [],
|
||||
"mappedFields": {
|
||||
"id": "",
|
||||
"name": "",
|
||||
"username": "",
|
||||
"avatarURL": ""
|
||||
},
|
||||
"enabled": false
|
||||
},
|
||||
"passwordAuth": {
|
||||
"enabled": true,
|
||||
"identityFields": [
|
||||
"email"
|
||||
]
|
||||
},
|
||||
"mfa": {
|
||||
"enabled": false,
|
||||
"duration": 1800,
|
||||
"rule": ""
|
||||
},
|
||||
"otp": {
|
||||
"enabled": false,
|
||||
"duration": 180,
|
||||
"length": 8,
|
||||
"emailTemplate": {
|
||||
"subject": "OTP for {APP_NAME}",
|
||||
"body": "\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eYour one-time password is: \u003cstrong\u003e{OTP}\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask for the one-time password, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e"
|
||||
}
|
||||
},
|
||||
"authToken": {
|
||||
"duration": 86400
|
||||
},
|
||||
"passwordResetToken": {
|
||||
"duration": 1800
|
||||
},
|
||||
"emailChangeToken": {
|
||||
"duration": 1800
|
||||
},
|
||||
"verificationToken": {
|
||||
"duration": 259200
|
||||
},
|
||||
"fileToken": {
|
||||
"duration": 180
|
||||
},
|
||||
"verificationTemplate": {
|
||||
"subject": "Verify your {APP_NAME} email",
|
||||
"body": "\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eThank you for joining us at {APP_NAME}.\u003c/p\u003e\n\u003cp\u003eClick on the button below to verify your email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}\" target=\"_blank\" rel=\"noopener\"\u003eVerify\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e"
|
||||
},
|
||||
"resetPasswordTemplate": {
|
||||
"subject": "Reset your {APP_NAME} password",
|
||||
"body": "\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to reset your password.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}\" target=\"_blank\" rel=\"noopener\"\u003eReset password\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to reset your password, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e"
|
||||
},
|
||||
"confirmEmailChangeTemplate": {
|
||||
"subject": "Confirm your {APP_NAME} new email address",
|
||||
"body": "\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to confirm your new email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}\" target=\"_blank\" rel=\"noopener\"\u003eConfirm new email\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to change your email address, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pbc_1736455494",
|
||||
"listRule": null,
|
||||
"viewRule": null,
|
||||
"createRule": null,
|
||||
"updateRule": null,
|
||||
"deleteRule": null,
|
||||
"name": "users",
|
||||
"type": "auth",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cost": 0,
|
||||
"hidden": true,
|
||||
"id": "password901924565",
|
||||
"max": 0,
|
||||
"min": 8,
|
||||
"name": "password",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "password"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "[a-zA-Z0-9]{50}",
|
||||
"hidden": true,
|
||||
"id": "text2504183744",
|
||||
"max": 60,
|
||||
"min": 30,
|
||||
"name": "tokenKey",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"exceptDomains": null,
|
||||
"hidden": false,
|
||||
"id": "email3885137012",
|
||||
"name": "email",
|
||||
"onlyDomains": null,
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "email"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "bool1547992806",
|
||||
"name": "emailVisibility",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": true,
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "bool256245529",
|
||||
"name": "verified",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": true,
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate2990389176",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate3332085495",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
"CREATE UNIQUE INDEX `idx_tokenKey_pbc_1736455494` ON `users` (`tokenKey`)",
|
||||
"CREATE UNIQUE INDEX `idx_email_pbc_1736455494` ON `users` (`email`) WHERE `email` != ''"
|
||||
],
|
||||
"created": "2026-04-03 11:46:26.465Z",
|
||||
"updated": "2026-04-03 11:46:26.465Z",
|
||||
"system": false,
|
||||
"authRule": "",
|
||||
"manageRule": null,
|
||||
"authAlert": {
|
||||
"enabled": true,
|
||||
"emailTemplate": {
|
||||
"subject": "Login from a new location",
|
||||
"body": "\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eWe noticed a login to your {APP_NAME} account from a new location:\u003c/p\u003e\n\u003cp\u003e\u003cem\u003e{ALERT_INFO}\u003c/em\u003e\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eIf this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eIf this was you, you may disregard this email.\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e"
|
||||
}
|
||||
},
|
||||
"oauth2": {
|
||||
"providers": [],
|
||||
"mappedFields": {
|
||||
"id": "",
|
||||
"name": "",
|
||||
"username": "",
|
||||
"avatarURL": ""
|
||||
},
|
||||
"enabled": false
|
||||
},
|
||||
"passwordAuth": {
|
||||
"enabled": true,
|
||||
"identityFields": [
|
||||
"email"
|
||||
]
|
||||
},
|
||||
"mfa": {
|
||||
"enabled": false,
|
||||
"duration": 1800,
|
||||
"rule": ""
|
||||
},
|
||||
"otp": {
|
||||
"enabled": false,
|
||||
"duration": 180,
|
||||
"length": 8,
|
||||
"emailTemplate": {
|
||||
"subject": "OTP for {APP_NAME}",
|
||||
"body": "\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eYour one-time password is: \u003cstrong\u003e{OTP}\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask for the one-time password, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e"
|
||||
}
|
||||
},
|
||||
"authToken": {
|
||||
"duration": 604800
|
||||
},
|
||||
"passwordResetToken": {
|
||||
"duration": 1800
|
||||
},
|
||||
"emailChangeToken": {
|
||||
"duration": 1800
|
||||
},
|
||||
"verificationToken": {
|
||||
"duration": 259200
|
||||
},
|
||||
"fileToken": {
|
||||
"duration": 180
|
||||
},
|
||||
"verificationTemplate": {
|
||||
"subject": "Verify your {APP_NAME} email",
|
||||
"body": "\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eThank you for joining us at {APP_NAME}.\u003c/p\u003e\n\u003cp\u003eClick on the button below to verify your email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}\" target=\"_blank\" rel=\"noopener\"\u003eVerify\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e"
|
||||
},
|
||||
"resetPasswordTemplate": {
|
||||
"subject": "Reset your {APP_NAME} password",
|
||||
"body": "\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to reset your password.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}\" target=\"_blank\" rel=\"noopener\"\u003eReset password\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to reset your password, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e"
|
||||
},
|
||||
"confirmEmailChangeTemplate": {
|
||||
"subject": "Confirm your {APP_NAME} new email address",
|
||||
"body": "\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to confirm your new email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}\" target=\"_blank\" rel=\"noopener\"\u003eConfirm new email\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to change your email address, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pbc_4195113088",
|
||||
"listRule": "@request.auth.id != \"\"",
|
||||
"viewRule": "@request.auth.id != \"\"",
|
||||
"createRule": "@request.auth.id != \"\"",
|
||||
"updateRule": null,
|
||||
"deleteRule": null,
|
||||
"name": "cameras",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text1579384326",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "name",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text2841347104",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "onvif_host",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text4166911607",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "username",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text901924565",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "password",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate2990389176",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate3332085495",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_2662073616",
|
||||
"hidden": false,
|
||||
"id": "relation2461399138",
|
||||
"maxSelect": 0,
|
||||
"minSelect": 0,
|
||||
"name": "record_stream",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"created": "2026-04-03 11:46:26.469Z",
|
||||
"updated": "2026-04-03 11:46:26.475Z",
|
||||
"system": false
|
||||
},
|
||||
{
|
||||
"id": "pbc_2662073616",
|
||||
"listRule": "@request.auth.id != \"\"",
|
||||
"viewRule": "@request.auth.id != \"\"",
|
||||
"createRule": null,
|
||||
"updateRule": null,
|
||||
"deleteRule": null,
|
||||
"name": "streams",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "pbc_4195113088",
|
||||
"hidden": false,
|
||||
"id": "relation991751685",
|
||||
"maxSelect": 0,
|
||||
"minSelect": 0,
|
||||
"name": "camera",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text4101391790",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "url",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number1428699120",
|
||||
"max": null,
|
||||
"min": 0,
|
||||
"name": "fps",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number4115522831",
|
||||
"max": null,
|
||||
"min": 0,
|
||||
"name": "height",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number2350531887",
|
||||
"max": null,
|
||||
"min": 0,
|
||||
"name": "width",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"created": "2026-04-03 11:46:26.472Z",
|
||||
"updated": "2026-04-03 11:46:26.472Z",
|
||||
"system": false
|
||||
}
|
||||
]
|
||||
27
server/config/config.go
Normal file
27
server/config/config.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/caarlos0/env/v11"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Environment string `env:"ENVIRONMENT" envDefault:"production"`
|
||||
DbDataDir string `env:"CCTV_PB_DATA_DIR" envDefault:"./pb_data"`
|
||||
DbEncryptionKey string `env:"CCTV_PB_ENCRYPTION_KEY" envDefault:""`
|
||||
RecordingsDir string `env:"CCTV_REC_DIR" envDefault:"./recordings"`
|
||||
ExternalWebApp string `env:"CCTV_EXTERNAL_WEB_APP" envDefault:""`
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
var cfg Config
|
||||
if err := env.ParseWithOptions(&cfg, env.Options{
|
||||
RequiredIfNoDef: true,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) IsDev() bool {
|
||||
return c.Environment == "development"
|
||||
}
|
||||
52
server/go.mod
Normal file
52
server/go.mod
Normal file
@@ -0,0 +1,52 @@
|
||||
module git.koval.net/cyclane/cctv/server
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/caarlos0/env/v11 v11.4.0
|
||||
github.com/go-gst/go-glib v1.4.1-0.20250303082535-35ebad1471fd
|
||||
github.com/go-gst/go-gst v1.4.0
|
||||
github.com/gowvp/onvif v0.0.14
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/pocketbase/pocketbase v0.36.7
|
||||
golang.org/x/sync v0.20.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/beevik/etree v1.1.0 // indirect
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/elgs/gostrgen v0.0.0-20251010065124-dce324c66371 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
|
||||
github.com/go-gst/go-pointer v0.0.0-20241127163939-ba766f075b4c // indirect
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
||||
github.com/gofrs/uuid v4.4.0+incompatible // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/juju/errors v1.0.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-pointer v0.0.1 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pocketbase/dbx v1.12.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/image v0.37.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.46.2 // indirect
|
||||
)
|
||||
154
server/go.sum
Normal file
154
server/go.sum
Normal file
@@ -0,0 +1,154 @@
|
||||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
|
||||
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
|
||||
github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc=
|
||||
github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/elgs/gostrgen v0.0.0-20251010065124-dce324c66371 h1:4WADfZZW26C7UgER4MEwZpS/THGj0VEf6HiXS3PyRfo=
|
||||
github.com/elgs/gostrgen v0.0.0-20251010065124-dce324c66371/go.mod h1:qxVxKgX2MC/LcAmvASQ96hjjlcBOV6wQ+ZV9r1+nB3k=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
||||
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||
github.com/go-gst/go-glib v1.4.1-0.20250303082535-35ebad1471fd h1:mKnu0UUFvvDMEeK4HXxmX+haP6+qppIvCW0HgUOfwmk=
|
||||
github.com/go-gst/go-glib v1.4.1-0.20250303082535-35ebad1471fd/go.mod h1:ZWT4LXOO2PH8lSNu/dR5O2yoNQJKEgmijNa2d7nByK8=
|
||||
github.com/go-gst/go-gst v1.4.0 h1:EikB43u4c3wc8d2RzlFRSfIGIXYzDy6Zls2vJqrG2BU=
|
||||
github.com/go-gst/go-gst v1.4.0/go.mod h1:p8TLGtOxJLcrp6PCkTPdnanwWBxPZvYiHDbuSuwgO3c=
|
||||
github.com/go-gst/go-pointer v0.0.0-20241127163939-ba766f075b4c h1:x8kKRVDmz5BRlolmDZGcsuZ1l+js6TRL3QWBJjGVctM=
|
||||
github.com/go-gst/go-pointer v0.0.0-20241127163939-ba766f075b4c/go.mod h1:qKw5ZZ0U58W6PU/7F/Lopv+14nKYmdXlOd7VnAZ17Mk=
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
|
||||
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gowvp/onvif v0.0.14 h1:NNrFqzqBHf9Z9MEQOiDpunpagXUHQraRjFmyiXhUwr4=
|
||||
github.com/gowvp/onvif v0.0.14/go.mod h1:Dshr55Q/Xgwa9XMQBPBQBMOWj/2Sq+DxLhdNY35uoFc=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/juju/errors v1.0.0 h1:yiq7kjCLll1BiaRuNY53MGI0+EQ3rF6GB+wvboZDefM=
|
||||
github.com/juju/errors v1.0.0/go.mod h1:B5x9thDqx0wIMH3+aLIMP9HjItInYWObRovoCFM5Qe8=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0=
|
||||
github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
||||
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pocketbase/pocketbase v0.36.7 h1:MrViB7BptPYrf2Nt25pJEYBqUdFjuhRKu1p5GTrkvPA=
|
||||
github.com/pocketbase/pocketbase v0.36.7/go.mod h1:qX4HuVjoKXtEg41fSJVM0JLfGWXbBmHxVv/FaE446r4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.46.2 h1:gkXQ6R0+AjxFC/fTDaeIVLbNLNrRoOK7YYVz5BKhTcE=
|
||||
modernc.org/sqlite v1.46.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
67
server/hooks.go
Normal file
67
server/hooks.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"git.koval.net/cyclane/cctv/server/ingest"
|
||||
"git.koval.net/cyclane/cctv/server/models"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
func handleCameraUpdate(e *core.RecordEvent) error {
|
||||
if e.Record.Collection().Name != "cameras" {
|
||||
return e.Next()
|
||||
}
|
||||
camera := &models.Camera{}
|
||||
camera.SetProxyRecord(e.Record)
|
||||
log := e.App.Logger().With("camera", camera.Id)
|
||||
|
||||
// Encrypt password
|
||||
password := camera.PasswordRaw()
|
||||
log.Info("Old password", "password", password)
|
||||
if err := camera.SetPassword(password, e.App.EncryptionEnv()); err != nil {
|
||||
return fmt.Errorf("failed to encrypt password: %w", err)
|
||||
}
|
||||
log.Info("New password", "password", camera.PasswordRaw())
|
||||
|
||||
onvifProfiles, err := ingest.GetOnvifProfiles(e.Context, camera.OnvifHost(), camera.Username(), password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find onvif profiles: %w", err)
|
||||
}
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
streams, err := e.App.FindCollectionByNameOrId("streams")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find streams collection: %w", err)
|
||||
}
|
||||
errGroup := &errgroup.Group{}
|
||||
for _, p := range onvifProfiles {
|
||||
log.Debug("Found ONVIF profile", "name", p.Name, "token", p.Token)
|
||||
errGroup.Go(func() error {
|
||||
log := log.With("profile", p.Token)
|
||||
rawURL := p.URI.String()
|
||||
rtspUrl := p.URI
|
||||
rtspUrl.User = url.UserPassword(camera.Username(), password)
|
||||
stats, err := ingest.GetStreamStats(e.Context, log, rtspUrl, string(p.Token))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get stream stats for profile %s: %w", p.Token, err)
|
||||
}
|
||||
log.Debug("Retrieved stream stats", "fps", stats.FPS, "width", stats.Width, "height", stats.Height)
|
||||
|
||||
stream := &models.Stream{}
|
||||
stream.SetProxyRecord(core.NewRecord(streams))
|
||||
stream.SetCameraID(camera.Id)
|
||||
stream.SetURL(rawURL)
|
||||
stream.SetFPS(stats.FPS)
|
||||
stream.SetWidth(stats.Width)
|
||||
stream.SetHeight(stats.Height)
|
||||
return e.App.Save(stream)
|
||||
})
|
||||
}
|
||||
return errGroup.Wait()
|
||||
}
|
||||
335
server/ingest/branch_live.go
Normal file
335
server/ingest/branch_live.go
Normal file
@@ -0,0 +1,335 @@
|
||||
package ingest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-gst/go-gst/gst"
|
||||
"github.com/go-gst/go-gst/gst/app"
|
||||
)
|
||||
|
||||
const (
|
||||
liveSampleTimeout = 200 * time.Millisecond
|
||||
liveSampleBufferSize = 5
|
||||
)
|
||||
|
||||
type subscribeReq struct {
|
||||
BufferSize int
|
||||
Result chan<- subscribeRes
|
||||
}
|
||||
type subscribeRes struct {
|
||||
Id int
|
||||
Stream <-chan *bytes.Reader
|
||||
Err error
|
||||
}
|
||||
|
||||
type unsubscribeReq struct {
|
||||
Id int
|
||||
}
|
||||
|
||||
// addLiveBranch populates the pipeline with:
|
||||
//
|
||||
// [vTee] ---> queue ---> valve ---> mp4mux ---> appsink
|
||||
// [aTee] ---> queue ---> valve -----^
|
||||
func (p *CameraPipeline) addLiveBranch(branchTimeout time.Duration) error {
|
||||
if p.vTee == nil {
|
||||
return fmt.Errorf("video tee not initialized")
|
||||
}
|
||||
if p.aTee == nil {
|
||||
return fmt.Errorf("audio tee not initialized")
|
||||
}
|
||||
vPad := p.vTee.GetRequestPad("src_%u")
|
||||
if vPad == nil {
|
||||
return fmt.Errorf("failed to get request pad from video tee")
|
||||
}
|
||||
aPad := p.aTee.GetRequestPad("src_%u")
|
||||
if aPad == nil {
|
||||
return fmt.Errorf("failed to get request pad from audio tee")
|
||||
}
|
||||
|
||||
vQueue, err := gst.NewElement("queue")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create video queue element: %w", err)
|
||||
}
|
||||
if err := setGlibValueProperty(vQueue, "leaky", 2); err != nil { // downstream
|
||||
return fmt.Errorf("failed to set video queue leaky property: %w", err)
|
||||
}
|
||||
if err := vQueue.SetProperty("max-size-time", uint64(2*time.Second.Nanoseconds())); err != nil {
|
||||
return fmt.Errorf("failed to set video queue max-size-time property: %w", err)
|
||||
}
|
||||
|
||||
vValve, err := gst.NewElement("valve")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create video valve element: %w", err)
|
||||
}
|
||||
if err := vValve.SetProperty("drop", true); err != nil {
|
||||
return fmt.Errorf("failed to set video valve drop property: %w", err)
|
||||
}
|
||||
if err := setGlibValueProperty(vValve, "drop-mode", 1); err != nil { // forward-sticky
|
||||
return fmt.Errorf("failed to set video valve drop-mode property: %w", err)
|
||||
}
|
||||
|
||||
aQueue, err := gst.NewElement("queue")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create audio queue element: %w", err)
|
||||
}
|
||||
if err := setGlibValueProperty(aQueue, "leaky", 2); err != nil { // downstream
|
||||
return fmt.Errorf("failed to set audio queue leaky property: %w", err)
|
||||
}
|
||||
if err := aQueue.SetProperty("max-size-time", uint64(2*time.Second.Nanoseconds())); err != nil {
|
||||
return fmt.Errorf("failed to set audio queue max-size-time property: %w", err)
|
||||
}
|
||||
|
||||
aValve, err := gst.NewElement("valve")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create audio valve element: %w", err)
|
||||
}
|
||||
if err := aValve.SetProperty("drop", true); err != nil {
|
||||
return fmt.Errorf("failed to set audio valve drop property: %w", err)
|
||||
}
|
||||
if err := setGlibValueProperty(aValve, "drop-mode", 1); err != nil { // forward-sticky
|
||||
return fmt.Errorf("failed to set audio valve drop-mode property: %w", err)
|
||||
}
|
||||
|
||||
mp4Mux, err := gst.NewElement("mp4mux")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create mp4mux element: %w", err)
|
||||
}
|
||||
if err := mp4Mux.SetProperty("fragment-duration", uint(100*time.Millisecond.Nanoseconds())); err != nil {
|
||||
return fmt.Errorf("failed to set mp4mux fragment-duration property: %w", err)
|
||||
}
|
||||
if err := setGlibValueProperty(mp4Mux, "fragment-mode", 0); err != nil { // dash-or-mss
|
||||
return fmt.Errorf("failed to set mp4mux fragment-mode property: %w", err)
|
||||
}
|
||||
if err := mp4Mux.SetProperty("interleave-time", uint64(0)); err != nil {
|
||||
return fmt.Errorf("failed to set mp4mux interleave-time property: %w", err)
|
||||
}
|
||||
if err := mp4Mux.SetProperty("latency", uint64(0)); err != nil {
|
||||
return fmt.Errorf("failed to set mp4mux latency property: %w", err)
|
||||
}
|
||||
|
||||
appSink, err := gst.NewElement("appsink")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create appsink element: %w", err)
|
||||
}
|
||||
if err := appSink.SetProperty("max-buffers", uint(5)); err != nil {
|
||||
return fmt.Errorf("failed to set appsink max-buffers property: %w", err)
|
||||
}
|
||||
if err := appSink.SetProperty("sync", false); err != nil {
|
||||
return fmt.Errorf("failed to set appsink sync property: %w", err)
|
||||
}
|
||||
if err := appSink.SetProperty("async", false); err != nil {
|
||||
return fmt.Errorf("failed to set appsink async property: %w", err)
|
||||
}
|
||||
|
||||
if err := p.pipeline.AddMany(vQueue, vValve, aQueue, aValve, mp4Mux, appSink); err != nil {
|
||||
return fmt.Errorf("failed to add live branch elements to pipeline: %w", err)
|
||||
}
|
||||
|
||||
if res := vPad.Link(vQueue.GetStaticPad("sink")); res != gst.PadLinkOK {
|
||||
return fmt.Errorf("failed to link video tee to video queue: %s", res)
|
||||
}
|
||||
if res := aPad.Link(aQueue.GetStaticPad("sink")); res != gst.PadLinkOK {
|
||||
return fmt.Errorf("failed to link audio tee to audio queue: %s", res)
|
||||
}
|
||||
if res := vQueue.GetStaticPad("src").Link(vValve.GetStaticPad("sink")); res != gst.PadLinkOK {
|
||||
return fmt.Errorf("failed to link video queue to video valve: %s", res)
|
||||
}
|
||||
if res := aQueue.GetStaticPad("src").Link(aValve.GetStaticPad("sink")); res != gst.PadLinkOK {
|
||||
return fmt.Errorf("failed to link audio queue to audio valve: %s", res)
|
||||
}
|
||||
if res := vValve.GetStaticPad("src").Link(mp4Mux.GetRequestPad("video_%u")); res != gst.PadLinkOK {
|
||||
return fmt.Errorf("failed to link video valve to mp4mux: %s", res)
|
||||
}
|
||||
if res := aValve.GetStaticPad("src").Link(mp4Mux.GetRequestPad("audio_%u")); res != gst.PadLinkOK {
|
||||
return fmt.Errorf("failed to link audio valve to mp4mux: %s", res)
|
||||
}
|
||||
if res := mp4Mux.GetStaticPad("src").Link(appSink.GetStaticPad("sink")); res != gst.PadLinkOK {
|
||||
return fmt.Errorf("failed to link mp4mux to appsink: %s", res)
|
||||
}
|
||||
|
||||
p.liveVValve = vValve
|
||||
p.liveAValve = aValve
|
||||
p.liveSink = app.SinkFromElement(appSink)
|
||||
p.liveTimeout = branchTimeout
|
||||
p.liveSubscribe = make(chan subscribeReq)
|
||||
p.liveUnsubscribe = make(chan unsubscribeReq)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *CameraPipeline) liveSampler(ctx context.Context, result chan<- []byte) {
|
||||
SamplerLoop:
|
||||
for {
|
||||
sample := p.liveSink.TryPullSample(gst.ClockTime(liveSampleTimeout.Nanoseconds()))
|
||||
if sample == nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
p.log.Debug("Live sampler timed out waiting for sample")
|
||||
continue SamplerLoop
|
||||
}
|
||||
}
|
||||
buf := sample.GetBuffer()
|
||||
if buf == nil {
|
||||
p.log.Warn("Received nil buffer from live sink")
|
||||
continue SamplerLoop
|
||||
}
|
||||
data, err := gstBufferToBytes(buf)
|
||||
if err != nil {
|
||||
p.log.Warn("Failed to convert buffer to bytes")
|
||||
continue SamplerLoop
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case result <- data:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *CameraPipeline) liveManager(ctx context.Context) {
|
||||
active := false
|
||||
var initSeg []byte
|
||||
subscribers := &subscribersManager{m: make(map[int]subscriber), nextID: 0}
|
||||
samples := make(chan []byte, liveSampleBufferSize)
|
||||
samplerCtx, samplerCancel := context.WithCancel(ctx)
|
||||
defer func() {
|
||||
subscribers.Close()
|
||||
samplerCancel()
|
||||
}()
|
||||
|
||||
defer p.log.Debug("Live branch manager exiting")
|
||||
var start time.Time
|
||||
var numSamples int
|
||||
for {
|
||||
var timeout <-chan time.Time
|
||||
if active && subscribers.len() == 0 {
|
||||
timeout = time.After(p.liveTimeout)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
samplerCancel()
|
||||
p.log.Info("Context cancelled", "numSamples", numSamples, "duration", time.Since(start))
|
||||
return
|
||||
case req := <-p.liveSubscribe:
|
||||
if !active {
|
||||
p.log.Info("Activating live branch")
|
||||
if err := p.liveVValve.SetProperty("drop", false); err != nil {
|
||||
req.Result <- subscribeRes{Err: fmt.Errorf("failed to activate video valve: %w", err)}
|
||||
continue
|
||||
}
|
||||
if err := p.liveAValve.SetProperty("drop", false); err != nil {
|
||||
if rollbackErr := p.liveVValve.SetProperty("drop", true); rollbackErr != nil { // try to rollback
|
||||
p.log.Warn("Failed to rollback video valve state after audio valve activation failure", "error", rollbackErr)
|
||||
}
|
||||
req.Result <- subscribeRes{Err: fmt.Errorf("failed to activate audio valve: %w", err)}
|
||||
continue
|
||||
}
|
||||
go p.liveSampler(samplerCtx, samples)
|
||||
active = true
|
||||
start = time.Now()
|
||||
}
|
||||
stream := make(chan *bytes.Reader, req.BufferSize)
|
||||
id := subscribers.AddSubscriber(stream)
|
||||
if initSeg != nil {
|
||||
if !subscribers.SendOrUnsubscribe(id, initSeg) {
|
||||
req.Result <- subscribeRes{Err: fmt.Errorf("subscriber missed init segment")}
|
||||
continue
|
||||
}
|
||||
}
|
||||
req.Result <- subscribeRes{Id: id, Stream: stream}
|
||||
case req := <-p.liveUnsubscribe:
|
||||
subscribers.RemoveSubscriber(req.Id)
|
||||
case data := <-samples:
|
||||
// TODO : init segment
|
||||
p.log.Debug("got video sample")
|
||||
numSamples += 1
|
||||
subscribers.Broadcast(data)
|
||||
case <-timeout:
|
||||
p.log.Info("Deactivating live branch due to inactivity")
|
||||
if err := p.liveVValve.SetProperty("drop", true); err != nil {
|
||||
p.log.Warn("Failed to deactivate video valve: %w", err)
|
||||
}
|
||||
if err := p.liveAValve.SetProperty("drop", true); err != nil {
|
||||
p.log.Warn("Failed to deactivate audio valve: %w", err)
|
||||
}
|
||||
samplerCancel()
|
||||
samplerCtx, samplerCancel = context.WithCancel(ctx)
|
||||
active = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *CameraPipeline) LiveSubscribe(timeout time.Duration, bufferSize int) (id int, stream <-chan *bytes.Reader, err error) {
|
||||
result := make(chan subscribeRes)
|
||||
req := subscribeReq{
|
||||
BufferSize: bufferSize,
|
||||
Result: result,
|
||||
}
|
||||
select {
|
||||
case <-time.After(timeout):
|
||||
return 0, nil, fmt.Errorf("live subscribe request timed out")
|
||||
case p.liveSubscribe <- req:
|
||||
}
|
||||
res := <-result
|
||||
return res.Id, res.Stream, res.Err
|
||||
}
|
||||
|
||||
func (p *CameraPipeline) LiveUnsubscribe(id int) {
|
||||
p.liveUnsubscribe <- unsubscribeReq{Id: id}
|
||||
}
|
||||
|
||||
type subscriber struct {
|
||||
Stream chan<- *bytes.Reader
|
||||
}
|
||||
|
||||
type subscribersManager struct {
|
||||
m map[int]subscriber
|
||||
nextID int
|
||||
}
|
||||
|
||||
func (sm subscribersManager) len() int {
|
||||
return len(sm.m)
|
||||
}
|
||||
|
||||
func (sm *subscribersManager) AddSubscriber(stream chan<- *bytes.Reader) int {
|
||||
id := sm.nextID
|
||||
sm.m[id] = subscriber{Stream: stream}
|
||||
sm.nextID++
|
||||
return id
|
||||
}
|
||||
|
||||
func (sm *subscribersManager) RemoveSubscriber(id int) {
|
||||
if sub, ok := sm.m[id]; ok {
|
||||
close(sub.Stream)
|
||||
delete(sm.m, id)
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *subscribersManager) SendOrUnsubscribe(id int, data []byte) bool {
|
||||
if sub, ok := sm.m[id]; ok {
|
||||
select {
|
||||
case sub.Stream <- bytes.NewReader(data):
|
||||
return true
|
||||
default:
|
||||
sm.RemoveSubscriber(id)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (sm *subscribersManager) Close() {
|
||||
for id := range sm.m {
|
||||
sm.RemoveSubscriber(id)
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *subscribersManager) Broadcast(data []byte) {
|
||||
for id := range sm.m {
|
||||
sm.SendOrUnsubscribe(id, data)
|
||||
}
|
||||
}
|
||||
111
server/ingest/branch_stats.go
Normal file
111
server/ingest/branch_stats.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package ingest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-gst/go-gst/gst"
|
||||
)
|
||||
|
||||
// addStatsBranch populates the pipeline with:
|
||||
//
|
||||
// [vTee] ---> fakesink
|
||||
//
|
||||
// StreamStats are extracted from the fakesink's handovers (using the sink pad caps) and sent to the returned channel.
|
||||
// If the channel has no received or is full, the stats are dropped to avoid blocking the pipeline. The channel is never closed.
|
||||
func (p *CameraPipeline) addStatsBranch() (<-chan StreamStats, error) {
|
||||
if p.vTee == nil {
|
||||
return nil, fmt.Errorf("video tee not initialized")
|
||||
}
|
||||
vPad := p.vTee.GetRequestPad("src_%u")
|
||||
if vPad == nil {
|
||||
return nil, fmt.Errorf("failed to get request pad from video tee")
|
||||
}
|
||||
|
||||
statsChan := make(chan StreamStats)
|
||||
fakeSink, err := gst.NewElement("fakesink")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create fakesink element: %w", err)
|
||||
}
|
||||
if err := fakeSink.SetProperty("signal-handoffs", true); err != nil {
|
||||
return nil, fmt.Errorf("failed to set fakesink signal-handoffs property: %w", err)
|
||||
}
|
||||
|
||||
if err := p.pipeline.Add(fakeSink); err != nil {
|
||||
return nil, fmt.Errorf("failed to add fakesink to pipeline: %w", err)
|
||||
}
|
||||
|
||||
if res := vPad.Link(fakeSink.GetStaticPad("sink")); res != gst.PadLinkOK {
|
||||
return nil, fmt.Errorf("failed to link video tee to fakesink: %s", res)
|
||||
}
|
||||
|
||||
fakeSink.Connect("handoff", func(element *gst.Element, _ *gst.Buffer, pad *gst.Pad) {
|
||||
caps := pad.GetCurrentCaps()
|
||||
if caps == nil || caps.GetSize() == 0 {
|
||||
p.log.Error("Stats pad has no or empty caps")
|
||||
return
|
||||
}
|
||||
if caps.GetSize() > 1 {
|
||||
// Note: should be impossible
|
||||
p.log.Warn("Stats pad has multiple structures, using the first one", "caps", caps.String())
|
||||
}
|
||||
structure := caps.GetStructureAt(0)
|
||||
if structure == nil {
|
||||
p.log.Error("Stats pad received nil structure")
|
||||
return
|
||||
}
|
||||
|
||||
rawWidth, err := structure.GetValue("width")
|
||||
if err != nil {
|
||||
p.log.Error("Failed to get width from stats pad caps", "error", err)
|
||||
return
|
||||
}
|
||||
width, ok := rawWidth.(int)
|
||||
if !ok {
|
||||
p.log.Error("Width field in stats pad caps is not an int", "value", rawWidth)
|
||||
return
|
||||
}
|
||||
|
||||
rawHeight, err := structure.GetValue("height")
|
||||
if err != nil {
|
||||
p.log.Error("Failed to get height from stats pad caps", "error", err)
|
||||
return
|
||||
}
|
||||
height, ok := rawHeight.(int)
|
||||
if !ok {
|
||||
p.log.Error("Height field in stats pad caps is not an int", "value", rawHeight)
|
||||
return
|
||||
}
|
||||
|
||||
fps, err := structure.GetValue("framerate")
|
||||
if err != nil {
|
||||
p.log.Error("Failed to get framerate from stats pad caps", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
var fpsFloat float64
|
||||
switch v := fps.(type) {
|
||||
case *gst.FractionValue:
|
||||
fpsFloat = float64(v.Num()) / float64(v.Denom())
|
||||
case gst.FractionValue:
|
||||
fpsFloat = float64(v.Num()) / float64(v.Denom())
|
||||
case float64:
|
||||
fpsFloat = v
|
||||
default:
|
||||
p.log.Error("Framerate field in stats pad caps is not a fraction or float", "type", fmt.Sprintf("%T", fps), "value", fps)
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
|
||||
case statsChan <- StreamStats{
|
||||
FPS: fpsFloat,
|
||||
Width: width,
|
||||
Height: height,
|
||||
}:
|
||||
default:
|
||||
p.log.Warn("Stats channel is full, dropping stats update")
|
||||
}
|
||||
return
|
||||
})
|
||||
return statsChan, nil
|
||||
}
|
||||
306
server/ingest/branch_thumbnail.go
Normal file
306
server/ingest/branch_thumbnail.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package ingest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/go-gst/go-gst/gst"
|
||||
"github.com/go-gst/go-gst/gst/app"
|
||||
)
|
||||
|
||||
type thumbnailReq struct {
|
||||
Result chan<- thumbnailRes
|
||||
}
|
||||
type thumbnailRes struct {
|
||||
Reader *bytes.Reader
|
||||
Err error
|
||||
}
|
||||
|
||||
// addThumbnailBranch populates the pipeline with:
|
||||
//
|
||||
// [vTee] ---> identity[drop D, limit fps] ---> queue ---> [dynamic decoder]
|
||||
// ---> videoconvert ---> aspectratiocrop ---> videoscale ---> capsfilter
|
||||
// ---> jpegenc ---> appsink
|
||||
//
|
||||
// Essentially, this branch:
|
||||
// - Removes any deltas from the stream.
|
||||
// - Limits FPS (we only have keyframes, so this is roughly possible before decoding).
|
||||
// - Decode, convert, crop to aspect ratio, scale and encode to JPEG
|
||||
// - Then we can query the appsink for thumbnails
|
||||
//
|
||||
// width and height are for the dimensions of the generated thumbnails.
|
||||
// minInterval has no effect when zero or negative, otherwise specifies the minimum interval between thumbnails.
|
||||
// timeoutSecs is used to deactivate this branch after a period of inactivity (to save CPU), this is done by blocking after the queue.
|
||||
func (p *CameraPipeline) addThumbnailBranch(width, height int, minInterval, branchTimeout time.Duration) error {
|
||||
if p.thumbnailSink != nil {
|
||||
return fmt.Errorf("thumbnail branch already added")
|
||||
}
|
||||
if p.vTee == nil {
|
||||
return fmt.Errorf("video tee not initialized")
|
||||
}
|
||||
vPad := p.vTee.GetRequestPad("src_%u")
|
||||
if vPad == nil {
|
||||
return fmt.Errorf("failed to get request pad from video tee")
|
||||
}
|
||||
|
||||
preDecodeChain, err := p.addStaticChainProps(
|
||||
vPad, nil,
|
||||
elementFactory{name: "identity", props: map[string]any{
|
||||
"drop-buffer-flags": gst.BufferFlagDeltaUnit,
|
||||
}},
|
||||
elementFactory{name: "queue", props: map[string]any{
|
||||
"max-size-buffers": uint(1),
|
||||
"leaky": 2, // downstream
|
||||
}},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add pre-decode chain: %w", err)
|
||||
}
|
||||
preDecodeSrc := preDecodeChain[len(preDecodeChain)-1].GetStaticPad("src")
|
||||
if preDecodeSrc == nil {
|
||||
return fmt.Errorf("failed to get src pad of pre-decode chain")
|
||||
}
|
||||
identitySrc := preDecodeChain[0].GetStaticPad("src")
|
||||
if identitySrc == nil {
|
||||
return fmt.Errorf("failed to get src pad of identity element in pre-decode chain")
|
||||
}
|
||||
|
||||
transformChain, err := p.addStaticChainProps(
|
||||
nil, nil,
|
||||
elementFactory{name: "videoconvert", props: nil},
|
||||
elementFactory{name: "aspectratiocrop", props: map[string]any{
|
||||
"aspect-ratio": gst.Fraction(width, height),
|
||||
}},
|
||||
elementFactory{name: "videoscale", props: nil},
|
||||
elementFactory{name: "capsfilter", props: map[string]any{
|
||||
"caps": gst.NewCapsFromString(fmt.Sprintf("video/x-raw,width=%d,height=%d", width, height)),
|
||||
}},
|
||||
elementFactory{name: "jpegenc", props: map[string]any{
|
||||
"quality": 85,
|
||||
}},
|
||||
elementFactory{name: "appsink", props: map[string]any{
|
||||
"max-buffers": uint(1),
|
||||
"drop": true,
|
||||
"sync": false,
|
||||
"async": false,
|
||||
}},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add transform chain: %w", err)
|
||||
}
|
||||
|
||||
if minInterval > 0 {
|
||||
minInterval := uint64(minInterval.Nanoseconds())
|
||||
lastKeptTS, haveLastTS := uint64(0), false
|
||||
identitySrc.AddProbe(gst.PadProbeTypeBuffer, func(_ *gst.Pad, info *gst.PadProbeInfo) gst.PadProbeReturn {
|
||||
buf := info.GetBuffer()
|
||||
if buf == nil {
|
||||
return gst.PadProbeOK
|
||||
}
|
||||
|
||||
ts := buf.PresentationTimestamp()
|
||||
if ts == gst.ClockTimeNone {
|
||||
ts = buf.DecodingTimestamp()
|
||||
}
|
||||
if ts == gst.ClockTimeNone || ts < 0 {
|
||||
return gst.PadProbeOK
|
||||
}
|
||||
|
||||
curr := uint64(ts)
|
||||
if haveLastTS {
|
||||
resetOccurred := curr < lastKeptTS
|
||||
if !resetOccurred && curr-lastKeptTS < minInterval {
|
||||
return gst.PadProbeDrop
|
||||
}
|
||||
}
|
||||
|
||||
haveLastTS = true
|
||||
lastKeptTS = curr
|
||||
return gst.PadProbeOK
|
||||
})
|
||||
}
|
||||
|
||||
// Dynamically add decoder (decodebin has an internal queue that is unnecessary for our use case,
|
||||
// so it just adds unwanted latency).
|
||||
transformSink := transformChain[0].GetStaticPad("sink")
|
||||
if transformSink == nil {
|
||||
return fmt.Errorf("failed to get sink pad for transform chain")
|
||||
}
|
||||
once := sync.Once{}
|
||||
preDecodeSrc.AddProbe(gst.PadProbeTypeEventDownstream, func(_ *gst.Pad, info *gst.PadProbeInfo) gst.PadProbeReturn {
|
||||
event := info.GetEvent()
|
||||
if event != nil && event.Type() == gst.EventTypeCaps {
|
||||
caps := event.ParseCaps()
|
||||
if caps == nil || caps.GetSize() == 0 {
|
||||
p.log.Error("Thumbnail pad probe received caps event with no or empty caps")
|
||||
return gst.PadProbeOK
|
||||
}
|
||||
if caps.GetSize() > 1 {
|
||||
// Note: should be impossible
|
||||
p.log.Warn("Thumbnail pad probe received caps event with multiple structures, using the first one", "caps", caps.String())
|
||||
}
|
||||
structure := caps.GetStructureAt(0)
|
||||
if structure == nil {
|
||||
p.log.Error("Thumbnail pad probe received caps event with nil structure")
|
||||
return gst.PadProbeOK
|
||||
}
|
||||
|
||||
res := gst.PadProbeOK
|
||||
once.Do(func() {
|
||||
res = gst.PadProbeRemove
|
||||
var (
|
||||
chain []*gst.Element
|
||||
err error
|
||||
)
|
||||
switch structure.Name() {
|
||||
case "video/x-h264":
|
||||
chain, err = p.addStaticChain(preDecodeSrc, transformSink, "avdec_h264")
|
||||
case "video/x-h265":
|
||||
chain, err = p.addStaticChain(preDecodeSrc, transformSink, "avdec_h265")
|
||||
default:
|
||||
p.log.Error("Thumbnail pad probe received caps event with unsupported encoding", "caps", caps.String())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
p.log.Error("Failed to add decoder chain in thumbnail pad probe", "caps", caps.String(), "error", err)
|
||||
return
|
||||
}
|
||||
for _, element := range chain {
|
||||
element.SyncStateWithParent()
|
||||
}
|
||||
})
|
||||
return res
|
||||
} else {
|
||||
return gst.PadProbeOK
|
||||
}
|
||||
})
|
||||
|
||||
p.thumbnailBlockPad = preDecodeSrc
|
||||
p.thumbnailSink = app.SinkFromElement(transformChain[len(transformChain)-1])
|
||||
p.thumbnailTimeout = branchTimeout
|
||||
p.thumbnailReq = make(chan thumbnailReq)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *CameraPipeline) addThumbnailBlockProbe() {
|
||||
p.thumbnailBlockProbe = p.thumbnailBlockPad.AddProbe(gst.PadProbeTypeBlock, func(pad *gst.Pad, info *gst.PadProbeInfo) gst.PadProbeReturn {
|
||||
p.thumbnailBlocked.Store(true)
|
||||
return gst.PadProbeOK
|
||||
})
|
||||
}
|
||||
|
||||
func (p *CameraPipeline) handleThumbnailReq(active bool, lastThumbnail []byte) (bool, []byte, error) {
|
||||
var sample *gst.Sample
|
||||
if active {
|
||||
sample = p.thumbnailSink.TryPullSample(0)
|
||||
} else {
|
||||
p.log.Info("Activating thumbnail branch")
|
||||
// Install one-shot probe to drop the first buffer (which is a stale frame that was blocked by the blocking probe
|
||||
// when the branch was first deactivated).
|
||||
if p.thumbnailBlocked.Swap(false) {
|
||||
dropProbeId := atomic.Pointer[uint64]{}
|
||||
once := sync.Once{}
|
||||
probeId := p.thumbnailBlockPad.AddProbe(gst.PadProbeTypeBuffer, func(_ *gst.Pad, _ *gst.PadProbeInfo) gst.PadProbeReturn {
|
||||
if probeId := dropProbeId.Load(); probeId != nil {
|
||||
p.thumbnailBlockPad.RemoveProbe(*probeId)
|
||||
}
|
||||
res := gst.PadProbeRemove
|
||||
once.Do(func() {
|
||||
res = gst.PadProbeDrop
|
||||
})
|
||||
return res
|
||||
})
|
||||
dropProbeId.Store(&probeId)
|
||||
}
|
||||
p.thumbnailBlockPad.RemoveProbe(p.thumbnailBlockProbe)
|
||||
active = true
|
||||
sample = p.thumbnailSink.PullSample()
|
||||
}
|
||||
if sample == nil {
|
||||
if lastThumbnail == nil {
|
||||
return active, lastThumbnail, fmt.Errorf("no thumbnail available")
|
||||
} else {
|
||||
p.log.Debug("Resolving last thumbnail")
|
||||
return active, lastThumbnail, nil
|
||||
}
|
||||
}
|
||||
p.log.Debug("Resolving new thumbnail")
|
||||
|
||||
data, err := gstBufferToBytes(sample.GetBuffer())
|
||||
if err != nil {
|
||||
return active, lastThumbnail, fmt.Errorf("failed to convert thumbnail buffer to bytes: %w", err)
|
||||
}
|
||||
return active, data, nil
|
||||
}
|
||||
|
||||
func (p *CameraPipeline) thumbnailManager(ctx context.Context) {
|
||||
active := true
|
||||
var lastThumbnail []byte
|
||||
|
||||
defer p.log.Debug("Thumbnail manager exiting")
|
||||
ManagerLoop:
|
||||
for {
|
||||
var timeout <-chan time.Time
|
||||
if active {
|
||||
timeout = time.After(p.thumbnailTimeout)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case req := <-p.thumbnailReq:
|
||||
var (
|
||||
thumbnail []byte
|
||||
err error
|
||||
)
|
||||
active, thumbnail, err = p.handleThumbnailReq(active, lastThumbnail)
|
||||
if err != nil {
|
||||
req.Result <- thumbnailRes{Err: err}
|
||||
continue ManagerLoop
|
||||
}
|
||||
|
||||
lastThumbnail = thumbnail
|
||||
select {
|
||||
case req.Result <- thumbnailRes{Reader: bytes.NewReader(thumbnail)}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
case <-timeout:
|
||||
p.log.Info("Deactivating thumbnail branch due to inactivity")
|
||||
// Install block probe to stop flow into the thumbnail branch
|
||||
p.addThumbnailBlockProbe()
|
||||
// Flush buffer to avoid sending stale thumbnail when reactivating
|
||||
for {
|
||||
sample := p.thumbnailSink.TryPullSample(0)
|
||||
if sample == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
active = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetThumbnail either retrieves a JPEG thumbnail from the pipeline, or returns the previous if no new thumbnail is available.
|
||||
func (p *CameraPipeline) GetThumbnail(ctx context.Context) (*bytes.Reader, error) {
|
||||
if p.thumbnailSink == nil {
|
||||
return nil, fmt.Errorf("thumbnail branch not initialized")
|
||||
}
|
||||
|
||||
resp := make(chan thumbnailRes, 1)
|
||||
select {
|
||||
case p.thumbnailReq <- thumbnailReq{Result: resp}:
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("context cancelled while requesting thumbnail")
|
||||
}
|
||||
|
||||
select {
|
||||
case thumbnail := <-resp:
|
||||
return thumbnail.Reader, thumbnail.Err
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("context cancelled while waiting for thumbnail")
|
||||
}
|
||||
}
|
||||
110
server/ingest/discovery.go
Normal file
110
server/ingest/discovery.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package ingest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
|
||||
"github.com/gowvp/onvif"
|
||||
"github.com/gowvp/onvif/media"
|
||||
mediaSdk "github.com/gowvp/onvif/sdk/media"
|
||||
onvifXsd "github.com/gowvp/onvif/xsd/onvif"
|
||||
)
|
||||
|
||||
type OnvifProfile struct {
|
||||
Name onvifXsd.Name
|
||||
Token onvifXsd.ReferenceToken
|
||||
URI *url.URL
|
||||
}
|
||||
|
||||
// GetOnvifProfiles retrieves all available ONVIF stream profiles for a camera.
|
||||
//
|
||||
// Note that the result is very limited since many cameras provide extremely incomplete data,
|
||||
// so it's safest to assume that we only have the bare minimum.
|
||||
func GetOnvifProfiles(ctx context.Context, host, username, password string) ([]OnvifProfile, error) {
|
||||
dev, err := onvif.NewDevice(onvif.DeviceParams{
|
||||
Xaddr: host,
|
||||
Username: username,
|
||||
Password: password,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create ONVIF device: %w", err)
|
||||
}
|
||||
|
||||
// Get available profiles
|
||||
profiles, err := mediaSdk.Call_GetProfiles(ctx, dev, media.GetProfiles{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get ONVIF profiles: %w", err)
|
||||
}
|
||||
|
||||
// Get available URIs for each profile
|
||||
onvifProfiles := make([]OnvifProfile, 0, len(profiles.Profiles))
|
||||
for _, p := range profiles.Profiles {
|
||||
uri, err := mediaSdk.Call_GetStreamUri(ctx, dev, media.GetStreamUri{
|
||||
StreamSetup: onvifXsd.StreamSetup{
|
||||
Stream: onvifXsd.StreamType("RTP-Unicast"),
|
||||
Transport: onvifXsd.Transport{
|
||||
Protocol: onvifXsd.TransportProtocol("TCP"),
|
||||
},
|
||||
},
|
||||
ProfileToken: p.Token,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get stream URI for profile %s: %w", p.Token, err)
|
||||
}
|
||||
if uri.MediaUri.Uri != "" {
|
||||
u, err := url.Parse(string(uri.MediaUri.Uri))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse stream URI for profile %s: %w", p.Token, err)
|
||||
}
|
||||
onvifProfiles = append(onvifProfiles, OnvifProfile{
|
||||
Name: p.Name,
|
||||
Token: p.Token,
|
||||
URI: u,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return onvifProfiles, nil
|
||||
}
|
||||
|
||||
type StreamStats struct {
|
||||
FPS float64
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
// GetStreamStats starts a temporary pipeline to retrieve some basic (pre-decoding) information about the RTSP stream.
|
||||
func GetStreamStats(ctx context.Context, log *slog.Logger, rtspUrl *url.URL, profileToken string) (stats StreamStats, err error) {
|
||||
p, err := CreatePipeline(log)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("failed to create pipeline: %w", err)
|
||||
}
|
||||
if err := p.addRtspSource(rtspUrl); err != nil {
|
||||
return stats, fmt.Errorf("failed to add RTSP source: %w", err)
|
||||
}
|
||||
statsChan, err := p.addStatsBranch()
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("failed to add stats branch: %w", err)
|
||||
}
|
||||
|
||||
statsFound := false
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
go func() {
|
||||
select {
|
||||
case stats = <-statsChan:
|
||||
statsFound = true
|
||||
cancel()
|
||||
case <-runCtx.Done():
|
||||
}
|
||||
}()
|
||||
if err := p.Run(runCtx); err != nil {
|
||||
return stats, fmt.Errorf("failed to run pipeline: %w", err)
|
||||
}
|
||||
if !statsFound {
|
||||
return stats, fmt.Errorf("failed to retrieve stream stats: no stats received")
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
166
server/ingest/ingest.go
Normal file
166
server/ingest/ingest.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package ingest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.koval.net/cyclane/cctv/server/models"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/store"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type Ingest struct {
|
||||
sync.WaitGroup
|
||||
app core.App
|
||||
log *slog.Logger
|
||||
pipelines *store.Store[string, *activePipeline]
|
||||
}
|
||||
|
||||
type activePipeline struct {
|
||||
stream *models.Stream
|
||||
pipeline *CameraPipeline
|
||||
cancel context.CancelFunc
|
||||
terminated <-chan struct{}
|
||||
}
|
||||
|
||||
func BeginIngest(ctx context.Context, app core.App) *Ingest {
|
||||
log := app.Logger().With("svc", "ingest")
|
||||
ingest := &Ingest{
|
||||
WaitGroup: sync.WaitGroup{},
|
||||
app: app,
|
||||
log: log,
|
||||
pipelines: store.New[string, *activePipeline](nil),
|
||||
}
|
||||
|
||||
// initialise thumbnail pipelines
|
||||
thumbnailStreams := map[string]*models.Stream{}
|
||||
streams := []*models.Stream{}
|
||||
err := app.RunInTransaction(func(txApp core.App) error {
|
||||
if err := txApp.RecordQuery("streams").All(&streams); err != nil {
|
||||
return fmt.Errorf("failed to fetch stream records: %w", err)
|
||||
}
|
||||
for _, stream := range streams {
|
||||
other, ok := thumbnailStreams[stream.CameraID()]
|
||||
if !ok || thumbnailStreamScore(stream) < thumbnailStreamScore(other) {
|
||||
thumbnailStreams[stream.CameraID()] = stream
|
||||
}
|
||||
}
|
||||
|
||||
streamRecords := make([]*core.Record, len(streams))
|
||||
for i, stream := range streams {
|
||||
streamRecords[i] = stream.Record
|
||||
}
|
||||
if errs := txApp.ExpandRecords(streamRecords, []string{"camera"}, nil); len(errs) > 0 {
|
||||
return fmt.Errorf("failed to expand camera relation for stream records: %v", errs)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("Failed to initialize stream pipelines", "error", err)
|
||||
return ingest
|
||||
}
|
||||
|
||||
group := errgroup.Group{}
|
||||
for _, stream := range streams {
|
||||
group.Go(func() error {
|
||||
log := log.With("stream", stream.Id)
|
||||
rtspUrl, err := url.Parse(stream.URL())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse stream URL for stream %s: %w", stream.Id, err)
|
||||
}
|
||||
password, err := stream.Camera().Password(app.EncryptionEnv())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt camera password for stream %s: %w", stream.Id, err)
|
||||
}
|
||||
rtspUrl.User = url.UserPassword(stream.Camera().Username(), password)
|
||||
pipeline, err := CreatePipeline(log)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create pipeline for stream %s: %w", stream.Id, err)
|
||||
}
|
||||
if err := pipeline.addRtspSource(rtspUrl); err != nil {
|
||||
return fmt.Errorf("failed to add RTSP source to pipeline for stream %s: %w", stream.Id, err)
|
||||
}
|
||||
consumers := false
|
||||
if thumbnailStream, ok := thumbnailStreams[stream.CameraID()]; ok && thumbnailStream.Id == stream.Id {
|
||||
consumers = true
|
||||
if err := pipeline.addThumbnailBranch(480, 270, 1000*time.Millisecond, 20*time.Second); err != nil {
|
||||
return fmt.Errorf("failed to add thumbnail branch to pipeline for stream %s: %w", stream.Id, err)
|
||||
}
|
||||
}
|
||||
if err := pipeline.addLiveBranch(20 * time.Second); err != nil {
|
||||
return fmt.Errorf("failed to add live branch to pipeline for stream %s: %w", stream.Id, err)
|
||||
}
|
||||
|
||||
if consumers {
|
||||
log.Info("Starting pipeline for stream with consumers.")
|
||||
} else {
|
||||
log.Info("Stream has no consumers, ignoring pipeline.")
|
||||
pipeline.Close()
|
||||
return nil
|
||||
}
|
||||
pipelineCtx, cancel := context.WithCancel(ctx)
|
||||
terminated := make(chan struct{})
|
||||
ingest.pipelines.Set(stream.Id, &activePipeline{
|
||||
stream: stream,
|
||||
pipeline: pipeline,
|
||||
cancel: cancel,
|
||||
terminated: terminated,
|
||||
})
|
||||
ingest.WaitGroup.Go(func() {
|
||||
if err := pipeline.Run(pipelineCtx); err != nil {
|
||||
log.Error("Stream pipeline exited with error", "error", err)
|
||||
}
|
||||
close(terminated)
|
||||
})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if err := group.Wait(); err != nil {
|
||||
log.Error("Failed to start ingest pipelines", "error", err)
|
||||
}
|
||||
|
||||
return ingest
|
||||
}
|
||||
|
||||
func (ingest *Ingest) GetThumbnail(ctx context.Context, streamId string) (*bytes.Reader, error) {
|
||||
active, ok := ingest.pipelines.GetOk(streamId)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no active pipeline for stream %s", streamId)
|
||||
}
|
||||
thumb, err := active.pipeline.GetThumbnail(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get thumbnail from pipeline for stream %s: %w", streamId, err)
|
||||
}
|
||||
return thumb, nil
|
||||
}
|
||||
|
||||
func thumbnailStreamScore(stream *models.Stream) float64 {
|
||||
return stream.FPS() * float64(stream.Height()*stream.Width())
|
||||
}
|
||||
|
||||
func (ingest *Ingest) SubscribeLive(ctx context.Context, streamId string) (<-chan *bytes.Reader, error) {
|
||||
active, ok := ingest.pipelines.GetOk(streamId)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no active pipeline for stream %s", streamId)
|
||||
}
|
||||
id, stream, err := active.pipeline.LiveSubscribe(1*time.Second, 16)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to subscribe to live stream %s: %w", streamId, err)
|
||||
}
|
||||
defer active.pipeline.LiveUnsubscribe(id)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, nil
|
||||
case buf := <-stream:
|
||||
ingest.log.Debug("Received live stream chunk", "streamId", streamId, "chunkSize", buf.Len())
|
||||
}
|
||||
}
|
||||
}
|
||||
323
server/ingest/pipeline.go
Normal file
323
server/ingest/pipeline.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package ingest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/go-gst/go-glib/glib"
|
||||
"github.com/go-gst/go-gst/gst"
|
||||
"github.com/go-gst/go-gst/gst/app"
|
||||
)
|
||||
|
||||
type CameraPipeline struct {
|
||||
log *slog.Logger
|
||||
pipeline *gst.Pipeline
|
||||
|
||||
vTee *gst.Element
|
||||
aTee *gst.Element
|
||||
|
||||
thumbnailBlockPad *gst.Pad
|
||||
thumbnailSink *app.Sink
|
||||
thumbnailBlockProbe uint64
|
||||
thumbnailBlocked atomic.Bool
|
||||
thumbnailTimeout time.Duration
|
||||
thumbnailReq chan thumbnailReq
|
||||
|
||||
liveVValve *gst.Element
|
||||
liveAValve *gst.Element
|
||||
liveSink *app.Sink
|
||||
liveTimeout time.Duration
|
||||
liveSubscribe chan subscribeReq
|
||||
liveUnsubscribe chan unsubscribeReq
|
||||
}
|
||||
|
||||
type jobResult struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func CreatePipeline(log *slog.Logger) (*CameraPipeline, error) {
|
||||
var err error
|
||||
p := &CameraPipeline{
|
||||
log: log,
|
||||
}
|
||||
p.pipeline, err = gst.NewPipeline("")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GStreamer pipeline: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (p *CameraPipeline) Close() {
|
||||
p.pipeline.SetState(gst.StateNull)
|
||||
|
||||
p.thumbnailBlockPad = nil
|
||||
p.thumbnailSink = nil
|
||||
|
||||
p.liveVValve = nil
|
||||
p.liveAValve = nil
|
||||
p.liveSink = nil
|
||||
|
||||
p.vTee = nil
|
||||
p.aTee = nil
|
||||
|
||||
p.pipeline = nil
|
||||
}
|
||||
|
||||
func (p *CameraPipeline) Run(ctx context.Context) error {
|
||||
if err := p.pipeline.SetState(gst.StatePlaying); err != nil {
|
||||
return fmt.Errorf("failed to set pipeline to playing: %w", err)
|
||||
}
|
||||
if p.thumbnailSink != nil {
|
||||
go p.thumbnailManager(ctx)
|
||||
}
|
||||
if p.liveSink != nil {
|
||||
go p.liveManager(ctx)
|
||||
}
|
||||
bus := p.pipeline.GetBus()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
p.Close()
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
done, err := handleMessage(p.log, bus.TimedPop(gst.ClockTime((500 * time.Millisecond).Nanoseconds())))
|
||||
if err != nil {
|
||||
p.Close()
|
||||
return fmt.Errorf("pipeline error: %w", err)
|
||||
}
|
||||
if done {
|
||||
p.Close()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleMessage(log *slog.Logger, msg *gst.Message) (bool, error) {
|
||||
if msg == nil {
|
||||
return false, nil
|
||||
}
|
||||
// nolint:exhaustive
|
||||
switch msg.Type() {
|
||||
case gst.MessageEOS:
|
||||
log.Info("Pipeline reached EOS")
|
||||
return true, nil
|
||||
case gst.MessageError:
|
||||
err := msg.ParseError()
|
||||
return true, fmt.Errorf("pipeline error: %w", err)
|
||||
default:
|
||||
log.Debug(fmt.Sprintf("Pipeline message: %s", msg))
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// addRtspSource populates the pipeline with:
|
||||
//
|
||||
// rtspsrc
|
||||
// |---> (dynamic video depayloader + parser) --> vTee
|
||||
// |---> (dynamic audio depayloader + parser) --> aTee
|
||||
func (p *CameraPipeline) addRtspSource(rtspURL *url.URL) error {
|
||||
if p.vTee != nil || p.aTee != nil {
|
||||
return fmt.Errorf("source already added")
|
||||
}
|
||||
|
||||
src, err := gst.NewElement("rtspsrc")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create rtspsrc element: %w", err)
|
||||
}
|
||||
if err := src.SetProperty("location", rtspURL.String()); err != nil {
|
||||
return fmt.Errorf("failed to set rtspsrc location property: %w", err)
|
||||
}
|
||||
if err := src.SetProperty("latency", uint(200)); err != nil {
|
||||
return fmt.Errorf("failed to set rtspsrc latency property: %w", err)
|
||||
}
|
||||
|
||||
p.vTee, err = gst.NewElement("tee")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create video tee element: %w", err)
|
||||
}
|
||||
if err := p.vTee.SetProperty("name", "video_tee"); err != nil {
|
||||
return fmt.Errorf("failed to set video tee name property: %w", err)
|
||||
}
|
||||
|
||||
p.aTee, err = gst.NewElement("tee")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create audio tee element: %w", err)
|
||||
}
|
||||
if err := p.aTee.SetProperty("name", "audio_tee"); err != nil {
|
||||
return fmt.Errorf("failed to set audio tee name property: %w", err)
|
||||
}
|
||||
|
||||
if err := p.pipeline.AddMany(src, p.vTee, p.aTee); err != nil {
|
||||
return fmt.Errorf("failed to add elements to pipeline: %w", err)
|
||||
}
|
||||
|
||||
// Dynamically link the appropriate depayloader
|
||||
src.Connect("pad-added", func(element *gst.Element, pad *gst.Pad) {
|
||||
log := p.log.With("src", element.GetName(), "pad", pad.GetName())
|
||||
caps := pad.GetCurrentCaps()
|
||||
if caps == nil || caps.GetSize() == 0 {
|
||||
log.Error("Ignoring pad (no or empty caps)")
|
||||
return
|
||||
}
|
||||
|
||||
if caps.GetSize() > 1 {
|
||||
// Note: should be impossible
|
||||
log.Warn("Pad has multiple structures, using the first one", "caps", caps.String())
|
||||
}
|
||||
|
||||
structure := caps.GetStructureAt(0)
|
||||
if structure == nil || structure.Name() != "application/x-rtp" {
|
||||
log.Error("Ignoring pad (not RTP)", "caps", caps.String())
|
||||
return
|
||||
}
|
||||
|
||||
mediaRaw, err := structure.GetValue("media")
|
||||
media, ok := mediaRaw.(string)
|
||||
if err != nil || !ok {
|
||||
log.Error("Ignoring pad (no media field)", "caps", caps.String())
|
||||
return
|
||||
}
|
||||
|
||||
encodingRaw, err := structure.GetValue("encoding-name")
|
||||
encoding, ok := encodingRaw.(string)
|
||||
if err != nil || !ok {
|
||||
log.Error("Ignoring pad (no encoding-name field)", "caps", caps.String())
|
||||
return
|
||||
}
|
||||
|
||||
var chain []*gst.Element
|
||||
switch media {
|
||||
case "video":
|
||||
switch encoding {
|
||||
case "H264":
|
||||
chain, err = p.addStaticChain(pad, p.vTee.GetStaticPad("sink"), "rtph264depay", "h264parse")
|
||||
case "H265":
|
||||
chain, err = p.addStaticChain(pad, p.vTee.GetStaticPad("sink"), "rtph265depay", "h265parse")
|
||||
default:
|
||||
log.Error("Ignoring video pad (unsupported encoding)", "caps", caps.String())
|
||||
return
|
||||
}
|
||||
case "audio":
|
||||
switch encoding {
|
||||
case "MPEG4-GENERIC":
|
||||
chain, err = p.addStaticChain(pad, p.aTee.GetStaticPad("sink"), "rtpmp4gdepay", "aacparse")
|
||||
default:
|
||||
log.Error("Ignoring audio pad (unsupported encoding)", "caps", caps.String())
|
||||
return
|
||||
}
|
||||
default:
|
||||
log.Error("Ignoring pad (unsupported media)", "caps", caps.String())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("Failed to add depayloader", "caps", caps.String(), "error", err)
|
||||
return
|
||||
}
|
||||
for _, element := range chain {
|
||||
element.SyncStateWithParent()
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *CameraPipeline) addStaticChain(src *gst.Pad, sink *gst.Pad, chain ...string) ([]*gst.Element, error) {
|
||||
factories := make([]elementFactory, len(chain))
|
||||
for i, name := range chain {
|
||||
factories[i] = elementFactory{name: name, props: nil}
|
||||
}
|
||||
return p.addStaticChainProps(src, sink, factories...)
|
||||
}
|
||||
|
||||
type elementFactory struct {
|
||||
name string
|
||||
props map[string]any
|
||||
}
|
||||
|
||||
func (p *CameraPipeline) addStaticChainProps(src *gst.Pad, sink *gst.Pad, chain ...elementFactory) ([]*gst.Element, error) {
|
||||
chainElements := make([]*gst.Element, len(chain))
|
||||
last := src
|
||||
for i, factory := range chain {
|
||||
elem, err := gst.NewElement(factory.name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s: %w", factory.name, err)
|
||||
}
|
||||
for propName, propValue := range factory.props {
|
||||
if err := elem.SetProperty(propName, propValue); err != nil {
|
||||
// try to set enum property
|
||||
valueErr := setGlibValueProperty(elem, propName, propValue)
|
||||
if valueErr != nil {
|
||||
p.log.Warn("Failed to set property glib value", "name", propName, "value", propValue, "error", valueErr)
|
||||
return nil, fmt.Errorf("failed to set property %s on %s: %w", propName, factory.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := p.pipeline.Add(elem); err != nil {
|
||||
return nil, fmt.Errorf("failed to add %s to pipeline: %w", factory.name, err)
|
||||
}
|
||||
if last != nil {
|
||||
if res := last.Link(elem.GetStaticPad("sink")); res != gst.PadLinkOK {
|
||||
return nil, fmt.Errorf("failed to link %s to src: %s", factory.name, res)
|
||||
}
|
||||
}
|
||||
last = elem.GetStaticPad("src")
|
||||
chainElements[i] = elem
|
||||
}
|
||||
if sink != nil {
|
||||
if res := last.Link(sink); res != gst.PadLinkOK {
|
||||
return nil, fmt.Errorf("failed to link depayloader chain to sink: %s", res)
|
||||
}
|
||||
}
|
||||
return chainElements, nil
|
||||
}
|
||||
|
||||
func gstBufferToBytes(buf *gst.Buffer) ([]byte, error) {
|
||||
mapInfo := buf.Map(gst.MapRead)
|
||||
if mapInfo == nil {
|
||||
return nil, fmt.Errorf("failed to map buffer")
|
||||
}
|
||||
defer buf.Unmap()
|
||||
|
||||
data := make([]byte, mapInfo.Size())
|
||||
if _, err := io.ReadFull(mapInfo.Reader(), data); err != nil {
|
||||
return nil, fmt.Errorf("failed to read buffer data: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func setGlibValueProperty(elem *gst.Element, name string, value any) error {
|
||||
propType, err := elem.GetPropertyType(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get property type %s: %w", name, err)
|
||||
}
|
||||
|
||||
v, err := glib.ValueInit(propType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init gvalue for %s: %w", name, err)
|
||||
}
|
||||
|
||||
val := reflect.ValueOf(value)
|
||||
switch {
|
||||
case propType.IsA(glib.TYPE_ENUM) && val.CanInt():
|
||||
v.SetEnum(int(val.Int())) // safe because these config values are all done locally and guaranteed to be within int32
|
||||
case propType.IsA(glib.TYPE_ENUM) && val.CanUint():
|
||||
v.SetEnum(int(val.Uint())) // safe because these config values are all done locally and guaranteed to be within int32
|
||||
case propType.IsA(glib.TYPE_FLAGS) && val.CanInt():
|
||||
v.SetFlags(uint(val.Int())) // safe because these config values are all done locally and guaranteed to be within uint32
|
||||
case propType.IsA(glib.TYPE_FLAGS) && val.CanUint():
|
||||
v.SetFlags(uint(val.Uint())) // safe because these config values are all done locally and guaranteed to be within uint32
|
||||
default:
|
||||
return fmt.Errorf("unsupported property type for %s: %T (kind = %s), expected %s", name, value, val.Kind(), propType.Name())
|
||||
}
|
||||
if err := elem.SetPropertyValue(name, v); err != nil {
|
||||
return fmt.Errorf("failed to set glib value property %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
140
server/main.go
Normal file
140
server/main.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// cctv server entrypoint
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/go-gst/go-gst/gst"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
||||
|
||||
"git.koval.net/cyclane/cctv/server/config"
|
||||
"git.koval.net/cyclane/cctv/server/ingest"
|
||||
_ "git.koval.net/cyclane/cctv/server/migrations"
|
||||
)
|
||||
|
||||
var (
|
||||
ingestService *ingest.Ingest
|
||||
)
|
||||
|
||||
func loadEnvFile(filenames ...string) {
|
||||
for _, filename := range filenames {
|
||||
if err := godotenv.Load(filename); err == nil {
|
||||
log.Printf("Loaded environment variables from %s", filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func exportCollectionsJSON(e *core.BootstrapEvent) error {
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
collections, err := e.App.FindAllCollections()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to export collections: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.OpenFile("collections.json", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open collections.json: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
log.Printf("Failed to close collections.json: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
encoder := json.NewEncoder(f)
|
||||
encoder.SetIndent("", "\t")
|
||||
if err := encoder.Encode(collections); err != nil {
|
||||
return fmt.Errorf("failed to write collections to JSON: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
loadEnvFile(".env.local", ".env", ".env.default")
|
||||
config, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load configuration: %v", err)
|
||||
}
|
||||
|
||||
app := pocketbase.NewWithConfig(pocketbase.Config{
|
||||
DefaultDev: config.IsDev(),
|
||||
DefaultDataDir: config.DbDataDir,
|
||||
DefaultEncryptionEnv: config.DbEncryptionKey,
|
||||
})
|
||||
|
||||
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
|
||||
// enable auto creation of migration files when making collection changes in the Dashboard
|
||||
Automigrate: config.IsDev(),
|
||||
})
|
||||
|
||||
if config.IsDev() {
|
||||
app.OnBootstrap().BindFunc(exportCollectionsJSON)
|
||||
}
|
||||
var proxy *httputil.ReverseProxy
|
||||
if config.ExternalWebApp != "" {
|
||||
proxyURL, err := url.Parse(config.ExternalWebApp)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse external web app URL: %v", err)
|
||||
}
|
||||
proxy = httputil.NewSingleHostReverseProxy(proxyURL)
|
||||
proxy.ErrorLog = slog.NewLogLogger(app.Logger().With("svc", "proxy").Handler(), slog.LevelDebug)
|
||||
}
|
||||
ingestCtx, cancelIngestCtx := context.WithCancel(context.Background())
|
||||
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
||||
gst.Init(nil)
|
||||
ingestService = ingest.BeginIngest(ingestCtx, app)
|
||||
|
||||
if !config.IsDev() {
|
||||
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
||||
if strings.HasPrefix(e.Request.URL.Path, "/_/") {
|
||||
return e.NotFoundError("Page not found", nil)
|
||||
} else {
|
||||
return e.Next()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if proxy == nil {
|
||||
// TODO: serve bundled (static) files
|
||||
} else {
|
||||
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
||||
proxy.ServeHTTP(e.Response, e.Request)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
registerAPI(se)
|
||||
|
||||
return se.Next()
|
||||
})
|
||||
|
||||
app.OnTerminate().BindFunc(func(e *core.TerminateEvent) error {
|
||||
app.Logger().Info("Shutting down ingest service")
|
||||
cancelIngestCtx()
|
||||
return nil
|
||||
})
|
||||
|
||||
app.OnRecordCreateExecute().BindFunc(handleCameraUpdate)
|
||||
app.OnRecordUpdateExecute().BindFunc(handleCameraUpdate)
|
||||
|
||||
if err := app.Start(); err != nil {
|
||||
app.Logger().Info("Application terminated with error")
|
||||
cancelIngestCtx()
|
||||
log.Fatal(err)
|
||||
}
|
||||
ingestService.Wait()
|
||||
}
|
||||
19
server/migrations/1754019877_delete_default_users.go
Normal file
19
server/migrations/1754019877_delete_default_users.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return app.Delete(collection)
|
||||
}, func(_ core.App) error {
|
||||
// eh...
|
||||
return nil
|
||||
})
|
||||
}
|
||||
24
server/migrations/1754019890_users.go
Normal file
24
server/migrations/1754019890_users.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
const (
|
||||
colUsers = "users"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
users := core.NewAuthCollection(colUsers)
|
||||
users.Fields.Add(createdUpdatedFields()...)
|
||||
return app.Save(users)
|
||||
}, func(app core.App) error {
|
||||
users, err := app.FindCollectionByNameOrId(colUsers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return app.Delete(users)
|
||||
})
|
||||
}
|
||||
102
server/migrations/1754091129_cameras.go
Normal file
102
server/migrations/1754091129_cameras.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"git.koval.net/cyclane/cctv/server/models"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
const (
|
||||
colCameras = "cameras"
|
||||
colStreams = "streams"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
cameras := core.NewBaseCollection(colCameras)
|
||||
cameras.Fields.Add(
|
||||
&core.TextField{
|
||||
Name: "name",
|
||||
Required: true,
|
||||
},
|
||||
&core.TextField{
|
||||
Name: "onvif_host",
|
||||
},
|
||||
&core.TextField{
|
||||
Name: "username",
|
||||
Required: true,
|
||||
},
|
||||
&core.TextField{
|
||||
Name: models.CameraFPassword,
|
||||
Required: true,
|
||||
},
|
||||
)
|
||||
cameras.Fields.Add(createdUpdatedFields()...)
|
||||
cameras.CreateRule = types.Pointer("@request.auth.id != \"\"")
|
||||
cameras.ViewRule = types.Pointer("@request.auth.id != \"\"")
|
||||
cameras.ListRule = types.Pointer("@request.auth.id != \"\"")
|
||||
if err := app.Save(cameras); err != nil {
|
||||
return err
|
||||
}
|
||||
streams := core.NewBaseCollection(colStreams)
|
||||
streams.Fields.Add(
|
||||
&core.RelationField{
|
||||
Name: models.StreamFCamera,
|
||||
Required: true,
|
||||
CascadeDelete: true,
|
||||
CollectionId: cameras.Id,
|
||||
},
|
||||
&core.TextField{
|
||||
Name: "url",
|
||||
Required: true,
|
||||
},
|
||||
&core.NumberField{
|
||||
Name: "fps",
|
||||
Required: true,
|
||||
Min: types.Pointer[float64](0),
|
||||
},
|
||||
&core.NumberField{
|
||||
Name: "height",
|
||||
Required: true,
|
||||
OnlyInt: true,
|
||||
Min: types.Pointer[float64](0),
|
||||
},
|
||||
&core.NumberField{
|
||||
Name: "width",
|
||||
Required: true,
|
||||
OnlyInt: true,
|
||||
Min: types.Pointer[float64](0),
|
||||
},
|
||||
)
|
||||
streams.ViewRule = types.Pointer("@request.auth.id != \"\"")
|
||||
streams.ListRule = types.Pointer("@request.auth.id != \"\"")
|
||||
if err := app.Save(streams); err != nil {
|
||||
return err
|
||||
}
|
||||
cameras.Fields.Add(
|
||||
&core.RelationField{
|
||||
Name: models.CameraFRecordStream,
|
||||
CollectionId: streams.Id,
|
||||
},
|
||||
)
|
||||
return app.Save(cameras)
|
||||
}, func(app core.App) error {
|
||||
cameras, err := app.FindCollectionByNameOrId(colCameras)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
streams, err := app.FindCollectionByNameOrId(colStreams)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cameras.Fields.RemoveByName(models.CameraFRecordStream)
|
||||
if err := app.Save(cameras); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := app.Delete(streams); err != nil {
|
||||
return err
|
||||
}
|
||||
return app.Delete(cameras)
|
||||
})
|
||||
}
|
||||
17
server/migrations/utils.go
Normal file
17
server/migrations/utils.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package migrations
|
||||
|
||||
import "github.com/pocketbase/pocketbase/core"
|
||||
|
||||
func createdUpdatedFields() []core.Field {
|
||||
return []core.Field{
|
||||
&core.AutodateField{
|
||||
Name: "created",
|
||||
OnCreate: true,
|
||||
},
|
||||
&core.AutodateField{
|
||||
Name: "updated",
|
||||
OnCreate: true,
|
||||
OnUpdate: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
63
server/models/cameras.go
Normal file
63
server/models/cameras.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
const (
|
||||
CameraFPassword = "password"
|
||||
CameraFRecordStream = "record_stream"
|
||||
)
|
||||
|
||||
type Camera struct {
|
||||
core.BaseRecordProxy
|
||||
}
|
||||
|
||||
func (c *Camera) Name() string {
|
||||
return c.GetString("name")
|
||||
}
|
||||
|
||||
func (c *Camera) OnvifHost() string {
|
||||
return c.GetString("onvif_host")
|
||||
}
|
||||
|
||||
func (c *Camera) Username() string {
|
||||
return c.GetString("username")
|
||||
}
|
||||
|
||||
func (c *Camera) PasswordRaw() string {
|
||||
return c.GetString(CameraFPassword)
|
||||
}
|
||||
|
||||
func (c *Camera) Password(key string) (string, error) {
|
||||
password, err := security.Decrypt(c.PasswordRaw(), key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decrypt password: %w", err)
|
||||
}
|
||||
return string(password), nil
|
||||
}
|
||||
|
||||
func (c *Camera) SetPassword(password string, key string) error {
|
||||
encrypted, err := security.Encrypt([]byte(password), key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt password: %w", err)
|
||||
}
|
||||
c.Set(CameraFPassword, encrypted)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Camera) RecordStreamID() string {
|
||||
return c.GetString(CameraFRecordStream)
|
||||
}
|
||||
|
||||
func (c *Camera) RecordStream() *Stream {
|
||||
if r := c.ExpandedOne(CameraFRecordStream); r != nil {
|
||||
stream := &Stream{}
|
||||
stream.SetProxyRecord(r)
|
||||
return stream
|
||||
}
|
||||
return nil
|
||||
}
|
||||
60
server/models/streams.go
Normal file
60
server/models/streams.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package models
|
||||
|
||||
import "github.com/pocketbase/pocketbase/core"
|
||||
|
||||
const (
|
||||
StreamFCamera = "camera"
|
||||
)
|
||||
|
||||
type Stream struct {
|
||||
core.BaseRecordProxy
|
||||
}
|
||||
|
||||
func (s *Stream) CameraID() string {
|
||||
return s.GetString(StreamFCamera)
|
||||
}
|
||||
|
||||
func (s *Stream) SetCameraID(id string) {
|
||||
s.Set("camera", id)
|
||||
}
|
||||
|
||||
func (s *Stream) Camera() *Camera {
|
||||
if r := s.ExpandedOne(StreamFCamera); r != nil {
|
||||
camera := &Camera{}
|
||||
camera.SetProxyRecord(r)
|
||||
return camera
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Stream) URL() string {
|
||||
return s.GetString("url")
|
||||
}
|
||||
|
||||
func (s *Stream) SetURL(url string) {
|
||||
s.Set("url", url)
|
||||
}
|
||||
|
||||
func (s *Stream) FPS() float64 {
|
||||
return s.GetFloat("fps")
|
||||
}
|
||||
|
||||
func (s *Stream) SetFPS(fps float64) {
|
||||
s.Set("fps", fps)
|
||||
}
|
||||
|
||||
func (s *Stream) Height() int {
|
||||
return s.GetInt("height")
|
||||
}
|
||||
|
||||
func (s *Stream) SetHeight(height int) {
|
||||
s.Set("height", height)
|
||||
}
|
||||
|
||||
func (s *Stream) Width() int {
|
||||
return s.GetInt("width")
|
||||
}
|
||||
|
||||
func (s *Stream) SetWidth(width int) {
|
||||
s.Set("width", width)
|
||||
}
|
||||
1
web/.env.example
Normal file
1
web/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
PUBLIC_PROD_AUTH_STORE=false
|
||||
1
web/.eslintignore
Normal file
1
web/.eslintignore
Normal file
@@ -0,0 +1 @@
|
||||
src/lib/pocketbase-types.ts
|
||||
28
web/.gitignore
vendored
Normal file
28
web/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
# Playwright
|
||||
test-results
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
1
web/.npmrc
Normal file
1
web/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
10
web/.prettierignore
Normal file
10
web/.prettierignore
Normal file
@@ -0,0 +1,10 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
|
||||
# Miscellaneous
|
||||
/static/
|
||||
src/lib/pocketbase-types.ts
|
||||
16
web/.prettierrc
Normal file
16
web/.prettierrc
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tailwindStylesheet": "./src/routes/layout.css"
|
||||
}
|
||||
14
web/.storybook/main.ts
Normal file
14
web/.storybook/main.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { StorybookConfig } from "@storybook/sveltekit";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|ts|svelte)"],
|
||||
addons: [
|
||||
"@storybook/addon-svelte-csf",
|
||||
"@chromatic-com/storybook",
|
||||
"@storybook/addon-vitest",
|
||||
"@storybook/addon-a11y",
|
||||
"@storybook/addon-docs"
|
||||
],
|
||||
framework: "@storybook/sveltekit"
|
||||
};
|
||||
export default config;
|
||||
21
web/.storybook/preview.ts
Normal file
21
web/.storybook/preview.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Preview } from "@storybook/sveltekit";
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i
|
||||
}
|
||||
},
|
||||
|
||||
a11y: {
|
||||
// 'todo' - show a11y violations in the test UI only
|
||||
// 'error' - fail CI on a11y violations
|
||||
// 'off' - skip a11y checks entirely
|
||||
test: "todo"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default preview;
|
||||
7
web/.vscode/settings.json
vendored
Normal file
7
web/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "always"
|
||||
}
|
||||
}
|
||||
20
web/components.json
Normal file
20
web/components.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"tailwind": {
|
||||
"css": "src/routes/layout.css",
|
||||
"baseColor": "mist"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry",
|
||||
"style": "luma",
|
||||
"iconLibrary": "remixicon",
|
||||
"menuColor": "default",
|
||||
"menuAccent": "bold"
|
||||
}
|
||||
42
web/eslint.config.js
Normal file
42
web/eslint.config.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
|
||||
import storybook from "eslint-plugin-storybook";
|
||||
|
||||
import prettier from "eslint-config-prettier";
|
||||
import path from "node:path";
|
||||
import { includeIgnoreFile } from "@eslint/compat";
|
||||
import js from "@eslint/js";
|
||||
import svelte from "eslint-plugin-svelte";
|
||||
import { defineConfig } from "eslint/config";
|
||||
import globals from "globals";
|
||||
import ts from "typescript-eslint";
|
||||
import svelteConfig from "./svelte.config.js";
|
||||
|
||||
const gitignorePath = path.resolve(import.meta.dirname, ".gitignore");
|
||||
|
||||
export default defineConfig(
|
||||
includeIgnoreFile(gitignorePath),
|
||||
js.configs.recommended,
|
||||
ts.configs.recommended,
|
||||
svelte.configs.recommended,
|
||||
prettier,
|
||||
svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: { globals: { ...globals.browser, ...globals.node } },
|
||||
rules: {
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
"no-undef": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
extraFileExtensions: [".svelte"],
|
||||
parser: ts.parser,
|
||||
svelteConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
8100
web/package-lock.json
generated
Normal file
8100
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
75
web/package.json
Normal file
75
web/package.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write .",
|
||||
"test:unit": "vitest",
|
||||
"test": "npm run test:unit -- --run && npm run test:e2e",
|
||||
"test:e2e": "playwright test",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"gen:types": "pocketbase-typegen -j ../server/collections.json -o ./src/lib/pocketbase-types.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^5.1.1",
|
||||
"@eslint/compat": "^2.0.3",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@fontsource-variable/outfit": "^5.2.8",
|
||||
"@internationalized/date": "^3.12.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@storybook/addon-a11y": "^10.3.3",
|
||||
"@storybook/addon-docs": "^10.3.3",
|
||||
"@storybook/addon-svelte-csf": "^5.1.2",
|
||||
"@storybook/addon-vitest": "^10.3.3",
|
||||
"@storybook/sveltekit": "^10.3.3",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/node": "^22",
|
||||
"@vitest/browser-playwright": "^4.1.0",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"bits-ui": "^2.16.5",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint": "^10.0.3",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-storybook": "^10.3.3",
|
||||
"eslint-plugin-svelte": "^3.15.2",
|
||||
"globals": "^17.4.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"playwright": "^1.58.2",
|
||||
"pocketbase-typegen": "^1.3.3",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.4.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"remixicon-svelte": "^0.0.5",
|
||||
"shadcn-svelte": "^1.2.6",
|
||||
"storybook": "^10.3.3",
|
||||
"svelte": "^5.54.0",
|
||||
"svelte-check": "^4.4.2",
|
||||
"svelte-sonner": "^1.1.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vaul-svelte": "^1.0.0-next.7",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.1.0",
|
||||
"vitest-browser-svelte": "^2.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"pocketbase": "^0.26.8"
|
||||
}
|
||||
}
|
||||
6
web/playwright.config.ts
Normal file
6
web/playwright.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
webServer: { command: "npm run build && npm run preview", port: 4173 },
|
||||
testMatch: "**/*.e2e.{ts,js}"
|
||||
});
|
||||
13
web/src/app.d.ts
vendored
Normal file
13
web/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
11
web/src/app.html
Normal file
11
web/src/app.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
1
web/src/lib/assets/favicon.svg
Normal file
1
web/src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
orientation = "vertical",
|
||||
...restProps
|
||||
}: ComponentProps<typeof Separator> = $props();
|
||||
</script>
|
||||
|
||||
<Separator
|
||||
bind:ref
|
||||
data-slot="button-group-separator"
|
||||
{orientation}
|
||||
class={cn(
|
||||
"bg-input relative self-stretch data-[orientation=horizontal]:mx-px data-[orientation=horizontal]:w-auto data-[orientation=vertical]:my-px data-[orientation=vertical]:h-auto",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
child,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
child?: Snippet<[{ props: Record<string, unknown> }]>;
|
||||
} = $props();
|
||||
|
||||
const mergedProps = $derived({
|
||||
...restProps,
|
||||
class: cn("bg-muted gap-2 rounded-4xl border px-2.5 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none", className),
|
||||
"data-slot": "button-group-text",
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div bind:this={ref} {...mergedProps}>
|
||||
{@render mergedProps.children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
46
web/src/lib/components/ui/button-group/button-group.svelte
Normal file
46
web/src/lib/components/ui/button-group/button-group.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts" module>
|
||||
import { tv, type VariantProps } from "tailwind-variants";
|
||||
|
||||
export const buttonGroupVariants = tv({
|
||||
base: "has-[>[data-variant=outline]]:[&>input]:border-border has-[>[data-variant=outline]]:[&>input:focus-visible]:border-ring has-[>[data-variant=outline]]:*:data-[slot=input-group]:border-border has-[>[data-variant=outline]]:[&>[data-slot=input-group]:has(:focus-visible)]:border-ring has-[>[data-variant=outline]]:*:data-[slot=select-trigger]:border-border has-[>[data-variant=outline]]:[&>[data-slot=select-trigger]:focus-visible]:border-ring has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-4xl flex w-fit items-stretch [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-4xl! [&>[data-slot]]:rounded-r-none [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
|
||||
vertical:
|
||||
"[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-4xl! flex-col [&>[data-slot]]:rounded-b-none [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
});
|
||||
|
||||
export type ButtonGroupOrientation = VariantProps<typeof buttonGroupVariants>["orientation"];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
orientation = "horizontal",
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
orientation?: ButtonGroupOrientation;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
class={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
15
web/src/lib/components/ui/button-group/index.ts
Normal file
15
web/src/lib/components/ui/button-group/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import Root, { buttonGroupVariants, type ButtonGroupOrientation } from "./button-group.svelte";
|
||||
import Text from "./button-group-text.svelte";
|
||||
import Separator from "./button-group-separator.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Text,
|
||||
Separator,
|
||||
buttonGroupVariants,
|
||||
type ButtonGroupOrientation,
|
||||
//
|
||||
Root as ButtonGroup,
|
||||
Text as ButtonGroupText,
|
||||
Separator as ButtonGroupSeparator,
|
||||
};
|
||||
82
web/src/lib/components/ui/button/button.svelte
Normal file
82
web/src/lib/components/ui/button/button.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts" module>
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-4xl border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:hover:bg-input/30 aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-transparent",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
|
||||
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||
|
||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||
WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let {
|
||||
class: className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
ref = $bindable(null),
|
||||
href = undefined,
|
||||
type = "button",
|
||||
disabled,
|
||||
children,
|
||||
...restProps
|
||||
}: ButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
href={disabled ? undefined : href}
|
||||
aria-disabled={disabled}
|
||||
role={disabled ? "link" : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
{disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
17
web/src/lib/components/ui/button/index.ts
Normal file
17
web/src/lib/components/ui/button/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import Root, {
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
buttonVariants,
|
||||
} from "./button.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
type ButtonProps as Props,
|
||||
//
|
||||
Root as Button,
|
||||
buttonVariants,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
};
|
||||
11
web/src/lib/components/ui/dialog/dialog-close.svelte
Normal file
11
web/src/lib/components/ui/dialog/dialog-close.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
type = "button",
|
||||
...restProps
|
||||
}: DialogPrimitive.CloseProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {type} {...restProps} />
|
||||
48
web/src/lib/components/ui/dialog/dialog-content.svelte
Normal file
48
web/src/lib/components/ui/dialog/dialog-content.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import DialogPortal from "./dialog-portal.svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
import * as Dialog from "./index.js";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import RiCloseLine from 'remixicon-svelte/icons/close-line';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
portalProps,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
|
||||
children: Snippet;
|
||||
showCloseButton?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DialogPortal {...portalProps}>
|
||||
<Dialog.Overlay />
|
||||
<DialogPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="dialog-content"
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/5 dark:ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-6 rounded-4xl p-6 text-sm shadow-xl ring-1 duration-100 sm:max-w-md fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if showCloseButton}
|
||||
<DialogPrimitive.Close data-slot="dialog-close">
|
||||
{#snippet child({ props })}
|
||||
<Button variant="ghost" class="bg-secondary absolute top-4 right-4" size="icon-sm" {...props}>
|
||||
<RiCloseLine />
|
||||
<span class="sr-only">Close</span>
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DialogPrimitive.Close>
|
||||
{/if}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
17
web/src/lib/components/ui/dialog/dialog-description.svelte
Normal file
17
web/src/lib/components/ui/dialog/dialog-description.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.DescriptionProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Description
|
||||
bind:ref
|
||||
data-slot="dialog-description"
|
||||
class={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
32
web/src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
32
web/src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
showCloseButton = false,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
showCloseButton?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dialog-footer"
|
||||
class={cn("gap-2 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if showCloseButton}
|
||||
<DialogPrimitive.Close>
|
||||
{#snippet child({ props })}
|
||||
<Button variant="outline" {...props}>Close</Button>
|
||||
{/snippet}
|
||||
</DialogPrimitive.Close>
|
||||
{/if}
|
||||
</div>
|
||||
20
web/src/lib/components/ui/dialog/dialog-header.svelte
Normal file
20
web/src/lib/components/ui/dialog/dialog-header.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dialog-header"
|
||||
class={cn("gap-1.5 flex flex-col", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
17
web/src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
17
web/src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.OverlayProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Overlay
|
||||
bind:ref
|
||||
data-slot="dialog-overlay"
|
||||
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-sm fixed inset-0 isolate z-50", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
7
web/src/lib/components/ui/dialog/dialog-portal.svelte
Normal file
7
web/src/lib/components/ui/dialog/dialog-portal.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: DialogPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Portal {...restProps} />
|
||||
17
web/src/lib/components/ui/dialog/dialog-title.svelte
Normal file
17
web/src/lib/components/ui/dialog/dialog-title.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.TitleProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Title
|
||||
bind:ref
|
||||
data-slot="dialog-title"
|
||||
class={cn("text-base leading-none font-medium", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
11
web/src/lib/components/ui/dialog/dialog-trigger.svelte
Normal file
11
web/src/lib/components/ui/dialog/dialog-trigger.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
type = "button",
|
||||
...restProps
|
||||
}: DialogPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {type} {...restProps} />
|
||||
7
web/src/lib/components/ui/dialog/dialog.svelte
Normal file
7
web/src/lib/components/ui/dialog/dialog.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Root bind:open {...restProps} />
|
||||
34
web/src/lib/components/ui/dialog/index.ts
Normal file
34
web/src/lib/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import Root from "./dialog.svelte";
|
||||
import Portal from "./dialog-portal.svelte";
|
||||
import Title from "./dialog-title.svelte";
|
||||
import Footer from "./dialog-footer.svelte";
|
||||
import Header from "./dialog-header.svelte";
|
||||
import Overlay from "./dialog-overlay.svelte";
|
||||
import Content from "./dialog-content.svelte";
|
||||
import Description from "./dialog-description.svelte";
|
||||
import Trigger from "./dialog-trigger.svelte";
|
||||
import Close from "./dialog-close.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Title,
|
||||
Portal,
|
||||
Footer,
|
||||
Header,
|
||||
Trigger,
|
||||
Overlay,
|
||||
Content,
|
||||
Description,
|
||||
Close,
|
||||
//
|
||||
Root as Dialog,
|
||||
Title as DialogTitle,
|
||||
Portal as DialogPortal,
|
||||
Footer as DialogFooter,
|
||||
Header as DialogHeader,
|
||||
Trigger as DialogTrigger,
|
||||
Overlay as DialogOverlay,
|
||||
Content as DialogContent,
|
||||
Description as DialogDescription,
|
||||
Close as DialogClose,
|
||||
};
|
||||
7
web/src/lib/components/ui/drawer/drawer-close.svelte
Normal file
7
web/src/lib/components/ui/drawer/drawer-close.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DrawerPrimitive.CloseProps = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Close bind:ref data-slot="drawer-close" {...restProps} />
|
||||
33
web/src/lib/components/ui/drawer/drawer-content.svelte
Normal file
33
web/src/lib/components/ui/drawer/drawer-content.svelte
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||
import DrawerPortal from "./drawer-portal.svelte";
|
||||
import DrawerOverlay from "./drawer-overlay.svelte";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
import type { WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
portalProps,
|
||||
children,
|
||||
...restProps
|
||||
}: DrawerPrimitive.ContentProps & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DrawerPortal>>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPortal {...portalProps}>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="drawer-content"
|
||||
class={cn("before:bg-popover before:border-border relative flex h-auto flex-col bg-transparent p-4 text-sm before:absolute before:inset-2 before:-z-10 before:rounded-4xl before:border before:shadow-xl data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm group/drawer-content fixed z-50", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<div
|
||||
class="bg-muted mx-auto mt-4 hidden h-1.5 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block bg-muted mx-auto hidden shrink-0 group-data-[vaul-drawer-direction=bottom]/drawer-content:block"
|
||||
></div>
|
||||
{@render children?.()}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
17
web/src/lib/components/ui/drawer/drawer-description.svelte
Normal file
17
web/src/lib/components/ui/drawer/drawer-description.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DrawerPrimitive.DescriptionProps = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Description
|
||||
bind:ref
|
||||
data-slot="drawer-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
20
web/src/lib/components/ui/drawer/drawer-footer.svelte
Normal file
20
web/src/lib/components/ui/drawer/drawer-footer.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="drawer-footer"
|
||||
class={cn("gap-2 p-4 mt-auto flex flex-col", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
20
web/src/lib/components/ui/drawer/drawer-header.svelte
Normal file
20
web/src/lib/components/ui/drawer/drawer-header.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="drawer-header"
|
||||
class={cn("gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left flex flex-col", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
12
web/src/lib/components/ui/drawer/drawer-nested.svelte
Normal file
12
web/src/lib/components/ui/drawer/drawer-nested.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||
|
||||
let {
|
||||
shouldScaleBackground = true,
|
||||
open = $bindable(false),
|
||||
activeSnapPoint = $bindable(null),
|
||||
...restProps
|
||||
}: DrawerPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.NestedRoot {shouldScaleBackground} bind:open bind:activeSnapPoint {...restProps} />
|
||||
17
web/src/lib/components/ui/drawer/drawer-overlay.svelte
Normal file
17
web/src/lib/components/ui/drawer/drawer-overlay.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DrawerPrimitive.OverlayProps = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Overlay
|
||||
bind:ref
|
||||
data-slot="drawer-overlay"
|
||||
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/30 supports-backdrop-filter:backdrop-blur-sm fixed inset-0 z-50", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
7
web/src/lib/components/ui/drawer/drawer-portal.svelte
Normal file
7
web/src/lib/components/ui/drawer/drawer-portal.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||
|
||||
let { ...restProps }: DrawerPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Portal {...restProps} />
|
||||
17
web/src/lib/components/ui/drawer/drawer-title.svelte
Normal file
17
web/src/lib/components/ui/drawer/drawer-title.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DrawerPrimitive.TitleProps = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Title
|
||||
bind:ref
|
||||
data-slot="drawer-title"
|
||||
class={cn("text-foreground text-base font-medium", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
7
web/src/lib/components/ui/drawer/drawer-trigger.svelte
Normal file
7
web/src/lib/components/ui/drawer/drawer-trigger.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DrawerPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Trigger bind:ref data-slot="drawer-trigger" {...restProps} />
|
||||
12
web/src/lib/components/ui/drawer/drawer.svelte
Normal file
12
web/src/lib/components/ui/drawer/drawer.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||
|
||||
let {
|
||||
shouldScaleBackground = true,
|
||||
open = $bindable(false),
|
||||
activeSnapPoint = $bindable(null),
|
||||
...restProps
|
||||
}: DrawerPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Root {shouldScaleBackground} bind:open bind:activeSnapPoint {...restProps} />
|
||||
38
web/src/lib/components/ui/drawer/index.ts
Normal file
38
web/src/lib/components/ui/drawer/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import Root from "./drawer.svelte";
|
||||
import Content from "./drawer-content.svelte";
|
||||
import Description from "./drawer-description.svelte";
|
||||
import Overlay from "./drawer-overlay.svelte";
|
||||
import Footer from "./drawer-footer.svelte";
|
||||
import Header from "./drawer-header.svelte";
|
||||
import Title from "./drawer-title.svelte";
|
||||
import NestedRoot from "./drawer-nested.svelte";
|
||||
import Close from "./drawer-close.svelte";
|
||||
import Trigger from "./drawer-trigger.svelte";
|
||||
import Portal from "./drawer-portal.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
NestedRoot,
|
||||
Content,
|
||||
Description,
|
||||
Overlay,
|
||||
Footer,
|
||||
Header,
|
||||
Title,
|
||||
Trigger,
|
||||
Portal,
|
||||
Close,
|
||||
|
||||
//
|
||||
Root as Drawer,
|
||||
NestedRoot as DrawerNestedRoot,
|
||||
Content as DrawerContent,
|
||||
Description as DrawerDescription,
|
||||
Overlay as DrawerOverlay,
|
||||
Footer as DrawerFooter,
|
||||
Header as DrawerHeader,
|
||||
Title as DrawerTitle,
|
||||
Trigger as DrawerTrigger,
|
||||
Portal as DrawerPortal,
|
||||
Close as DrawerClose,
|
||||
};
|
||||
20
web/src/lib/components/ui/field/field-content.svelte
Normal file
20
web/src/lib/components/ui/field/field-content.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="field-content"
|
||||
class={cn("gap-1 group/field-content flex flex-1 flex-col leading-snug", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
25
web/src/lib/components/ui/field/field-description.svelte
Normal file
25
web/src/lib/components/ui/field/field-description.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
||||
</script>
|
||||
|
||||
<p
|
||||
bind:this={ref}
|
||||
data-slot="field-description"
|
||||
class={cn(
|
||||
"text-muted-foreground text-left text-sm [[data-variant=legend]+&]:-mt-1.5 leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||
"last:mt-0 nth-last-2:-mt-1",
|
||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</p>
|
||||
58
web/src/lib/components/ui/field/field-error.svelte
Normal file
58
web/src/lib/components/ui/field/field-error.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
errors,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
children?: Snippet;
|
||||
errors?: { message?: string }[];
|
||||
} = $props();
|
||||
|
||||
const hasContent = $derived.by(() => {
|
||||
// has slotted error
|
||||
if (children) return true;
|
||||
|
||||
// no errors
|
||||
if (!errors || errors.length === 0) return false;
|
||||
|
||||
// has an error but no message
|
||||
if (errors.length === 1 && !errors[0]?.message) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const isMultipleErrors = $derived(errors && errors.length > 1);
|
||||
const singleErrorMessage = $derived(errors && errors.length === 1 && errors[0]?.message);
|
||||
</script>
|
||||
|
||||
{#if hasContent}
|
||||
<div
|
||||
bind:this={ref}
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
class={cn("text-destructive text-sm font-normal", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else if singleErrorMessage}
|
||||
{singleErrorMessage}
|
||||
{:else if isMultipleErrors}
|
||||
<ul class="ml-4 flex list-disc flex-col gap-1">
|
||||
{#each errors ?? [] as error, index (index)}
|
||||
{#if error?.message}
|
||||
<li>{error.message}</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
23
web/src/lib/components/ui/field/field-group.svelte
Normal file
23
web/src/lib/components/ui/field/field-group.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="field-group"
|
||||
class={cn(
|
||||
"gap-7 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4 group/field-group @container/field-group flex w-full flex-col",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
25
web/src/lib/components/ui/field/field-label.svelte
Normal file
25
web/src/lib/components/ui/field/field-label.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { Label } from "$lib/components/ui/label/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: ComponentProps<typeof Label> = $props();
|
||||
</script>
|
||||
|
||||
<Label
|
||||
bind:ref
|
||||
data-slot="field-label"
|
||||
class={cn(
|
||||
"has-data-checked:bg-input/30 gap-2 group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-2xl has-[>[data-slot=field]]:border *:data-[slot=field]:p-4 group/field-label peer/field-label flex w-fit leading-snug",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</Label>
|
||||
24
web/src/lib/components/ui/field/field-legend.svelte
Normal file
24
web/src/lib/components/ui/field/field-legend.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
variant = "legend",
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLLegendElement>> & {
|
||||
variant?: "legend" | "label";
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<legend
|
||||
bind:this={ref}
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
class={cn("mb-3 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</legend>
|
||||
35
web/src/lib/components/ui/field/field-separator.svelte
Normal file
35
web/src/lib/components/ui/field/field-separator.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
const hasContent = $derived(!!children);
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="field-separator"
|
||||
data-content={hasContent}
|
||||
class={cn("-my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2 relative", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<Separator class="absolute inset-0 top-1/2" />
|
||||
{#if children}
|
||||
<span
|
||||
class="text-muted-foreground px-2 bg-background relative mx-auto block w-fit"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{@render children()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
20
web/src/lib/components/ui/field/field-set.svelte
Normal file
20
web/src/lib/components/ui/field/field-set.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLFieldsetAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLFieldsetAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<fieldset
|
||||
bind:this={ref}
|
||||
data-slot="field-set"
|
||||
class={cn("gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</fieldset>
|
||||
20
web/src/lib/components/ui/field/field-title.svelte
Normal file
20
web/src/lib/components/ui/field/field-title.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="field-label"
|
||||
class={cn("gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50 flex w-fit items-center leading-snug", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
47
web/src/lib/components/ui/field/field.svelte
Normal file
47
web/src/lib/components/ui/field/field.svelte
Normal file
@@ -0,0 +1,47 @@
|
||||
<script lang="ts" module>
|
||||
import { tv, type VariantProps } from "tailwind-variants";
|
||||
|
||||
export const fieldVariants = tv({
|
||||
base: "data-[invalid=true]:text-destructive gap-3 group/field flex w-full",
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: "cn-field-orientation-vertical flex-col [&>*]:w-full [&>.sr-only]:w-auto",
|
||||
horizontal:
|
||||
"cn-field-orientation-horizontal flex-row items-center has-[>[data-slot=field-content]]:items-start [&>[data-slot=field-label]]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
responsive:
|
||||
"cn-field-orientation-responsive flex-col @md/field-group:flex-row @md/field-group:items-center @md/field-group:has-[>[data-slot=field-content]]:items-start [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto @md/field-group:[&>[data-slot=field-label]]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
});
|
||||
|
||||
export type FieldOrientation = VariantProps<typeof fieldVariants>["orientation"];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
orientation = "vertical",
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
orientation?: FieldOrientation;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
class={cn(fieldVariants({ orientation }), className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
33
web/src/lib/components/ui/field/index.ts
Normal file
33
web/src/lib/components/ui/field/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import Field from "./field.svelte";
|
||||
import Set from "./field-set.svelte";
|
||||
import Legend from "./field-legend.svelte";
|
||||
import Group from "./field-group.svelte";
|
||||
import Content from "./field-content.svelte";
|
||||
import Label from "./field-label.svelte";
|
||||
import Title from "./field-title.svelte";
|
||||
import Description from "./field-description.svelte";
|
||||
import Separator from "./field-separator.svelte";
|
||||
import Error from "./field-error.svelte";
|
||||
|
||||
export {
|
||||
Field,
|
||||
Set,
|
||||
Legend,
|
||||
Group,
|
||||
Content,
|
||||
Label,
|
||||
Title,
|
||||
Description,
|
||||
Separator,
|
||||
Error,
|
||||
//
|
||||
Set as FieldSet,
|
||||
Legend as FieldLegend,
|
||||
Group as FieldGroup,
|
||||
Content as FieldContent,
|
||||
Label as FieldLabel,
|
||||
Title as FieldTitle,
|
||||
Description as FieldDescription,
|
||||
Separator as FieldSeparator,
|
||||
Error as FieldError,
|
||||
};
|
||||
7
web/src/lib/components/ui/input/index.ts
Normal file
7
web/src/lib/components/ui/input/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./input.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
};
|
||||
48
web/src/lib/components/ui/input/input.svelte
Normal file
48
web/src/lib/components/ui/input/input.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
|
||||
|
||||
type Props = WithElementRef<
|
||||
Omit<HTMLInputAttributes, "type"> &
|
||||
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
|
||||
>;
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
type,
|
||||
files = $bindable(),
|
||||
class: className,
|
||||
"data-slot": dataSlot = "input",
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if type === "file"}
|
||||
<input
|
||||
bind:this={ref}
|
||||
data-slot={dataSlot}
|
||||
class={cn(
|
||||
"bg-input/50 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-3xl border border-transparent px-3 py-1 text-base transition-[color,box-shadow,background-color] file:h-7 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
type="file"
|
||||
bind:files
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
bind:this={ref}
|
||||
data-slot={dataSlot}
|
||||
class={cn(
|
||||
"bg-input/50 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-3xl border border-transparent px-3 py-1 text-base transition-[color,box-shadow,background-color] file:h-7 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{type}
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
||||
{/if}
|
||||
7
web/src/lib/components/ui/label/index.ts
Normal file
7
web/src/lib/components/ui/label/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
};
|
||||
20
web/src/lib/components/ui/label/label.svelte
Normal file
20
web/src/lib/components/ui/label/label.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Label as LabelPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: LabelPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<LabelPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="label"
|
||||
class={cn(
|
||||
"gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
7
web/src/lib/components/ui/separator/index.ts
Normal file
7
web/src/lib/components/ui/separator/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./separator.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Separator,
|
||||
};
|
||||
23
web/src/lib/components/ui/separator/separator.svelte
Normal file
23
web/src/lib/components/ui/separator/separator.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { Separator as SeparatorPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
"data-slot": dataSlot = "separator",
|
||||
...restProps
|
||||
}: SeparatorPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<SeparatorPrimitive.Root
|
||||
bind:ref
|
||||
data-slot={dataSlot}
|
||||
class={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
|
||||
// this is different in shadcn/ui but self-stretch breaks things for us
|
||||
"data-[orientation=vertical]:h-full",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
1
web/src/lib/components/ui/sonner/index.ts
Normal file
1
web/src/lib/components/ui/sonner/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Toaster } from "./sonner.svelte";
|
||||
34
web/src/lib/components/ui/sonner/sonner.svelte
Normal file
34
web/src/lib/components/ui/sonner/sonner.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
|
||||
import { mode } from "mode-watcher";
|
||||
import RiLoaderLine from 'remixicon-svelte/icons/loader-line';
|
||||
import RiCheckboxCircleLine from 'remixicon-svelte/icons/checkbox-circle-line';
|
||||
import RiErrorWarningLine from 'remixicon-svelte/icons/error-warning-line';
|
||||
import RiInformationLine from 'remixicon-svelte/icons/information-line';
|
||||
import RiCloseCircleLine from 'remixicon-svelte/icons/close-circle-line';
|
||||
|
||||
let { ...restProps }: SonnerProps = $props();
|
||||
</script>
|
||||
|
||||
<Sonner
|
||||
theme={mode.current}
|
||||
class="toaster group"
|
||||
style="--normal-bg: var(--color-popover); --normal-text: var(--color-popover-foreground); --normal-border: var(--color-border);"
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet loadingIcon()}
|
||||
<RiLoaderLine class="size-4 animate-spin" />
|
||||
{/snippet}
|
||||
{#snippet successIcon()}
|
||||
<RiCheckboxCircleLine class="size-4" />
|
||||
{/snippet}
|
||||
{#snippet errorIcon()}
|
||||
<RiErrorWarningLine class="size-4" />
|
||||
{/snippet}
|
||||
{#snippet infoIcon()}
|
||||
<RiInformationLine class="size-4" />
|
||||
{/snippet}
|
||||
{#snippet warningIcon()}
|
||||
<RiCloseCircleLine class="size-4" />
|
||||
{/snippet}
|
||||
</Sonner>
|
||||
1
web/src/lib/components/ui/spinner/index.ts
Normal file
1
web/src/lib/components/ui/spinner/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Spinner } from "./spinner.svelte";
|
||||
18
web/src/lib/components/ui/spinner/spinner.svelte
Normal file
18
web/src/lib/components/ui/spinner/spinner.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import RiLoaderLine from 'remixicon-svelte/icons/loader-line';
|
||||
import type { SVGAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
class: className,
|
||||
role = "status",
|
||||
// we add name, color, and stroke for compatibility with different icon libraries props
|
||||
name,
|
||||
color,
|
||||
stroke,
|
||||
"aria-label": ariaLabel = "Loading",
|
||||
...restProps
|
||||
}: SVGAttributes<SVGSVGElement> = $props();
|
||||
</script>
|
||||
|
||||
<RiLoaderLine {role} name={name === null ? undefined : name} color={color === null ? undefined : color} stroke={stroke === null ? undefined : stroke} aria-label={ariaLabel} class={cn("size-4 animate-spin", className)} {...restProps} />
|
||||
20
web/src/lib/index.ts
Normal file
20
web/src/lib/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { dev } from "$app/environment";
|
||||
import * as env from "$env/static/public";
|
||||
import PocketBase, { LocalAuthStore } from "pocketbase";
|
||||
import type { TypedPocketBase } from "./pocketbase-types";
|
||||
export type { ClientResponseError } from "pocketbase";
|
||||
|
||||
const forcePublicAuthStore =
|
||||
"PUBLIC_PROD_AUTH_STORE" in env &&
|
||||
typeof env.PUBLIC_PROD_AUTH_STORE === "string" &&
|
||||
["true", "t", "yes", "y", "1"].includes(env.PUBLIC_PROD_AUTH_STORE.toLowerCase());
|
||||
export const pb = new PocketBase(
|
||||
undefined,
|
||||
dev && !forcePublicAuthStore
|
||||
? new LocalAuthStore("__pb_superuser_auth__") // When in development, and not forcing the prod auth store, we just hijack the PocketBase Admin UI session
|
||||
: new LocalAuthStore("cctv_auth")
|
||||
) as TypedPocketBase;
|
||||
|
||||
export function tw(s: TemplateStringsArray, ...args: unknown[]) {
|
||||
return s.reduce((acc, str, idx) => acc + str + (args[idx] || ""), "");
|
||||
}
|
||||
226
web/src/lib/pocketbase-types.ts
Normal file
226
web/src/lib/pocketbase-types.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* This file was @generated using pocketbase-typegen
|
||||
*/
|
||||
|
||||
import type PocketBase from 'pocketbase'
|
||||
import type { RecordService } from 'pocketbase'
|
||||
|
||||
export enum Collections {
|
||||
Authorigins = "_authOrigins",
|
||||
Externalauths = "_externalAuths",
|
||||
Mfas = "_mfas",
|
||||
Otps = "_otps",
|
||||
Superusers = "_superusers",
|
||||
Cameras = "cameras",
|
||||
Streams = "streams",
|
||||
Users = "users",
|
||||
}
|
||||
|
||||
// Alias types for improved usability
|
||||
export type IsoDateString = string
|
||||
export type IsoAutoDateString = string & { readonly autodate: unique symbol }
|
||||
export type RecordIdString = string
|
||||
export type FileNameString = string & { readonly filename: unique symbol }
|
||||
export type HTMLString = string
|
||||
|
||||
type ExpandType<T> = unknown extends T
|
||||
? T extends unknown
|
||||
? { expand?: unknown }
|
||||
: { expand: T }
|
||||
: { expand: T }
|
||||
|
||||
// System fields
|
||||
export type BaseSystemFields<T = unknown> = {
|
||||
id: RecordIdString
|
||||
collectionId: string
|
||||
collectionName: Collections
|
||||
} & ExpandType<T>
|
||||
|
||||
export type AuthSystemFields<T = unknown> = {
|
||||
email: string
|
||||
emailVisibility: boolean
|
||||
username: string
|
||||
verified: boolean
|
||||
} & BaseSystemFields<T>
|
||||
|
||||
// Record types for each collection
|
||||
|
||||
export type AuthoriginsRecord = {
|
||||
collectionRef: string
|
||||
created: IsoAutoDateString
|
||||
fingerprint: string
|
||||
id: string
|
||||
recordRef: string
|
||||
updated: IsoAutoDateString
|
||||
}
|
||||
|
||||
export type ExternalauthsRecord = {
|
||||
collectionRef: string
|
||||
created: IsoAutoDateString
|
||||
id: string
|
||||
provider: string
|
||||
providerId: string
|
||||
recordRef: string
|
||||
updated: IsoAutoDateString
|
||||
}
|
||||
|
||||
export type MfasRecord = {
|
||||
collectionRef: string
|
||||
created: IsoAutoDateString
|
||||
id: string
|
||||
method: string
|
||||
recordRef: string
|
||||
updated: IsoAutoDateString
|
||||
}
|
||||
|
||||
export type OtpsRecord = {
|
||||
collectionRef: string
|
||||
created: IsoAutoDateString
|
||||
id: string
|
||||
password: string
|
||||
recordRef: string
|
||||
sentTo?: string
|
||||
updated: IsoAutoDateString
|
||||
}
|
||||
|
||||
export type SuperusersRecord = {
|
||||
created: IsoAutoDateString
|
||||
email: string
|
||||
emailVisibility?: boolean
|
||||
id: string
|
||||
password: string
|
||||
tokenKey: string
|
||||
updated: IsoAutoDateString
|
||||
verified?: boolean
|
||||
}
|
||||
|
||||
export type CamerasRecord = {
|
||||
created: IsoAutoDateString
|
||||
id: string
|
||||
name: string
|
||||
onvif_host?: string
|
||||
password: string
|
||||
record_stream?: RecordIdString
|
||||
updated?: IsoAutoDateString
|
||||
username: string
|
||||
}
|
||||
|
||||
export type StreamsRecord = {
|
||||
camera: RecordIdString
|
||||
fps: number
|
||||
height: number
|
||||
id: string
|
||||
url: string
|
||||
width: number
|
||||
}
|
||||
|
||||
export type UsersRecord = {
|
||||
created: IsoAutoDateString
|
||||
email: string
|
||||
emailVisibility?: boolean
|
||||
id: string
|
||||
password: string
|
||||
tokenKey: string
|
||||
updated?: IsoAutoDateString
|
||||
verified?: boolean
|
||||
}
|
||||
|
||||
// Response types include system fields and match responses from the PocketBase API
|
||||
export type AuthoriginsResponse<Texpand = unknown> = Required<AuthoriginsRecord> & BaseSystemFields<Texpand>
|
||||
export type ExternalauthsResponse<Texpand = unknown> = Required<ExternalauthsRecord> & BaseSystemFields<Texpand>
|
||||
export type MfasResponse<Texpand = unknown> = Required<MfasRecord> & BaseSystemFields<Texpand>
|
||||
export type OtpsResponse<Texpand = unknown> = Required<OtpsRecord> & BaseSystemFields<Texpand>
|
||||
export type SuperusersResponse<Texpand = unknown> = Required<SuperusersRecord> & AuthSystemFields<Texpand>
|
||||
export type CamerasResponse<Texpand = unknown> = Required<CamerasRecord> & BaseSystemFields<Texpand>
|
||||
export type StreamsResponse<Texpand = unknown> = Required<StreamsRecord> & BaseSystemFields<Texpand>
|
||||
export type UsersResponse<Texpand = unknown> = Required<UsersRecord> & AuthSystemFields<Texpand>
|
||||
|
||||
// Types containing all Records and Responses, useful for creating typing helper functions
|
||||
|
||||
export type CollectionRecords = {
|
||||
_authOrigins: AuthoriginsRecord
|
||||
_externalAuths: ExternalauthsRecord
|
||||
_mfas: MfasRecord
|
||||
_otps: OtpsRecord
|
||||
_superusers: SuperusersRecord
|
||||
cameras: CamerasRecord
|
||||
streams: StreamsRecord
|
||||
users: UsersRecord
|
||||
}
|
||||
|
||||
export type CollectionResponses = {
|
||||
_authOrigins: AuthoriginsResponse
|
||||
_externalAuths: ExternalauthsResponse
|
||||
_mfas: MfasResponse
|
||||
_otps: OtpsResponse
|
||||
_superusers: SuperusersResponse
|
||||
cameras: CamerasResponse
|
||||
streams: StreamsResponse
|
||||
users: UsersResponse
|
||||
}
|
||||
|
||||
// Utility types for create/update operations
|
||||
|
||||
type ProcessCreateAndUpdateFields<T> = Omit<{
|
||||
// Omit AutoDate fields
|
||||
[K in keyof T as Extract<T[K], IsoAutoDateString> extends never ? K : never]:
|
||||
// Convert FileNameString to File
|
||||
T[K] extends infer U ?
|
||||
U extends (FileNameString | FileNameString[]) ?
|
||||
U extends any[] ? File[] : File
|
||||
: U
|
||||
: never
|
||||
}, 'id'>
|
||||
|
||||
// Create type for Auth collections
|
||||
export type CreateAuth<T> = {
|
||||
id?: RecordIdString
|
||||
email: string
|
||||
emailVisibility?: boolean
|
||||
password: string
|
||||
passwordConfirm: string
|
||||
verified?: boolean
|
||||
} & ProcessCreateAndUpdateFields<T>
|
||||
|
||||
// Create type for Base collections
|
||||
export type CreateBase<T> = {
|
||||
id?: RecordIdString
|
||||
} & ProcessCreateAndUpdateFields<T>
|
||||
|
||||
// Update type for Auth collections
|
||||
export type UpdateAuth<T> = Partial<
|
||||
Omit<ProcessCreateAndUpdateFields<T>, keyof AuthSystemFields>
|
||||
> & {
|
||||
email?: string
|
||||
emailVisibility?: boolean
|
||||
oldPassword?: string
|
||||
password?: string
|
||||
passwordConfirm?: string
|
||||
verified?: boolean
|
||||
}
|
||||
|
||||
// Update type for Base collections
|
||||
export type UpdateBase<T> = Partial<
|
||||
Omit<ProcessCreateAndUpdateFields<T>, keyof BaseSystemFields>
|
||||
>
|
||||
|
||||
// Get the correct create type for any collection
|
||||
export type Create<T extends keyof CollectionResponses> =
|
||||
CollectionResponses[T] extends AuthSystemFields
|
||||
? CreateAuth<CollectionRecords[T]>
|
||||
: CreateBase<CollectionRecords[T]>
|
||||
|
||||
// Get the correct update type for any collection
|
||||
export type Update<T extends keyof CollectionResponses> =
|
||||
CollectionResponses[T] extends AuthSystemFields
|
||||
? UpdateAuth<CollectionRecords[T]>
|
||||
: UpdateBase<CollectionRecords[T]>
|
||||
|
||||
// Type for usage with type asserted PocketBase instance
|
||||
// https://github.com/pocketbase/js-sdk#specify-typescript-definitions
|
||||
|
||||
export type TypedPocketBase = {
|
||||
collection<T extends keyof CollectionResponses>(
|
||||
idOrName: T
|
||||
): RecordService<CollectionResponses[T]>
|
||||
} & PocketBase
|
||||
13
web/src/lib/utils.ts
Normal file
13
web/src/lib/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
|
||||
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
|
||||
22
web/src/routes/(authenticated)/(home)/+layout.svelte
Normal file
22
web/src/routes/(authenticated)/(home)/+layout.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from "$app/paths";
|
||||
import Button from "$lib/components/ui/button/button.svelte";
|
||||
import VideoAdd from "remixicon-svelte/icons/video-add-fill";
|
||||
|
||||
const { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="fixed top-0 left-0 flex h-12 w-screen items-center p-2">
|
||||
<h2 class="flex-1 text-2xl">CCTV</h2>
|
||||
<Button href={resolve("/new")} variant="outline" size="icon" aria-label="New camera">
|
||||
<VideoAdd />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
{#each Array(1000) as _, idx (idx)}
|
||||
<p>Hey!</p>
|
||||
{/each}
|
||||
</main>
|
||||
|
||||
{@render children()}
|
||||
0
web/src/routes/(authenticated)/(home)/+page.svelte
Normal file
0
web/src/routes/(authenticated)/(home)/+page.svelte
Normal file
137
web/src/routes/(authenticated)/(home)/new/+page.svelte
Normal file
137
web/src/routes/(authenticated)/(home)/new/+page.svelte
Normal file
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { resolve } from "$app/paths";
|
||||
import { pb, type ClientResponseError } from "$lib";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as ButtonGroup from "$lib/components/ui/button-group";
|
||||
import * as Drawer from "$lib/components/ui/drawer";
|
||||
import * as Field from "$lib/components/ui/field";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Spinner } from "$lib/components/ui/spinner";
|
||||
import CloseCircleLine from "remixicon-svelte/icons/close-circle-line";
|
||||
import { toast } from "svelte-sonner";
|
||||
|
||||
let name = $state("");
|
||||
let host = $state("");
|
||||
let port = $state("");
|
||||
let username = $state("");
|
||||
let password = $state("");
|
||||
let adding = $state(false);
|
||||
|
||||
let portEl = $state<HTMLInputElement | null>(null);
|
||||
|
||||
function onHostInput(e: Event) {
|
||||
const el = e.currentTarget as HTMLInputElement;
|
||||
const value = el.value;
|
||||
const separatorIndex = value.lastIndexOf(":");
|
||||
|
||||
if (separatorIndex === -1) return;
|
||||
|
||||
host = value.slice(0, separatorIndex);
|
||||
const rawPort = value.slice(separatorIndex + 1);
|
||||
if (rawPort.length > 0) port = rawPort.replace(/\D/g, "").slice(0, 5);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
portEl?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
async function tryAddCamera() {
|
||||
adding = true;
|
||||
try {
|
||||
const camera = await pb.collection("cameras").create({
|
||||
name,
|
||||
onvif_host: `${host}:${port}`,
|
||||
username,
|
||||
password
|
||||
});
|
||||
await goto(resolve(`/cam/${camera.id}`));
|
||||
} catch (_err) {
|
||||
const err = _err as ClientResponseError;
|
||||
console.error("Add camera error:", err);
|
||||
toast.error(err.message ?? "An error occurred while adding the camera.", {
|
||||
icon: CloseCircleLine
|
||||
});
|
||||
} finally {
|
||||
adding = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer.Root
|
||||
bind:open={
|
||||
() => true,
|
||||
(v) => {
|
||||
if (!v) goto(resolve("/"));
|
||||
}
|
||||
}
|
||||
>
|
||||
<Drawer.Content>
|
||||
<div class="mx-auto w-full max-w-sm overflow-y-auto p-1">
|
||||
<Drawer.Header>
|
||||
<Drawer.Title class="text-xl">New Camera</Drawer.Title>
|
||||
</Drawer.Header>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
tryAddCamera();
|
||||
}}
|
||||
>
|
||||
<Field.Set>
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label for="name">Camera Name</Field.Label>
|
||||
<Input id="name" placeholder="My Camera" bind:value={name} disabled={adding} />
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="host">Camera URL</Field.Label>
|
||||
<ButtonGroup.Root>
|
||||
<ButtonGroup.Text>onvif://</ButtonGroup.Text>
|
||||
<Input
|
||||
id="host"
|
||||
bind:value={host}
|
||||
oninput={onHostInput}
|
||||
placeholder="IP Address"
|
||||
disabled={adding}
|
||||
/>
|
||||
<ButtonGroup.Separator />
|
||||
<ButtonGroup.Text>:</ButtonGroup.Text>
|
||||
<Input
|
||||
id="port"
|
||||
bind:ref={portEl}
|
||||
bind:value={port}
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
class="min-w-22! flex-0!"
|
||||
placeholder="Port"
|
||||
disabled={adding}
|
||||
/>
|
||||
</ButtonGroup.Root>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="username">ONVIF Username</Field.Label>
|
||||
<Input id="username" placeholder="Username" bind:value={username} disabled={adding} />
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="password">ONVIF Password</Field.Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
bind:value={password}
|
||||
disabled={adding}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Button type="submit" disabled={adding}>
|
||||
{#if adding}
|
||||
<Spinner />
|
||||
{/if}
|
||||
Add Camera
|
||||
</Button>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
</form>
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Root>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user