updated
This commit is contained in:
parent
b62c1273c3
commit
b3c07355a8
@ -1,7 +1,7 @@
|
|||||||
PORT=8080
|
PORT=8080
|
||||||
DATABASE_URL=postgres://postgres:Timmy0104199%3F@localhost:5432/teg?sslmode=disable
|
DATABASE_URL=postgres://postgres:Timmy0104199%3F@localhost:5432/teg?sslmode=disable
|
||||||
JWT_SECRET=tegvideo7010!
|
JWT_SECRET=tegvideo7010!
|
||||||
FRONTEND_ORIGIN=http://localhost:5173
|
FRONTEND_ORIGIN=http://10.0.1.25:5173
|
||||||
ADDRESS_SEARCH_PROVIDER=photon
|
ADDRESS_SEARCH_PROVIDER=photon
|
||||||
ADDRESS_SEARCH_URL=https://photon.komoot.io/api
|
ADDRESS_SEARCH_URL=https://photon.komoot.io/api
|
||||||
ADDRESS_SEARCH_LANGUAGE=de
|
ADDRESS_SEARCH_LANGUAGE=de
|
||||||
|
|||||||
@ -3,8 +3,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -13,12 +15,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type reverseAddressSuggestion struct {
|
type reverseAddressSuggestion struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
Lat float64 `json:"lat"`
|
Lat float64 `json:"lat"`
|
||||||
Lon float64 `json:"lon"`
|
Lon float64 `json:"lon"`
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
GeoJSON json.RawMessage `json:"geojson,omitempty"`
|
HasHouseNumber bool `json:"hasHouseNumber"`
|
||||||
|
GeoJSON json.RawMessage `json:"geojson,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type nominatimReverseAddress struct {
|
type nominatimReverseAddress struct {
|
||||||
@ -112,6 +115,215 @@ func formatReverseAddressLabel(result nominatimReverseResponse) string {
|
|||||||
return strings.TrimSpace(result.DisplayName)
|
return strings.TrimSpace(result.DisplayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isPolygonGeoJSON(value json.RawMessage) bool {
|
||||||
|
if len(value) == 0 || string(value) == "null" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var object struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Geometry json.RawMessage `json:"geometry"`
|
||||||
|
Features []json.RawMessage `json:"features"`
|
||||||
|
Geometries []json.RawMessage `json:"geometries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(value, &object); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch object.Type {
|
||||||
|
case "Polygon", "MultiPolygon":
|
||||||
|
return true
|
||||||
|
case "Feature":
|
||||||
|
return isPolygonGeoJSON(object.Geometry)
|
||||||
|
case "FeatureCollection":
|
||||||
|
for _, feature := range object.Features {
|
||||||
|
if isPolygonGeoJSON(feature) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "GeometryCollection":
|
||||||
|
for _, geometry := range object.Geometries {
|
||||||
|
if isPolygonGeoJSON(geometry) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func coordinateDistanceMeters(firstLat, firstLon, secondLat, secondLon float64) float64 {
|
||||||
|
const earthRadius = 6371000
|
||||||
|
|
||||||
|
firstLatitude := firstLat * math.Pi / 180
|
||||||
|
secondLatitude := secondLat * math.Pi / 180
|
||||||
|
latitudeDifference := (secondLat - firstLat) * math.Pi / 180
|
||||||
|
longitudeDifference := (secondLon - firstLon) * math.Pi / 180
|
||||||
|
|
||||||
|
a := math.Sin(latitudeDifference/2)*math.Sin(latitudeDifference/2) +
|
||||||
|
math.Cos(firstLatitude)*math.Cos(secondLatitude)*
|
||||||
|
math.Sin(longitudeDifference/2)*math.Sin(longitudeDifference/2)
|
||||||
|
|
||||||
|
return earthRadius * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||||
|
}
|
||||||
|
|
||||||
|
type addressPolygonLookupResult struct {
|
||||||
|
GeoJSON json.RawMessage
|
||||||
|
HasHouseNumber bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupAddressPolygon(
|
||||||
|
ctx context.Context,
|
||||||
|
query string,
|
||||||
|
latitude float64,
|
||||||
|
longitude float64,
|
||||||
|
) addressPolygonLookupResult {
|
||||||
|
query = strings.TrimSpace(query)
|
||||||
|
if query == "" {
|
||||||
|
return addressPolygonLookupResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("format", "jsonv2")
|
||||||
|
params.Set("addressdetails", "1")
|
||||||
|
params.Set("polygon_geojson", "1")
|
||||||
|
params.Set("limit", "10")
|
||||||
|
params.Set("accept-language", "de")
|
||||||
|
params.Set("q", query)
|
||||||
|
|
||||||
|
searchURL := fmt.Sprintf(
|
||||||
|
"https://nominatim.openstreetmap.org/search?%s",
|
||||||
|
params.Encode(),
|
||||||
|
)
|
||||||
|
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return addressPolygonLookupResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
request.Header.Set("User-Agent", addressSearchUserAgent())
|
||||||
|
|
||||||
|
response, err := httpClient().Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return addressPolygonLookupResult{}
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
|
return addressPolygonLookupResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []nominatimSearchResult
|
||||||
|
if err := json.NewDecoder(response.Body).Decode(&results); err != nil {
|
||||||
|
return addressPolygonLookupResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bestResult addressPolygonLookupResult
|
||||||
|
bestDistance := math.Inf(1)
|
||||||
|
|
||||||
|
for _, result := range results {
|
||||||
|
if !isPolygonGeoJSON(result.GeoJSON) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
resultLatitude, latitudeErr := strconv.ParseFloat(result.Lat, 64)
|
||||||
|
resultLongitude, longitudeErr := strconv.ParseFloat(result.Lon, 64)
|
||||||
|
if latitudeErr != nil || longitudeErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
distance := coordinateDistanceMeters(
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
resultLatitude,
|
||||||
|
resultLongitude,
|
||||||
|
)
|
||||||
|
if distance > 250 || distance >= bestDistance {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
bestDistance = distance
|
||||||
|
bestResult = addressPolygonLookupResult{
|
||||||
|
GeoJSON: result.GeoJSON,
|
||||||
|
HasHouseNumber: strings.TrimSpace(result.Address.HouseNumber) != "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeOSMIdentifier(value string) string {
|
||||||
|
value = strings.ToUpper(strings.TrimSpace(value))
|
||||||
|
value = strings.ReplaceAll(value, ":", "")
|
||||||
|
|
||||||
|
if len(value) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if value[0] != 'N' && value[0] != 'W' && value[0] != 'R' {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := strconv.ParseInt(value[1:], 10, 64); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupOSMPolygon(ctx context.Context, osmIdentifier string) addressPolygonLookupResult {
|
||||||
|
osmIdentifier = normalizeOSMIdentifier(osmIdentifier)
|
||||||
|
if osmIdentifier == "" {
|
||||||
|
return addressPolygonLookupResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("format", "jsonv2")
|
||||||
|
params.Set("polygon_geojson", "1")
|
||||||
|
params.Set("osm_ids", osmIdentifier)
|
||||||
|
|
||||||
|
lookupURL := fmt.Sprintf(
|
||||||
|
"https://nominatim.openstreetmap.org/lookup?%s",
|
||||||
|
params.Encode(),
|
||||||
|
)
|
||||||
|
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, lookupURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return addressPolygonLookupResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
request.Header.Set("User-Agent", addressSearchUserAgent())
|
||||||
|
|
||||||
|
response, err := httpClient().Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return addressPolygonLookupResult{}
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
|
return addressPolygonLookupResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []nominatimSearchResult
|
||||||
|
if err := json.NewDecoder(response.Body).Decode(&results); err != nil {
|
||||||
|
return addressPolygonLookupResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, result := range results {
|
||||||
|
if isPolygonGeoJSON(result.GeoJSON) {
|
||||||
|
return addressPolygonLookupResult{
|
||||||
|
GeoJSON: result.GeoJSON,
|
||||||
|
HasHouseNumber: strings.TrimSpace(result.Address.HouseNumber) != "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return addressPolygonLookupResult{}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleAddressReverse(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAddressReverse(w http.ResponseWriter, r *http.Request) {
|
||||||
latRaw := strings.TrimSpace(r.URL.Query().Get("lat"))
|
latRaw := strings.TrimSpace(r.URL.Query().Get("lat"))
|
||||||
lonRaw := strings.TrimSpace(r.URL.Query().Get("lon"))
|
lonRaw := strings.TrimSpace(r.URL.Query().Get("lon"))
|
||||||
@ -179,14 +391,42 @@ func (s *Server) handleAddressReverse(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
geoJSON := result.GeoJSON
|
||||||
|
hasHouseNumber := strings.TrimSpace(result.Address.HouseNumber) != ""
|
||||||
|
if !isPolygonGeoJSON(geoJSON) {
|
||||||
|
lookupResult := lookupOSMPolygon(
|
||||||
|
r.Context(),
|
||||||
|
r.URL.Query().Get("osm_id"),
|
||||||
|
)
|
||||||
|
geoJSON = lookupResult.GeoJSON
|
||||||
|
hasHouseNumber = hasHouseNumber || lookupResult.HasHouseNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isPolygonGeoJSON(geoJSON) {
|
||||||
|
searchQuery := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
if searchQuery == "" {
|
||||||
|
searchQuery = label
|
||||||
|
}
|
||||||
|
|
||||||
|
lookupResult := lookupAddressPolygon(
|
||||||
|
r.Context(),
|
||||||
|
searchQuery,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
)
|
||||||
|
geoJSON = lookupResult.GeoJSON
|
||||||
|
hasHouseNumber = hasHouseNumber || lookupResult.HasHouseNumber
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"suggestion": reverseAddressSuggestion{
|
"suggestion": reverseAddressSuggestion{
|
||||||
ID: fmt.Sprintf("reverse-%d-%d", result.PlaceID, time.Now().UnixNano()),
|
ID: fmt.Sprintf("reverse-%d-%d", result.PlaceID, time.Now().UnixNano()),
|
||||||
Label: label,
|
Label: label,
|
||||||
Lat: lat,
|
Lat: lat,
|
||||||
Lon: lon,
|
Lon: lon,
|
||||||
Type: result.Type,
|
Type: result.Type,
|
||||||
GeoJSON: result.GeoJSON,
|
HasHouseNumber: hasHouseNumber,
|
||||||
|
GeoJSON: geoJSON,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,11 +13,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AddressSuggestion struct {
|
type AddressSuggestion struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
Lat float64 `json:"lat"`
|
Lat float64 `json:"lat"`
|
||||||
Lon float64 `json:"lon"`
|
Lon float64 `json:"lon"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
HasHouseNumber bool `json:"hasHouseNumber"`
|
||||||
|
GeoJSON json.RawMessage `json:"geojson,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type photonResponse struct {
|
type photonResponse struct {
|
||||||
@ -55,6 +57,7 @@ type nominatimSearchResult struct {
|
|||||||
Lon string `json:"lon"`
|
Lon string `json:"lon"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Address nominatimReverseAddress `json:"address"`
|
Address nominatimReverseAddress `json:"address"`
|
||||||
|
GeoJSON json.RawMessage `json:"geojson,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAddressSearch(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAddressSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -164,11 +167,12 @@ func (s *Server) handlePhotonAddressSearch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suggestions = append(suggestions, AddressSuggestion{
|
suggestions = append(suggestions, AddressSuggestion{
|
||||||
ID: buildPhotonID(feature.Properties, label),
|
ID: buildPhotonID(feature.Properties, label),
|
||||||
Label: label,
|
Label: label,
|
||||||
Lat: lat,
|
Lat: lat,
|
||||||
Lon: lon,
|
Lon: lon,
|
||||||
Type: buildPhotonType(feature.Properties),
|
Type: buildPhotonType(feature.Properties),
|
||||||
|
HasHouseNumber: strings.TrimSpace(feature.Properties.HouseNumber) != "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -196,6 +200,7 @@ func (s *Server) handleNominatimAddressSearch(
|
|||||||
values.Set("q", query)
|
values.Set("q", query)
|
||||||
values.Set("format", "jsonv2")
|
values.Set("format", "jsonv2")
|
||||||
values.Set("addressdetails", "1")
|
values.Set("addressdetails", "1")
|
||||||
|
values.Set("polygon_geojson", "1")
|
||||||
values.Set("limit", strconv.Itoa(limit))
|
values.Set("limit", strconv.Itoa(limit))
|
||||||
|
|
||||||
if countryCode != "" && values.Get("countrycodes") == "" {
|
if countryCode != "" && values.Get("countrycodes") == "" {
|
||||||
@ -252,11 +257,13 @@ func (s *Server) handleNominatimAddressSearch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suggestions = append(suggestions, AddressSuggestion{
|
suggestions = append(suggestions, AddressSuggestion{
|
||||||
ID: strconv.FormatInt(result.PlaceID, 10),
|
ID: strconv.FormatInt(result.PlaceID, 10),
|
||||||
Label: label,
|
Label: label,
|
||||||
Lat: lat,
|
Lat: lat,
|
||||||
Lon: lon,
|
Lon: lon,
|
||||||
Type: result.Type,
|
Type: result.Type,
|
||||||
|
HasHouseNumber: strings.TrimSpace(result.Address.HouseNumber) != "",
|
||||||
|
GeoJSON: result.GeoJSON,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,17 +24,20 @@ type AdminTeam struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AdminUser struct {
|
type AdminUser struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
Unit string `json:"unit"`
|
Unit string `json:"unit"`
|
||||||
Group string `json:"group"`
|
Group string `json:"group"`
|
||||||
Rights []string `json:"rights"`
|
Rights []string `json:"rights"`
|
||||||
Teams []AdminTeam `json:"teams"`
|
Teams []AdminTeam `json:"teams"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
Online bool `json:"onlineStatus"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
PresenceStatus string `json:"presenceStatus"`
|
||||||
|
LastSeenAt *time.Time `json:"lastSeenAt"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminUserRequest struct {
|
type AdminUserRequest struct {
|
||||||
@ -120,6 +123,8 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
COALESCE(unit, ''),
|
COALESCE(unit, ''),
|
||||||
COALESCE(user_group, ''),
|
COALESCE(user_group, ''),
|
||||||
rights,
|
rights,
|
||||||
|
`+presenceStatusSQL+`,
|
||||||
|
last_seen_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM users
|
FROM users
|
||||||
@ -148,6 +153,8 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
&user.Unit,
|
&user.Unit,
|
||||||
&user.Group,
|
&user.Group,
|
||||||
&user.Rights,
|
&user.Rights,
|
||||||
|
&user.PresenceStatus,
|
||||||
|
&user.LastSeenAt,
|
||||||
&user.CreatedAt,
|
&user.CreatedAt,
|
||||||
&user.UpdatedAt,
|
&user.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@ -156,6 +163,7 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
user.Teams = []AdminTeam{}
|
user.Teams = []AdminTeam{}
|
||||||
|
user.Online = user.PresenceStatus == "online"
|
||||||
|
|
||||||
users = append(users, user)
|
users = append(users, user)
|
||||||
userIndexByID[user.ID] = len(users) - 1
|
userIndexByID[user.ID] = len(users) - 1
|
||||||
|
|||||||
612
backend/channels.go
Normal file
612
backend/channels.go
Normal file
@ -0,0 +1,612 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const zabbixWebhookScript = `var params = JSON.parse(value),
|
||||||
|
request = new HttpRequest(),
|
||||||
|
payload = {
|
||||||
|
subject: params.Subject,
|
||||||
|
message: params.Message,
|
||||||
|
host: params.Host,
|
||||||
|
severity: params.Severity,
|
||||||
|
status: params.Status,
|
||||||
|
eventId: params.EventId,
|
||||||
|
eventUrl: params.EventUrl
|
||||||
|
};
|
||||||
|
|
||||||
|
request.addHeader('Content-Type: application/json');
|
||||||
|
request.addHeader('Authorization: Bearer ' + params.Token);
|
||||||
|
|
||||||
|
var response = request.post(params.URL, JSON.stringify(payload)),
|
||||||
|
status = request.getStatus();
|
||||||
|
|
||||||
|
if (status < 200 || status >= 300) {
|
||||||
|
throw 'Webhook failed with HTTP status ' + status + ': ' + response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;`
|
||||||
|
|
||||||
|
var channelSlugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||||
|
|
||||||
|
type AdminChannel struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
SourceName string `json:"sourceName"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
AllUsers bool `json:"allUsers"`
|
||||||
|
UserIDs []string `json:"userIds"`
|
||||||
|
WebhookEnabled bool `json:"webhookEnabled"`
|
||||||
|
TokenConfigured bool `json:"tokenConfigured"`
|
||||||
|
WebhookTokenUpdatedAt *time.Time `json:"webhookTokenUpdatedAt"`
|
||||||
|
WebhookPath string `json:"webhookPath"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminChannelUser struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type saveAdminChannelRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
SourceName string `json:"sourceName"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
AllUsers bool `json:"allUsers"`
|
||||||
|
UserIDs []string `json:"userIds"`
|
||||||
|
WebhookEnabled bool `json:"webhookEnabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type channelWebhookPayload struct {
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
EventID string `json:"eventId"`
|
||||||
|
EventURL string `json:"eventUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ensureAllUserChannels(ctx context.Context, userID string) error {
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
INSERT INTO conversation_members (conversation_id, user_id)
|
||||||
|
SELECT c.id, $1
|
||||||
|
FROM conversations c
|
||||||
|
WHERE c.type = 'channel'
|
||||||
|
AND c.channel_all_users
|
||||||
|
ON CONFLICT (conversation_id, user_id) DO NOTHING
|
||||||
|
`,
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeChannelSlug(value string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAdminChannelInput(input saveAdminChannelRequest) (saveAdminChannelRequest, error) {
|
||||||
|
input.Name = strings.TrimSpace(input.Name)
|
||||||
|
input.Slug = normalizeChannelSlug(input.Slug)
|
||||||
|
input.SourceName = strings.TrimSpace(input.SourceName)
|
||||||
|
input.Image = strings.TrimSpace(input.Image)
|
||||||
|
userIDs := input.UserIDs
|
||||||
|
uniqueUserIDs := make(map[string]struct{}, len(userIDs))
|
||||||
|
input.UserIDs = make([]string, 0, len(userIDs))
|
||||||
|
for _, userID := range userIDs {
|
||||||
|
userID = strings.TrimSpace(userID)
|
||||||
|
if userID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := uniqueUserIDs[userID]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
uniqueUserIDs[userID] = struct{}{}
|
||||||
|
input.UserIDs = append(input.UserIDs, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.Name == "" {
|
||||||
|
return input, errors.New("Channelname ist erforderlich")
|
||||||
|
}
|
||||||
|
if !channelSlugPattern.MatchString(input.Slug) {
|
||||||
|
return input, errors.New("Der Webhook-Slug darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten")
|
||||||
|
}
|
||||||
|
if input.SourceName == "" {
|
||||||
|
input.SourceName = input.Name
|
||||||
|
}
|
||||||
|
if len(input.Image) > 3*1024*1024 {
|
||||||
|
return input, errors.New("Das Channel-Bild ist zu groß")
|
||||||
|
}
|
||||||
|
return input, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminListChannels(w http.ResponseWriter, r *http.Request) {
|
||||||
|
channels, err := s.listAdminChannels(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Channels konnten nicht geladen werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := s.listAdminChannelUsers(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht geladen werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"channels": channels,
|
||||||
|
"users": users,
|
||||||
|
"zabbixScript": zabbixWebhookScript,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listAdminChannels(ctx context.Context) ([]AdminChannel, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
c.id::TEXT,
|
||||||
|
c.name,
|
||||||
|
c.slug,
|
||||||
|
c.channel_source_name,
|
||||||
|
c.channel_image,
|
||||||
|
c.channel_all_users,
|
||||||
|
c.webhook_enabled,
|
||||||
|
c.webhook_token_hash <> '',
|
||||||
|
c.webhook_token_updated_at,
|
||||||
|
COALESCE(array_agg(cm.user_id::TEXT) FILTER (WHERE cm.user_id IS NOT NULL), '{}')
|
||||||
|
FROM conversations c
|
||||||
|
LEFT JOIN conversation_members cm ON cm.conversation_id = c.id
|
||||||
|
WHERE c.type = 'channel'
|
||||||
|
GROUP BY c.id
|
||||||
|
ORDER BY lower(c.name)
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
channels := []AdminChannel{}
|
||||||
|
for rows.Next() {
|
||||||
|
var channel AdminChannel
|
||||||
|
if err := rows.Scan(
|
||||||
|
&channel.ID,
|
||||||
|
&channel.Name,
|
||||||
|
&channel.Slug,
|
||||||
|
&channel.SourceName,
|
||||||
|
&channel.Image,
|
||||||
|
&channel.AllUsers,
|
||||||
|
&channel.WebhookEnabled,
|
||||||
|
&channel.TokenConfigured,
|
||||||
|
&channel.WebhookTokenUpdatedAt,
|
||||||
|
&channel.UserIDs,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
channel.WebhookPath = "/webhooks/channels/" + channel.Slug
|
||||||
|
channels = append(channels, channel)
|
||||||
|
}
|
||||||
|
return channels, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listAdminChannelUsers(ctx context.Context) ([]AdminChannelUser, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
id::TEXT,
|
||||||
|
COALESCE(username, ''),
|
||||||
|
COALESCE(display_name, ''),
|
||||||
|
email,
|
||||||
|
COALESCE(unit, '')
|
||||||
|
FROM users
|
||||||
|
ORDER BY lower(COALESCE(NULLIF(display_name, ''), username, email))
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
users := []AdminChannelUser{}
|
||||||
|
for rows.Next() {
|
||||||
|
var user AdminChannelUser
|
||||||
|
if err := rows.Scan(
|
||||||
|
&user.ID,
|
||||||
|
&user.Username,
|
||||||
|
&user.DisplayName,
|
||||||
|
&user.Email,
|
||||||
|
&user.Unit,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
users = append(users, user)
|
||||||
|
}
|
||||||
|
return users, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminCreateChannel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input saveAdminChannelRequest
|
||||||
|
if err := readJSON(r, &input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input, err := validateAdminChannelInput(input)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.Begin(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Channel konnte nicht erstellt werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
|
var channelID string
|
||||||
|
err = tx.QueryRow(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
INSERT INTO conversations (
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
channel_source_name,
|
||||||
|
channel_image,
|
||||||
|
channel_all_users,
|
||||||
|
webhook_enabled
|
||||||
|
)
|
||||||
|
VALUES ('channel', $1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id::TEXT
|
||||||
|
`,
|
||||||
|
input.Name,
|
||||||
|
input.Slug,
|
||||||
|
input.SourceName,
|
||||||
|
input.Image,
|
||||||
|
input.AllUsers,
|
||||||
|
input.WebhookEnabled,
|
||||||
|
).Scan(&channelID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusConflict, "Channelname oder Webhook-Slug wird bereits verwendet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := syncChannelMembers(r.Context(), tx, channelID, input.AllUsers, input.UserIDs); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Channelmitglieder konnten nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Channel konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{"id": channelID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminUpdateChannel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
channelID := strings.TrimSpace(r.PathValue("id"))
|
||||||
|
|
||||||
|
var input saveAdminChannelRequest
|
||||||
|
if err := readJSON(r, &input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input, err := validateAdminChannelInput(input)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.Begin(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Channel konnte nicht aktualisiert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
|
commandTag, err := tx.Exec(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
UPDATE conversations
|
||||||
|
SET
|
||||||
|
name = $2,
|
||||||
|
slug = $3,
|
||||||
|
channel_source_name = $4,
|
||||||
|
channel_image = $5,
|
||||||
|
channel_all_users = $6,
|
||||||
|
webhook_enabled = $7,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
AND type = 'channel'
|
||||||
|
`,
|
||||||
|
channelID,
|
||||||
|
input.Name,
|
||||||
|
input.Slug,
|
||||||
|
input.SourceName,
|
||||||
|
input.Image,
|
||||||
|
input.AllUsers,
|
||||||
|
input.WebhookEnabled,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusConflict, "Channelname oder Webhook-Slug wird bereits verwendet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if commandTag.RowsAffected() == 0 {
|
||||||
|
writeError(w, http.StatusNotFound, "Channel wurde nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := syncChannelMembers(r.Context(), tx, channelID, input.AllUsers, input.UserIDs); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Channelmitglieder konnten nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Channel konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"id": channelID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncChannelMembers(
|
||||||
|
ctx context.Context,
|
||||||
|
tx pgx.Tx,
|
||||||
|
channelID string,
|
||||||
|
allUsers bool,
|
||||||
|
userIDs []string,
|
||||||
|
) error {
|
||||||
|
if allUsers {
|
||||||
|
_, err := tx.Exec(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
INSERT INTO conversation_members (conversation_id, user_id)
|
||||||
|
SELECT $1, id FROM users
|
||||||
|
ON CONFLICT (conversation_id, user_id) DO NOTHING
|
||||||
|
`,
|
||||||
|
channelID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
DELETE FROM conversation_members
|
||||||
|
WHERE conversation_id = $1
|
||||||
|
AND NOT (
|
||||||
|
user_id::TEXT = ANY(
|
||||||
|
COALESCE($2::TEXT[], ARRAY[]::TEXT[])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
channelID,
|
||||||
|
userIDs,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, userID := range userIDs {
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
INSERT INTO conversation_members (conversation_id, user_id)
|
||||||
|
SELECT $1, id FROM users WHERE id = $2
|
||||||
|
ON CONFLICT (conversation_id, user_id) DO NOTHING
|
||||||
|
`,
|
||||||
|
channelID,
|
||||||
|
strings.TrimSpace(userID),
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateChannelWebhookToken() (string, string, error) {
|
||||||
|
raw := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
token := base64.RawURLEncoding.EncodeToString(raw)
|
||||||
|
hash := sha256.Sum256([]byte(token))
|
||||||
|
return token, hex.EncodeToString(hash[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminRotateChannelToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
channelID := strings.TrimSpace(r.PathValue("id"))
|
||||||
|
token, tokenHash, err := generateChannelWebhookToken()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Webhook-Token konnte nicht erzeugt werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
commandTag, err := s.db.Exec(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
UPDATE conversations
|
||||||
|
SET
|
||||||
|
webhook_token_hash = $2,
|
||||||
|
webhook_token_updated_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
AND type = 'channel'
|
||||||
|
`,
|
||||||
|
channelID,
|
||||||
|
tokenHash,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Webhook-Token konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if commandTag.RowsAffected() == 0 {
|
||||||
|
writeError(w, http.StatusNotFound, "Channel wurde nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"token": token})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleChannelWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
|
||||||
|
slug := normalizeChannelSlug(r.PathValue("slug"))
|
||||||
|
|
||||||
|
var channelID string
|
||||||
|
var tokenHash string
|
||||||
|
var enabled bool
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
SELECT id::TEXT, webhook_token_hash, webhook_enabled
|
||||||
|
FROM conversations
|
||||||
|
WHERE type = 'channel'
|
||||||
|
AND slug = $1
|
||||||
|
`,
|
||||||
|
slug,
|
||||||
|
).Scan(&channelID, &tokenHash, &enabled)
|
||||||
|
if err != nil || !enabled || tokenHash == "" {
|
||||||
|
writeError(w, http.StatusNotFound, "Webhook wurde nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||||||
|
if token == "" {
|
||||||
|
token = strings.TrimSpace(r.Header.Get("X-Webhook-Token"))
|
||||||
|
}
|
||||||
|
hash := sha256.Sum256([]byte(token))
|
||||||
|
providedHash := hex.EncodeToString(hash[:])
|
||||||
|
if subtle.ConstantTimeCompare([]byte(providedHash), []byte(tokenHash)) != 1 {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Ungültiges Webhook-Token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var input channelWebhookPayload
|
||||||
|
if err := readJSON(r, &input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültige Webhook-Nachricht")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body := formatChannelWebhookMessage(input)
|
||||||
|
if body == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Nachricht darf nicht leer sein")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
message, err := s.createChannelMessage(r.Context(), channelID, body)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Channel-Nachricht konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.publishChatMessage(r.Context(), message, "")
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"messageId": message.ID,
|
||||||
|
"status": "accepted",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatChannelWebhookMessage(input channelWebhookPayload) string {
|
||||||
|
parts := []string{}
|
||||||
|
if subject := strings.TrimSpace(input.Subject); subject != "" {
|
||||||
|
parts = append(parts, subject)
|
||||||
|
}
|
||||||
|
if message := strings.TrimSpace(input.Message); message != "" {
|
||||||
|
parts = append(parts, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
details := []string{}
|
||||||
|
for _, detail := range []struct {
|
||||||
|
label string
|
||||||
|
value string
|
||||||
|
}{
|
||||||
|
{"Host", input.Host},
|
||||||
|
{"Status", input.Status},
|
||||||
|
{"Schweregrad", input.Severity},
|
||||||
|
{"Event-ID", input.EventID},
|
||||||
|
{"Link", input.EventURL},
|
||||||
|
} {
|
||||||
|
if value := strings.TrimSpace(detail.value); value != "" {
|
||||||
|
details = append(details, detail.label+": "+value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(details) > 0 {
|
||||||
|
parts = append(parts, strings.Join(details, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
body := strings.Join(parts, "\n\n")
|
||||||
|
runes := []rune(body)
|
||||||
|
if len(runes) > 4000 {
|
||||||
|
body = string(runes[:4000])
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createChannelMessage(
|
||||||
|
ctx context.Context,
|
||||||
|
conversationID string,
|
||||||
|
body string,
|
||||||
|
) (ChatMessage, error) {
|
||||||
|
tx, err := s.db.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return ChatMessage{}, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
var messageID string
|
||||||
|
err = tx.QueryRow(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
INSERT INTO messages (conversation_id, body)
|
||||||
|
SELECT id, $2
|
||||||
|
FROM conversations
|
||||||
|
WHERE id = $1
|
||||||
|
AND type = 'channel'
|
||||||
|
RETURNING id::TEXT
|
||||||
|
`,
|
||||||
|
conversationID,
|
||||||
|
body,
|
||||||
|
).Scan(&messageID)
|
||||||
|
if err != nil {
|
||||||
|
return ChatMessage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
UPDATE conversations
|
||||||
|
SET last_message_at = now(), updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
conversationID,
|
||||||
|
); err != nil {
|
||||||
|
return ChatMessage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return ChatMessage{}, err
|
||||||
|
}
|
||||||
|
return s.getChatMessage(ctx, messageID)
|
||||||
|
}
|
||||||
1366
backend/chat.go
Normal file
1366
backend/chat.go
Normal file
File diff suppressed because it is too large
Load Diff
160
backend/chat_attachments.go
Normal file
160
backend/chat_attachments.go
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxChatAttachmentSize = 10 << 20
|
||||||
|
|
||||||
|
func (s *Server) handleUploadChatAttachment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := userFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxChatAttachmentSize+(1<<20))
|
||||||
|
if err := r.ParseMultipartForm(maxChatAttachmentSize); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Datei ist zu groß oder ungültig")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Datei fehlt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(io.LimitReader(file, maxChatAttachmentSize+1))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Datei konnte nicht gelesen werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "Datei ist leer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(data) > maxChatAttachmentSize {
|
||||||
|
writeError(w, http.StatusBadRequest, "Datei darf höchstens 10 MB groß sein")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := strings.TrimSpace(filepath.Base(header.Filename))
|
||||||
|
if fileName == "" || fileName == "." {
|
||||||
|
fileName = "Anhang"
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := strings.TrimSpace(header.Header.Get("Content-Type"))
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = http.DetectContentType(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
var attachment ChatAttachment
|
||||||
|
err = s.db.QueryRow(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
INSERT INTO message_attachments (
|
||||||
|
uploader_id,
|
||||||
|
file_name,
|
||||||
|
content_type,
|
||||||
|
size_bytes,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id::TEXT, file_name, content_type, size_bytes
|
||||||
|
`,
|
||||||
|
user.ID,
|
||||||
|
fileName,
|
||||||
|
contentType,
|
||||||
|
len(data),
|
||||||
|
data,
|
||||||
|
).Scan(
|
||||||
|
&attachment.ID,
|
||||||
|
&attachment.FileName,
|
||||||
|
&attachment.ContentType,
|
||||||
|
&attachment.SizeBytes,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Datei konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
attachment.URL = "/chat/attachments/" + attachment.ID
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{"attachment": attachment})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDownloadChatAttachment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := userFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
attachmentID := strings.TrimSpace(r.PathValue("id"))
|
||||||
|
var uploaderID string
|
||||||
|
var messageID string
|
||||||
|
var conversationID string
|
||||||
|
var fileName string
|
||||||
|
var contentType string
|
||||||
|
var data []byte
|
||||||
|
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
a.uploader_id::TEXT,
|
||||||
|
COALESCE(a.message_id::TEXT, ''),
|
||||||
|
COALESCE(m.conversation_id::TEXT, ''),
|
||||||
|
a.file_name,
|
||||||
|
a.content_type,
|
||||||
|
a.data
|
||||||
|
FROM message_attachments a
|
||||||
|
LEFT JOIN messages m ON m.id = a.message_id
|
||||||
|
WHERE a.id = $1
|
||||||
|
`,
|
||||||
|
attachmentID,
|
||||||
|
).Scan(
|
||||||
|
&uploaderID,
|
||||||
|
&messageID,
|
||||||
|
&conversationID,
|
||||||
|
&fileName,
|
||||||
|
&contentType,
|
||||||
|
&data,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "Datei wurde nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed := messageID == "" && uploaderID == user.ID
|
||||||
|
if messageID != "" {
|
||||||
|
allowed = s.canAccessConversation(r.Context(), conversationID, user.ID)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
writeError(w, http.StatusForbidden, "Kein Zugriff auf diese Datei")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
dispositionType := "attachment"
|
||||||
|
if strings.HasPrefix(strings.ToLower(contentType), "image/") {
|
||||||
|
dispositionType = "inline"
|
||||||
|
}
|
||||||
|
disposition := mime.FormatMediaType(dispositionType, map[string]string{
|
||||||
|
"filename": fileName,
|
||||||
|
})
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
w.Header().Set("Content-Disposition", disposition)
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
361
backend/chat_link_preview.go
Normal file
361
backend/chat_link_preview.go
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxLinkPreviewBytes = 512 * 1024
|
||||||
|
linkPreviewUserAgent = "Mozilla/5.0 (compatible; TEG-LinkPreview/1.0)"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errLinkPreviewBlocked = errors.New("zieladresse ist nicht erlaubt")
|
||||||
|
|
||||||
|
chatURLPattern = regexp.MustCompile(`https?://[^\s<>"']+`)
|
||||||
|
metaTagPattern = regexp.MustCompile(`(?is)<meta\b[^>]*>`)
|
||||||
|
metaAttrPattern = regexp.MustCompile(`(?is)([a-zA-Z:_-]+)\s*=\s*"([^"]*)"|([a-zA-Z:_-]+)\s*=\s*'([^']*)'`)
|
||||||
|
titleTagPattern = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
|
||||||
|
htmlTagPattern = regexp.MustCompile(`(?is)<[^>]+>`)
|
||||||
|
trailingTrimSet = ".,!?;:)]}'\""
|
||||||
|
)
|
||||||
|
|
||||||
|
// firstHTTPURL liefert die erste http(s)-URL aus dem Nachrichtentext.
|
||||||
|
func firstHTTPURL(body string) string {
|
||||||
|
match := chatURLPattern.FindString(body)
|
||||||
|
if match == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimRight(match, trailingTrimSet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isBlockedIP verhindert SSRF: interne/lokale Adressbereiche sind tabu.
|
||||||
|
func isBlockedIP(ip net.IP) bool {
|
||||||
|
if ip == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if ip4 := ip.To4(); ip4 != nil {
|
||||||
|
ip = ip4
|
||||||
|
}
|
||||||
|
return ip.IsLoopback() ||
|
||||||
|
ip.IsPrivate() ||
|
||||||
|
ip.IsUnspecified() ||
|
||||||
|
ip.IsLinkLocalUnicast() ||
|
||||||
|
ip.IsLinkLocalMulticast() ||
|
||||||
|
ip.IsInterfaceLocalMulticast() ||
|
||||||
|
ip.IsMulticast()
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeDialControl wird unmittelbar vor dem Verbindungsaufbau mit der bereits
|
||||||
|
// aufgelösten Ziel-IP aufgerufen – das blockt auch DNS-Rebinding.
|
||||||
|
func safeDialControl(network, address string, _ syscall.RawConn) error {
|
||||||
|
switch network {
|
||||||
|
case "tcp", "tcp4", "tcp6":
|
||||||
|
default:
|
||||||
|
return errLinkPreviewBlocked
|
||||||
|
}
|
||||||
|
|
||||||
|
host, _, err := net.SplitHostPort(address)
|
||||||
|
if err != nil {
|
||||||
|
return errLinkPreviewBlocked
|
||||||
|
}
|
||||||
|
if isBlockedIP(net.ParseIP(host)) {
|
||||||
|
return errLinkPreviewBlocked
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLinkPreviewClient() *http.Client {
|
||||||
|
dialer := &net.Dialer{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
Control: safeDialControl,
|
||||||
|
}
|
||||||
|
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: dialer.DialContext,
|
||||||
|
TLSHandshakeTimeout: 5 * time.Second,
|
||||||
|
ResponseHeaderTimeout: 5 * time.Second,
|
||||||
|
DisableKeepAlives: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
Transport: transport,
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= 3 {
|
||||||
|
return errLinkPreviewBlocked
|
||||||
|
}
|
||||||
|
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
|
||||||
|
return errLinkPreviewBlocked
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchLinkPreview(ctx context.Context, rawURL string) (*ChatLinkPreview, error) {
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||||
|
return nil, errLinkPreviewBlocked
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", linkPreviewUserAgent)
|
||||||
|
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||||
|
req.Header.Set("Accept-Language", "de,en;q=0.8")
|
||||||
|
|
||||||
|
resp, err := newLinkPreviewClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, errLinkPreviewBlocked
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
|
||||||
|
if contentType != "" && !strings.Contains(contentType, "html") {
|
||||||
|
return nil, errLinkPreviewBlocked
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxLinkPreviewBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
preview := parseOpenGraph(string(body), resp.Request.URL)
|
||||||
|
preview.URL = rawURL
|
||||||
|
return preview, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOpenGraph(htmlBody string, baseURL *url.URL) *ChatLinkPreview {
|
||||||
|
meta := map[string]string{}
|
||||||
|
|
||||||
|
for _, tag := range metaTagPattern.FindAllString(htmlBody, 300) {
|
||||||
|
var key, content string
|
||||||
|
hasContent := false
|
||||||
|
|
||||||
|
for _, attr := range metaAttrPattern.FindAllStringSubmatch(tag, -1) {
|
||||||
|
name := strings.ToLower(attr[1])
|
||||||
|
value := attr[2]
|
||||||
|
if name == "" {
|
||||||
|
name = strings.ToLower(attr[3])
|
||||||
|
value = attr[4]
|
||||||
|
}
|
||||||
|
value = html.UnescapeString(value)
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "property", "name":
|
||||||
|
key = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
case "content":
|
||||||
|
content = value
|
||||||
|
hasContent = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if key != "" && hasContent {
|
||||||
|
if _, exists := meta[key]; !exists {
|
||||||
|
meta[key] = content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pick := func(keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value := strings.TrimSpace(meta[key]); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
preview := &ChatLinkPreview{}
|
||||||
|
|
||||||
|
preview.Title = pick("og:title", "twitter:title")
|
||||||
|
if preview.Title == "" {
|
||||||
|
if match := titleTagPattern.FindStringSubmatch(htmlBody); len(match) > 1 {
|
||||||
|
preview.Title = strings.TrimSpace(
|
||||||
|
html.UnescapeString(htmlTagPattern.ReplaceAllString(match[1], "")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
preview.Description = pick("og:description", "twitter:description", "description")
|
||||||
|
preview.SiteName = pick("og:site_name")
|
||||||
|
if preview.SiteName == "" && baseURL != nil {
|
||||||
|
preview.SiteName = baseURL.Hostname()
|
||||||
|
}
|
||||||
|
|
||||||
|
if image := pick("og:image", "og:image:url", "og:image:secure_url", "twitter:image", "twitter:image:src"); image != "" {
|
||||||
|
preview.ImageURL = resolveImageURL(image, baseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
preview.Title = truncateRunes(preview.Title, 200)
|
||||||
|
preview.Description = truncateRunes(preview.Description, 400)
|
||||||
|
preview.SiteName = truncateRunes(preview.SiteName, 100)
|
||||||
|
|
||||||
|
return preview
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveImageURL(image string, baseURL *url.URL) string {
|
||||||
|
parsed, err := url.Parse(strings.TrimSpace(image))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if baseURL != nil {
|
||||||
|
parsed = baseURL.ResolveReference(parsed)
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parsed.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateRunes(value string, max int) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if runes := []rune(value); len(runes) > max {
|
||||||
|
return strings.TrimSpace(string(runes[:max]))
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getChatLinkPreview(ctx context.Context, messageID string) (*ChatLinkPreview, error) {
|
||||||
|
var preview ChatLinkPreview
|
||||||
|
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT url, title, description, image_url, site_name
|
||||||
|
FROM message_link_previews
|
||||||
|
WHERE message_id = $1
|
||||||
|
`,
|
||||||
|
messageID,
|
||||||
|
).Scan(
|
||||||
|
&preview.URL,
|
||||||
|
&preview.Title,
|
||||||
|
&preview.Description,
|
||||||
|
&preview.ImageURL,
|
||||||
|
&preview.SiteName,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &preview, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatLinkPreviewRequest struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleChatLinkPreview liefert eine Vorschau für eine einzelne URL on demand,
|
||||||
|
// damit der Absender sie schon vor dem Senden über dem Eingabefeld sieht.
|
||||||
|
func (s *Server) handleChatLinkPreview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := userFromContext(r.Context()); !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var input chatLinkPreviewRequest
|
||||||
|
if err := readJSON(r, &input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rawURL := firstHTTPURL(input.URL)
|
||||||
|
if rawURL == "" {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"preview": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
preview, err := fetchLinkPreview(ctx, rawURL)
|
||||||
|
if err != nil ||
|
||||||
|
preview == nil ||
|
||||||
|
(preview.Title == "" && preview.Description == "" && preview.ImageURL == "") {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"preview": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"preview": preview})
|
||||||
|
}
|
||||||
|
|
||||||
|
// scheduleLinkPreview lädt – sofern die Nachricht eine URL enthält – die
|
||||||
|
// Vorschau asynchron, damit der Versand nicht durch externe Requests blockiert.
|
||||||
|
func (s *Server) scheduleLinkPreview(message ChatMessage) {
|
||||||
|
rawURL := firstHTTPURL(message.Body)
|
||||||
|
if rawURL == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go s.generateLinkPreview(message.ID, rawURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) generateLinkPreview(messageID string, rawURL string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
preview, err := fetchLinkPreview(ctx, rawURL)
|
||||||
|
if err != nil || preview == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if preview.Title == "" && preview.Description == "" && preview.ImageURL == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.Exec(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
INSERT INTO message_link_previews (
|
||||||
|
message_id, url, title, description, image_url, site_name
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (message_id) DO UPDATE SET
|
||||||
|
url = EXCLUDED.url,
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
image_url = EXCLUDED.image_url,
|
||||||
|
site_name = EXCLUDED.site_name
|
||||||
|
`,
|
||||||
|
messageID,
|
||||||
|
preview.URL,
|
||||||
|
preview.Title,
|
||||||
|
preview.Description,
|
||||||
|
preview.ImageURL,
|
||||||
|
preview.SiteName,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
message, err := s.getChatMessage(ctx, messageID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.publishChatMessageUpdate(ctx, message)
|
||||||
|
}
|
||||||
65
backend/chat_preferences.go
Normal file
65
backend/chat_preferences.go
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type updateConversationMuteRequest struct {
|
||||||
|
Muted bool `json:"muted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdateConversationMute(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := userFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conversationID := strings.TrimSpace(r.PathValue("id"))
|
||||||
|
if !s.canAccessConversation(r.Context(), conversationID, user.ID) {
|
||||||
|
writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var conversationType string
|
||||||
|
if err := s.db.QueryRow(
|
||||||
|
r.Context(),
|
||||||
|
`SELECT type FROM conversations WHERE id = $1`,
|
||||||
|
conversationID,
|
||||||
|
).Scan(&conversationType); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "Chat wurde nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if conversationType == "direct" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Einzelchats können nicht stummgeschaltet werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var input updateConversationMuteRequest
|
||||||
|
if err := readJSON(r, &input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
INSERT INTO conversation_members (conversation_id, user_id, muted)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (conversation_id, user_id)
|
||||||
|
DO UPDATE SET muted = EXCLUDED.muted
|
||||||
|
`,
|
||||||
|
conversationID,
|
||||||
|
user.ID,
|
||||||
|
input.Muted,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Stummschaltung konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"muted": input.Muted,
|
||||||
|
})
|
||||||
|
}
|
||||||
109
backend/chat_search.go
Normal file
109
backend/chat_search.go
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChatSearchResult struct {
|
||||||
|
ConversationID string `json:"conversationId"`
|
||||||
|
MessageID string `json:"messageId"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
SenderDisplayName string `json:"senderDisplayName"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSearchChatMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := userFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
if query == "" {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"results": []ChatSearchResult{},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
SELECT DISTINCT ON (m.conversation_id)
|
||||||
|
m.conversation_id::TEXT,
|
||||||
|
m.id::TEXT,
|
||||||
|
m.body,
|
||||||
|
COALESCE(
|
||||||
|
NULLIF(u.display_name, ''),
|
||||||
|
NULLIF(u.username, ''),
|
||||||
|
u.email,
|
||||||
|
NULLIF(c.channel_source_name, ''),
|
||||||
|
c.name,
|
||||||
|
''
|
||||||
|
),
|
||||||
|
m.created_at
|
||||||
|
FROM messages m
|
||||||
|
JOIN conversations c ON c.id = m.conversation_id
|
||||||
|
LEFT JOIN users u ON u.id = m.sender_id
|
||||||
|
WHERE m.deleted_at IS NULL
|
||||||
|
AND strpos(lower(m.body), lower($2)) > 0
|
||||||
|
AND (
|
||||||
|
(
|
||||||
|
c.type = 'team'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM user_teams ut
|
||||||
|
WHERE ut.team_id = c.team_id
|
||||||
|
AND ut.user_id = $1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
c.type <> 'team'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM conversation_members cm
|
||||||
|
WHERE cm.conversation_id = c.id
|
||||||
|
AND cm.user_id = $1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY m.conversation_id, m.created_at DESC, m.id DESC
|
||||||
|
LIMIT 50
|
||||||
|
`,
|
||||||
|
user.ID,
|
||||||
|
query,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Chatnachrichten konnten nicht durchsucht werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
results := []ChatSearchResult{}
|
||||||
|
for rows.Next() {
|
||||||
|
var result ChatSearchResult
|
||||||
|
if err := rows.Scan(
|
||||||
|
&result.ConversationID,
|
||||||
|
&result.MessageID,
|
||||||
|
&result.Body,
|
||||||
|
&result.SenderDisplayName,
|
||||||
|
&result.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Suchergebnisse konnten nicht gelesen werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results = append(results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Suchergebnisse konnten nicht vollständig geladen werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"results": results,
|
||||||
|
})
|
||||||
|
}
|
||||||
121
backend/chat_unread.go
Normal file
121
backend/chat_unread.go
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) markChatConversationRead(
|
||||||
|
ctx context.Context,
|
||||||
|
conversationID string,
|
||||||
|
userID string,
|
||||||
|
) error {
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
INSERT INTO conversation_members (
|
||||||
|
conversation_id,
|
||||||
|
user_id,
|
||||||
|
last_read_at
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, now())
|
||||||
|
ON CONFLICT (conversation_id, user_id)
|
||||||
|
DO UPDATE SET last_read_at = EXCLUDED.last_read_at
|
||||||
|
`,
|
||||||
|
conversationID,
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getChatUnreadCount(ctx context.Context, userID string) (int, error) {
|
||||||
|
var unreadCount int
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT COUNT(*)::INT
|
||||||
|
FROM messages m
|
||||||
|
JOIN conversations c ON c.id = m.conversation_id
|
||||||
|
LEFT JOIN conversation_members cm
|
||||||
|
ON cm.conversation_id = c.id
|
||||||
|
AND cm.user_id = $1
|
||||||
|
WHERE m.deleted_at IS NULL
|
||||||
|
AND m.sender_id IS DISTINCT FROM $1::UUID
|
||||||
|
AND m.created_at > COALESCE(cm.last_read_at, '-infinity'::TIMESTAMPTZ)
|
||||||
|
AND (
|
||||||
|
(
|
||||||
|
c.type = 'team'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM user_teams ut
|
||||||
|
WHERE ut.team_id = c.team_id
|
||||||
|
AND ut.user_id = $1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
c.type <> 'team'
|
||||||
|
AND cm.user_id IS NOT NULL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
userID,
|
||||||
|
).Scan(&unreadCount)
|
||||||
|
return unreadCount, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetChatUnreadCount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := userFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.ensureTeamConversations(r.Context(), user.ID); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Team-Chats konnten nicht vorbereitet werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.ensureAllUserChannels(r.Context(), user.ID); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Channels konnten nicht vorbereitet werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
unreadCount, err := s.getChatUnreadCount(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Ungelesene Nachrichten konnten nicht geladen werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"unreadCount": unreadCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMarkChatConversationRead(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := userFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conversationID := strings.TrimSpace(r.PathValue("id"))
|
||||||
|
if !s.canAccessConversation(r.Context(), conversationID, user.ID) {
|
||||||
|
writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.markChatConversationRead(r.Context(), conversationID, user.ID); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Lesestatus konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
unreadCount, err := s.getChatUnreadCount(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Ungelesene Nachrichten konnten nicht geladen werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"unreadCount": unreadCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
464
backend/chat_websocket.go
Normal file
464
backend/chat_websocket.go
Normal file
@ -0,0 +1,464 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
"github.com/coder/websocket/wsjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
type chatSocketCommand struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ClientID string `json:"clientId"`
|
||||||
|
ConversationID string `json:"conversationId"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
AttachmentIDs []string `json:"attachmentIds"`
|
||||||
|
ReplyToMessageID string `json:"replyToMessageId"`
|
||||||
|
ForwardedFromMessageID string `json:"forwardedFromMessageId"`
|
||||||
|
Location *ChatLocation `json:"location"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatSocketEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ClientID string `json:"clientId,omitempty"`
|
||||||
|
ConversationID string `json:"conversationId,omitempty"`
|
||||||
|
UserID string `json:"userId,omitempty"`
|
||||||
|
UserDisplayName string `json:"userDisplayName,omitempty"`
|
||||||
|
Typing bool `json:"typing,omitempty"`
|
||||||
|
Message *ChatMessage `json:"message,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatSocketClient struct {
|
||||||
|
userID string
|
||||||
|
ch chan chatSocketEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatSocketBroker struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
clients map[*chatSocketClient]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var chatSockets = &chatSocketBroker{
|
||||||
|
clients: map[*chatSocketClient]struct{}{},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *chatSocketBroker) subscribe(userID string) *chatSocketClient {
|
||||||
|
client := &chatSocketClient{
|
||||||
|
userID: userID,
|
||||||
|
ch: make(chan chatSocketEvent, 32),
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
b.clients[client] = struct{}{}
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *chatSocketBroker) unsubscribe(client *chatSocketClient) {
|
||||||
|
b.mu.Lock()
|
||||||
|
delete(b.clients, client)
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *chatSocketBroker) publish(userID string, event chatSocketEvent) {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
|
||||||
|
for client := range b.clients {
|
||||||
|
if client.userID != userID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case client.ch <- event:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chatSocketClient) send(event chatSocketEvent) {
|
||||||
|
select {
|
||||||
|
case c.ch <- event:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleChatWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := userFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
originPatterns := []string{}
|
||||||
|
if frontendOrigin := strings.TrimRight(s.frontendOrigin, "/"); frontendOrigin != "" {
|
||||||
|
originPatterns = append(originPatterns, frontendOrigin)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||||
|
OriginPatterns: originPatterns,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.CloseNow()
|
||||||
|
|
||||||
|
conn.SetReadLimit(8 * 1024)
|
||||||
|
|
||||||
|
client := chatSockets.subscribe(user.ID)
|
||||||
|
defer chatSockets.unsubscribe(client)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
go s.readChatSocket(ctx, cancel, conn, client, user)
|
||||||
|
|
||||||
|
client.send(chatSocketEvent{Type: "connected"})
|
||||||
|
|
||||||
|
pingTicker := time.NewTicker(30 * time.Second)
|
||||||
|
defer pingTicker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
|
||||||
|
case event, open := <-client.ch:
|
||||||
|
if !open {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeCtx, writeCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
err := wsjson.Write(writeCtx, conn, event)
|
||||||
|
writeCancel()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-pingTicker.C:
|
||||||
|
pingCtx, pingCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
err := conn.Ping(pingCtx)
|
||||||
|
pingCancel()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) readChatSocket(
|
||||||
|
ctx context.Context,
|
||||||
|
cancel context.CancelFunc,
|
||||||
|
conn *websocket.Conn,
|
||||||
|
client *chatSocketClient,
|
||||||
|
user User,
|
||||||
|
) {
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
for {
|
||||||
|
var command chatSocketCommand
|
||||||
|
if err := wsjson.Read(ctx, conn, &command); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch command.Type {
|
||||||
|
case "typing.start", "typing.stop":
|
||||||
|
if !s.canAccessConversation(ctx, command.ConversationID, user.ID) ||
|
||||||
|
!s.isConversationWritable(ctx, command.ConversationID) {
|
||||||
|
client.send(chatSocketEvent{
|
||||||
|
Type: "error",
|
||||||
|
Error: "Dieser Chat ist schreibgeschützt",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
s.publishChatTyping(
|
||||||
|
ctx,
|
||||||
|
user,
|
||||||
|
command.ConversationID,
|
||||||
|
command.Type == "typing.start",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
case "message.send":
|
||||||
|
message, err := s.createChatMessage(
|
||||||
|
ctx,
|
||||||
|
user,
|
||||||
|
command.ConversationID,
|
||||||
|
createChatMessageInput{
|
||||||
|
Body: command.Body,
|
||||||
|
AttachmentIDs: command.AttachmentIDs,
|
||||||
|
ReplyToMessageID: command.ReplyToMessageID,
|
||||||
|
ForwardedFromMessageID: command.ForwardedFromMessageID,
|
||||||
|
Location: command.Location,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
client.send(chatSocketEvent{
|
||||||
|
Type: "error",
|
||||||
|
ClientID: command.ClientID,
|
||||||
|
Error: chatSocketErrorMessage(err),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
s.publishChatMessage(ctx, message, command.ClientID)
|
||||||
|
continue
|
||||||
|
|
||||||
|
default:
|
||||||
|
client.send(chatSocketEvent{
|
||||||
|
Type: "error",
|
||||||
|
ClientID: command.ClientID,
|
||||||
|
Error: "Unbekannte Chat-Aktion",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatSocketErrorMessage(err error) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errChatAccessDenied):
|
||||||
|
return "Kein Zugriff auf diesen Chat"
|
||||||
|
case errors.Is(err, errChatMessageEmpty):
|
||||||
|
return "Nachricht darf nicht leer sein"
|
||||||
|
case errors.Is(err, errChatMessageTooLong):
|
||||||
|
return "Nachricht darf höchstens 4000 Zeichen enthalten"
|
||||||
|
case errors.Is(err, errChatLocationInvalid):
|
||||||
|
return "Ungültiger Standort"
|
||||||
|
case errors.Is(err, errChatForwardSame):
|
||||||
|
return "Nachrichten können nicht in denselben Chat weitergeleitet werden"
|
||||||
|
case errors.Is(err, errChatReadOnly):
|
||||||
|
return "Channels sind schreibgeschützt"
|
||||||
|
default:
|
||||||
|
return "Nachricht konnte nicht gesendet werden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) publishChatTyping(
|
||||||
|
ctx context.Context,
|
||||||
|
user User,
|
||||||
|
conversationID string,
|
||||||
|
typing bool,
|
||||||
|
) {
|
||||||
|
memberIDs, err := s.chatConversationMemberIDs(ctx, conversationID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event := chatSocketEvent{
|
||||||
|
Type: "typing.updated",
|
||||||
|
ConversationID: conversationID,
|
||||||
|
UserID: user.ID,
|
||||||
|
UserDisplayName: user.DisplayName,
|
||||||
|
Typing: typing,
|
||||||
|
}
|
||||||
|
if event.UserDisplayName == "" {
|
||||||
|
event.UserDisplayName = user.Username
|
||||||
|
}
|
||||||
|
if event.UserDisplayName == "" {
|
||||||
|
event.UserDisplayName = user.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, memberID := range memberIDs {
|
||||||
|
if memberID != user.ID {
|
||||||
|
chatSockets.publish(memberID, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) publishChatMessage(
|
||||||
|
ctx context.Context,
|
||||||
|
message ChatMessage,
|
||||||
|
clientID string,
|
||||||
|
) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT cm.user_id::TEXT, cm.muted
|
||||||
|
FROM conversation_members cm
|
||||||
|
JOIN conversations c ON c.id = cm.conversation_id
|
||||||
|
WHERE cm.conversation_id = $1
|
||||||
|
AND (
|
||||||
|
c.type <> 'team'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM user_teams ut
|
||||||
|
WHERE ut.team_id = c.team_id
|
||||||
|
AND ut.user_id = cm.user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
message.ConversationID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
event := chatSocketEvent{
|
||||||
|
Type: "message.created",
|
||||||
|
ClientID: clientID,
|
||||||
|
Message: &message,
|
||||||
|
}
|
||||||
|
|
||||||
|
recipientIDs := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var recipientID string
|
||||||
|
var muted bool
|
||||||
|
if err := rows.Scan(&recipientID, &muted); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
chatSockets.publish(recipientID, event)
|
||||||
|
if recipientID != message.Sender.ID && !muted {
|
||||||
|
recipientIDs = append(recipientIDs, recipientID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
title, conversationType, err := s.chatNotificationTitle(ctx, message)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
preview := strings.TrimSpace(message.Body)
|
||||||
|
if preview == "" && len(message.Attachments) > 0 {
|
||||||
|
preview = "Datei angehängt"
|
||||||
|
}
|
||||||
|
previewRunes := []rune(preview)
|
||||||
|
if len(previewRunes) > 160 {
|
||||||
|
preview = string(previewRunes[:160]) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(map[string]any{
|
||||||
|
"conversationId": message.ConversationID,
|
||||||
|
"messageId": message.ID,
|
||||||
|
"senderId": message.Sender.ID,
|
||||||
|
"senderAvatar": message.Sender.Avatar,
|
||||||
|
"senderName": chatUserDisplayName(message.Sender),
|
||||||
|
"conversationType": conversationType,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, recipientID := range recipientIDs {
|
||||||
|
_ = s.createAndPublishNotification(
|
||||||
|
ctx,
|
||||||
|
recipientID,
|
||||||
|
"chat.message",
|
||||||
|
title,
|
||||||
|
preview,
|
||||||
|
"chat",
|
||||||
|
message.ConversationID,
|
||||||
|
data,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.scheduleLinkPreview(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) chatConversationMemberIDs(ctx context.Context, conversationID string) ([]string, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT cm.user_id::TEXT
|
||||||
|
FROM conversation_members cm
|
||||||
|
JOIN conversations c ON c.id = cm.conversation_id
|
||||||
|
WHERE cm.conversation_id = $1
|
||||||
|
AND (
|
||||||
|
c.type <> 'team'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM user_teams ut
|
||||||
|
WHERE ut.team_id = c.team_id
|
||||||
|
AND ut.user_id = cm.user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
conversationID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
memberIDs := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var memberID string
|
||||||
|
if err := rows.Scan(&memberID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
memberIDs = append(memberIDs, memberID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return memberIDs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) publishChatMessageUpdate(ctx context.Context, message ChatMessage) {
|
||||||
|
memberIDs, err := s.chatConversationMemberIDs(ctx, message.ConversationID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event := chatSocketEvent{
|
||||||
|
Type: "message.updated",
|
||||||
|
Message: &message,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, memberID := range memberIDs {
|
||||||
|
chatSockets.publish(memberID, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatUserDisplayName(user ChatUser) string {
|
||||||
|
if user.DisplayName != "" {
|
||||||
|
return user.DisplayName
|
||||||
|
}
|
||||||
|
if user.Username != "" {
|
||||||
|
return user.Username
|
||||||
|
}
|
||||||
|
return user.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) chatNotificationTitle(
|
||||||
|
ctx context.Context,
|
||||||
|
message ChatMessage,
|
||||||
|
) (string, string, error) {
|
||||||
|
var conversationType string
|
||||||
|
var conversationName string
|
||||||
|
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT type, name
|
||||||
|
FROM conversations
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
message.ConversationID,
|
||||||
|
).Scan(&conversationType, &conversationName)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
senderName := chatUserDisplayName(message.Sender)
|
||||||
|
|
||||||
|
if conversationType == "team" && conversationName != "" {
|
||||||
|
return senderName + " in " + conversationName, conversationType, nil
|
||||||
|
}
|
||||||
|
if conversationType == "channel" && conversationName != "" {
|
||||||
|
return "Neue Meldung in " + conversationName, conversationType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Neue Nachricht von " + senderName, conversationType, nil
|
||||||
|
}
|
||||||
@ -15,8 +15,17 @@ import (
|
|||||||
|
|
||||||
type CreateFeedbackRequest struct {
|
type CreateFeedbackRequest struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
|
// ClientLog ist das clientseitig erfasste Aktivitäts-/Fehlerprotokoll
|
||||||
|
// (Array). Client enthält Browser-/Geräte-Metadaten.
|
||||||
|
ClientLog json.RawMessage `json:"clientLog"`
|
||||||
|
Client json.RawMessage `json:"client"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxFeedbackClientLogBytes = 512 * 1024
|
||||||
|
maxFeedbackClientMetaBytes = 16 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
type FeedbackResponse struct {
|
type FeedbackResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
@ -63,6 +72,16 @@ func (s *Server) handleCreateFeedback(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var clientActivity any = json.RawMessage("[]")
|
||||||
|
if len(input.ClientLog) > 0 && len(input.ClientLog) <= maxFeedbackClientLogBytes {
|
||||||
|
clientActivity = input.ClientLog
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientMeta any
|
||||||
|
if len(input.Client) > 0 && len(input.Client) <= maxFeedbackClientMetaBytes {
|
||||||
|
clientMeta = input.Client
|
||||||
|
}
|
||||||
|
|
||||||
feedbackLog := map[string]any{
|
feedbackLog := map[string]any{
|
||||||
"user": buildUserLogSnapshot(user),
|
"user": buildUserLogSnapshot(user),
|
||||||
"request": mergeLogFields(
|
"request": mergeLogFields(
|
||||||
@ -74,6 +93,8 @@ func (s *Server) handleCreateFeedback(w http.ResponseWriter, r *http.Request) {
|
|||||||
"server": map[string]any{
|
"server": map[string]any{
|
||||||
"createdAt": time.Now().UTC().Format(time.RFC3339Nano),
|
"createdAt": time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
},
|
},
|
||||||
|
"client": clientMeta,
|
||||||
|
"activity": clientActivity,
|
||||||
}
|
}
|
||||||
|
|
||||||
logJSON, err := json.Marshal(feedbackLog)
|
logJSON, err := json.Marshal(feedbackLog)
|
||||||
|
|||||||
@ -5,13 +5,12 @@ module main
|
|||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/coder/websocket v1.8.14
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/jackc/pgx/v5 v5.9.2
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
golang.org/x/crypto v0.51.0
|
golang.org/x/crypto v0.51.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require github.com/coder/websocket v1.8.14 // indirect
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
|||||||
@ -37,6 +37,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
server := NewServer(db, jwtSecret, frontendOrigin)
|
server := NewServer(db, jwtSecret, frontendOrigin)
|
||||||
|
server.startPresenceMonitor(ctx)
|
||||||
|
|
||||||
log.Printf("Backend läuft auf http://localhost:%s", port)
|
log.Printf("Backend läuft auf http://localhost:%s", port)
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,8 @@ type notificationPreferencesResponse struct {
|
|||||||
DeviceUpdates bool `json:"deviceUpdates"`
|
DeviceUpdates bool `json:"deviceUpdates"`
|
||||||
DeviceJournals bool `json:"deviceJournals"`
|
DeviceJournals bool `json:"deviceJournals"`
|
||||||
SystemMessages bool `json:"systemMessages"`
|
SystemMessages bool `json:"systemMessages"`
|
||||||
|
ChatMessages bool `json:"chatMessages"`
|
||||||
|
DesktopEnabled bool `json:"desktopEnabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultNotificationPreferences() notificationPreferencesResponse {
|
func defaultNotificationPreferences() notificationPreferencesResponse {
|
||||||
@ -34,6 +36,8 @@ func defaultNotificationPreferences() notificationPreferencesResponse {
|
|||||||
DeviceUpdates: true,
|
DeviceUpdates: true,
|
||||||
DeviceJournals: true,
|
DeviceJournals: true,
|
||||||
SystemMessages: true,
|
SystemMessages: true,
|
||||||
|
ChatMessages: true,
|
||||||
|
DesktopEnabled: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,9 +106,11 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
|
|||||||
operation_journals,
|
operation_journals,
|
||||||
device_updates,
|
device_updates,
|
||||||
device_journals,
|
device_journals,
|
||||||
system_messages
|
system_messages,
|
||||||
|
chat_messages,
|
||||||
|
desktop_enabled
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
ON CONFLICT (user_id)
|
ON CONFLICT (user_id)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
in_app_enabled = EXCLUDED.in_app_enabled,
|
in_app_enabled = EXCLUDED.in_app_enabled,
|
||||||
@ -116,6 +122,8 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
|
|||||||
device_updates = EXCLUDED.device_updates,
|
device_updates = EXCLUDED.device_updates,
|
||||||
device_journals = EXCLUDED.device_journals,
|
device_journals = EXCLUDED.device_journals,
|
||||||
system_messages = EXCLUDED.system_messages,
|
system_messages = EXCLUDED.system_messages,
|
||||||
|
chat_messages = EXCLUDED.chat_messages,
|
||||||
|
desktop_enabled = EXCLUDED.desktop_enabled,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
`,
|
`,
|
||||||
user.ID,
|
user.ID,
|
||||||
@ -128,6 +136,8 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
|
|||||||
input.DeviceUpdates,
|
input.DeviceUpdates,
|
||||||
input.DeviceJournals,
|
input.DeviceJournals,
|
||||||
input.SystemMessages,
|
input.SystemMessages,
|
||||||
|
input.ChatMessages,
|
||||||
|
input.DesktopEnabled,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen konnten nicht gespeichert werden")
|
writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen konnten nicht gespeichert werden")
|
||||||
@ -160,7 +170,9 @@ func (s *Server) getNotificationPreferences(ctx context.Context, userID string)
|
|||||||
operation_journals,
|
operation_journals,
|
||||||
device_updates,
|
device_updates,
|
||||||
device_journals,
|
device_journals,
|
||||||
system_messages
|
system_messages,
|
||||||
|
chat_messages,
|
||||||
|
desktop_enabled
|
||||||
FROM notification_preferences
|
FROM notification_preferences
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@ -176,6 +188,8 @@ func (s *Server) getNotificationPreferences(ctx context.Context, userID string)
|
|||||||
&settings.DeviceUpdates,
|
&settings.DeviceUpdates,
|
||||||
&settings.DeviceJournals,
|
&settings.DeviceJournals,
|
||||||
&settings.SystemMessages,
|
&settings.SystemMessages,
|
||||||
|
&settings.ChatMessages,
|
||||||
|
&settings.DesktopEnabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
settings.SoundName = normalizeNotificationSoundName(settings.SoundName)
|
settings.SoundName = normalizeNotificationSoundName(settings.SoundName)
|
||||||
|
|||||||
@ -24,6 +24,8 @@ type Notification struct {
|
|||||||
Data json.RawMessage `json:"data"`
|
Data json.RawMessage `json:"data"`
|
||||||
Silent bool `json:"silent"`
|
Silent bool `json:"silent"`
|
||||||
SoundName string `json:"soundName,omitempty"`
|
SoundName string `json:"soundName,omitempty"`
|
||||||
|
Desktop bool `json:"desktop,omitempty"`
|
||||||
|
InApp bool `json:"inAppEnabled"`
|
||||||
ReadAt *time.Time `json:"readAt"`
|
ReadAt *time.Time `json:"readAt"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
@ -95,11 +97,13 @@ func shouldDeliverNotification(
|
|||||||
notificationType string,
|
notificationType string,
|
||||||
entityType string,
|
entityType string,
|
||||||
) bool {
|
) bool {
|
||||||
if !preferences.InAppEnabled {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
|
case strings.HasPrefix(notificationType, "chat.") || entityType == "chat":
|
||||||
|
return preferences.ChatMessages
|
||||||
|
|
||||||
|
case !preferences.InAppEnabled:
|
||||||
|
return false
|
||||||
|
|
||||||
case notificationType == "operation.journal":
|
case notificationType == "operation.journal":
|
||||||
return preferences.OperationJournals
|
return preferences.OperationJournals
|
||||||
|
|
||||||
@ -150,6 +154,8 @@ func (s *Server) createAndPublishNotification(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inAppVisible := silent || (preferences.InAppEnabled && entityType != "chat")
|
||||||
|
|
||||||
var notification Notification
|
var notification Notification
|
||||||
var rawData []byte
|
var rawData []byte
|
||||||
|
|
||||||
@ -164,7 +170,8 @@ func (s *Server) createAndPublishNotification(
|
|||||||
entity_type,
|
entity_type,
|
||||||
entity_id,
|
entity_id,
|
||||||
data,
|
data,
|
||||||
silent
|
silent,
|
||||||
|
in_app_visible
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
$1,
|
$1,
|
||||||
@ -174,7 +181,8 @@ func (s *Server) createAndPublishNotification(
|
|||||||
$5,
|
$5,
|
||||||
NULLIF($6, '')::UUID,
|
NULLIF($6, '')::UUID,
|
||||||
$7::JSONB,
|
$7::JSONB,
|
||||||
$8
|
$8,
|
||||||
|
$9
|
||||||
)
|
)
|
||||||
RETURNING
|
RETURNING
|
||||||
id::TEXT,
|
id::TEXT,
|
||||||
@ -196,6 +204,7 @@ func (s *Server) createAndPublishNotification(
|
|||||||
entityID,
|
entityID,
|
||||||
string(dataJSON),
|
string(dataJSON),
|
||||||
silent,
|
silent,
|
||||||
|
inAppVisible,
|
||||||
).Scan(
|
).Scan(
|
||||||
¬ification.ID,
|
¬ification.ID,
|
||||||
¬ification.UserID,
|
¬ification.UserID,
|
||||||
@ -216,6 +225,11 @@ func (s *Server) createAndPublishNotification(
|
|||||||
|
|
||||||
if !notification.Silent {
|
if !notification.Silent {
|
||||||
notification.SoundName = notificationSoundName(preferences)
|
notification.SoundName = notificationSoundName(preferences)
|
||||||
|
notification.Desktop = preferences.DesktopEnabled
|
||||||
|
notification.InApp = preferences.InAppEnabled
|
||||||
|
if notification.EntityType == "chat" {
|
||||||
|
notification.InApp = preferences.ChatMessages
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
notificationsBroker.publish(notification)
|
notificationsBroker.publish(notification)
|
||||||
@ -306,6 +320,8 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
|||||||
FROM notifications
|
FROM notifications
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
AND silent = false
|
AND silent = false
|
||||||
|
AND in_app_visible = true
|
||||||
|
AND entity_type <> 'chat'
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
`,
|
`,
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -16,16 +17,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Operation struct {
|
type Operation struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
OperationNumber string `json:"operationNumber"`
|
OperationNumber string `json:"operationNumber"`
|
||||||
OperationName string `json:"operationName"`
|
OperationName string `json:"operationName"`
|
||||||
Offense string `json:"offense"`
|
Offense string `json:"offense"`
|
||||||
CaseWorker string `json:"caseWorker"`
|
CaseWorker string `json:"caseWorker"`
|
||||||
TargetObject string `json:"targetObject"`
|
TargetObject string `json:"targetObject"`
|
||||||
KwAddress string `json:"kwAddress"`
|
KwAddress string `json:"kwAddress"`
|
||||||
OperationLeader string `json:"operationLeader"`
|
KwLatitude *float64 `json:"kwLatitude"`
|
||||||
OperationTeam string `json:"operationTeam"`
|
KwLongitude *float64 `json:"kwLongitude"`
|
||||||
Legend string `json:"legend"`
|
TargetLatitude *float64 `json:"targetLatitude"`
|
||||||
|
TargetLongitude *float64 `json:"targetLongitude"`
|
||||||
|
ViewConeFOV float64 `json:"viewConeFov"`
|
||||||
|
ViewConeLength float64 `json:"viewConeLength"`
|
||||||
|
OperationLeader string `json:"operationLeader"`
|
||||||
|
OperationTeam string `json:"operationTeam"`
|
||||||
|
Legend string `json:"legend"`
|
||||||
Camera string `json:"camera"`
|
Camera string `json:"camera"`
|
||||||
Router string `json:"router"`
|
Router string `json:"router"`
|
||||||
Technology string `json:"technology"`
|
Technology string `json:"technology"`
|
||||||
@ -47,23 +54,29 @@ type UpdateOperationJournalEntryRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CreateOperationRequest struct {
|
type CreateOperationRequest struct {
|
||||||
OperationNumber string `json:"operationNumber"`
|
OperationNumber string `json:"operationNumber"`
|
||||||
OperationName string `json:"operationName"`
|
OperationName string `json:"operationName"`
|
||||||
Offense string `json:"offense"`
|
Offense string `json:"offense"`
|
||||||
CaseWorker string `json:"caseWorker"`
|
CaseWorker string `json:"caseWorker"`
|
||||||
TargetObject string `json:"targetObject"`
|
TargetObject string `json:"targetObject"`
|
||||||
KwAddress string `json:"kwAddress"`
|
KwAddress string `json:"kwAddress"`
|
||||||
OperationLeader string `json:"operationLeader"`
|
KwLatitude *float64 `json:"kwLatitude"`
|
||||||
OperationTeam string `json:"operationTeam"`
|
KwLongitude *float64 `json:"kwLongitude"`
|
||||||
Legend string `json:"legend"`
|
TargetLatitude *float64 `json:"targetLatitude"`
|
||||||
Camera string `json:"camera"`
|
TargetLongitude *float64 `json:"targetLongitude"`
|
||||||
Router string `json:"router"`
|
ViewConeFOV float64 `json:"viewConeFov"`
|
||||||
Technology string `json:"technology"`
|
ViewConeLength float64 `json:"viewConeLength"`
|
||||||
Switchbox string `json:"switchbox"`
|
OperationLeader string `json:"operationLeader"`
|
||||||
HardDrive string `json:"hardDrive"`
|
OperationTeam string `json:"operationTeam"`
|
||||||
Laptop string `json:"laptop"`
|
Legend string `json:"legend"`
|
||||||
Remark string `json:"remark"`
|
Camera string `json:"camera"`
|
||||||
MilestoneStorage string `json:"milestoneStorage"`
|
Router string `json:"router"`
|
||||||
|
Technology string `json:"technology"`
|
||||||
|
Switchbox string `json:"switchbox"`
|
||||||
|
HardDrive string `json:"hardDrive"`
|
||||||
|
Laptop string `json:"laptop"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
MilestoneStorage string `json:"milestoneStorage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateOperationRequest = CreateOperationRequest
|
type UpdateOperationRequest = CreateOperationRequest
|
||||||
@ -119,6 +132,12 @@ func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) {
|
|||||||
case_worker,
|
case_worker,
|
||||||
target_object,
|
target_object,
|
||||||
kw_address,
|
kw_address,
|
||||||
|
kw_latitude,
|
||||||
|
kw_longitude,
|
||||||
|
target_latitude,
|
||||||
|
target_longitude,
|
||||||
|
view_cone_fov,
|
||||||
|
view_cone_length,
|
||||||
operation_leader,
|
operation_leader,
|
||||||
operation_team,
|
operation_team,
|
||||||
legend,
|
legend,
|
||||||
@ -156,6 +175,12 @@ func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) {
|
|||||||
&operation.CaseWorker,
|
&operation.CaseWorker,
|
||||||
&operation.TargetObject,
|
&operation.TargetObject,
|
||||||
&operation.KwAddress,
|
&operation.KwAddress,
|
||||||
|
&operation.KwLatitude,
|
||||||
|
&operation.KwLongitude,
|
||||||
|
&operation.TargetLatitude,
|
||||||
|
&operation.TargetLongitude,
|
||||||
|
&operation.ViewConeFOV,
|
||||||
|
&operation.ViewConeLength,
|
||||||
&operation.OperationLeader,
|
&operation.OperationLeader,
|
||||||
&operation.OperationTeam,
|
&operation.OperationTeam,
|
||||||
&operation.Legend,
|
&operation.Legend,
|
||||||
@ -201,6 +226,10 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "Einsatznummer ist erforderlich")
|
writeError(w, http.StatusBadRequest, "Einsatznummer ist erforderlich")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := validateOperationGeometry(input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
user, ok := userFromContext(r.Context())
|
user, ok := userFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@ -227,6 +256,12 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
|
|||||||
case_worker,
|
case_worker,
|
||||||
target_object,
|
target_object,
|
||||||
kw_address,
|
kw_address,
|
||||||
|
kw_latitude,
|
||||||
|
kw_longitude,
|
||||||
|
target_latitude,
|
||||||
|
target_longitude,
|
||||||
|
view_cone_fov,
|
||||||
|
view_cone_length,
|
||||||
operation_leader,
|
operation_leader,
|
||||||
operation_team,
|
operation_team,
|
||||||
legend,
|
legend,
|
||||||
@ -239,7 +274,7 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
|
|||||||
remark,
|
remark,
|
||||||
milestone_storage
|
milestone_storage
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)
|
||||||
RETURNING
|
RETURNING
|
||||||
id,
|
id,
|
||||||
operation_number,
|
operation_number,
|
||||||
@ -248,6 +283,12 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
|
|||||||
case_worker,
|
case_worker,
|
||||||
target_object,
|
target_object,
|
||||||
kw_address,
|
kw_address,
|
||||||
|
kw_latitude,
|
||||||
|
kw_longitude,
|
||||||
|
target_latitude,
|
||||||
|
target_longitude,
|
||||||
|
view_cone_fov,
|
||||||
|
view_cone_length,
|
||||||
operation_leader,
|
operation_leader,
|
||||||
operation_team,
|
operation_team,
|
||||||
legend,
|
legend,
|
||||||
@ -268,6 +309,12 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
|
|||||||
input.CaseWorker,
|
input.CaseWorker,
|
||||||
input.TargetObject,
|
input.TargetObject,
|
||||||
input.KwAddress,
|
input.KwAddress,
|
||||||
|
input.KwLatitude,
|
||||||
|
input.KwLongitude,
|
||||||
|
input.TargetLatitude,
|
||||||
|
input.TargetLongitude,
|
||||||
|
input.ViewConeFOV,
|
||||||
|
input.ViewConeLength,
|
||||||
input.OperationLeader,
|
input.OperationLeader,
|
||||||
input.OperationTeam,
|
input.OperationTeam,
|
||||||
input.Legend,
|
input.Legend,
|
||||||
@ -287,6 +334,12 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
|
|||||||
&operation.CaseWorker,
|
&operation.CaseWorker,
|
||||||
&operation.TargetObject,
|
&operation.TargetObject,
|
||||||
&operation.KwAddress,
|
&operation.KwAddress,
|
||||||
|
&operation.KwLatitude,
|
||||||
|
&operation.KwLongitude,
|
||||||
|
&operation.TargetLatitude,
|
||||||
|
&operation.TargetLongitude,
|
||||||
|
&operation.ViewConeFOV,
|
||||||
|
&operation.ViewConeLength,
|
||||||
&operation.OperationLeader,
|
&operation.OperationLeader,
|
||||||
&operation.OperationTeam,
|
&operation.OperationTeam,
|
||||||
&operation.Legend,
|
&operation.Legend,
|
||||||
@ -380,6 +433,10 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "Einsatznummer ist erforderlich")
|
writeError(w, http.StatusBadRequest, "Einsatznummer ist erforderlich")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := validateOperationGeometry(input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
user, ok := userFromContext(r.Context())
|
user, ok := userFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@ -418,17 +475,23 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
|
|||||||
case_worker = $5,
|
case_worker = $5,
|
||||||
target_object = $6,
|
target_object = $6,
|
||||||
kw_address = $7,
|
kw_address = $7,
|
||||||
operation_leader = $8,
|
kw_latitude = $8,
|
||||||
operation_team = $9,
|
kw_longitude = $9,
|
||||||
legend = $10,
|
target_latitude = $10,
|
||||||
camera = $11,
|
target_longitude = $11,
|
||||||
router = $12,
|
view_cone_fov = $12,
|
||||||
technology = $13,
|
view_cone_length = $13,
|
||||||
switchbox = $14,
|
operation_leader = $14,
|
||||||
hard_drive = $15,
|
operation_team = $15,
|
||||||
laptop = $16,
|
legend = $16,
|
||||||
remark = $17,
|
camera = $17,
|
||||||
milestone_storage = $18,
|
router = $18,
|
||||||
|
technology = $19,
|
||||||
|
switchbox = $20,
|
||||||
|
hard_drive = $21,
|
||||||
|
laptop = $22,
|
||||||
|
remark = $23,
|
||||||
|
milestone_storage = $24,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING
|
RETURNING
|
||||||
@ -439,6 +502,12 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
|
|||||||
case_worker,
|
case_worker,
|
||||||
target_object,
|
target_object,
|
||||||
kw_address,
|
kw_address,
|
||||||
|
kw_latitude,
|
||||||
|
kw_longitude,
|
||||||
|
target_latitude,
|
||||||
|
target_longitude,
|
||||||
|
view_cone_fov,
|
||||||
|
view_cone_length,
|
||||||
operation_leader,
|
operation_leader,
|
||||||
operation_team,
|
operation_team,
|
||||||
legend,
|
legend,
|
||||||
@ -460,6 +529,12 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
|
|||||||
input.CaseWorker,
|
input.CaseWorker,
|
||||||
input.TargetObject,
|
input.TargetObject,
|
||||||
input.KwAddress,
|
input.KwAddress,
|
||||||
|
input.KwLatitude,
|
||||||
|
input.KwLongitude,
|
||||||
|
input.TargetLatitude,
|
||||||
|
input.TargetLongitude,
|
||||||
|
input.ViewConeFOV,
|
||||||
|
input.ViewConeLength,
|
||||||
input.OperationLeader,
|
input.OperationLeader,
|
||||||
input.OperationTeam,
|
input.OperationTeam,
|
||||||
input.Legend,
|
input.Legend,
|
||||||
@ -479,6 +554,12 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
|
|||||||
&operation.CaseWorker,
|
&operation.CaseWorker,
|
||||||
&operation.TargetObject,
|
&operation.TargetObject,
|
||||||
&operation.KwAddress,
|
&operation.KwAddress,
|
||||||
|
&operation.KwLatitude,
|
||||||
|
&operation.KwLongitude,
|
||||||
|
&operation.TargetLatitude,
|
||||||
|
&operation.TargetLongitude,
|
||||||
|
&operation.ViewConeFOV,
|
||||||
|
&operation.ViewConeLength,
|
||||||
&operation.OperationLeader,
|
&operation.OperationLeader,
|
||||||
&operation.OperationTeam,
|
&operation.OperationTeam,
|
||||||
&operation.Legend,
|
&operation.Legend,
|
||||||
@ -644,6 +725,12 @@ func getOperationForHistory(ctx context.Context, tx pgx.Tx, operationID string)
|
|||||||
case_worker,
|
case_worker,
|
||||||
target_object,
|
target_object,
|
||||||
kw_address,
|
kw_address,
|
||||||
|
kw_latitude,
|
||||||
|
kw_longitude,
|
||||||
|
target_latitude,
|
||||||
|
target_longitude,
|
||||||
|
view_cone_fov,
|
||||||
|
view_cone_length,
|
||||||
operation_leader,
|
operation_leader,
|
||||||
operation_team,
|
operation_team,
|
||||||
legend,
|
legend,
|
||||||
@ -670,6 +757,12 @@ func getOperationForHistory(ctx context.Context, tx pgx.Tx, operationID string)
|
|||||||
&operation.CaseWorker,
|
&operation.CaseWorker,
|
||||||
&operation.TargetObject,
|
&operation.TargetObject,
|
||||||
&operation.KwAddress,
|
&operation.KwAddress,
|
||||||
|
&operation.KwLatitude,
|
||||||
|
&operation.KwLongitude,
|
||||||
|
&operation.TargetLatitude,
|
||||||
|
&operation.TargetLongitude,
|
||||||
|
&operation.ViewConeFOV,
|
||||||
|
&operation.ViewConeLength,
|
||||||
&operation.OperationLeader,
|
&operation.OperationLeader,
|
||||||
&operation.OperationTeam,
|
&operation.OperationTeam,
|
||||||
&operation.Legend,
|
&operation.Legend,
|
||||||
@ -730,6 +823,12 @@ func buildOperationCreateHistoryChanges(input CreateOperationRequest) []Operatio
|
|||||||
changes = appendOperationHistoryChange(changes, "caseWorker", "Sachbearbeitung", "", input.CaseWorker)
|
changes = appendOperationHistoryChange(changes, "caseWorker", "Sachbearbeitung", "", input.CaseWorker)
|
||||||
changes = appendOperationHistoryChange(changes, "targetObject", "Zielobjekt", "", input.TargetObject)
|
changes = appendOperationHistoryChange(changes, "targetObject", "Zielobjekt", "", input.TargetObject)
|
||||||
changes = appendOperationHistoryChange(changes, "kwAddress", "KW", "", input.KwAddress)
|
changes = appendOperationHistoryChange(changes, "kwAddress", "KW", "", input.KwAddress)
|
||||||
|
changes = appendOperationHistoryChange(changes, "kwPosition", "KW-Position", "", formatOperationCoordinates(input.KwLatitude, input.KwLongitude))
|
||||||
|
changes = appendOperationHistoryChange(changes, "targetPosition", "ZO-Position", "", formatOperationCoordinates(input.TargetLatitude, input.TargetLongitude))
|
||||||
|
if input.KwLatitude != nil && input.TargetLatitude != nil {
|
||||||
|
changes = appendOperationHistoryChange(changes, "viewConeFov", "Sichtfeld", "", fmt.Sprintf("%.0f°", input.ViewConeFOV))
|
||||||
|
changes = appendOperationHistoryChange(changes, "viewConeLength", "Sichtweite", "", formatOperationDistance(input.ViewConeLength))
|
||||||
|
}
|
||||||
changes = appendOperationHistoryChange(changes, "operationLeader", "Einsatzleiter", "", input.OperationLeader)
|
changes = appendOperationHistoryChange(changes, "operationLeader", "Einsatzleiter", "", input.OperationLeader)
|
||||||
changes = appendOperationHistoryChange(changes, "operationTeam", "Einsatzteam", "", input.OperationTeam)
|
changes = appendOperationHistoryChange(changes, "operationTeam", "Einsatzteam", "", input.OperationTeam)
|
||||||
changes = appendOperationHistoryChange(changes, "legend", "Legende", "", input.Legend)
|
changes = appendOperationHistoryChange(changes, "legend", "Legende", "", input.Legend)
|
||||||
@ -754,6 +853,10 @@ func buildOperationUpdateHistoryChanges(oldOperation Operation, input UpdateOper
|
|||||||
changes = appendOperationHistoryChange(changes, "caseWorker", "Sachbearbeitung", oldOperation.CaseWorker, input.CaseWorker)
|
changes = appendOperationHistoryChange(changes, "caseWorker", "Sachbearbeitung", oldOperation.CaseWorker, input.CaseWorker)
|
||||||
changes = appendOperationHistoryChange(changes, "targetObject", "Zielobjekt", oldOperation.TargetObject, input.TargetObject)
|
changes = appendOperationHistoryChange(changes, "targetObject", "Zielobjekt", oldOperation.TargetObject, input.TargetObject)
|
||||||
changes = appendOperationHistoryChange(changes, "kwAddress", "KW", oldOperation.KwAddress, input.KwAddress)
|
changes = appendOperationHistoryChange(changes, "kwAddress", "KW", oldOperation.KwAddress, input.KwAddress)
|
||||||
|
changes = appendOperationHistoryChange(changes, "kwPosition", "KW-Position", formatOperationCoordinates(oldOperation.KwLatitude, oldOperation.KwLongitude), formatOperationCoordinates(input.KwLatitude, input.KwLongitude))
|
||||||
|
changes = appendOperationHistoryChange(changes, "targetPosition", "ZO-Position", formatOperationCoordinates(oldOperation.TargetLatitude, oldOperation.TargetLongitude), formatOperationCoordinates(input.TargetLatitude, input.TargetLongitude))
|
||||||
|
changes = appendOperationHistoryChange(changes, "viewConeFov", "Sichtfeld", fmt.Sprintf("%.0f°", oldOperation.ViewConeFOV), fmt.Sprintf("%.0f°", input.ViewConeFOV))
|
||||||
|
changes = appendOperationHistoryChange(changes, "viewConeLength", "Sichtweite", formatOperationDistance(oldOperation.ViewConeLength), formatOperationDistance(input.ViewConeLength))
|
||||||
changes = appendOperationHistoryChange(changes, "operationLeader", "Einsatzleiter", oldOperation.OperationLeader, input.OperationLeader)
|
changes = appendOperationHistoryChange(changes, "operationLeader", "Einsatzleiter", oldOperation.OperationLeader, input.OperationLeader)
|
||||||
changes = appendOperationHistoryChange(changes, "operationTeam", "Einsatzteam", oldOperation.OperationTeam, input.OperationTeam)
|
changes = appendOperationHistoryChange(changes, "operationTeam", "Einsatzteam", oldOperation.OperationTeam, input.OperationTeam)
|
||||||
changes = appendOperationHistoryChange(changes, "legend", "Legende", oldOperation.Legend, input.Legend)
|
changes = appendOperationHistoryChange(changes, "legend", "Legende", oldOperation.Legend, input.Legend)
|
||||||
@ -809,6 +912,56 @@ func normalizeOperationInput(input *CreateOperationRequest) {
|
|||||||
input.Laptop = strings.TrimSpace(input.Laptop)
|
input.Laptop = strings.TrimSpace(input.Laptop)
|
||||||
input.Remark = strings.TrimSpace(input.Remark)
|
input.Remark = strings.TrimSpace(input.Remark)
|
||||||
input.MilestoneStorage = strings.TrimSpace(input.MilestoneStorage)
|
input.MilestoneStorage = strings.TrimSpace(input.MilestoneStorage)
|
||||||
|
if input.ViewConeFOV == 0 {
|
||||||
|
input.ViewConeFOV = 36
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateOperationGeometry(input CreateOperationRequest) error {
|
||||||
|
if err := validateOperationCoordinatePair("KW", input.KwLatitude, input.KwLongitude); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateOperationCoordinatePair("ZO", input.TargetLatitude, input.TargetLongitude); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if input.ViewConeFOV < 1 || input.ViewConeFOV > 180 {
|
||||||
|
return errors.New("Das Sichtfeld muss zwischen 1 und 180 Grad liegen")
|
||||||
|
}
|
||||||
|
if math.IsNaN(input.ViewConeLength) || math.IsInf(input.ViewConeLength, 0) ||
|
||||||
|
input.ViewConeLength < 0 || input.ViewConeLength > 100000 {
|
||||||
|
return errors.New("Die Sichtweite muss zwischen 0 und 100000 Metern liegen")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateOperationCoordinatePair(label string, latitude, longitude *float64) error {
|
||||||
|
if latitude == nil && longitude == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if latitude == nil || longitude == nil {
|
||||||
|
return fmt.Errorf("%s-Koordinaten sind unvollständig", label)
|
||||||
|
}
|
||||||
|
if math.IsNaN(*latitude) || math.IsInf(*latitude, 0) ||
|
||||||
|
math.IsNaN(*longitude) || math.IsInf(*longitude, 0) ||
|
||||||
|
*latitude < -90 || *latitude > 90 ||
|
||||||
|
*longitude < -180 || *longitude > 180 {
|
||||||
|
return fmt.Errorf("%s-Koordinaten sind ungültig", label)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatOperationCoordinates(latitude, longitude *float64) string {
|
||||||
|
if latitude == nil || longitude == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.6f, %.6f", *latitude, *longitude)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatOperationDistance(distance float64) string {
|
||||||
|
if distance <= 0 {
|
||||||
|
return "Automatisch bis ZO"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.0f m", distance)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleCreateOperationJournalEntry(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleCreateOperationJournalEntry(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
309
backend/presence.go
Normal file
309
backend/presence.go
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const presenceStatusSQL = `
|
||||||
|
CASE
|
||||||
|
WHEN presence_mode = 'offline'
|
||||||
|
OR last_seen_at IS NULL
|
||||||
|
OR last_seen_at < now() - INTERVAL '2 minutes'
|
||||||
|
THEN 'offline'
|
||||||
|
WHEN presence_mode = 'away'
|
||||||
|
OR last_active_at IS NULL
|
||||||
|
OR last_active_at < now() - make_interval(mins => away_after_minutes)
|
||||||
|
THEN 'away'
|
||||||
|
ELSE 'online'
|
||||||
|
END
|
||||||
|
`
|
||||||
|
|
||||||
|
type presenceHeartbeatRequest struct {
|
||||||
|
Active bool `json:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type presenceSettingsRequest struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
AwayAfterMinutes int `json:"awayAfterMinutes"`
|
||||||
|
ShowLastSeen bool `json:"showLastSeen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type presenceStatusRequest struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePresenceMode(value string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "away":
|
||||||
|
return "away"
|
||||||
|
case "offline":
|
||||||
|
return "offline"
|
||||||
|
default:
|
||||||
|
return "online"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) startPresenceMonitor(ctx context.Context) {
|
||||||
|
s.refreshPresenceStatuses(ctx, false)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.refreshPresenceStatuses(ctx, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) refreshPresenceStatuses(ctx context.Context, publish bool) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT id::TEXT, `+presenceStatusSQL+`
|
||||||
|
FROM users
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
currentStatuses := map[string]string{}
|
||||||
|
changedUserIDs := []string{}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var userID string
|
||||||
|
var status string
|
||||||
|
if err := rows.Scan(&userID, &status); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentStatuses[userID] = status
|
||||||
|
|
||||||
|
s.presenceMu.Lock()
|
||||||
|
previousStatus, known := s.presenceStatus[userID]
|
||||||
|
s.presenceMu.Unlock()
|
||||||
|
|
||||||
|
if publish && known && previousStatus != status {
|
||||||
|
changedUserIDs = append(changedUserIDs, userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.presenceMu.Lock()
|
||||||
|
s.presenceStatus = currentStatuses
|
||||||
|
s.presenceMu.Unlock()
|
||||||
|
|
||||||
|
for _, userID := range changedUserIDs {
|
||||||
|
user, err := s.getUserByID(ctx, userID)
|
||||||
|
if err == nil {
|
||||||
|
s.publishUserProfileEvent("user.presence", user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) publishPresenceIfChanged(user User) {
|
||||||
|
s.presenceMu.Lock()
|
||||||
|
previousStatus, known := s.presenceStatus[user.ID]
|
||||||
|
s.presenceStatus[user.ID] = user.PresenceStatus
|
||||||
|
s.presenceMu.Unlock()
|
||||||
|
|
||||||
|
if !known || previousStatus != user.PresenceStatus {
|
||||||
|
s.publishUserProfileEvent("user.presence", user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := userFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var input presenceHeartbeatRequest
|
||||||
|
if r.Body != nil {
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&input)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
last_seen_at = now(),
|
||||||
|
last_active_at = CASE
|
||||||
|
WHEN $2 AND presence_mode = 'online' THEN now()
|
||||||
|
ELSE last_active_at
|
||||||
|
END
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
user.ID,
|
||||||
|
input.Active,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Präsenz konnte nicht aktualisiert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedUser, err := s.getUserByID(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Präsenz konnte nicht geladen werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.publishPresenceIfChanged(updatedUser)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"presenceStatus": updatedUser.PresenceStatus,
|
||||||
|
"lastSeenAt": updatedUser.LastSeenAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetPresenceSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := userFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"settings": map[string]any{
|
||||||
|
"mode": user.PresenceMode,
|
||||||
|
"awayAfterMinutes": user.AwayAfterMinutes,
|
||||||
|
"currentStatus": user.PresenceStatus,
|
||||||
|
"showLastSeen": user.ShowLastSeen,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdatePresenceSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := userFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var input presenceSettingsRequest
|
||||||
|
if err := readJSON(r, &input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := user.PresenceMode
|
||||||
|
if strings.TrimSpace(input.Mode) != "" {
|
||||||
|
mode = normalizePresenceMode(input.Mode)
|
||||||
|
}
|
||||||
|
if input.AwayAfterMinutes < 1 || input.AwayAfterMinutes > 120 {
|
||||||
|
writeError(w, http.StatusBadRequest, "Automatische Abwesenheit muss zwischen 1 und 120 Minuten liegen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
presence_mode_updated_at = CASE
|
||||||
|
WHEN presence_mode IS DISTINCT FROM $2 THEN now()
|
||||||
|
ELSE presence_mode_updated_at
|
||||||
|
END,
|
||||||
|
presence_mode = $2,
|
||||||
|
away_after_minutes = $3,
|
||||||
|
show_last_seen = $4,
|
||||||
|
last_active_at = CASE WHEN $2 = 'online' THEN now() ELSE last_active_at END,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
user.ID,
|
||||||
|
mode,
|
||||||
|
input.AwayAfterMinutes,
|
||||||
|
input.ShowLastSeen,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Präsenzeinstellungen konnten nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedUser, err := s.getUserByID(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Präsenzeinstellungen konnten nicht geladen werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.publishPresenceIfChanged(updatedUser)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"settings": map[string]any{
|
||||||
|
"mode": updatedUser.PresenceMode,
|
||||||
|
"awayAfterMinutes": updatedUser.AwayAfterMinutes,
|
||||||
|
"currentStatus": updatedUser.PresenceStatus,
|
||||||
|
"showLastSeen": updatedUser.ShowLastSeen,
|
||||||
|
},
|
||||||
|
"user": updatedUser,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdatePresenceStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := userFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var input presenceStatusRequest
|
||||||
|
if err := readJSON(r, &input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := strings.ToLower(strings.TrimSpace(input.Mode))
|
||||||
|
if mode != "online" && mode != "away" && mode != "offline" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültiger Status")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
presence_mode_updated_at = CASE
|
||||||
|
WHEN presence_mode IS DISTINCT FROM $2 THEN now()
|
||||||
|
ELSE presence_mode_updated_at
|
||||||
|
END,
|
||||||
|
presence_mode = $2,
|
||||||
|
last_active_at = CASE WHEN $2 = 'online' THEN now() ELSE last_active_at END,
|
||||||
|
last_seen_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
user.ID,
|
||||||
|
mode,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Status konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedUser, err := s.getUserByID(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Status konnte nicht geladen werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.publishPresenceIfChanged(updatedUser)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"user": updatedUser})
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ func (s *Server) Routes() http.Handler {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
mux.HandleFunc("GET /health", s.handleHealth)
|
mux.HandleFunc("GET /health", s.handleHealth)
|
||||||
|
mux.HandleFunc("POST /webhooks/channels/{slug}", s.handleChannelWebhook)
|
||||||
|
|
||||||
mux.HandleFunc("POST /auth/register", s.handleRegister)
|
mux.HandleFunc("POST /auth/register", s.handleRegister)
|
||||||
mux.HandleFunc("POST /auth/login", s.handleLogin)
|
mux.HandleFunc("POST /auth/login", s.handleLogin)
|
||||||
@ -81,6 +82,24 @@ func (s *Server) Routes() http.Handler {
|
|||||||
mux.HandleFunc("POST /notifications/read-all", s.requireAuth(s.handleMarkAllNotificationsRead))
|
mux.HandleFunc("POST /notifications/read-all", s.requireAuth(s.handleMarkAllNotificationsRead))
|
||||||
|
|
||||||
mux.HandleFunc("GET /events", s.requireAuth(s.handleNotificationEvents))
|
mux.HandleFunc("GET /events", s.requireAuth(s.handleNotificationEvents))
|
||||||
|
mux.HandleFunc("POST /presence", s.requireAuth(s.handlePresence))
|
||||||
|
mux.HandleFunc("GET /settings/presence", s.requireAuth(s.handleGetPresenceSettings))
|
||||||
|
mux.HandleFunc("PUT /settings/presence", s.requireAuth(s.handleUpdatePresenceSettings))
|
||||||
|
mux.HandleFunc("PUT /settings/presence/status", s.requireAuth(s.handleUpdatePresenceStatus))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /chat/conversations", s.requireAuth(s.handleListChatConversations))
|
||||||
|
mux.HandleFunc("GET /chat/search", s.requireAuth(s.handleSearchChatMessages))
|
||||||
|
mux.HandleFunc("GET /chat/unread", s.requireAuth(s.handleGetChatUnreadCount))
|
||||||
|
mux.HandleFunc("POST /chat/direct", s.requireAuth(s.handleCreateDirectConversation))
|
||||||
|
mux.HandleFunc("GET /chat/users", s.requireAuth(s.handleListChatUsers))
|
||||||
|
mux.HandleFunc("GET /chat/ws", s.requireAuth(s.handleChatWebSocket))
|
||||||
|
mux.HandleFunc("POST /chat/attachments", s.requireAuth(s.handleUploadChatAttachment))
|
||||||
|
mux.HandleFunc("GET /chat/attachments/{id}", s.requireAuth(s.handleDownloadChatAttachment))
|
||||||
|
mux.HandleFunc("POST /chat/link-preview", s.requireAuth(s.handleChatLinkPreview))
|
||||||
|
mux.HandleFunc("GET /chat/conversations/{id}/messages", s.requireAuth(s.handleListChatMessages))
|
||||||
|
mux.HandleFunc("POST /chat/conversations/{id}/messages", s.requireAuth(s.handleCreateChatMessage))
|
||||||
|
mux.HandleFunc("POST /chat/conversations/{id}/read", s.requireAuth(s.handleMarkChatConversationRead))
|
||||||
|
mux.HandleFunc("PUT /chat/conversations/{id}/mute", s.requireAuth(s.handleUpdateConversationMute))
|
||||||
|
|
||||||
mux.HandleFunc("GET /dashboard/operation-changes", s.requireAuth(s.requireRight("operations:read", s.handleDashboardOperationChanges)))
|
mux.HandleFunc("GET /dashboard/operation-changes", s.requireAuth(s.requireRight("operations:read", s.handleDashboardOperationChanges)))
|
||||||
|
|
||||||
@ -107,6 +126,10 @@ func (s *Server) Routes() http.Handler {
|
|||||||
|
|
||||||
mux.HandleFunc("GET /admin/feedback", s.requireAuth(s.requireRight("administration:read", s.handleListFeedback)))
|
mux.HandleFunc("GET /admin/feedback", s.requireAuth(s.requireRight("administration:read", s.handleListFeedback)))
|
||||||
mux.HandleFunc("DELETE /admin/feedback/{id}", s.requireAuth(s.requireRight("administration:write", s.handleDeleteFeedback)))
|
mux.HandleFunc("DELETE /admin/feedback/{id}", s.requireAuth(s.requireRight("administration:write", s.handleDeleteFeedback)))
|
||||||
|
mux.HandleFunc("GET /admin/channels", s.requireAuth(s.requireRight("administration:read", s.handleAdminListChannels)))
|
||||||
|
mux.HandleFunc("POST /admin/channels", s.requireAuth(s.requireRight("administration:write", s.handleAdminCreateChannel)))
|
||||||
|
mux.HandleFunc("PUT /admin/channels/{id}", s.requireAuth(s.requireRight("administration:write", s.handleAdminUpdateChannel)))
|
||||||
|
mux.HandleFunc("POST /admin/channels/{id}/token", s.requireAuth(s.requireRight("administration:write", s.handleAdminRotateChannelToken)))
|
||||||
|
|
||||||
/* milestone settings */
|
/* milestone settings */
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
@ -27,19 +28,27 @@ type Server struct {
|
|||||||
db *pgxpool.Pool
|
db *pgxpool.Pool
|
||||||
jwtSecret []byte
|
jwtSecret []byte
|
||||||
frontendOrigin string
|
frontendOrigin string
|
||||||
|
presenceMu sync.Mutex
|
||||||
|
presenceStatus map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
Unit string `json:"unit"`
|
Unit string `json:"unit"`
|
||||||
Group string `json:"group"`
|
Group string `json:"group"`
|
||||||
Rights []string `json:"rights"`
|
Rights []string `json:"rights"`
|
||||||
Teams []Team `json:"teams"`
|
Teams []Team `json:"teams"`
|
||||||
TokenVersion int `json:"-"`
|
Online bool `json:"onlineStatus"`
|
||||||
|
PresenceMode string `json:"presenceMode"`
|
||||||
|
PresenceStatus string `json:"presenceStatus"`
|
||||||
|
AwayAfterMinutes int `json:"awayAfterMinutes"`
|
||||||
|
ShowLastSeen bool `json:"showLastSeen"`
|
||||||
|
LastSeenAt *time.Time `json:"lastSeenAt"`
|
||||||
|
TokenVersion int `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Team struct {
|
type Team struct {
|
||||||
@ -87,6 +96,7 @@ func NewServer(db *pgxpool.Pool, jwtSecret string, frontendOrigin string) *Serve
|
|||||||
db: db,
|
db: db,
|
||||||
jwtSecret: []byte(jwtSecret),
|
jwtSecret: []byte(jwtSecret),
|
||||||
frontendOrigin: frontendOrigin,
|
frontendOrigin: frontendOrigin,
|
||||||
|
presenceStatus: map[string]string{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -196,6 +206,14 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user.Online = true
|
||||||
|
user.PresenceMode = "online"
|
||||||
|
user.PresenceStatus = "online"
|
||||||
|
user.AwayAfterMinutes = 3
|
||||||
|
user.ShowLastSeen = false
|
||||||
|
now := time.Now()
|
||||||
|
user.LastSeenAt = &now
|
||||||
|
|
||||||
if err := s.setSessionCookie(w, user, sessionID); err != nil {
|
if err := s.setSessionCookie(w, user, sessionID); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Could not create session")
|
writeError(w, http.StatusInternalServerError, "Could not create session")
|
||||||
return
|
return
|
||||||
@ -249,6 +267,11 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user.PresenceStatus = user.PresenceMode
|
||||||
|
user.Online = user.PresenceStatus == "online"
|
||||||
|
now := time.Now()
|
||||||
|
user.LastSeenAt = &now
|
||||||
|
|
||||||
if err := s.setSessionCookie(w, user, sessionID); err != nil {
|
if err := s.setSessionCookie(w, user, sessionID); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Could not create session")
|
writeError(w, http.StatusInternalServerError, "Could not create session")
|
||||||
return
|
return
|
||||||
@ -264,10 +287,22 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = s.db.Exec(
|
_, _ = s.db.Exec(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
`
|
`
|
||||||
UPDATE user_sessions
|
WITH revoked_session AS (
|
||||||
SET revoked_at = now()
|
UPDATE user_sessions
|
||||||
WHERE id = $1
|
SET revoked_at = now()
|
||||||
AND revoked_at IS NULL
|
WHERE id = $1
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
RETURNING user_id
|
||||||
|
)
|
||||||
|
UPDATE users
|
||||||
|
SET last_seen_at = (
|
||||||
|
SELECT MAX(us.last_seen_at)
|
||||||
|
FROM user_sessions us
|
||||||
|
WHERE us.user_id = revoked_session.user_id
|
||||||
|
AND us.revoked_at IS NULL
|
||||||
|
)
|
||||||
|
FROM revoked_session
|
||||||
|
WHERE users.id = revoked_session.user_id
|
||||||
`,
|
`,
|
||||||
sessionID,
|
sessionID,
|
||||||
)
|
)
|
||||||
@ -381,6 +416,11 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_ = s.touchUserSession(r.Context(), user.ID, claims.SessionID)
|
_ = s.touchUserSession(r.Context(), user.ID, claims.SessionID)
|
||||||
|
user, err = s.getUserByID(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), userContextKey, user)
|
ctx := context.WithValue(r.Context(), userContextKey, user)
|
||||||
ctx = context.WithValue(ctx, sessionContextKey, claims.SessionID)
|
ctx = context.WithValue(ctx, sessionContextKey, claims.SessionID)
|
||||||
@ -408,6 +448,11 @@ func (s *Server) getUserByIdentifier(ctx context.Context, identifier string) (Us
|
|||||||
COALESCE(unit, ''),
|
COALESCE(unit, ''),
|
||||||
COALESCE(user_group, ''),
|
COALESCE(user_group, ''),
|
||||||
COALESCE(rights, '{}'::TEXT[]),
|
COALESCE(rights, '{}'::TEXT[]),
|
||||||
|
COALESCE(presence_mode, 'online'),
|
||||||
|
away_after_minutes,
|
||||||
|
show_last_seen,
|
||||||
|
`+presenceStatusSQL+`,
|
||||||
|
last_seen_at,
|
||||||
token_version
|
token_version
|
||||||
FROM users
|
FROM users
|
||||||
WHERE lower(email) = $1 OR lower(username) = $1
|
WHERE lower(email) = $1 OR lower(username) = $1
|
||||||
@ -424,8 +469,14 @@ func (s *Server) getUserByIdentifier(ctx context.Context, identifier string) (Us
|
|||||||
&user.Unit,
|
&user.Unit,
|
||||||
&user.Group,
|
&user.Group,
|
||||||
&user.Rights,
|
&user.Rights,
|
||||||
|
&user.PresenceMode,
|
||||||
|
&user.AwayAfterMinutes,
|
||||||
|
&user.ShowLastSeen,
|
||||||
|
&user.PresenceStatus,
|
||||||
|
&user.LastSeenAt,
|
||||||
&user.TokenVersion,
|
&user.TokenVersion,
|
||||||
)
|
)
|
||||||
|
user.Online = user.PresenceStatus == "online"
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return user, passwordHash, err
|
return user, passwordHash, err
|
||||||
@ -456,6 +507,11 @@ func (s *Server) getUserByID(ctx context.Context, userID string) (User, error) {
|
|||||||
COALESCE(unit, ''),
|
COALESCE(unit, ''),
|
||||||
COALESCE(user_group, ''),
|
COALESCE(user_group, ''),
|
||||||
COALESCE(rights, '{}'::TEXT[]),
|
COALESCE(rights, '{}'::TEXT[]),
|
||||||
|
COALESCE(presence_mode, 'online'),
|
||||||
|
away_after_minutes,
|
||||||
|
show_last_seen,
|
||||||
|
`+presenceStatusSQL+`,
|
||||||
|
last_seen_at,
|
||||||
token_version
|
token_version
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
@ -471,8 +527,14 @@ func (s *Server) getUserByID(ctx context.Context, userID string) (User, error) {
|
|||||||
&user.Unit,
|
&user.Unit,
|
||||||
&user.Group,
|
&user.Group,
|
||||||
&user.Rights,
|
&user.Rights,
|
||||||
|
&user.PresenceMode,
|
||||||
|
&user.AwayAfterMinutes,
|
||||||
|
&user.ShowLastSeen,
|
||||||
|
&user.PresenceStatus,
|
||||||
|
&user.LastSeenAt,
|
||||||
&user.TokenVersion,
|
&user.TokenVersion,
|
||||||
)
|
)
|
||||||
|
user.Online = user.PresenceStatus == "online"
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return user, err
|
return user, err
|
||||||
|
|||||||
@ -229,6 +229,13 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
token_version INTEGER NOT NULL DEFAULT 1,
|
token_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
last_seen_at TIMESTAMPTZ,
|
last_seen_at TIMESTAMPTZ,
|
||||||
|
last_active_at TIMESTAMPTZ,
|
||||||
|
presence_mode TEXT NOT NULL DEFAULT 'online'
|
||||||
|
CHECK (presence_mode IN ('online', 'away', 'offline')),
|
||||||
|
presence_mode_updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
away_after_minutes INTEGER NOT NULL DEFAULT 3
|
||||||
|
CHECK (away_after_minutes BETWEEN 1 AND 120),
|
||||||
|
show_last_seen BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
@ -239,6 +246,58 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
// aktualisiert. "Online" wird daraus abgeleitet (z. B. Aktivität innerhalb
|
// aktualisiert. "Online" wird daraus abgeleitet (z. B. Aktivität innerhalb
|
||||||
// der letzten Minuten).
|
// der letzten Minuten).
|
||||||
`ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ`,
|
`ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ`,
|
||||||
|
`ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS last_active_at TIMESTAMPTZ`,
|
||||||
|
`ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS presence_mode TEXT NOT NULL DEFAULT 'online'`,
|
||||||
|
`ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS presence_mode_updated_at TIMESTAMPTZ NOT NULL DEFAULT now()`,
|
||||||
|
`ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS away_after_minutes INTEGER NOT NULL DEFAULT 3`,
|
||||||
|
`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = current_schema()
|
||||||
|
AND table_name = 'users'
|
||||||
|
AND column_name = 'away_after_minutes'
|
||||||
|
AND column_default LIKE '5%'
|
||||||
|
) THEN
|
||||||
|
UPDATE users
|
||||||
|
SET away_after_minutes = 3
|
||||||
|
WHERE away_after_minutes = 5;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ALTER COLUMN away_after_minutes SET DEFAULT 3;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$
|
||||||
|
`,
|
||||||
|
`ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS show_last_seen BOOLEAN NOT NULL DEFAULT false`,
|
||||||
|
|
||||||
|
// Der Online-Status wird nicht dauerhaft als Boolean gespeichert, damit
|
||||||
|
// abgebrochene Browser-Sitzungen nicht für immer als online erscheinen.
|
||||||
|
// Die API liefert onlineStatus=true, wenn last_seen_at höchstens zwei
|
||||||
|
// Minuten zurückliegt; der Client aktualisiert den Wert per Heartbeat.
|
||||||
|
`
|
||||||
|
CREATE OR REPLACE VIEW user_presence AS
|
||||||
|
SELECT
|
||||||
|
id AS user_id,
|
||||||
|
last_seen_at,
|
||||||
|
last_active_at,
|
||||||
|
presence_mode,
|
||||||
|
away_after_minutes,
|
||||||
|
CASE
|
||||||
|
WHEN presence_mode = 'offline'
|
||||||
|
OR last_seen_at IS NULL
|
||||||
|
OR last_seen_at < now() - INTERVAL '2 minutes'
|
||||||
|
THEN 'offline'
|
||||||
|
WHEN presence_mode = 'away'
|
||||||
|
OR last_active_at IS NULL
|
||||||
|
OR last_active_at < now() - make_interval(mins => away_after_minutes)
|
||||||
|
THEN 'away'
|
||||||
|
ELSE 'online'
|
||||||
|
END AS status
|
||||||
|
FROM users
|
||||||
|
`,
|
||||||
|
|
||||||
`
|
`
|
||||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||||
@ -436,6 +495,12 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
case_worker TEXT NOT NULL DEFAULT '',
|
case_worker TEXT NOT NULL DEFAULT '',
|
||||||
target_object TEXT NOT NULL DEFAULT '',
|
target_object TEXT NOT NULL DEFAULT '',
|
||||||
kw_address TEXT NOT NULL DEFAULT '',
|
kw_address TEXT NOT NULL DEFAULT '',
|
||||||
|
kw_latitude DOUBLE PRECISION,
|
||||||
|
kw_longitude DOUBLE PRECISION,
|
||||||
|
target_latitude DOUBLE PRECISION,
|
||||||
|
target_longitude DOUBLE PRECISION,
|
||||||
|
view_cone_fov DOUBLE PRECISION NOT NULL DEFAULT 36,
|
||||||
|
view_cone_length DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
operation_leader TEXT NOT NULL DEFAULT '',
|
operation_leader TEXT NOT NULL DEFAULT '',
|
||||||
operation_team TEXT NOT NULL DEFAULT '',
|
operation_team TEXT NOT NULL DEFAULT '',
|
||||||
legend TEXT NOT NULL DEFAULT '',
|
legend TEXT NOT NULL DEFAULT '',
|
||||||
@ -454,6 +519,12 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
`,
|
`,
|
||||||
|
|
||||||
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS milestone_storage TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS milestone_storage TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS kw_latitude DOUBLE PRECISION`,
|
||||||
|
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS kw_longitude DOUBLE PRECISION`,
|
||||||
|
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS target_latitude DOUBLE PRECISION`,
|
||||||
|
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS target_longitude DOUBLE PRECISION`,
|
||||||
|
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS view_cone_fov DOUBLE PRECISION NOT NULL DEFAULT 36`,
|
||||||
|
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS view_cone_length DOUBLE PRECISION NOT NULL DEFAULT 0`,
|
||||||
|
|
||||||
`
|
`
|
||||||
CREATE TABLE IF NOT EXISTS operation_history (
|
CREATE TABLE IF NOT EXISTS operation_history (
|
||||||
@ -485,12 +556,15 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
entity_id UUID,
|
entity_id UUID,
|
||||||
|
|
||||||
data JSONB NOT NULL DEFAULT '{}'::JSONB,
|
data JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||||
|
in_app_visible BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
read_at TIMESTAMPTZ,
|
read_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
|
|
||||||
|
`ALTER TABLE IF EXISTS notifications ADD COLUMN IF NOT EXISTS in_app_visible BOOLEAN NOT NULL DEFAULT true`,
|
||||||
|
|
||||||
`
|
`
|
||||||
CREATE TABLE IF NOT EXISTS milestone_settings (
|
CREATE TABLE IF NOT EXISTS milestone_settings (
|
||||||
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||||
@ -548,12 +622,17 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
device_updates BOOLEAN NOT NULL DEFAULT true,
|
device_updates BOOLEAN NOT NULL DEFAULT true,
|
||||||
device_journals BOOLEAN NOT NULL DEFAULT true,
|
device_journals BOOLEAN NOT NULL DEFAULT true,
|
||||||
system_messages BOOLEAN NOT NULL DEFAULT true,
|
system_messages BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
chat_messages BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
desktop_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
|
|
||||||
|
`ALTER TABLE IF EXISTS notification_preferences ADD COLUMN IF NOT EXISTS chat_messages BOOLEAN NOT NULL DEFAULT true`,
|
||||||
|
`ALTER TABLE IF EXISTS notification_preferences ADD COLUMN IF NOT EXISTS desktop_enabled BOOLEAN NOT NULL DEFAULT false`,
|
||||||
|
|
||||||
`
|
`
|
||||||
CREATE TABLE IF NOT EXISTS feedback (
|
CREATE TABLE IF NOT EXISTS feedback (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@ -619,15 +698,14 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
`,
|
`,
|
||||||
|
|
||||||
// ── Chat ────────────────────────────────────────────────────────────
|
// ── Chat ────────────────────────────────────────────────────────────
|
||||||
// Eine Konversation ist entweder ein Team-Gruppenchat (type='team',
|
// Eine Konversation ist entweder ein Team-Gruppenchat, Einzelchat,
|
||||||
// genau einer pro Team), ein Einzelchat zwischen zwei Personen
|
// freier Gruppenchat oder ein schreibgeschützter Integrations-Channel.
|
||||||
// (type='direct') oder ein freier Gruppenchat (type='group').
|
|
||||||
`
|
`
|
||||||
CREATE TABLE IF NOT EXISTS conversations (
|
CREATE TABLE IF NOT EXISTS conversations (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
type TEXT NOT NULL DEFAULT 'direct'
|
type TEXT NOT NULL DEFAULT 'direct'
|
||||||
CHECK (type IN ('team', 'direct', 'group')),
|
CHECK (type IN ('team', 'direct', 'group', 'channel')),
|
||||||
name TEXT NOT NULL DEFAULT '',
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
|
||||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
@ -635,6 +713,13 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
-- Stabiler Schlüssel für Einzelchats (sortierte User-IDs),
|
-- Stabiler Schlüssel für Einzelchats (sortierte User-IDs),
|
||||||
-- damit pro Personenpaar nur eine Konversation existiert.
|
-- damit pro Personenpaar nur eine Konversation existiert.
|
||||||
direct_key TEXT NOT NULL DEFAULT '',
|
direct_key TEXT NOT NULL DEFAULT '',
|
||||||
|
slug TEXT NOT NULL DEFAULT '',
|
||||||
|
channel_source_name TEXT NOT NULL DEFAULT '',
|
||||||
|
channel_image TEXT NOT NULL DEFAULT '',
|
||||||
|
channel_all_users BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
webhook_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
webhook_token_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
webhook_token_updated_at TIMESTAMPTZ,
|
||||||
|
|
||||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
@ -644,6 +729,15 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
|
`ALTER TABLE IF EXISTS conversations DROP CONSTRAINT IF EXISTS conversations_type_check`,
|
||||||
|
`ALTER TABLE IF EXISTS conversations ADD CONSTRAINT conversations_type_check CHECK (type IN ('team', 'direct', 'group', 'channel'))`,
|
||||||
|
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS slug TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS channel_source_name TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS channel_image TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS channel_all_users BOOLEAN NOT NULL DEFAULT false`,
|
||||||
|
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS webhook_enabled BOOLEAN NOT NULL DEFAULT true`,
|
||||||
|
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS webhook_token_hash TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS webhook_token_updated_at TIMESTAMPTZ`,
|
||||||
|
|
||||||
`
|
`
|
||||||
CREATE TABLE IF NOT EXISTS conversation_members (
|
CREATE TABLE IF NOT EXISTS conversation_members (
|
||||||
@ -651,6 +745,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
last_read_at TIMESTAMPTZ,
|
last_read_at TIMESTAMPTZ,
|
||||||
|
muted BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
@ -658,6 +753,8 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
|
|
||||||
|
`ALTER TABLE IF EXISTS conversation_members ADD COLUMN IF NOT EXISTS muted BOOLEAN NOT NULL DEFAULT false`,
|
||||||
|
|
||||||
`
|
`
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@ -666,6 +763,8 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
sender_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
sender_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
body TEXT NOT NULL DEFAULT '',
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
reply_to_message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||||
|
forwarded_from_message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
edited_at TIMESTAMPTZ,
|
edited_at TIMESTAMPTZ,
|
||||||
@ -673,6 +772,46 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
|
|
||||||
|
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS reply_to_message_id UUID REFERENCES messages(id) ON DELETE SET NULL`,
|
||||||
|
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS forwarded_from_message_id UUID REFERENCES messages(id) ON DELETE SET NULL`,
|
||||||
|
|
||||||
|
// Standort-Nachrichten: lat/lng sind NULL, wenn die Nachricht keinen Standort enthält.
|
||||||
|
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS location_lat DOUBLE PRECISION`,
|
||||||
|
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS location_lng DOUBLE PRECISION`,
|
||||||
|
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS location_label TEXT NOT NULL DEFAULT ''`,
|
||||||
|
|
||||||
|
`
|
||||||
|
CREATE TABLE IF NOT EXISTS message_attachments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
uploader_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
message_id UUID REFERENCES messages(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
file_name TEXT NOT NULL,
|
||||||
|
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
data BYTEA NOT NULL,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
|
||||||
|
// Link-Vorschau (OpenGraph-Metadaten), serverseitig asynchron geladen.
|
||||||
|
// Eine Vorschau pro Nachricht (erste erkannte URL).
|
||||||
|
`
|
||||||
|
CREATE TABLE IF NOT EXISTS message_link_previews (
|
||||||
|
message_id UUID PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
image_url TEXT NOT NULL DEFAULT '',
|
||||||
|
site_name TEXT NOT NULL DEFAULT '',
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`,
|
`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)`,
|
`CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_users_last_seen_at ON users(last_seen_at)`,
|
`CREATE INDEX IF NOT EXISTS idx_users_last_seen_at ON users(last_seen_at)`,
|
||||||
@ -751,14 +890,41 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
// Genau eine Konversation pro Team bzw. pro Einzelchat-Paar.
|
// Genau eine Konversation pro Team bzw. pro Einzelchat-Paar.
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_conversations_team_id_unique ON conversations(team_id) WHERE team_id IS NOT NULL`,
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_conversations_team_id_unique ON conversations(team_id) WHERE team_id IS NOT NULL`,
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_conversations_direct_key_unique ON conversations(direct_key) WHERE direct_key <> ''`,
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_conversations_direct_key_unique ON conversations(direct_key) WHERE direct_key <> ''`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_conversations_channel_slug_unique ON conversations(slug) WHERE type = 'channel' AND slug <> ''`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_conversations_type ON conversations(type)`,
|
`CREATE INDEX IF NOT EXISTS idx_conversations_type ON conversations(type)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_conversations_last_message_at ON conversations(last_message_at DESC)`,
|
`CREATE INDEX IF NOT EXISTS idx_conversations_last_message_at ON conversations(last_message_at DESC)`,
|
||||||
|
`
|
||||||
|
INSERT INTO conversations (
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
channel_source_name,
|
||||||
|
channel_all_users,
|
||||||
|
webhook_enabled
|
||||||
|
)
|
||||||
|
VALUES ('channel', 'Zabbix', 'zabbix', 'Zabbix', true, true)
|
||||||
|
ON CONFLICT (slug) WHERE type = 'channel' AND slug <> ''
|
||||||
|
DO NOTHING
|
||||||
|
`,
|
||||||
|
`
|
||||||
|
INSERT INTO conversation_members (conversation_id, user_id)
|
||||||
|
SELECT c.id, u.id
|
||||||
|
FROM conversations c
|
||||||
|
CROSS JOIN users u
|
||||||
|
WHERE c.type = 'channel'
|
||||||
|
AND c.channel_all_users
|
||||||
|
ON CONFLICT (conversation_id, user_id) DO NOTHING
|
||||||
|
`,
|
||||||
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_conversation_members_user_id ON conversation_members(user_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_conversation_members_user_id ON conversation_members(user_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_conversation_members_conversation_id ON conversation_members(conversation_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_conversation_members_conversation_id ON conversation_members(conversation_id)`,
|
||||||
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_messages_conversation_created_at ON messages(conversation_id, created_at DESC, id DESC)`,
|
`CREATE INDEX IF NOT EXISTS idx_messages_conversation_created_at ON messages(conversation_id, created_at DESC, id DESC)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_messages_sender_id ON messages(sender_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_messages_sender_id ON messages(sender_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_messages_reply_to_message_id ON messages(reply_to_message_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_messages_forwarded_from_message_id ON messages(forwarded_from_message_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_message_attachments_message_id ON message_attachments(message_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_message_attachments_uploader_id ON message_attachments(uploader_id)`,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, query := range queries {
|
for _, query := range queries {
|
||||||
|
|||||||
@ -9,6 +9,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) publishUserProfileEvent(eventType string, user User) {
|
func (s *Server) publishUserProfileEvent(eventType string, user User) {
|
||||||
|
// Detaillierte Aktivitätszeiten werden nur über autorisierte Direktchat-
|
||||||
|
// Abfragen ausgeliefert, nicht über das globale User-Event.
|
||||||
|
user.LastSeenAt = nil
|
||||||
|
|
||||||
data, err := json.Marshal(map[string]any{
|
data, err := json.Marshal(map[string]any{
|
||||||
"user": user,
|
"user": user,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -54,6 +54,16 @@ func (s *Server) createUserSession(
|
|||||||
err := s.db.QueryRow(
|
err := s.db.QueryRow(
|
||||||
ctx,
|
ctx,
|
||||||
`
|
`
|
||||||
|
WITH touched_user AS (
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
last_seen_at = now(),
|
||||||
|
last_active_at = CASE
|
||||||
|
WHEN presence_mode = 'online' THEN now()
|
||||||
|
ELSE last_active_at
|
||||||
|
END
|
||||||
|
WHERE id = $1
|
||||||
|
)
|
||||||
INSERT INTO user_sessions (
|
INSERT INTO user_sessions (
|
||||||
user_id,
|
user_id,
|
||||||
browser,
|
browser,
|
||||||
@ -144,11 +154,18 @@ func (s *Server) touchUserSession(
|
|||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
ctx,
|
ctx,
|
||||||
`
|
`
|
||||||
UPDATE user_sessions
|
WITH touched_session AS (
|
||||||
|
UPDATE user_sessions
|
||||||
|
SET last_seen_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
AND user_id = $2
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
RETURNING user_id
|
||||||
|
)
|
||||||
|
UPDATE users
|
||||||
SET last_seen_at = now()
|
SET last_seen_at = now()
|
||||||
WHERE id = $1
|
FROM touched_session
|
||||||
AND user_id = $2
|
WHERE users.id = touched_session.user_id
|
||||||
AND revoked_at IS NULL
|
|
||||||
`,
|
`,
|
||||||
sessionID,
|
sessionID,
|
||||||
userID,
|
userID,
|
||||||
|
|||||||
@ -18,8 +18,6 @@ type updateSettingsAccountRequest struct {
|
|||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
Unit string `json:"unit"`
|
|
||||||
Group string `json:"group"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateSettingsPasswordRequest struct {
|
type updateSettingsPasswordRequest struct {
|
||||||
@ -49,8 +47,6 @@ func (s *Server) handleUpdateSettingsAccount(w http.ResponseWriter, r *http.Requ
|
|||||||
email := normalizeEmail(payload.Email)
|
email := normalizeEmail(payload.Email)
|
||||||
displayName := strings.TrimSpace(payload.DisplayName)
|
displayName := strings.TrimSpace(payload.DisplayName)
|
||||||
avatar := strings.TrimSpace(payload.Avatar)
|
avatar := strings.TrimSpace(payload.Avatar)
|
||||||
unit := strings.TrimSpace(payload.Unit)
|
|
||||||
group := strings.TrimSpace(payload.Group)
|
|
||||||
|
|
||||||
if username == "" {
|
if username == "" {
|
||||||
writeSettingsError(w, http.StatusBadRequest, "Benutzername ist erforderlich")
|
writeSettingsError(w, http.StatusBadRequest, "Benutzername ist erforderlich")
|
||||||
@ -66,10 +62,6 @@ func (s *Server) handleUpdateSettingsAccount(w http.ResponseWriter, r *http.Requ
|
|||||||
displayName = username
|
displayName = username
|
||||||
}
|
}
|
||||||
|
|
||||||
if group == "" {
|
|
||||||
group = "user"
|
|
||||||
}
|
|
||||||
|
|
||||||
commandTag, err := s.db.Exec(
|
commandTag, err := s.db.Exec(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
`
|
`
|
||||||
@ -79,17 +71,13 @@ func (s *Server) handleUpdateSettingsAccount(w http.ResponseWriter, r *http.Requ
|
|||||||
display_name = $2,
|
display_name = $2,
|
||||||
email = $3,
|
email = $3,
|
||||||
avatar = $4,
|
avatar = $4,
|
||||||
unit = $5,
|
|
||||||
user_group = $6,
|
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $7
|
WHERE id = $5
|
||||||
`,
|
`,
|
||||||
username,
|
username,
|
||||||
displayName,
|
displayName,
|
||||||
email,
|
email,
|
||||||
avatar,
|
avatar,
|
||||||
unit,
|
|
||||||
group,
|
|
||||||
userID,
|
userID,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
// frontend/src/App.tsx
|
// frontend/src/App.tsx
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Navigate, Route, Routes, useNavigate } from 'react-router'
|
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'
|
||||||
import { NotificationProvider } from './components/Notifications'
|
import { useNotifications } from './components/Notifications'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import AppLayout from './components/AppLayout'
|
import AppLayout from './components/AppLayout'
|
||||||
import DashboardPage from './pages/DashboardPage'
|
import DashboardPage from './pages/DashboardPage'
|
||||||
@ -21,6 +21,8 @@ import Feedback from './components/Feedback'
|
|||||||
import MilestoneStoragePage from './pages/operations/milestone-storage/MilestoneStoragePage'
|
import MilestoneStoragePage from './pages/operations/milestone-storage/MilestoneStoragePage'
|
||||||
import MilestoneDevicesPage from './pages/operations/milestone-devices/MilestoneDevicesPage'
|
import MilestoneDevicesPage from './pages/operations/milestone-devices/MilestoneDevicesPage'
|
||||||
import { hasRight } from './components/permissions'
|
import { hasRight } from './components/permissions'
|
||||||
|
import ChatPage from './pages/chat/ChatPage'
|
||||||
|
import { recordNavigation } from './utils/activityLog'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
@ -65,6 +67,23 @@ function getUserFromNotification(notification: Notification) {
|
|||||||
return user as User
|
return user as User
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveNotificationImage(value: unknown) {
|
||||||
|
if (typeof value !== 'string' || !value.trim()) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = value.trim()
|
||||||
|
if (image.startsWith('data:') || image.startsWith('blob:')) {
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(image, API_URL).toString()
|
||||||
|
} catch {
|
||||||
|
return new URL('/favicon.svg', window.location.origin).toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function playNotificationSound(soundName?: string) {
|
function playNotificationSound(soundName?: string) {
|
||||||
if (!soundName) {
|
if (!soundName) {
|
||||||
return
|
return
|
||||||
@ -96,31 +115,57 @@ function RequireRight({
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const { showToast } = useNotifications()
|
||||||
|
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||||
|
const [unreadChatCount, setUnreadChatCount] = useState(0)
|
||||||
const [unreadJournalOperationIds, setUnreadJournalOperationIds] = useState<Set<string>>(
|
const [unreadJournalOperationIds, setUnreadJournalOperationIds] = useState<Set<string>>(
|
||||||
() => new Set(),
|
() => new Set(),
|
||||||
)
|
)
|
||||||
|
const currentUserId = user?.id
|
||||||
|
const awayAfterMinutes = user?.awayAfterMinutes
|
||||||
|
|
||||||
const unreadNotificationCount = notifications.filter(
|
const unreadNotificationCount = notifications.filter(
|
||||||
(notification) => !notification.readAt,
|
(notification) => !notification.readAt,
|
||||||
).length
|
).length
|
||||||
|
|
||||||
|
const loadUnreadChatCount = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/chat/unread`, {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
setUnreadChatCount(Number(data.unreadCount) || 0)
|
||||||
|
} catch {
|
||||||
|
// Der Zähler wird beim nächsten Chat- oder Notification-Ereignis erneut geladen.
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadUser()
|
void loadUser()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) {
|
recordNavigation(location.pathname)
|
||||||
|
}, [location.pathname])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUserId) {
|
||||||
setNotifications([])
|
setNotifications([])
|
||||||
setUnreadJournalOperationIds(new Set())
|
setUnreadJournalOperationIds(new Set())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadNotifications()
|
void loadNotifications()
|
||||||
|
void loadUnreadChatCount()
|
||||||
void loadUnreadJournalOperations()
|
void loadUnreadJournalOperations()
|
||||||
|
|
||||||
const eventSource = new EventSource(`${API_URL}/events`, {
|
const eventSource = new EventSource(`${API_URL}/events`, {
|
||||||
@ -132,6 +177,89 @@ function App() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (notification.entityType === 'chat') {
|
||||||
|
const data = getNotificationData(notification)
|
||||||
|
const senderAvatar = resolveNotificationImage(data.senderAvatar)
|
||||||
|
const desktopIcon =
|
||||||
|
senderAvatar ||
|
||||||
|
new URL('/favicon.svg', window.location.origin).toString()
|
||||||
|
const senderName =
|
||||||
|
typeof data.senderName === 'string' ? data.senderName : notification.title
|
||||||
|
const isChannel = data.conversationType === 'channel'
|
||||||
|
const chatPath = isChannel
|
||||||
|
? `/chat/channel/${notification.entityId}`
|
||||||
|
: `/chat/${notification.entityId}`
|
||||||
|
|
||||||
|
if (
|
||||||
|
notification.desktop &&
|
||||||
|
'Notification' in window &&
|
||||||
|
Notification.permission === 'granted'
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const desktopNotification = new Notification(notification.title, {
|
||||||
|
body: notification.message,
|
||||||
|
icon: desktopIcon,
|
||||||
|
badge: desktopIcon,
|
||||||
|
tag: `chat-${notification.entityId}`,
|
||||||
|
requireInteraction: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
desktopNotification.onclick = () => {
|
||||||
|
window.focus()
|
||||||
|
navigate(chatPath)
|
||||||
|
desktopNotification.close()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Das sichtbare Browser-Popup darunter bleibt als Fallback erhalten.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
notification.inAppEnabled !== false &&
|
||||||
|
document.visibilityState === 'visible'
|
||||||
|
) {
|
||||||
|
showToast({
|
||||||
|
title: notification.title,
|
||||||
|
message: notification.message,
|
||||||
|
imageUrl: senderAvatar,
|
||||||
|
imageName: senderName,
|
||||||
|
actionLabel: 'Öffnen',
|
||||||
|
onAction: () => navigate(chatPath),
|
||||||
|
...(isChannel
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
replyPlaceholder: `An ${senderName} antworten...`,
|
||||||
|
onReply: async (message: string) => {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_URL}/chat/conversations/${notification.entityId}/messages`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ body: message }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
const responseData = await response.json().catch(() => null)
|
||||||
|
throw new Error(
|
||||||
|
responseData?.error ||
|
||||||
|
'Antwort konnte nicht gesendet werden.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
autoClose: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
playNotificationSound(notification.soundName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.inAppEnabled === false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
playNotificationSound(notification.soundName)
|
playNotificationSound(notification.soundName)
|
||||||
|
|
||||||
setNotifications((currentNotifications) => [
|
setNotifications((currentNotifications) => [
|
||||||
@ -167,6 +295,10 @@ function App() {
|
|||||||
|
|
||||||
addVisibleNotification(notification)
|
addVisibleNotification(notification)
|
||||||
dispatchEntityEvent(notification)
|
dispatchEntityEvent(notification)
|
||||||
|
|
||||||
|
if (notification.entityType === 'chat') {
|
||||||
|
void loadUnreadChatCount()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOperationEvent(event: MessageEvent) {
|
function handleOperationEvent(event: MessageEvent) {
|
||||||
@ -248,7 +380,103 @@ function App() {
|
|||||||
eventSource.removeEventListener('user-event', handleUserEvent)
|
eventSource.removeEventListener('user-event', handleUserEvent)
|
||||||
eventSource.close()
|
eventSource.close()
|
||||||
}
|
}
|
||||||
}, [user])
|
}, [currentUserId, loadUnreadChatCount, navigate, showToast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUserId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUnreadChatChange() {
|
||||||
|
void loadUnreadChatCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('chat-unread-changed', handleUnreadChatChange)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('chat-unread-changed', handleUnreadChatChange)
|
||||||
|
}
|
||||||
|
}, [currentUserId, loadUnreadChatCount])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUserId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let idle = false
|
||||||
|
let lastActiveHeartbeat = 0
|
||||||
|
let idleTimer: number | undefined
|
||||||
|
const awayAfterMilliseconds =
|
||||||
|
Math.max(1, awayAfterMinutes || 3) * 60_000
|
||||||
|
|
||||||
|
function sendHeartbeat(active: boolean) {
|
||||||
|
void fetch(`${API_URL}/presence`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ active }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleIdleTimer() {
|
||||||
|
if (idleTimer !== undefined) {
|
||||||
|
window.clearTimeout(idleTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
idleTimer = window.setTimeout(() => {
|
||||||
|
idle = true
|
||||||
|
sendHeartbeat(false)
|
||||||
|
}, awayAfterMilliseconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActivity() {
|
||||||
|
const now = Date.now()
|
||||||
|
idle = false
|
||||||
|
scheduleIdleTimer()
|
||||||
|
|
||||||
|
if (now - lastActiveHeartbeat >= 15_000) {
|
||||||
|
lastActiveHeartbeat = now
|
||||||
|
sendHeartbeat(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendHeartbeat(true)
|
||||||
|
lastActiveHeartbeat = Date.now()
|
||||||
|
scheduleIdleTimer()
|
||||||
|
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
sendHeartbeat(!idle && document.visibilityState === 'visible')
|
||||||
|
}, 45_000)
|
||||||
|
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
handleActivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activityEvents: Array<keyof WindowEventMap> = [
|
||||||
|
'pointerdown',
|
||||||
|
'keydown',
|
||||||
|
'mousemove',
|
||||||
|
'touchstart',
|
||||||
|
'scroll',
|
||||||
|
]
|
||||||
|
|
||||||
|
activityEvents.forEach((eventName) => {
|
||||||
|
window.addEventListener(eventName, handleActivity, { passive: true })
|
||||||
|
})
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(intervalId)
|
||||||
|
if (idleTimer !== undefined) {
|
||||||
|
window.clearTimeout(idleTimer)
|
||||||
|
}
|
||||||
|
activityEvents.forEach((eventName) => {
|
||||||
|
window.removeEventListener(eventName, handleActivity)
|
||||||
|
})
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
}
|
||||||
|
}, [awayAfterMinutes, currentUserId])
|
||||||
|
|
||||||
async function loadUser() {
|
async function loadUser() {
|
||||||
try {
|
try {
|
||||||
@ -352,6 +580,16 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleNotificationClick(notification: Notification) {
|
function handleNotificationClick(notification: Notification) {
|
||||||
|
if (notification.entityType === 'chat' && notification.entityId) {
|
||||||
|
const data = getNotificationData(notification)
|
||||||
|
navigate(
|
||||||
|
data.conversationType === 'channel'
|
||||||
|
? `/chat/channel/${notification.entityId}`
|
||||||
|
: `/chat/${notification.entityId}`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
notification.type === 'operation.created' ||
|
notification.type === 'operation.created' ||
|
||||||
notification.entityType === 'operation'
|
notification.entityType === 'operation'
|
||||||
@ -369,6 +607,7 @@ function App() {
|
|||||||
} finally {
|
} finally {
|
||||||
setUser(null)
|
setUser(null)
|
||||||
setNotifications([])
|
setNotifications([])
|
||||||
|
setUnreadChatCount(0)
|
||||||
setUnreadJournalOperationIds(new Set())
|
setUnreadJournalOperationIds(new Set())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -396,10 +635,11 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NotificationProvider>
|
<AppLayout
|
||||||
<AppLayout
|
|
||||||
user={user}
|
user={user}
|
||||||
|
onUserUpdated={setUser}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
unreadChatCount={unreadChatCount}
|
||||||
notificationCenter={
|
notificationCenter={
|
||||||
<NotificationCenter
|
<NotificationCenter
|
||||||
notifications={notifications}
|
notifications={notifications}
|
||||||
@ -511,12 +751,24 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path="/feedback" element={<Feedback />} />
|
<Route path="/feedback" element={<Feedback />} />
|
||||||
|
<Route path="/chat" element={<ChatPage currentUser={user} />} />
|
||||||
|
<Route
|
||||||
|
path="/chat/team/:teamId"
|
||||||
|
element={<ChatPage currentUser={user} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/chat/channel/:channelId"
|
||||||
|
element={<ChatPage currentUser={user} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/chat/:conversationId"
|
||||||
|
element={<ChatPage currentUser={user} />}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</NotificationProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
118
frontend/src/components/ActivityLogView.tsx
Normal file
118
frontend/src/components/ActivityLogView.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
// frontend/src/components/ActivityLogView.tsx
|
||||||
|
|
||||||
|
import {
|
||||||
|
ArrowLongRightIcon,
|
||||||
|
CursorArrowRaysIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
} from '@heroicons/react/24/outline'
|
||||||
|
import type { ActivityLogEntry, ActivityLogType } from '../utils/activityLog'
|
||||||
|
|
||||||
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||||
|
return classes.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value: string) {
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('de-DE', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeMeta: Record<
|
||||||
|
ActivityLogType,
|
||||||
|
{ icon: typeof CursorArrowRaysIcon; iconClassName: string }
|
||||||
|
> = {
|
||||||
|
action: {
|
||||||
|
icon: CursorArrowRaysIcon,
|
||||||
|
iconClassName: 'text-indigo-500 dark:text-indigo-400',
|
||||||
|
},
|
||||||
|
navigation: {
|
||||||
|
icon: ArrowLongRightIcon,
|
||||||
|
iconClassName: 'text-sky-500 dark:text-sky-400',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
icon: ExclamationTriangleIcon,
|
||||||
|
iconClassName: 'text-red-500 dark:text-red-400',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityLogView({
|
||||||
|
entries,
|
||||||
|
showPath = true,
|
||||||
|
emptyLabel = 'Keine Aktivitäten aufgezeichnet.',
|
||||||
|
}: {
|
||||||
|
entries: ActivityLogEntry[]
|
||||||
|
showPath?: boolean
|
||||||
|
emptyLabel?: string
|
||||||
|
}) {
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="px-3 py-6 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{emptyLabel}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ol className="space-y-0.5">
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const meta = typeMeta[entry.type] ?? typeMeta.action
|
||||||
|
const Icon = meta.icon
|
||||||
|
const isError = entry.type === 'error'
|
||||||
|
const stack =
|
||||||
|
entry.details && typeof entry.details.stack === 'string'
|
||||||
|
? entry.details.stack
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={entry.id}
|
||||||
|
className={classNames(
|
||||||
|
'flex items-start gap-3 rounded-lg px-3 py-2',
|
||||||
|
isError
|
||||||
|
? 'bg-red-50 dark:bg-red-500/10'
|
||||||
|
: 'hover:bg-gray-50 dark:hover:bg-white/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={classNames('mt-0.5 size-4 shrink-0', meta.iconClassName)} />
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p
|
||||||
|
className={classNames(
|
||||||
|
'text-sm break-words',
|
||||||
|
isError
|
||||||
|
? 'font-medium text-red-700 dark:text-red-300'
|
||||||
|
: 'text-gray-700 dark:text-gray-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{entry.message}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{showPath && entry.path && (
|
||||||
|
<p className="mt-0.5 font-mono text-[0.625rem] text-gray-400">
|
||||||
|
{entry.path}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stack && (
|
||||||
|
<pre className="mt-1.5 max-h-40 overflow-auto rounded-md bg-gray-950 p-2 text-[0.625rem] leading-relaxed text-gray-300">
|
||||||
|
{stack}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<time className="shrink-0 font-mono text-[0.625rem] text-gray-400">
|
||||||
|
{formatTime(entry.timestamp)}
|
||||||
|
</time>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -21,14 +21,21 @@ export type AddressSuggestion = {
|
|||||||
lat: number
|
lat: number
|
||||||
lon: number
|
lon: number
|
||||||
type?: string
|
type?: string
|
||||||
|
hasHouseNumber?: boolean
|
||||||
geojson?: GeoJsonObject | null
|
geojson?: GeoJsonObject | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type Coordinates = {
|
export type AddressCoordinates = {
|
||||||
lat: number
|
lat: number
|
||||||
lon: number
|
lon: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AddressArea = {
|
||||||
|
center: AddressCoordinates
|
||||||
|
hasHouseNumber: boolean
|
||||||
|
geojson: GeoJsonObject | null
|
||||||
|
}
|
||||||
|
|
||||||
type ReverseGeocodeResponse = {
|
type ReverseGeocodeResponse = {
|
||||||
suggestion?: AddressSuggestion
|
suggestion?: AddressSuggestion
|
||||||
}
|
}
|
||||||
@ -39,8 +46,12 @@ type AddressComboboxProps = {
|
|||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
|
position?: AddressCoordinates | null
|
||||||
|
onPositionChange?: (position: AddressCoordinates | null) => void
|
||||||
|
onAreaChange?: (area: AddressArea | null) => void
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
mapVisible?: boolean
|
mapVisible?: boolean
|
||||||
|
showMap?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputClassName =
|
const inputClassName =
|
||||||
@ -73,6 +84,18 @@ function normalizeAddressSuggestion(
|
|||||||
...suggestion,
|
...suggestion,
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
|
hasHouseNumber: suggestion.hasHouseNumber === true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAddressArea(
|
||||||
|
suggestion: AddressSuggestion | null,
|
||||||
|
center: AddressCoordinates,
|
||||||
|
): AddressArea {
|
||||||
|
return {
|
||||||
|
center,
|
||||||
|
hasHouseNumber: suggestion?.hasHouseNumber === true,
|
||||||
|
geojson: suggestion?.geojson ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,8 +105,12 @@ export default function AddressCombobox({
|
|||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
position,
|
||||||
|
onPositionChange,
|
||||||
|
onAreaChange,
|
||||||
placeholder = 'Adresse suchen',
|
placeholder = 'Adresse suchen',
|
||||||
mapVisible = true,
|
mapVisible = true,
|
||||||
|
showMap = true,
|
||||||
}: AddressComboboxProps) {
|
}: AddressComboboxProps) {
|
||||||
const [query, setQuery] = useState(value)
|
const [query, setQuery] = useState(value)
|
||||||
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([])
|
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([])
|
||||||
@ -96,6 +123,21 @@ export default function AddressCombobox({
|
|||||||
const [mapFocusKey, setMapFocusKey] = useState(0)
|
const [mapFocusKey, setMapFocusKey] = useState(0)
|
||||||
const [isSearching, setIsSearching] = useState(false)
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
const [searchError, setSearchError] = useState<string | null>(null)
|
const [searchError, setSearchError] = useState<string | null>(null)
|
||||||
|
const controlledMarkerPosition =
|
||||||
|
position &&
|
||||||
|
Number.isFinite(position.lat) &&
|
||||||
|
Number.isFinite(position.lon)
|
||||||
|
? position
|
||||||
|
: null
|
||||||
|
const effectiveMarkerPosition =
|
||||||
|
position === undefined ? markerPosition : controlledMarkerPosition
|
||||||
|
const hasControlledMarkerPosition = controlledMarkerPosition !== null
|
||||||
|
const highlightedSuggestion =
|
||||||
|
selectedSuggestion?.geojson
|
||||||
|
? selectedSuggestion
|
||||||
|
: previewSuggestion?.geojson
|
||||||
|
? previewSuggestion
|
||||||
|
: null
|
||||||
|
|
||||||
const previousValueRef = useRef(value)
|
const previousValueRef = useRef(value)
|
||||||
const initializedAddressRef = useRef('')
|
const initializedAddressRef = useRef('')
|
||||||
@ -104,6 +146,26 @@ export default function AddressCombobox({
|
|||||||
const reverseGeocodeAbortRef = useRef<AbortController | null>(null)
|
const reverseGeocodeAbortRef = useRef<AbortController | null>(null)
|
||||||
const internalValueUpdateRef = useRef(false)
|
const internalValueUpdateRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!highlightedSuggestion?.geojson ||
|
||||||
|
!effectiveMarkerPosition
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onAreaChange?.(
|
||||||
|
createAddressArea(highlightedSuggestion, {
|
||||||
|
lat: highlightedSuggestion.lat,
|
||||||
|
lon: highlightedSuggestion.lon,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, [
|
||||||
|
effectiveMarkerPosition,
|
||||||
|
highlightedSuggestion,
|
||||||
|
onAreaChange,
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value !== previousValueRef.current) {
|
if (value !== previousValueRef.current) {
|
||||||
setQuery(value)
|
setQuery(value)
|
||||||
@ -136,7 +198,7 @@ export default function AddressCombobox({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (markerPosition) {
|
if (effectiveMarkerPosition) {
|
||||||
setMapFocusKey((currentKey) => currentKey + 1)
|
setMapFocusKey((currentKey) => currentKey + 1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -144,7 +206,7 @@ export default function AddressCombobox({
|
|||||||
initializedAddressRef.current = ''
|
initializedAddressRef.current = ''
|
||||||
|
|
||||||
void loadInitialMarkerForAddress(value)
|
void loadInitialMarkerForAddress(value)
|
||||||
}, [mapVisible, markerPosition, value])
|
}, [mapVisible, effectiveMarkerPosition, value])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const trimmedValue = value.trim()
|
const trimmedValue = value.trim()
|
||||||
@ -154,7 +216,7 @@ export default function AddressCombobox({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (markerPosition) {
|
if (effectiveMarkerPosition) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,7 +227,63 @@ export default function AddressCombobox({
|
|||||||
return () => {
|
return () => {
|
||||||
abortController.abort()
|
abortController.abort()
|
||||||
}
|
}
|
||||||
}, [value, markerPosition])
|
}, [value, effectiveMarkerPosition])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!hasControlledMarkerPosition ||
|
||||||
|
value.trim().length < 3 ||
|
||||||
|
selectedSuggestion
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const coordinates = {
|
||||||
|
lat: controlledMarkerPosition.lat,
|
||||||
|
lon: controlledMarkerPosition.lon,
|
||||||
|
}
|
||||||
|
const abortController = new AbortController()
|
||||||
|
|
||||||
|
onAreaChange?.(createAddressArea(null, coordinates))
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_URL}/address-reverse?lat=${encodeURIComponent(
|
||||||
|
coordinates.lat,
|
||||||
|
)}&lon=${encodeURIComponent(
|
||||||
|
coordinates.lon,
|
||||||
|
)}&q=${encodeURIComponent(value.trim())}`,
|
||||||
|
{
|
||||||
|
credentials: 'include',
|
||||||
|
signal: abortController.signal,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as ReverseGeocodeResponse
|
||||||
|
const suggestion = data.suggestion
|
||||||
|
? normalizeAddressSuggestion(data.suggestion)
|
||||||
|
: null
|
||||||
|
|
||||||
|
onAreaChange?.(createAddressArea(suggestion, coordinates))
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => abortController.abort()
|
||||||
|
}, [
|
||||||
|
hasControlledMarkerPosition,
|
||||||
|
value,
|
||||||
|
onAreaChange,
|
||||||
|
selectedSuggestion,
|
||||||
|
])
|
||||||
|
|
||||||
async function loadInitialMarkerForAddress(
|
async function loadInitialMarkerForAddress(
|
||||||
address: string,
|
address: string,
|
||||||
@ -177,7 +295,10 @@ export default function AddressCombobox({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (initializedAddressRef.current === trimmedAddress && markerPosition) {
|
if (
|
||||||
|
initializedAddressRef.current === trimmedAddress &&
|
||||||
|
effectiveMarkerPosition
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -207,7 +328,7 @@ export default function AddressCombobox({
|
|||||||
const reverseSuggestion = await reverseGeocodeCoordinates({
|
const reverseSuggestion = await reverseGeocodeCoordinates({
|
||||||
lat: firstSuggestion.lat,
|
lat: firstSuggestion.lat,
|
||||||
lon: firstSuggestion.lon,
|
lon: firstSuggestion.lon,
|
||||||
})
|
}, trimmedAddress, firstSuggestion.id)
|
||||||
|
|
||||||
const nextSuggestion: AddressSuggestion = {
|
const nextSuggestion: AddressSuggestion = {
|
||||||
...firstSuggestion,
|
...firstSuggestion,
|
||||||
@ -222,6 +343,16 @@ export default function AddressCombobox({
|
|||||||
lat: nextSuggestion.lat,
|
lat: nextSuggestion.lat,
|
||||||
lon: nextSuggestion.lon,
|
lon: nextSuggestion.lon,
|
||||||
})
|
})
|
||||||
|
onPositionChange?.({
|
||||||
|
lat: nextSuggestion.lat,
|
||||||
|
lon: nextSuggestion.lon,
|
||||||
|
})
|
||||||
|
onAreaChange?.(
|
||||||
|
createAddressArea(nextSuggestion, {
|
||||||
|
lat: nextSuggestion.lat,
|
||||||
|
lon: nextSuggestion.lon,
|
||||||
|
}),
|
||||||
|
)
|
||||||
setMapFocusKey((currentKey) => currentKey + 1)
|
setMapFocusKey((currentKey) => currentKey + 1)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
@ -231,7 +362,9 @@ export default function AddressCombobox({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function reverseGeocodeCoordinates(
|
async function reverseGeocodeCoordinates(
|
||||||
coordinates: Coordinates,
|
coordinates: AddressCoordinates,
|
||||||
|
address = value,
|
||||||
|
osmIdentifier = '',
|
||||||
): Promise<AddressSuggestion | null> {
|
): Promise<AddressSuggestion | null> {
|
||||||
reverseGeocodeAbortRef.current?.abort()
|
reverseGeocodeAbortRef.current?.abort()
|
||||||
|
|
||||||
@ -242,7 +375,11 @@ export default function AddressCombobox({
|
|||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${API_URL}/address-reverse?lat=${encodeURIComponent(
|
`${API_URL}/address-reverse?lat=${encodeURIComponent(
|
||||||
coordinates.lat,
|
coordinates.lat,
|
||||||
)}&lon=${encodeURIComponent(coordinates.lon)}`,
|
)}&lon=${encodeURIComponent(
|
||||||
|
coordinates.lon,
|
||||||
|
)}&q=${encodeURIComponent(
|
||||||
|
address.trim(),
|
||||||
|
)}&osm_id=${encodeURIComponent(osmIdentifier)}`,
|
||||||
{
|
{
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
@ -279,14 +416,16 @@ export default function AddressCombobox({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMapPositionChange(coordinates: Coordinates) {
|
async function handleMapPositionChange(coordinates: AddressCoordinates) {
|
||||||
setMarkerPosition(coordinates)
|
setMarkerPosition(coordinates)
|
||||||
|
onPositionChange?.(coordinates)
|
||||||
setSelectedSuggestion(null)
|
setSelectedSuggestion(null)
|
||||||
setPreviewSuggestion(null)
|
setPreviewSuggestion(null)
|
||||||
|
|
||||||
const suggestion = await reverseGeocodeCoordinates(coordinates)
|
const suggestion = await reverseGeocodeCoordinates(coordinates)
|
||||||
|
|
||||||
if (!suggestion) {
|
if (!suggestion) {
|
||||||
|
onAreaChange?.(createAddressArea(null, coordinates))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -296,6 +435,7 @@ export default function AddressCombobox({
|
|||||||
setQuery(suggestion.label)
|
setQuery(suggestion.label)
|
||||||
setSelectedSuggestion(suggestion)
|
setSelectedSuggestion(suggestion)
|
||||||
setPreviewSuggestion(suggestion)
|
setPreviewSuggestion(suggestion)
|
||||||
|
onAreaChange?.(createAddressArea(suggestion, coordinates))
|
||||||
|
|
||||||
onChange(suggestion.label)
|
onChange(suggestion.label)
|
||||||
}
|
}
|
||||||
@ -372,6 +512,8 @@ export default function AddressCombobox({
|
|||||||
setSelectedSuggestion(null)
|
setSelectedSuggestion(null)
|
||||||
setPreviewSuggestion(null)
|
setPreviewSuggestion(null)
|
||||||
setMarkerPosition(null)
|
setMarkerPosition(null)
|
||||||
|
onPositionChange?.(null)
|
||||||
|
onAreaChange?.(null)
|
||||||
onChange(nextValue)
|
onChange(nextValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -390,12 +532,22 @@ export default function AddressCombobox({
|
|||||||
lat: normalizedSuggestion.lat,
|
lat: normalizedSuggestion.lat,
|
||||||
lon: normalizedSuggestion.lon,
|
lon: normalizedSuggestion.lon,
|
||||||
})
|
})
|
||||||
|
onPositionChange?.({
|
||||||
|
lat: normalizedSuggestion.lat,
|
||||||
|
lon: normalizedSuggestion.lon,
|
||||||
|
})
|
||||||
|
onAreaChange?.(
|
||||||
|
createAddressArea(normalizedSuggestion, {
|
||||||
|
lat: normalizedSuggestion.lat,
|
||||||
|
lon: normalizedSuggestion.lon,
|
||||||
|
}),
|
||||||
|
)
|
||||||
setMapFocusKey((currentKey) => currentKey + 1)
|
setMapFocusKey((currentKey) => currentKey + 1)
|
||||||
|
|
||||||
const reverseSuggestion = await reverseGeocodeCoordinates({
|
const reverseSuggestion = await reverseGeocodeCoordinates({
|
||||||
lat: normalizedSuggestion.lat,
|
lat: normalizedSuggestion.lat,
|
||||||
lon: normalizedSuggestion.lon,
|
lon: normalizedSuggestion.lon,
|
||||||
})
|
}, normalizedSuggestion.label, normalizedSuggestion.id)
|
||||||
|
|
||||||
const nextSuggestion: AddressSuggestion = {
|
const nextSuggestion: AddressSuggestion = {
|
||||||
...normalizedSuggestion,
|
...normalizedSuggestion,
|
||||||
@ -409,6 +561,12 @@ export default function AddressCombobox({
|
|||||||
setQuery(nextSuggestion.label)
|
setQuery(nextSuggestion.label)
|
||||||
setSelectedSuggestion(nextSuggestion)
|
setSelectedSuggestion(nextSuggestion)
|
||||||
setPreviewSuggestion(nextSuggestion)
|
setPreviewSuggestion(nextSuggestion)
|
||||||
|
onAreaChange?.(
|
||||||
|
createAddressArea(nextSuggestion, {
|
||||||
|
lat: nextSuggestion.lat,
|
||||||
|
lon: nextSuggestion.lon,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
onChange(nextSuggestion.label)
|
onChange(nextSuggestion.label)
|
||||||
}
|
}
|
||||||
@ -472,14 +630,16 @@ export default function AddressCombobox({
|
|||||||
</div>
|
</div>
|
||||||
</HeadlessCombobox>
|
</HeadlessCombobox>
|
||||||
|
|
||||||
<AddressMapPicker
|
{showMap && (
|
||||||
key={mapKey}
|
<AddressMapPicker
|
||||||
value={markerPosition}
|
key={mapKey}
|
||||||
focusKey={mapFocusKey}
|
value={effectiveMarkerPosition}
|
||||||
visible={mapVisible}
|
focusKey={mapFocusKey}
|
||||||
highlightGeoJson={previewSuggestion?.geojson ?? null}
|
visible={mapVisible}
|
||||||
onChange={handleMapPositionChange}
|
highlightGeoJson={previewSuggestion?.geojson ?? null}
|
||||||
/>
|
onChange={handleMapPositionChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,16 +10,20 @@ import type { User } from './types'
|
|||||||
|
|
||||||
type AppLayoutProps = {
|
type AppLayoutProps = {
|
||||||
user: User
|
user: User
|
||||||
|
onUserUpdated: (user: User) => void
|
||||||
onLogout: () => void | Promise<void>
|
onLogout: () => void | Promise<void>
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
notificationCenter?: ReactNode
|
notificationCenter?: ReactNode
|
||||||
|
unreadChatCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AppLayout({
|
export default function AppLayout({
|
||||||
user,
|
user,
|
||||||
|
onUserUpdated,
|
||||||
onLogout,
|
onLogout,
|
||||||
children,
|
children,
|
||||||
notificationCenter,
|
notificationCenter,
|
||||||
|
unreadChatCount = 0,
|
||||||
}: AppLayoutProps) {
|
}: AppLayoutProps) {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
@ -48,11 +52,13 @@ export default function AppLayout({
|
|||||||
sidebarOpen={sidebarOpen}
|
sidebarOpen={sidebarOpen}
|
||||||
setSidebarOpen={setSidebarOpen}
|
setSidebarOpen={setSidebarOpen}
|
||||||
onNavigate={handleNavigation}
|
onNavigate={handleNavigation}
|
||||||
|
unreadChatCount={unreadChatCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative z-0 flex h-dvh min-w-0 flex-col lg:pl-72">
|
<div className="relative z-0 flex h-dvh min-w-0 flex-col lg:pl-72">
|
||||||
<Topbar
|
<Topbar
|
||||||
user={user}
|
user={user}
|
||||||
|
onUserUpdated={onUserUpdated}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
onOpenSidebar={() => setSidebarOpen(true)}
|
onOpenSidebar={() => setSidebarOpen(true)}
|
||||||
notificationCenter={notificationCenter}
|
notificationCenter={notificationCenter}
|
||||||
@ -85,4 +91,4 @@ export default function AppLayout({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,8 @@ import type { ImgHTMLAttributes } from 'react'
|
|||||||
|
|
||||||
export type AvatarSize = 6 | 8 | 9 | 10 | 12 | 14 | 16
|
export type AvatarSize = 6 | 8 | 9 | 10 | 12 | 14 | 16
|
||||||
export type AvatarShape = 'circle' | 'rounded'
|
export type AvatarShape = 'circle' | 'rounded'
|
||||||
export type AvatarStatus = 'gray' | 'red' | 'green'
|
export type AvatarStatus = 'gray' | 'red' | 'green' | 'amber'
|
||||||
|
export type PresenceStatus = 'online' | 'away' | 'offline'
|
||||||
export type AvatarStatusPosition = 'top' | 'bottom'
|
export type AvatarStatusPosition = 'top' | 'bottom'
|
||||||
|
|
||||||
export type AvatarProps = Omit<ImgHTMLAttributes<HTMLImageElement>, 'size'> & {
|
export type AvatarProps = Omit<ImgHTMLAttributes<HTMLImageElement>, 'size'> & {
|
||||||
@ -19,6 +20,9 @@ export type AvatarProps = Omit<ImgHTMLAttributes<HTMLImageElement>, 'size'> & {
|
|||||||
* Online-Status der Person. true = online (grün), false = offline (grau).
|
* Online-Status der Person. true = online (grün), false = offline (grau).
|
||||||
* Wird ignoriert, wenn `status` explizit gesetzt ist.
|
* Wird ignoriert, wenn `status` explizit gesetzt ist.
|
||||||
*/
|
*/
|
||||||
|
onlineStatus?: boolean
|
||||||
|
presenceStatus?: PresenceStatus
|
||||||
|
/** @deprecated Nutze `onlineStatus`. */
|
||||||
online?: boolean
|
online?: boolean
|
||||||
showPlaceholderIcon?: boolean
|
showPlaceholderIcon?: boolean
|
||||||
wrapperClassName?: string
|
wrapperClassName?: string
|
||||||
@ -62,6 +66,7 @@ const statusColorClassNames: Record<AvatarStatus, string> = {
|
|||||||
gray: 'bg-gray-300 dark:bg-gray-500',
|
gray: 'bg-gray-300 dark:bg-gray-500',
|
||||||
red: 'bg-red-400 dark:bg-red-500',
|
red: 'bg-red-400 dark:bg-red-500',
|
||||||
green: 'bg-green-400 dark:bg-green-500',
|
green: 'bg-green-400 dark:bg-green-500',
|
||||||
|
amber: 'bg-amber-400 dark:bg-amber-500',
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInitials(name?: string, initials?: string) {
|
function getInitials(name?: string, initials?: string) {
|
||||||
@ -104,6 +109,8 @@ export default function Avatar({
|
|||||||
shape = 'circle',
|
shape = 'circle',
|
||||||
status,
|
status,
|
||||||
statusPosition = 'bottom',
|
statusPosition = 'bottom',
|
||||||
|
onlineStatus,
|
||||||
|
presenceStatus,
|
||||||
online,
|
online,
|
||||||
showPlaceholderIcon = true,
|
showPlaceholderIcon = true,
|
||||||
wrapperClassName,
|
wrapperClassName,
|
||||||
@ -114,10 +121,35 @@ export default function Avatar({
|
|||||||
const avatarInitials = getInitials(name, initials)
|
const avatarInitials = getInitials(name, initials)
|
||||||
|
|
||||||
// `status` hat Vorrang; ansonsten leitet sich der Punkt aus `online` ab.
|
// `status` hat Vorrang; ansonsten leitet sich der Punkt aus `online` ab.
|
||||||
|
const resolvedOnlineStatus = onlineStatus ?? online
|
||||||
|
const presenceAvatarStatus: AvatarStatus | undefined =
|
||||||
|
presenceStatus === 'online'
|
||||||
|
? 'green'
|
||||||
|
: presenceStatus === 'away'
|
||||||
|
? 'amber'
|
||||||
|
: presenceStatus === 'offline'
|
||||||
|
? 'gray'
|
||||||
|
: undefined
|
||||||
const effectiveStatus: AvatarStatus | undefined =
|
const effectiveStatus: AvatarStatus | undefined =
|
||||||
status ?? (online === undefined ? undefined : online ? 'green' : 'gray')
|
status ??
|
||||||
|
presenceAvatarStatus ??
|
||||||
|
(resolvedOnlineStatus === undefined
|
||||||
|
? undefined
|
||||||
|
: resolvedOnlineStatus
|
||||||
|
? 'green'
|
||||||
|
: 'gray')
|
||||||
const statusLabel =
|
const statusLabel =
|
||||||
online === undefined ? undefined : online ? 'Online' : 'Offline'
|
presenceStatus === 'online'
|
||||||
|
? 'Online'
|
||||||
|
: presenceStatus === 'away'
|
||||||
|
? 'Abwesend'
|
||||||
|
: presenceStatus === 'offline'
|
||||||
|
? 'Offline'
|
||||||
|
: resolvedOnlineStatus === undefined
|
||||||
|
? undefined
|
||||||
|
: resolvedOnlineStatus
|
||||||
|
? 'Online'
|
||||||
|
: 'Offline'
|
||||||
|
|
||||||
const avatar = src ? (
|
const avatar = src ? (
|
||||||
<img
|
<img
|
||||||
@ -171,7 +203,12 @@ export default function Avatar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={classNames('relative inline-block', wrapperClassName)}>
|
<span
|
||||||
|
className={classNames(
|
||||||
|
'relative inline-flex shrink-0 align-middle leading-none',
|
||||||
|
wrapperClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{avatar}
|
{avatar}
|
||||||
|
|
||||||
<span
|
<span
|
||||||
@ -193,4 +230,4 @@ export default function Avatar({
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,54 +1,96 @@
|
|||||||
// frontend/src/pages/Feedback.tsx
|
// frontend/src/components/Feedback.tsx
|
||||||
|
|
||||||
import { useState, type FormEvent } from 'react'
|
import { useMemo, useState, type FormEvent } from 'react'
|
||||||
import Textarea from '../components/Textarea'
|
import {
|
||||||
|
ChatBubbleBottomCenterTextIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
CursorArrowRaysIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
} from '@heroicons/react/24/outline'
|
||||||
|
import Button from './Button'
|
||||||
|
import ActivityLogView from './ActivityLogView'
|
||||||
|
import { getActivityLog, type ActivityLogEntry } from '../utils/activityLog'
|
||||||
|
import { getCurrentSessionInfo } from '../utils/sessionInfo'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
|
const MAX_LENGTH = 5000
|
||||||
|
|
||||||
|
async function collectClientMeta() {
|
||||||
|
try {
|
||||||
|
const sessionInfo = await getCurrentSessionInfo()
|
||||||
|
|
||||||
|
return {
|
||||||
|
...sessionInfo,
|
||||||
|
url: window.location.href,
|
||||||
|
userAgent: window.navigator.userAgent,
|
||||||
|
language: window.navigator.language,
|
||||||
|
screen: `${window.screen.width}×${window.screen.height}`,
|
||||||
|
viewport: `${window.innerWidth}×${window.innerHeight}`,
|
||||||
|
collectedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
url: window.location.href,
|
||||||
|
userAgent: window.navigator.userAgent,
|
||||||
|
collectedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function Feedback() {
|
export default function Feedback() {
|
||||||
|
const [logSnapshot, setLogSnapshot] = useState<ActivityLogEntry[]>(() =>
|
||||||
|
getActivityLog(),
|
||||||
|
)
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null)
|
const [submitted, setSubmitted] = useState(false)
|
||||||
|
|
||||||
|
const errorCount = useMemo(
|
||||||
|
() => logSnapshot.filter((entry) => entry.type === 'error').length,
|
||||||
|
[logSnapshot],
|
||||||
|
)
|
||||||
|
const actionCount = logSnapshot.length - errorCount
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
const trimmedMessage = message.trim()
|
const trimmedMessage = message.trim()
|
||||||
|
|
||||||
if (!trimmedMessage) {
|
if (!trimmedMessage) {
|
||||||
setError('Bitte gib dein Feedback ein.')
|
setError('Bitte gib dein Feedback ein.')
|
||||||
setSuccessMessage(null)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccessMessage(null)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const client = await collectClientMeta()
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}/feedback`, {
|
const response = await fetch(`${API_URL}/feedback`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message: trimmedMessage,
|
message: trimmedMessage,
|
||||||
|
clientLog: logSnapshot,
|
||||||
|
client,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => null)
|
const errorData = await response.json().catch(() => null)
|
||||||
throw new Error(errorData?.error ?? 'Feedback konnte nicht gesendet werden')
|
throw new Error(
|
||||||
|
errorData?.error ?? 'Feedback konnte nicht gesendet werden',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
setMessage('')
|
setSubmitted(true)
|
||||||
setSuccessMessage('Danke, dein Feedback wurde gespeichert.')
|
} catch (submitError) {
|
||||||
} catch (error) {
|
|
||||||
setError(
|
setError(
|
||||||
error instanceof Error
|
submitError instanceof Error
|
||||||
? error.message
|
? submitError.message
|
||||||
: 'Feedback konnte nicht gesendet werden',
|
: 'Feedback konnte nicht gesendet werden',
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
@ -56,56 +98,141 @@ export default function Feedback() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
function startNewFeedback() {
|
||||||
<div className="h-full min-h-0 overflow-y-auto px-4 py-10 sm:px-6 lg:px-8 [scrollbar-gutter:stable]">
|
setSubmitted(false)
|
||||||
<div className="mx-auto max-w-3xl">
|
setMessage('')
|
||||||
<div>
|
setError(null)
|
||||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
setLogSnapshot(getActivityLog())
|
||||||
Feedback
|
}
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
return (
|
||||||
Teile uns mit, wenn dir etwas auffällt, etwas nicht funktioniert oder du eine Idee zur Verbesserung hast.
|
<div className="h-full min-h-0 overflow-y-auto bg-gray-50 px-4 py-10 sm:px-6 lg:px-8 dark:bg-gray-950 [scrollbar-gutter:stable]">
|
||||||
Beim Absenden wird automatisch ein technisches Log mit deinem Benutzerkonto angehängt.
|
<div className="mx-auto max-w-3xl">
|
||||||
</p>
|
<div className="flex items-start gap-4">
|
||||||
|
<span className="inline-flex size-12 shrink-0 items-center justify-center rounded-2xl bg-indigo-600/10 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300">
|
||||||
|
<ChatBubbleBottomCenterTextIcon className="size-6" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-gray-900 dark:text-white">
|
||||||
|
Feedback
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Fällt dir etwas auf, funktioniert etwas nicht oder hast du eine
|
||||||
|
Idee? Beim Absenden wird automatisch das unten sichtbare Protokoll
|
||||||
|
angehängt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{successMessage && (
|
{submitted ? (
|
||||||
<div className="mt-6 rounded-md bg-green-50 p-4 text-sm text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
<div className="mt-8 rounded-2xl border border-green-200 bg-white p-8 text-center shadow-sm dark:border-green-500/20 dark:bg-gray-900">
|
||||||
{successMessage}
|
<span className="mx-auto inline-flex size-14 items-center justify-center rounded-full bg-green-100 text-green-600 dark:bg-green-500/15 dark:text-green-300">
|
||||||
|
<CheckCircleIcon className="size-8" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<h2 className="mt-4 text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Danke für dein Feedback!
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto mt-1 max-w-md text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Dein Feedback wurde zusammen mit dem Protokoll gespeichert. Unser
|
||||||
|
Team schaut es sich an.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6">
|
||||||
|
<Button type="button" variant="secondary" color="gray" onClick={startNewFeedback}>
|
||||||
|
Weiteres Feedback geben
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{error && (
|
||||||
|
<div className="mt-6 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-300">
|
||||||
|
<ExclamationTriangleIcon className="mt-0.5 size-5 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="mt-6 rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
htmlFor="feedback"
|
||||||
|
className="block text-sm font-medium text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
Dein Feedback
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="feedback"
|
||||||
|
name="feedback"
|
||||||
|
rows={8}
|
||||||
|
value={message}
|
||||||
|
maxLength={MAX_LENGTH}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onChange={(event) => setMessage(event.target.value)}
|
||||||
|
placeholder="Beschreibe möglichst genau, was passiert ist oder was verbessert werden soll..."
|
||||||
|
className="mt-2 block w-full resize-none rounded-xl border-0 bg-gray-50 p-4 text-sm text-gray-900 outline-none ring-1 ring-gray-200 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 disabled:opacity-60 dark:bg-white/5 dark:text-white dark:ring-white/10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-3">
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{message.length} / {MAX_LENGTH} Zeichen
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
disabled={!message.trim() || isSubmitting}
|
||||||
|
>
|
||||||
|
Feedback senden
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-gray-100 px-5 py-4 dark:border-white/10">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Angehängtes Protokoll
|
||||||
|
</h2>
|
||||||
|
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Deine letzten Aktionen und Fehler in dieser Sitzung – hilft
|
||||||
|
uns, Probleme nachzuvollziehen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-indigo-50 px-2.5 py-1 text-xs font-medium text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300">
|
||||||
|
<CursorArrowRaysIcon className="size-3.5" />
|
||||||
|
{actionCount} Aktionen
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
errorCount > 0
|
||||||
|
? 'inline-flex items-center gap-1.5 rounded-full bg-red-50 px-2.5 py-1 text-xs font-medium text-red-700 dark:bg-red-500/15 dark:text-red-300'
|
||||||
|
: 'inline-flex items-center gap-1.5 rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-500 dark:bg-white/10 dark:text-gray-400'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ExclamationTriangleIcon className="size-3.5" />
|
||||||
|
{errorCount} Fehler
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-80 overflow-y-auto p-2">
|
||||||
|
<ActivityLogView
|
||||||
|
entries={logSnapshot}
|
||||||
|
showPath={false}
|
||||||
|
emptyLabel="Bisher wurden keine Aktionen oder Fehler aufgezeichnet."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 border-t border-gray-100 px-5 py-3 text-xs text-gray-500 dark:border-white/10 dark:text-gray-400">
|
||||||
|
<ShieldCheckIcon className="size-4 shrink-0 text-gray-400" />
|
||||||
|
Es werden keine Passwörter oder Eingabeinhalte erfasst – nur,
|
||||||
|
welche Elemente du benutzt hast, und technische Fehlermeldungen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mt-6 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className="mt-8 rounded-lg border border-gray-200 bg-white p-6 shadow-xs dark:border-white/10 dark:bg-gray-900"
|
|
||||||
>
|
|
||||||
<Textarea
|
|
||||||
id="feedback"
|
|
||||||
name="feedback"
|
|
||||||
label="Dein Feedback"
|
|
||||||
placeholder="Beschreibe möglichst genau, was passiert ist oder was verbessert werden soll..."
|
|
||||||
variant="avatar-actions"
|
|
||||||
rows={8}
|
|
||||||
value={message}
|
|
||||||
maxLength={5000}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
submitLabel="Feedback senden"
|
|
||||||
showAttachButton={false}
|
|
||||||
onChange={(event) => setMessage(event.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-2 text-right text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{message.length} / 5000 Zeichen
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
import { type ComponentType, type SVGProps, useState } from 'react'
|
import { type ComponentType, type SVGProps, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
|
ArrowRightIcon,
|
||||||
ChatBubbleLeftEllipsisIcon,
|
ChatBubbleLeftEllipsisIcon,
|
||||||
|
ClockIcon,
|
||||||
|
PaperAirplaneIcon,
|
||||||
PencilSquareIcon,
|
PencilSquareIcon,
|
||||||
PlusCircleIcon,
|
PlusCircleIcon,
|
||||||
UserCircleIcon,
|
|
||||||
} from '@heroicons/react/20/solid'
|
} from '@heroicons/react/20/solid'
|
||||||
|
import Avatar from './Avatar'
|
||||||
|
import { Badge, type BadgeTone } from './Badge'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import NotificationDot from './NotificationDot'
|
import NotificationDot from './NotificationDot'
|
||||||
|
|
||||||
@ -126,6 +130,25 @@ function getJournalIconBackground(item: JournalItem) {
|
|||||||
return 'bg-indigo-500'
|
return 'bg-indigo-500'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getJournalType(item: JournalItem): {
|
||||||
|
label: string
|
||||||
|
tone: BadgeTone
|
||||||
|
} {
|
||||||
|
if (item.type === 'created') {
|
||||||
|
return { label: 'Erstellt', tone: 'green' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === 'edited') {
|
||||||
|
return { label: 'Änderung', tone: 'blue' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === 'comment') {
|
||||||
|
return { label: 'Kommentar', tone: 'gray' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { label: 'Journal', tone: 'indigo' }
|
||||||
|
}
|
||||||
|
|
||||||
export default function Journal({
|
export default function Journal({
|
||||||
items = [],
|
items = [],
|
||||||
currentUserAvatar,
|
currentUserAvatar,
|
||||||
@ -177,151 +200,189 @@ export default function Journal({
|
|||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{showEntryForm && (
|
{showEntryForm && (
|
||||||
<div className="mb-8 flex gap-x-3">
|
<div className="mb-8 rounded-2xl border border-indigo-100 bg-gradient-to-br from-indigo-50/80 via-white to-violet-50/50 p-4 shadow-sm dark:border-indigo-400/15 dark:from-indigo-500/10 dark:via-gray-900/60 dark:to-violet-500/5">
|
||||||
{currentUserAvatar ? (
|
<div className="mb-3 flex items-center gap-3">
|
||||||
<img
|
<div className="flex size-9 items-center justify-center rounded-xl bg-indigo-600 text-white shadow-sm shadow-indigo-600/20 dark:bg-indigo-500">
|
||||||
alt=""
|
<ChatBubbleLeftEllipsisIcon
|
||||||
src={currentUserAvatar}
|
|
||||||
className="size-8 flex-none rounded-full bg-gray-50 outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex size-8 flex-none items-center justify-center rounded-full bg-gray-100 outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10">
|
|
||||||
<UserCircleIcon
|
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="size-6 text-gray-500 dark:text-gray-400"
|
className="size-5"
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="relative flex-auto">
|
|
||||||
<div className="overflow-hidden rounded-lg bg-white pb-12 outline-1 -outline-offset-1 outline-gray-300 focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-indigo-600 dark:bg-white/5 dark:outline-white/10 dark:focus-within:outline-indigo-500">
|
|
||||||
<label htmlFor="journalEntry" className="sr-only">
|
|
||||||
Journal-Eintrag hinzufügen
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
id="journalEntry"
|
|
||||||
name="journalEntry"
|
|
||||||
rows={3}
|
|
||||||
value={entry}
|
|
||||||
onChange={(event) => setEntry(event.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
className="block w-full resize-none bg-transparent px-3 py-2 text-base text-gray-900 placeholder:text-gray-400 focus:outline-none sm:text-sm/6 dark:text-white dark:placeholder:text-gray-500"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute inset-x-0 bottom-0 flex justify-end py-2 pr-2 pl-3">
|
<div>
|
||||||
<Button
|
<h4 className="text-sm font-semibold text-gray-950 dark:text-white">
|
||||||
type="button"
|
Neuer Journal-Eintrag
|
||||||
variant="secondary"
|
</h4>
|
||||||
color="indigo"
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
size="sm"
|
Notizen und wichtige Informationen nachvollziehbar festhalten.
|
||||||
isLoading={isSubmitting}
|
</p>
|
||||||
disabled={!entry.trim() || isSubmitting}
|
</div>
|
||||||
onClick={() => void handleSubmit()}
|
</div>
|
||||||
>
|
|
||||||
{submitLabel}
|
<div className="flex items-start gap-3">
|
||||||
</Button>
|
<Avatar
|
||||||
|
src={currentUserAvatar}
|
||||||
|
name="Du"
|
||||||
|
size={9}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 transition focus-within:ring-2 focus-within:ring-indigo-600 dark:bg-white/5 dark:ring-white/10 dark:focus-within:ring-indigo-400">
|
||||||
|
<label htmlFor="journalEntry" className="sr-only">
|
||||||
|
Journal-Eintrag hinzufügen
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
id="journalEntry"
|
||||||
|
name="journalEntry"
|
||||||
|
rows={3}
|
||||||
|
value={entry}
|
||||||
|
onChange={(event) => setEntry(event.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="block min-h-24 w-full resize-y bg-transparent px-3.5 py-3 text-base text-gray-900 placeholder:text-gray-400 focus:outline-none sm:text-sm/6 dark:text-white dark:placeholder:text-gray-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end border-t border-gray-100 bg-gray-50/70 px-2.5 py-2 dark:border-white/10 dark:bg-white/[0.03]">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
color="indigo"
|
||||||
|
size="sm"
|
||||||
|
rounded="full"
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
disabled={!entry.trim() || isSubmitting}
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
leadingIcon={
|
||||||
|
<PaperAirplaneIcon aria-hidden="true" className="size-4" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="rounded-2xl border border-dashed border-gray-300 bg-gray-50/70 px-6 py-10 text-center dark:border-white/15 dark:bg-white/[0.03]">
|
||||||
{emptyText}
|
<div className="mx-auto flex size-11 items-center justify-center rounded-2xl bg-white text-gray-400 shadow-sm ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-500 dark:ring-white/10">
|
||||||
</p>
|
<ChatBubbleLeftEllipsisIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Noch keine Aktivitäten
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{emptyText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flow-root">
|
<div className="flow-root">
|
||||||
<ul role="list" className="-mb-8">
|
<ul role="list" className="space-y-4">
|
||||||
{items.map((item, itemIndex) => {
|
{items.map((item, itemIndex) => {
|
||||||
const personName = getItemPersonName(item)
|
const personName = getItemPersonName(item)
|
||||||
const imageUrl = getItemImageUrl(item)
|
const imageUrl = getItemImageUrl(item)
|
||||||
const dateTime = getItemDateTime(item)
|
const dateTime = getItemDateTime(item)
|
||||||
const Icon = getJournalIcon(item)
|
const Icon = getJournalIcon(item)
|
||||||
|
const itemType = getJournalType(item)
|
||||||
const isManualEntry = item.type === 'journal' || item.type === 'comment'
|
const isManualEntry = item.type === 'journal' || item.type === 'comment'
|
||||||
const changes = item.changes ?? []
|
const changes = item.changes ?? []
|
||||||
const hasChanges = !isManualEntry && changes.length > 0
|
const hasChanges = !isManualEntry && changes.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={item.id}>
|
<li key={item.id} className="relative">
|
||||||
<div className="relative pb-8">
|
{itemIndex !== items.length - 1 && (
|
||||||
{itemIndex !== items.length - 1 && (
|
<span
|
||||||
<span
|
aria-hidden="true"
|
||||||
aria-hidden="true"
|
className="absolute top-10 bottom-[-1rem] left-5 w-px bg-gradient-to-b from-gray-300 to-gray-200 dark:from-white/20 dark:to-white/5"
|
||||||
className="absolute top-4 left-4 z-0 -ml-px h-full w-0.5 bg-gray-200 dark:bg-white/10"
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative flex items-start gap-3 sm:gap-4">
|
||||||
|
<div className="relative z-10 size-10 shrink-0">
|
||||||
|
<Avatar
|
||||||
|
src={imageUrl}
|
||||||
|
name={personName}
|
||||||
|
size={10}
|
||||||
|
className="ring-4 ring-white dark:ring-gray-900"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="relative z-10 flex space-x-3">
|
<span
|
||||||
<div className="relative z-10 size-8 flex-none self-start">
|
className={classNames(
|
||||||
{imageUrl ? (
|
getJournalIconBackground(item),
|
||||||
<img
|
'absolute -right-1 -bottom-1 z-20 flex size-5 items-center justify-center rounded-full shadow-sm ring-2 ring-white dark:ring-gray-900',
|
||||||
alt=""
|
|
||||||
src={imageUrl}
|
|
||||||
className="block size-8 rounded-full bg-gray-100 ring-8 ring-white outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:ring-gray-900 dark:outline-white/10"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex size-8 items-center justify-center rounded-full bg-gray-100 ring-8 ring-white outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:ring-gray-900 dark:outline-white/10">
|
|
||||||
<UserCircleIcon
|
|
||||||
aria-hidden="true"
|
|
||||||
className="size-6 text-gray-500 dark:text-gray-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<Icon aria-hidden="true" className="size-3 text-white" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span
|
<article className="min-w-0 flex-1 rounded-2xl border border-gray-200/80 bg-white p-4 shadow-sm ring-1 ring-black/[0.02] transition hover:border-gray-300 hover:shadow-md dark:border-white/10 dark:bg-gray-900/70 dark:ring-white/5 dark:hover:border-white/20">
|
||||||
className={classNames(
|
<header className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
getJournalIconBackground(item),
|
<div className="min-w-0">
|
||||||
'absolute -right-1 -bottom-1 z-20 flex size-5 items-center justify-center rounded-full ring-2 ring-white dark:ring-gray-900',
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
)}
|
{item.person?.href ? (
|
||||||
>
|
<a
|
||||||
<Icon aria-hidden="true" className="size-3.5 text-white" />
|
href={item.person.href}
|
||||||
</span>
|
className="truncate text-sm font-semibold text-gray-950 hover:text-indigo-600 dark:text-white dark:hover:text-indigo-300"
|
||||||
</div>
|
>
|
||||||
|
{personName}
|
||||||
<div className="min-w-0 flex-1 pt-1.5">
|
</a>
|
||||||
<div className="flex justify-between gap-x-4">
|
) : (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<span className="truncate text-sm font-semibold text-gray-950 dark:text-white">
|
||||||
<span className="inline-flex items-center gap-x-2 font-medium text-gray-900 dark:text-white">
|
{personName}
|
||||||
<span>{personName}</span>
|
|
||||||
|
|
||||||
{item.hasNotification && (
|
|
||||||
<NotificationDot
|
|
||||||
title="Neuer Journal-Eintrag"
|
|
||||||
label="Neuer Journal-Eintrag"
|
|
||||||
className="size-2"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</span>{' '}
|
|
||||||
|
|
||||||
{!isManualEntry && (
|
|
||||||
<span className="font-normal text-gray-700 dark:text-gray-300">
|
|
||||||
{getItemDescription(item)}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="text-right whitespace-nowrap">
|
<Badge
|
||||||
<time
|
tone={itemType.tone}
|
||||||
dateTime={dateTime}
|
variant="border"
|
||||||
className="block text-sm text-gray-500 dark:text-gray-400"
|
shape="pill"
|
||||||
|
size="small"
|
||||||
>
|
>
|
||||||
{item.date}
|
{itemType.label}
|
||||||
</time>
|
</Badge>
|
||||||
|
|
||||||
|
{item.hasNotification && (
|
||||||
|
<NotificationDot
|
||||||
|
title="Neuer Journal-Eintrag"
|
||||||
|
label="Neuer Journal-Eintrag"
|
||||||
|
className="size-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isManualEntry && (
|
||||||
|
<p className="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||||
|
{getItemDescription(item)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isManualEntry && editingItemId === item.id ? (
|
<time
|
||||||
<div className="mt-2">
|
dateTime={dateTime}
|
||||||
|
className="inline-flex w-fit shrink-0 items-center gap-1.5 rounded-full bg-gray-50 px-2.5 py-1 text-xs font-medium whitespace-nowrap text-gray-500 ring-1 ring-gray-200 ring-inset dark:bg-white/5 dark:text-gray-400 dark:ring-white/10"
|
||||||
|
>
|
||||||
|
<ClockIcon aria-hidden="true" className="size-3.5" />
|
||||||
|
{item.date}
|
||||||
|
</time>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{isManualEntry && editingItemId === item.id ? (
|
||||||
|
<div className="mt-4 rounded-xl bg-gray-50/70 p-3 ring-1 ring-gray-200 ring-inset dark:bg-white/[0.03] dark:ring-white/10">
|
||||||
<textarea
|
<textarea
|
||||||
rows={3}
|
rows={3}
|
||||||
value={editEntry}
|
value={editEntry}
|
||||||
onChange={(event) => setEditEntry(event.target.value)}
|
onChange={(event) => setEditEntry(event.target.value)}
|
||||||
className="block w-full resize-none rounded-md bg-white px-3 py-2 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:focus:outline-indigo-500"
|
className="block min-h-24 w-full resize-y rounded-xl border-0 bg-white px-3.5 py-3 text-sm text-gray-900 shadow-sm ring-1 ring-gray-200 ring-inset placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-600 dark:bg-white/5 dark:text-white dark:ring-white/10 dark:focus:ring-indigo-400"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-2 flex justify-end gap-x-2">
|
<div className="mt-3 flex justify-end gap-x-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@ -345,77 +406,87 @@ export default function Journal({
|
|||||||
Speichern
|
Speichern
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : isManualEntry ? (
|
) : isManualEntry ? (
|
||||||
<div className="mt-2 rounded-md bg-gray-50 p-2 text-sm text-gray-700 ring-1 ring-gray-200 ring-inset dark:bg-white/5 dark:text-gray-200 dark:ring-white/10">
|
<div className="mt-4 rounded-xl bg-gray-50/80 p-3.5 text-sm text-gray-700 ring-1 ring-gray-200 ring-inset dark:bg-white/[0.04] dark:text-gray-200 dark:ring-white/10">
|
||||||
<div className="flex items-start gap-x-3">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
|
||||||
<div className="min-w-0 flex-1 py-1 whitespace-pre-wrap break-words">
|
<div className="min-w-0 flex-1 whitespace-pre-wrap break-words leading-6">
|
||||||
{getItemDescription(item)}
|
{getItemDescription(item)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{item.canEdit && onEntryUpdate && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
color="indigo"
|
|
||||||
size="sm"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onClick={() => startEditing(item)}
|
|
||||||
leadingIcon={<PencilSquareIcon aria-hidden="true" className="size-4" />}
|
|
||||||
>
|
|
||||||
Bearbeiten
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{item.editedDate && (
|
{item.canEdit && onEntryUpdate && (
|
||||||
<div className="mt-2 flex items-center gap-x-1.5 border-t border-gray-200/70 pt-2 text-[11px] leading-4 text-gray-400 dark:border-white/10 dark:text-gray-500">
|
<Button
|
||||||
<PencilSquareIcon aria-hidden="true" className="size-3.5" />
|
type="button"
|
||||||
<span>
|
variant="secondary"
|
||||||
Zuletzt bearbeitet:{' '}
|
color="indigo"
|
||||||
<time dateTime={getItemEditedDateTime(item)}>
|
size="sm"
|
||||||
{item.editedDate}
|
disabled={isSubmitting}
|
||||||
</time>
|
onClick={() => startEditing(item)}
|
||||||
</span>
|
leadingIcon={
|
||||||
</div>
|
<PencilSquareIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-4"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : hasChanges ? (
|
|
||||||
<div className="mt-2 space-y-2">
|
{item.editedDate && (
|
||||||
{changes.map((change, changeIndex) => (
|
<div className="mt-3 flex items-center gap-x-1.5 border-t border-gray-200/70 pt-3 text-[11px] leading-4 text-gray-400 dark:border-white/10 dark:text-gray-500">
|
||||||
<div
|
<PencilSquareIcon aria-hidden="true" className="size-3.5" />
|
||||||
key={getChangeKey(change, changeIndex)}
|
<span>
|
||||||
className="rounded-md bg-gray-50 p-3 text-sm ring-1 ring-gray-200 ring-inset dark:bg-white/5 dark:ring-white/10"
|
Zuletzt bearbeitet:{' '}
|
||||||
>
|
<time dateTime={getItemEditedDateTime(item)}>
|
||||||
<div className="text-xs font-semibold text-gray-900 dark:text-white">
|
{item.editedDate}
|
||||||
{change.label}
|
</time>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : hasChanges ? (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{changes.map((change, changeIndex) => (
|
||||||
|
<section
|
||||||
|
key={getChangeKey(change, changeIndex)}
|
||||||
|
className="rounded-xl border border-gray-200 bg-gray-50/70 p-3.5 dark:border-white/10 dark:bg-white/[0.03]"
|
||||||
|
>
|
||||||
|
<h5 className="text-xs font-semibold text-gray-900 dark:text-white">
|
||||||
|
{change.label}
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<div className="mt-2.5 grid grid-cols-1 items-stretch gap-2 lg:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]">
|
||||||
|
<div className="min-w-0 rounded-lg bg-white p-3 ring-1 ring-gray-200 ring-inset dark:bg-gray-950/30 dark:ring-white/10">
|
||||||
|
<div className="text-[10px] font-semibold tracking-wider text-gray-400 uppercase dark:text-gray-500">
|
||||||
|
Vorher
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 whitespace-pre-wrap break-words text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{getChangeValue(change.oldValue)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 grid grid-cols-1 gap-2 lg:grid-cols-2">
|
<div className="hidden items-center lg:flex">
|
||||||
<div className="min-w-0 rounded-md bg-white p-2 ring-1 ring-gray-200 ring-inset dark:bg-gray-950/30 dark:ring-white/10">
|
<span className="flex size-7 items-center justify-center rounded-full bg-white text-gray-400 shadow-sm ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-500 dark:ring-white/10">
|
||||||
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-400 dark:text-gray-500">
|
<ArrowRightIcon aria-hidden="true" className="size-3.5" />
|
||||||
Vorher
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 whitespace-pre-wrap break-words text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{getChangeValue(change.oldValue)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0 rounded-md bg-white p-2 ring-1 ring-indigo-200 ring-inset dark:bg-indigo-500/10 dark:ring-indigo-400/20">
|
<div className="min-w-0 rounded-lg bg-indigo-50/80 p-3 ring-1 ring-indigo-200 ring-inset dark:bg-indigo-500/10 dark:ring-indigo-400/20">
|
||||||
<div className="text-[11px] font-medium uppercase tracking-wide text-indigo-500 dark:text-indigo-300">
|
<div className="text-[10px] font-semibold tracking-wider text-indigo-600 uppercase dark:text-indigo-300">
|
||||||
Nachher
|
Nachher
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 whitespace-pre-wrap break-words text-sm font-medium text-gray-900 dark:text-white">
|
<div className="mt-1.5 whitespace-pre-wrap break-words text-sm font-medium text-gray-950 dark:text-white">
|
||||||
{getChangeValue(change.newValue)}
|
{getChangeValue(change.newValue)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</section>
|
||||||
</div>
|
))}
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
</div>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
@ -425,4 +496,4 @@ export default function Journal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
|
type FormEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
@ -18,6 +19,7 @@ import {
|
|||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
import { XMarkIcon } from '@heroicons/react/20/solid'
|
import { XMarkIcon } from '@heroicons/react/20/solid'
|
||||||
|
import Avatar from './Avatar'
|
||||||
|
|
||||||
export type NotificationVariant =
|
export type NotificationVariant =
|
||||||
| 'success'
|
| 'success'
|
||||||
@ -34,6 +36,11 @@ export type ToastNotification = {
|
|||||||
onAction?: () => void
|
onAction?: () => void
|
||||||
autoClose?: boolean
|
autoClose?: boolean
|
||||||
duration?: number
|
duration?: number
|
||||||
|
imageUrl?: string
|
||||||
|
imageName?: string
|
||||||
|
replyPlaceholder?: string
|
||||||
|
replyActionLabel?: string
|
||||||
|
onReply?: (message: string) => void | Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
type NotificationsProps = {
|
type NotificationsProps = {
|
||||||
@ -119,9 +126,42 @@ function NotificationToast({
|
|||||||
onAction,
|
onAction,
|
||||||
autoClose = true,
|
autoClose = true,
|
||||||
duration = 3000,
|
duration = 3000,
|
||||||
|
imageUrl,
|
||||||
|
imageName,
|
||||||
|
replyPlaceholder,
|
||||||
|
replyActionLabel = 'Antworten',
|
||||||
|
onReply,
|
||||||
} = notification
|
} = notification
|
||||||
|
|
||||||
const { icon: Icon, iconClassName } = getVariantStyles(variant)
|
const { icon: Icon, iconClassName } = getVariantStyles(variant)
|
||||||
|
const [reply, setReply] = useState('')
|
||||||
|
const [replying, setReplying] = useState(false)
|
||||||
|
const [replyError, setReplyError] = useState('')
|
||||||
|
|
||||||
|
async function handleReply(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const message = reply.trim()
|
||||||
|
if (!message || !onReply || replying) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setReplying(true)
|
||||||
|
setReplyError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onReply(message)
|
||||||
|
onDismiss(id)
|
||||||
|
} catch (error) {
|
||||||
|
setReplyError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Antwort konnte nicht gesendet werden.',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setReplying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoClose) {
|
if (!autoClose) {
|
||||||
@ -143,10 +183,18 @@ function NotificationToast({
|
|||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<Icon
|
{imageUrl || imageName ? (
|
||||||
aria-hidden="true"
|
<Avatar
|
||||||
className={classNames('size-6', iconClassName)}
|
src={imageUrl}
|
||||||
/>
|
name={imageName || title}
|
||||||
|
size={10}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Icon
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classNames('size-6', iconClassName)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-3 w-0 flex-1 pt-0.5">
|
<div className="ml-3 w-0 flex-1 pt-0.5">
|
||||||
@ -160,11 +208,40 @@ function NotificationToast({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onReply && (
|
||||||
|
<form onSubmit={(event) => void handleReply(event)} className="mt-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={reply}
|
||||||
|
onChange={(event) => setReply(event.target.value)}
|
||||||
|
placeholder={replyPlaceholder || 'Direkt antworten...'}
|
||||||
|
maxLength={4000}
|
||||||
|
className="min-w-0 flex-1 rounded-md border-0 bg-gray-100 px-3 py-2 text-sm text-gray-900 outline-none ring-1 ring-gray-200 placeholder:text-gray-500 focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!reply.trim() || replying}
|
||||||
|
className="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{replying ? 'Sende...' : replyActionLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{replyError && (
|
||||||
|
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||||
|
{replyError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
{actionLabel && onAction && (
|
{actionLabel && onAction && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onAction}
|
onClick={() => {
|
||||||
|
onAction()
|
||||||
|
onDismiss(id)
|
||||||
|
}}
|
||||||
className="rounded-md text-sm font-medium text-indigo-600 hover:text-indigo-500 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 dark:focus:outline-indigo-400"
|
className="rounded-md text-sm font-medium text-indigo-600 hover:text-indigo-500 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 dark:focus:outline-indigo-400"
|
||||||
>
|
>
|
||||||
{actionLabel}
|
{actionLabel}
|
||||||
@ -311,4 +388,4 @@ export function useNotifications() {
|
|||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|
||||||
export default NotificationsViewport
|
export default NotificationsViewport
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
|
XMarkIcon,
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
import type { User } from './types'
|
import type { User } from './types'
|
||||||
import Checkbox from './Checkbox'
|
import Checkbox from './Checkbox'
|
||||||
@ -139,7 +140,7 @@ function HighlightedText({
|
|||||||
return (
|
return (
|
||||||
<mark
|
<mark
|
||||||
key={`${part}-${index}`}
|
key={`${part}-${index}`}
|
||||||
className="rounded bg-yellow-100 px-0.5 py-px font-semibold text-yellow-900 ring-1 ring-yellow-200 ring-inset"
|
className="rounded-md bg-amber-100 px-1 py-px font-semibold text-amber-950 ring-1 ring-amber-200 ring-inset dark:bg-amber-400/20 dark:text-amber-200 dark:ring-amber-300/20"
|
||||||
>
|
>
|
||||||
{part}
|
{part}
|
||||||
</mark>
|
</mark>
|
||||||
@ -284,13 +285,18 @@ export default function SearchOverlay({
|
|||||||
search.length >= 2 && !isLoading && !error && !hasResults
|
search.length >= 2 && !isLoading && !error && !hasResults
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto bg-white/95 backdrop-blur-sm [scrollbar-gutter:stable]">
|
<div className="relative h-full overflow-y-auto bg-gradient-to-br from-slate-50 via-white to-indigo-50/70 [scrollbar-gutter:stable] dark:from-gray-950 dark:via-gray-950 dark:to-indigo-950/30">
|
||||||
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||||
|
<div className="absolute -top-32 right-0 size-96 rounded-full bg-indigo-200/30 blur-3xl dark:bg-indigo-500/10" />
|
||||||
|
<div className="absolute bottom-0 -left-32 size-80 rounded-full bg-sky-100/50 blur-3xl dark:bg-sky-500/5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mx-auto max-w-7xl px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
<div className="mb-8 overflow-hidden rounded-3xl border border-white/80 bg-white/85 p-5 shadow-xl shadow-slate-900/5 ring-1 ring-slate-900/5 backdrop-blur-xl sm:p-6 dark:border-white/10 dark:bg-gray-900/80 dark:shadow-black/20 dark:ring-white/5">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex items-center gap-x-4">
|
||||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10">
|
<div className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500 to-violet-600 text-white shadow-lg shadow-indigo-500/20 ring-1 ring-white/20">
|
||||||
<MagnifyingGlassIcon
|
<MagnifyingGlassIcon
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="size-6"
|
className="size-6"
|
||||||
@ -298,11 +304,11 @@ export default function SearchOverlay({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-base font-semibold text-gray-900">
|
<p className="text-base font-semibold text-gray-950 dark:text-white">
|
||||||
Suche
|
Suche
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
{search.length >= 2
|
{search.length >= 2
|
||||||
? `${resultCount} Treffer für „${query}“`
|
? `${resultCount} Treffer für „${query}“`
|
||||||
: 'Gib mindestens 2 Zeichen ein.'}
|
: 'Gib mindestens 2 Zeichen ein.'}
|
||||||
@ -312,16 +318,25 @@ export default function SearchOverlay({
|
|||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:items-end">
|
<div className="flex flex-col gap-3 sm:items-end">
|
||||||
{search.length >= 2 && !error && (
|
{search.length >= 2 && !error && (
|
||||||
<div className="inline-flex items-center gap-x-2 rounded-full bg-gray-50 px-3 py-1 text-sm font-medium text-gray-600 ring-1 ring-gray-200 ring-inset">
|
<div className="inline-flex items-center gap-x-2 rounded-full bg-slate-100/80 px-3 py-1 text-sm font-medium text-slate-600 ring-1 ring-slate-200 ring-inset dark:bg-white/5 dark:text-gray-300 dark:ring-white/10">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<span className="size-3 animate-spin rounded-full border-2 border-gray-200 border-t-indigo-600" />
|
<span className="size-3 animate-spin rounded-full border-2 border-slate-300 border-t-indigo-600 dark:border-white/20 dark:border-t-indigo-400" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading ? 'Suche läuft...' : `${groups.length} Kategorien`}
|
{isLoading ? 'Suche läuft...' : `${groups.length} Kategorien`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="inline-flex size-8 items-center justify-center rounded-full text-gray-400 ring-1 ring-gray-200 transition hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:text-gray-500 dark:ring-white/10 dark:hover:bg-white/10 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Suche schließen</span>
|
||||||
|
<XMarkIcon aria-hidden="true" className="size-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={effectiveExactPhrase}
|
checked={effectiveExactPhrase}
|
||||||
disabled={!canUseExactPhrase}
|
disabled={!canUseExactPhrase}
|
||||||
@ -330,8 +345,8 @@ export default function SearchOverlay({
|
|||||||
wrapperClassName={[
|
wrapperClassName={[
|
||||||
'items-center gap-2 rounded-full px-3 py-1.5 ring-1 ring-inset',
|
'items-center gap-2 rounded-full px-3 py-1.5 ring-1 ring-inset',
|
||||||
canUseExactPhrase
|
canUseExactPhrase
|
||||||
? 'cursor-pointer bg-white text-gray-700 ring-gray-200 hover:bg-gray-50'
|
? 'cursor-pointer bg-white/80 text-gray-700 ring-gray-200 hover:bg-gray-50 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10'
|
||||||
: 'cursor-not-allowed bg-gray-50 text-gray-400 ring-gray-200',
|
: 'cursor-not-allowed bg-gray-50/80 text-gray-400 ring-gray-200 dark:bg-white/[0.03] dark:text-gray-600 dark:ring-white/5',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
labelClassName="text-xs/5 font-medium text-inherit"
|
labelClassName="text-xs/5 font-medium text-inherit"
|
||||||
inputClassName="size-4"
|
inputClassName="size-4"
|
||||||
@ -341,7 +356,7 @@ export default function SearchOverlay({
|
|||||||
checked={caseSensitive}
|
checked={caseSensitive}
|
||||||
onChange={(event) => setCaseSensitive(event.target.checked)}
|
onChange={(event) => setCaseSensitive(event.target.checked)}
|
||||||
label="Groß-/Kleinschreibung"
|
label="Groß-/Kleinschreibung"
|
||||||
wrapperClassName="items-center gap-2 rounded-full bg-white px-3 py-1.5 text-gray-700 ring-1 ring-gray-200 ring-inset hover:bg-gray-50"
|
wrapperClassName="items-center gap-2 rounded-full bg-white/80 px-3 py-1.5 text-gray-700 ring-1 ring-gray-200 ring-inset hover:bg-gray-50 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10"
|
||||||
labelClassName="text-xs/5 font-medium text-inherit"
|
labelClassName="text-xs/5 font-medium text-inherit"
|
||||||
inputClassName="size-4"
|
inputClassName="size-4"
|
||||||
/>
|
/>
|
||||||
@ -351,72 +366,72 @@ export default function SearchOverlay({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{search.length < 2 && (
|
{search.length < 2 && (
|
||||||
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-10 text-center shadow-sm">
|
<div className="rounded-3xl border border-dashed border-gray-300/80 bg-white/70 p-10 text-center shadow-sm backdrop-blur dark:border-white/15 dark:bg-gray-900/60">
|
||||||
<MagnifyingGlassIcon
|
<MagnifyingGlassIcon
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="mx-auto size-10 text-gray-300"
|
className="mx-auto size-10 text-gray-300 dark:text-gray-600"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p className="mt-4 text-sm font-medium text-gray-900">
|
<p className="mt-4 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
Suche starten
|
Suche starten
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Gib mindestens 2 Zeichen ein.
|
Gib mindestens 2 Zeichen ein.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showFullLoading && (
|
{showFullLoading && (
|
||||||
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-10 text-center shadow-sm">
|
<div className="rounded-3xl border border-dashed border-gray-300/80 bg-white/70 p-10 text-center shadow-sm backdrop-blur dark:border-white/15 dark:bg-gray-900/60">
|
||||||
<div className="mx-auto size-8 animate-spin rounded-full border-2 border-gray-200 border-t-indigo-600" />
|
<div className="mx-auto size-8 animate-spin rounded-full border-2 border-gray-200 border-t-indigo-600 dark:border-white/15 dark:border-t-indigo-400" />
|
||||||
|
|
||||||
<p className="mt-4 text-sm font-medium text-gray-900">
|
<p className="mt-4 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
Suche läuft...
|
Suche läuft...
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Ergebnisse werden geladen.
|
Ergebnisse werden geladen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showError && (
|
{showError && (
|
||||||
<div className="rounded-2xl border border-red-200 bg-red-50 p-10 text-center shadow-sm">
|
<div className="rounded-3xl border border-red-200 bg-red-50/90 p-10 text-center shadow-sm dark:border-red-500/20 dark:bg-red-950/40">
|
||||||
<ExclamationTriangleIcon
|
<ExclamationTriangleIcon
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="mx-auto size-10 text-red-400"
|
className="mx-auto size-10 text-red-400 dark:text-red-300"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p className="mt-4 text-sm font-semibold text-red-900">
|
<p className="mt-4 text-sm font-semibold text-red-900 dark:text-red-200">
|
||||||
Suche fehlgeschlagen
|
Suche fehlgeschlagen
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-1 text-sm text-red-700">
|
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showNoResults && (
|
{showNoResults && (
|
||||||
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-10 text-center shadow-sm">
|
<div className="rounded-3xl border border-dashed border-gray-300/80 bg-white/70 p-10 text-center shadow-sm backdrop-blur dark:border-white/15 dark:bg-gray-900/60">
|
||||||
<ExclamationTriangleIcon
|
<ExclamationTriangleIcon
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="mx-auto size-10 text-gray-300"
|
className="mx-auto size-10 text-gray-300 dark:text-gray-600"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p className="mt-4 text-sm font-semibold text-gray-900">
|
<p className="mt-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
Keine Ergebnisse gefunden
|
Keine Ergebnisse gefunden
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Prüfe den Suchbegriff oder versuche es mit weniger Begriffen.
|
Prüfe den Suchbegriff oder versuche es mit weniger Begriffen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && groups.length > 0 && (
|
{!error && groups.length > 0 && (
|
||||||
<div className="space-y-8">
|
<div className="space-y-10">
|
||||||
{groups.map((group) => {
|
{groups.map((group) => {
|
||||||
const Icon = group.icon
|
const Icon = group.icon
|
||||||
|
|
||||||
@ -424,7 +439,7 @@ export default function SearchOverlay({
|
|||||||
<section key={group.id}>
|
<section key={group.id}>
|
||||||
<div className="mb-4 flex items-center justify-between gap-x-4">
|
<div className="mb-4 flex items-center justify-between gap-x-4">
|
||||||
<div className="flex items-center gap-x-3">
|
<div className="flex items-center gap-x-3">
|
||||||
<div className="flex size-10 items-center justify-center rounded-xl bg-gray-50 text-gray-500 ring-1 ring-gray-200 ring-inset">
|
<div className="flex size-10 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600 ring-1 ring-indigo-100 ring-inset dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
|
||||||
<Icon
|
<Icon
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="size-5"
|
className="size-5"
|
||||||
@ -432,11 +447,11 @@ export default function SearchOverlay({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-gray-900">
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
{group.title}
|
{group.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{group.items.length} Treffer
|
{group.items.length} Treffer
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -449,15 +464,15 @@ export default function SearchOverlay({
|
|||||||
key={`${group.id}-${item.id}`}
|
key={`${group.id}-${item.id}`}
|
||||||
to={item.href}
|
to={item.href}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="group relative flex min-h-28 flex-col justify-between rounded-xl border border-gray-200 bg-white p-4 pr-10 shadow-sm transition hover:-translate-y-0.5 hover:border-indigo-200 hover:shadow-md"
|
className="group relative flex min-h-28 flex-col justify-between rounded-2xl border border-white/80 bg-white/85 p-4 pr-10 shadow-sm ring-1 ring-slate-900/5 backdrop-blur transition duration-200 hover:-translate-y-0.5 hover:border-indigo-200 hover:shadow-lg hover:shadow-indigo-900/5 dark:border-white/10 dark:bg-gray-900/75 dark:ring-white/5 dark:hover:border-indigo-400/30 dark:hover:bg-gray-900"
|
||||||
>
|
>
|
||||||
<ArrowRightIcon
|
<ArrowRightIcon
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="absolute top-4 right-4 size-4 text-gray-300 transition group-hover:translate-x-0.5 group-hover:text-indigo-500"
|
className="absolute top-4 right-4 size-4 text-gray-300 transition group-hover:translate-x-0.5 group-hover:text-indigo-500 dark:text-gray-600 dark:group-hover:text-indigo-300"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="line-clamp-1 text-sm font-semibold text-gray-900 group-hover:text-indigo-600">
|
<p className="line-clamp-1 text-sm font-semibold text-gray-900 transition group-hover:text-indigo-600 dark:text-white dark:group-hover:text-indigo-300">
|
||||||
<HighlightedText
|
<HighlightedText
|
||||||
value={item.title}
|
value={item.title}
|
||||||
search={search}
|
search={search}
|
||||||
@ -467,7 +482,7 @@ export default function SearchOverlay({
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{item.subtitle && (
|
{item.subtitle && (
|
||||||
<p className="mt-1 line-clamp-2 text-xs text-gray-500">
|
<p className="mt-1 line-clamp-2 text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||||
<HighlightedText
|
<HighlightedText
|
||||||
value={item.subtitle}
|
value={item.subtitle}
|
||||||
search={search}
|
search={search}
|
||||||
@ -488,4 +503,4 @@ export default function SearchOverlay({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,7 +23,7 @@ import {
|
|||||||
import type { ComponentType, SVGProps } from 'react'
|
import type { ComponentType, SVGProps } from 'react'
|
||||||
import { NavLink, useLocation } from 'react-router'
|
import { NavLink, useLocation } from 'react-router'
|
||||||
import { hasRight } from './permissions'
|
import { hasRight } from './permissions'
|
||||||
import type { User, Team } from './types'
|
import type { User } from './types'
|
||||||
|
|
||||||
type NavigationItem = {
|
type NavigationItem = {
|
||||||
name: string
|
name: string
|
||||||
@ -82,6 +82,11 @@ const navigationSections: NavigationSection[] = [
|
|||||||
{
|
{
|
||||||
name: 'Organisation',
|
name: 'Organisation',
|
||||||
items: [
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Chats',
|
||||||
|
href: '/chat',
|
||||||
|
icon: ChatBubbleLeftRightIcon,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Kalender',
|
name: 'Kalender',
|
||||||
href: '/kalender',
|
href: '/kalender',
|
||||||
@ -109,20 +114,13 @@ type SidebarProps = {
|
|||||||
sidebarOpen: boolean
|
sidebarOpen: boolean
|
||||||
setSidebarOpen: (open: boolean) => void
|
setSidebarOpen: (open: boolean) => void
|
||||||
onNavigate?: () => void
|
onNavigate?: () => void
|
||||||
|
unreadChatCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||||
return classes.filter(Boolean).join(' ')
|
return classes.filter(Boolean).join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTeamInitial(team: Team) {
|
|
||||||
if (team.initial) {
|
|
||||||
return team.initial
|
|
||||||
}
|
|
||||||
|
|
||||||
return team.name.charAt(0).toUpperCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPathActive(pathname: string, href: string) {
|
function isPathActive(pathname: string, href: string) {
|
||||||
if (href === '/') {
|
if (href === '/') {
|
||||||
return pathname === '/'
|
return pathname === '/'
|
||||||
@ -170,12 +168,13 @@ function SidebarContent({
|
|||||||
user,
|
user,
|
||||||
setSidebarOpen,
|
setSidebarOpen,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
|
unreadChatCount = 0,
|
||||||
}: {
|
}: {
|
||||||
user: User
|
user: User
|
||||||
setSidebarOpen: (open: boolean) => void
|
setSidebarOpen: (open: boolean) => void
|
||||||
onNavigate?: () => void
|
onNavigate?: () => void
|
||||||
|
unreadChatCount?: number
|
||||||
}) {
|
}) {
|
||||||
const teams = user.teams ?? []
|
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
const visibleNavigationSections = getVisibleNavigationSections(user)
|
const visibleNavigationSections = getVisibleNavigationSections(user)
|
||||||
@ -222,6 +221,11 @@ function SidebarContent({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="truncate">{item.name}</span>
|
<span className="truncate">{item.name}</span>
|
||||||
|
{item.href === '/chat' && unreadChatCount > 0 && (
|
||||||
|
<span className="ml-auto inline-flex min-w-5 items-center justify-center rounded-full bg-indigo-500 px-1.5 py-0.5 text-[0.6875rem] font-semibold leading-none text-white">
|
||||||
|
{unreadChatCount > 99 ? '99+' : unreadChatCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
{showChildren && (
|
{showChildren && (
|
||||||
@ -298,42 +302,6 @@ function SidebarContent({
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
|
||||||
<div className="border-t border-white/10 pt-5">
|
|
||||||
<h2 className="px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
|
|
||||||
Teams
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<ul role="list" className="mt-2 space-y-1">
|
|
||||||
{teams.length > 0 ? (
|
|
||||||
teams.map((team) => (
|
|
||||||
<li key={team.id}>
|
|
||||||
<a
|
|
||||||
href={team.href || '#'}
|
|
||||||
onClick={handleNavigate}
|
|
||||||
className={classNames(
|
|
||||||
team.current
|
|
||||||
? 'bg-white/10 text-white ring-1 ring-white/10'
|
|
||||||
: 'text-slate-400 hover:bg-white/5 hover:text-white',
|
|
||||||
'group flex items-center gap-x-3 rounded-xl px-3 py-2 text-sm/6 font-semibold transition',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-lg border border-white/10 bg-white/5 text-[0.625rem] font-medium text-slate-300 group-hover:border-white/20 group-hover:text-white">
|
|
||||||
{getTeamInitial(team)}
|
|
||||||
</span>
|
|
||||||
<span className="truncate">{team.name}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<li className="px-3 py-1 text-sm/6 text-slate-500">
|
|
||||||
Keine Teams zugeordnet
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li className="mt-auto border-t border-white/10 pt-5">
|
<li className="mt-auto border-t border-white/10 pt-5">
|
||||||
<h2 className="mb-2 px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
|
<h2 className="mb-2 px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
|
||||||
Hilfe & System
|
Hilfe & System
|
||||||
@ -413,6 +381,7 @@ export default function Sidebar({
|
|||||||
sidebarOpen,
|
sidebarOpen,
|
||||||
setSidebarOpen,
|
setSidebarOpen,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
|
unreadChatCount = 0,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -449,6 +418,7 @@ export default function Sidebar({
|
|||||||
user={user}
|
user={user}
|
||||||
setSidebarOpen={setSidebarOpen}
|
setSidebarOpen={setSidebarOpen}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
|
unreadChatCount={unreadChatCount}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DialogPanel>
|
</DialogPanel>
|
||||||
@ -461,6 +431,7 @@ export default function Sidebar({
|
|||||||
user={user}
|
user={user}
|
||||||
setSidebarOpen={setSidebarOpen}
|
setSidebarOpen={setSidebarOpen}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
|
unreadChatCount={unreadChatCount}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
// frontend/src/components/Topbar.tsx
|
// frontend/src/components/Topbar.tsx
|
||||||
|
|
||||||
import type { ReactNode } from 'react'
|
import { useState, type ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
Menu,
|
Menu,
|
||||||
MenuButton,
|
MenuButton,
|
||||||
@ -11,15 +11,19 @@ import {
|
|||||||
Bars3Icon,
|
Bars3Icon,
|
||||||
BellIcon,
|
BellIcon,
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||||
import { Link } from 'react-router'
|
import { Link } from 'react-router'
|
||||||
import Search from './Search'
|
import Search from './Search'
|
||||||
import Camera from './Camera'
|
import Camera from './Camera'
|
||||||
import Avatar from './Avatar'
|
import Avatar from './Avatar'
|
||||||
import type { User } from './types'
|
import type { User } from './types'
|
||||||
|
import { useNotifications } from './Notifications'
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
type TopbarProps = {
|
type TopbarProps = {
|
||||||
user: User
|
user: User
|
||||||
|
onUserUpdated: (user: User) => void
|
||||||
onLogout: () => void | Promise<void>
|
onLogout: () => void | Promise<void>
|
||||||
onOpenSidebar: () => void
|
onOpenSidebar: () => void
|
||||||
notificationCenter?: ReactNode
|
notificationCenter?: ReactNode
|
||||||
@ -34,6 +38,7 @@ const userNavigation = [
|
|||||||
|
|
||||||
export default function Topbar({
|
export default function Topbar({
|
||||||
user,
|
user,
|
||||||
|
onUserUpdated,
|
||||||
onLogout,
|
onLogout,
|
||||||
onOpenSidebar,
|
onOpenSidebar,
|
||||||
notificationCenter,
|
notificationCenter,
|
||||||
@ -41,10 +46,44 @@ export default function Topbar({
|
|||||||
onSearchQueryChange,
|
onSearchQueryChange,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
}: TopbarProps) {
|
}: TopbarProps) {
|
||||||
|
const { showErrorToast } = useNotifications()
|
||||||
|
const [updatingPresenceMode, setUpdatingPresenceMode] = useState<
|
||||||
|
User['presenceMode'] | null
|
||||||
|
>(null)
|
||||||
|
|
||||||
function handleQrScan(value: string) {
|
function handleQrScan(value: string) {
|
||||||
console.log('QR-Code erkannt:', value)
|
console.log('QR-Code erkannt:', value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updatePresenceMode(mode: User['presenceMode']) {
|
||||||
|
if (updatingPresenceMode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUpdatingPresenceMode(mode)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/settings/presence/status`, {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mode }),
|
||||||
|
})
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
if (!response.ok || !data?.user) {
|
||||||
|
throw new Error(data?.error || 'Status konnte nicht gesetzt werden')
|
||||||
|
}
|
||||||
|
onUserUpdated(data.user as User)
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast({
|
||||||
|
title: 'Status konnte nicht gesetzt werden',
|
||||||
|
error,
|
||||||
|
fallback: 'Status konnte nicht gesetzt werden',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setUpdatingPresenceMode(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sticky top-0 z-[9500] flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-xs sm:gap-x-6 sm:px-6 lg:px-8 dark:border-white/10 dark:bg-gray-900">
|
<div className="sticky top-0 z-[9500] flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-xs sm:gap-x-6 sm:px-6 lg:px-8 dark:border-white/10 dark:bg-gray-900">
|
||||||
<button
|
<button
|
||||||
@ -98,6 +137,7 @@ export default function Topbar({
|
|||||||
src={user.avatar}
|
src={user.avatar}
|
||||||
name={user.displayName || user.username || user.email}
|
name={user.displayName || user.username || user.email}
|
||||||
size={8}
|
size={8}
|
||||||
|
presenceStatus={user.presenceStatus}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span className="hidden lg:flex lg:items-center">
|
<span className="hidden lg:flex lg:items-center">
|
||||||
@ -116,8 +156,36 @@ export default function Topbar({
|
|||||||
|
|
||||||
<MenuItems
|
<MenuItems
|
||||||
transition
|
transition
|
||||||
className="absolute right-0 z-10 mt-2.5 w-40 origin-top-right rounded-md bg-white py-2 shadow-lg outline outline-gray-900/5 transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
|
className="absolute right-0 z-10 mt-2.5 w-52 origin-top-right rounded-md bg-white py-2 shadow-lg outline outline-gray-900/5 transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
|
||||||
>
|
>
|
||||||
|
<div className="px-3 pb-1 text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
Chat-Status
|
||||||
|
</div>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['online', 'Online', 'bg-green-500'],
|
||||||
|
['away', 'Abwesend', 'bg-amber-500'],
|
||||||
|
['offline', 'Offline', 'bg-gray-400'],
|
||||||
|
] as const
|
||||||
|
).map(([mode, label, colorClassName]) => (
|
||||||
|
<MenuItem key={mode}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={updatingPresenceMode !== null}
|
||||||
|
onClick={() => void updatePresenceMode(mode)}
|
||||||
|
className="flex w-full items-center gap-2.5 px-3 py-1 text-left text-sm/6 text-gray-900 data-focus:bg-gray-50 data-focus:outline-hidden disabled:opacity-60 dark:text-white dark:data-focus:bg-white/5"
|
||||||
|
>
|
||||||
|
<span className={`size-2.5 rounded-full ${colorClassName}`} />
|
||||||
|
<span className="flex-1">{label}</span>
|
||||||
|
{user.presenceMode === mode && (
|
||||||
|
<CheckIcon className="size-4 text-indigo-600 dark:text-indigo-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="my-1 border-t border-gray-200 dark:border-white/10" />
|
||||||
|
|
||||||
{userNavigation.map((item) => (
|
{userNavigation.map((item) => (
|
||||||
<MenuItem key={item.name}>
|
<MenuItem key={item.name}>
|
||||||
<Link
|
<Link
|
||||||
@ -148,4 +216,4 @@ export default function Topbar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,12 @@ export type User = {
|
|||||||
group: string
|
group: string
|
||||||
rights: string[]
|
rights: string[]
|
||||||
teams: Team[]
|
teams: Team[]
|
||||||
|
onlineStatus: boolean
|
||||||
|
presenceMode: 'online' | 'away' | 'offline'
|
||||||
|
presenceStatus: 'online' | 'away' | 'offline'
|
||||||
|
awayAfterMinutes: number
|
||||||
|
showLastSeen: boolean
|
||||||
|
lastSeenAt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeviceHistoryChange = {
|
export type DeviceHistoryChange = {
|
||||||
@ -149,6 +155,12 @@ export type Operation = {
|
|||||||
caseWorker: string
|
caseWorker: string
|
||||||
targetObject: string
|
targetObject: string
|
||||||
kwAddress: string
|
kwAddress: string
|
||||||
|
kwLatitude?: number | null
|
||||||
|
kwLongitude?: number | null
|
||||||
|
targetLatitude?: number | null
|
||||||
|
targetLongitude?: number | null
|
||||||
|
viewConeFov?: number
|
||||||
|
viewConeLength?: number
|
||||||
operationLeader: string
|
operationLeader: string
|
||||||
operationTeam: string
|
operationTeam: string
|
||||||
legend: string
|
legend: string
|
||||||
@ -194,6 +206,8 @@ export type Notification = {
|
|||||||
data: unknown
|
data: unknown
|
||||||
silent: boolean
|
silent: boolean
|
||||||
soundName?: string
|
soundName?: string
|
||||||
|
desktop?: boolean
|
||||||
|
inAppEnabled?: boolean
|
||||||
readAt?: string | null
|
readAt?: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,11 +5,17 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import { BrowserRouter } from 'react-router'
|
import { BrowserRouter } from 'react-router'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import { NotificationProvider } from './components/Notifications.tsx'
|
||||||
|
import { initActivityLog } from './utils/activityLog.ts'
|
||||||
|
|
||||||
|
initActivityLog()
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<NotificationProvider>
|
||||||
|
<App />
|
||||||
|
</NotificationProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { useLocation, Navigate } from 'react-router'
|
import { useLocation, Navigate } from 'react-router'
|
||||||
import {
|
import {
|
||||||
ChatBubbleLeftRightIcon,
|
ChatBubbleLeftRightIcon,
|
||||||
|
HashtagIcon,
|
||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
VideoCameraIcon,
|
VideoCameraIcon,
|
||||||
@ -12,6 +13,7 @@ import UsersAdministration from './users/UsersAdministration'
|
|||||||
import TeamsAdministration from './teams/TeamsAdministration'
|
import TeamsAdministration from './teams/TeamsAdministration'
|
||||||
import FeedbackAdministration from './feedback/FeedbackAdministration'
|
import FeedbackAdministration from './feedback/FeedbackAdministration'
|
||||||
import Milestone from './milestone/Milestone'
|
import Milestone from './milestone/Milestone'
|
||||||
|
import ChannelsAdministration from './channels/ChannelsAdministration'
|
||||||
|
|
||||||
export default function AdministrationPage() {
|
export default function AdministrationPage() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@ -33,6 +35,12 @@ export default function AdministrationPage() {
|
|||||||
icon: UserGroupIcon,
|
icon: UserGroupIcon,
|
||||||
current: section === 'teams',
|
current: section === 'teams',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Channels',
|
||||||
|
href: '/administration/channels',
|
||||||
|
icon: HashtagIcon,
|
||||||
|
current: section === 'channels',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Milestone',
|
name: 'Milestone',
|
||||||
href: '/administration/milestone',
|
href: '/administration/milestone',
|
||||||
@ -47,7 +55,7 @@ export default function AdministrationPage() {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
if (!['benutzer', 'teams', 'milestone', 'feedback'].includes(section)) {
|
if (!['benutzer', 'teams', 'channels', 'milestone', 'feedback'].includes(section)) {
|
||||||
return <Navigate to="/administration/benutzer" replace />
|
return <Navigate to="/administration/benutzer" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,10 +86,11 @@ export default function AdministrationPage() {
|
|||||||
|
|
||||||
{section === 'benutzer' && <UsersAdministration />}
|
{section === 'benutzer' && <UsersAdministration />}
|
||||||
{section === 'teams' && <TeamsAdministration />}
|
{section === 'teams' && <TeamsAdministration />}
|
||||||
|
{section === 'channels' && <ChannelsAdministration />}
|
||||||
{section === 'milestone' && <Milestone />}
|
{section === 'milestone' && <Milestone />}
|
||||||
{section === 'feedback' && <FeedbackAdministration />}
|
{section === 'feedback' && <FeedbackAdministration />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,666 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ChangeEvent,
|
||||||
|
type FormEvent,
|
||||||
|
} from 'react'
|
||||||
|
import {
|
||||||
|
ClipboardDocumentIcon,
|
||||||
|
HashtagIcon,
|
||||||
|
KeyIcon,
|
||||||
|
PlusIcon,
|
||||||
|
} from '@heroicons/react/20/solid'
|
||||||
|
import Button from '../../../components/Button'
|
||||||
|
import Avatar from '../../../components/Avatar'
|
||||||
|
import Checkbox from '../../../components/Checkbox'
|
||||||
|
import LoadingSpinner from '../../../components/LoadingSpinner'
|
||||||
|
import Switch from '../../../components/Switch'
|
||||||
|
import { useNotifications } from '../../../components/Notifications'
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
|
type AdminChannel = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
sourceName: string
|
||||||
|
image: string
|
||||||
|
allUsers: boolean
|
||||||
|
userIds: string[]
|
||||||
|
webhookEnabled: boolean
|
||||||
|
tokenConfigured: boolean
|
||||||
|
webhookTokenUpdatedAt?: string | null
|
||||||
|
webhookPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminChannelUser = {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
email: string
|
||||||
|
unit: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChannelDraft = {
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
sourceName: string
|
||||||
|
image: string
|
||||||
|
allUsers: boolean
|
||||||
|
userIds: string[]
|
||||||
|
webhookEnabled: boolean
|
||||||
|
tokenConfigured: boolean
|
||||||
|
webhookTokenUpdatedAt?: string | null
|
||||||
|
webhookPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClassName =
|
||||||
|
'block w-full rounded-md bg-white px-3 py-1.5 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:focus:outline-indigo-500'
|
||||||
|
|
||||||
|
const emptyDraft: ChannelDraft = {
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
sourceName: '',
|
||||||
|
image: '',
|
||||||
|
allUsers: true,
|
||||||
|
userIds: [],
|
||||||
|
webhookEnabled: true,
|
||||||
|
tokenConfigured: false,
|
||||||
|
webhookPath: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readApiError(response: Response, fallback: string) {
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
return data?.error ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function userDisplayName(user: AdminChannelUser) {
|
||||||
|
return user.displayName || user.username || user.email
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelToDraft(channel: AdminChannel): ChannelDraft {
|
||||||
|
return {
|
||||||
|
...channel,
|
||||||
|
userIds: channel.userIds ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChannelsAdministration() {
|
||||||
|
const { showToast, showErrorToast } = useNotifications()
|
||||||
|
const [channels, setChannels] = useState<AdminChannel[]>([])
|
||||||
|
const [users, setUsers] = useState<AdminChannelUser[]>([])
|
||||||
|
const [draft, setDraft] = useState<ChannelDraft>(emptyDraft)
|
||||||
|
const [zabbixScript, setZabbixScript] = useState('')
|
||||||
|
const [newToken, setNewToken] = useState('')
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [isRotatingToken, setIsRotatingToken] = useState(false)
|
||||||
|
const imageInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const webhookUrl = useMemo(() => {
|
||||||
|
const path = draft.webhookPath || `/webhooks/channels/${draft.slug}`
|
||||||
|
try {
|
||||||
|
return new URL(path, API_URL).toString()
|
||||||
|
} catch {
|
||||||
|
return `${API_URL.replace(/\/$/, '')}${path}`
|
||||||
|
}
|
||||||
|
}, [draft.slug, draft.webhookPath])
|
||||||
|
|
||||||
|
const loadChannels = useCallback(async (preferredId?: string) => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/admin/channels`, {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await readApiError(response, 'Channels konnten nicht geladen werden'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
const nextChannels = (data.channels ?? []) as AdminChannel[]
|
||||||
|
setChannels(nextChannels)
|
||||||
|
setUsers(data.users ?? [])
|
||||||
|
setZabbixScript(data.zabbixScript ?? '')
|
||||||
|
|
||||||
|
const selected =
|
||||||
|
nextChannels.find((channel) => channel.id === preferredId) ??
|
||||||
|
nextChannels[0]
|
||||||
|
setDraft(selected ? channelToDraft(selected) : emptyDraft)
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast({
|
||||||
|
title: 'Channels konnten nicht geladen werden',
|
||||||
|
error,
|
||||||
|
fallback: 'Channels konnten nicht geladen werden',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [showErrorToast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
void loadChannels()
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId)
|
||||||
|
}, [loadChannels])
|
||||||
|
|
||||||
|
function selectChannel(channel: AdminChannel) {
|
||||||
|
setDraft(channelToDraft(channel))
|
||||||
|
setNewToken('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChannel() {
|
||||||
|
setDraft(emptyDraft)
|
||||||
|
setNewToken('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDraft<Key extends keyof ChannelDraft>(
|
||||||
|
key: Key,
|
||||||
|
value: ChannelDraft[Key],
|
||||||
|
) {
|
||||||
|
setDraft((current) => ({ ...current, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleUser(userId: string, checked: boolean) {
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
userIds: checked
|
||||||
|
? [...new Set([...current.userIds, userId])]
|
||||||
|
: current.userIds.filter((id) => id !== userId),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
event.target.value = ''
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
showToast({
|
||||||
|
title: 'Ungültige Bilddatei',
|
||||||
|
message: 'Bitte wähle eine gültige Bilddatei aus.',
|
||||||
|
variant: 'error',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
showToast({
|
||||||
|
title: 'Channel-Bild zu groß',
|
||||||
|
message: 'Das Channel-Bild darf maximal 2 MB groß sein.',
|
||||||
|
variant: 'error',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
if (typeof reader.result === 'string') {
|
||||||
|
updateDraft('image', reader.result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveChannel(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
setIsSaving(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
draft.id
|
||||||
|
? `${API_URL}/admin/channels/${draft.id}`
|
||||||
|
: `${API_URL}/admin/channels`,
|
||||||
|
{
|
||||||
|
method: draft.id ? 'PUT' : 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: draft.name,
|
||||||
|
slug: draft.slug,
|
||||||
|
sourceName: draft.sourceName,
|
||||||
|
image: draft.image,
|
||||||
|
allUsers: draft.allUsers,
|
||||||
|
userIds: draft.userIds,
|
||||||
|
webhookEnabled: draft.webhookEnabled,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await readApiError(response, 'Channel konnte nicht gespeichert werden'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
await loadChannels(data.id ?? draft.id)
|
||||||
|
showToast({
|
||||||
|
title: 'Channel gespeichert',
|
||||||
|
message: 'Mitglieder und Webhook-Einstellungen wurden aktualisiert.',
|
||||||
|
variant: 'success',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast({
|
||||||
|
title: 'Channel konnte nicht gespeichert werden',
|
||||||
|
error,
|
||||||
|
fallback: 'Channel konnte nicht gespeichert werden',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotateToken() {
|
||||||
|
if (!draft.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIsRotatingToken(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_URL}/admin/channels/${draft.id}/token`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await readApiError(response, 'Webhook-Token konnte nicht erzeugt werden'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
setNewToken(data.token ?? '')
|
||||||
|
setDraft((current) => ({ ...current, tokenConfigured: true }))
|
||||||
|
showToast({
|
||||||
|
title: 'Webhook-Token erzeugt',
|
||||||
|
message: 'Das Token wird nur jetzt vollständig angezeigt.',
|
||||||
|
variant: 'success',
|
||||||
|
autoClose: false,
|
||||||
|
})
|
||||||
|
await loadChannels(draft.id)
|
||||||
|
setNewToken(data.token ?? '')
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast({
|
||||||
|
title: 'Webhook-Token konnte nicht erzeugt werden',
|
||||||
|
error,
|
||||||
|
fallback: 'Webhook-Token konnte nicht erzeugt werden',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsRotatingToken(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyText(value: string, label: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value)
|
||||||
|
showToast({
|
||||||
|
title: `${label} kopiert`,
|
||||||
|
message: 'Der Wert liegt jetzt in der Zwischenablage.',
|
||||||
|
variant: 'success',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast({
|
||||||
|
title: `${label} konnte nicht kopiert werden`,
|
||||||
|
error,
|
||||||
|
fallback: 'Die Zwischenablage ist nicht verfügbar.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-white p-8 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
||||||
|
<LoadingSpinner label="Channels werden geladen..." center />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[19rem_minmax(0,1fr)]">
|
||||||
|
<aside className="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-200 p-4 dark:border-white/10">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Channels
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Schreibgeschützte Integrationen
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
color="indigo"
|
||||||
|
leadingIcon={<PlusIcon className="size-4" />}
|
||||||
|
onClick={createChannel}
|
||||||
|
>
|
||||||
|
Neu
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 p-2">
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<button
|
||||||
|
key={channel.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectChannel(channel)}
|
||||||
|
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm ${
|
||||||
|
draft.id === channel.id
|
||||||
|
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300'
|
||||||
|
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{channel.image ? (
|
||||||
|
<Avatar
|
||||||
|
src={channel.image}
|
||||||
|
name={channel.name}
|
||||||
|
size={9}
|
||||||
|
shape="rounded"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<HashtagIcon className="size-5 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate font-medium">{channel.name}</span>
|
||||||
|
<span className="block truncate text-xs opacity-70">
|
||||||
|
/{channel.slug}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<form
|
||||||
|
onSubmit={saveChannel}
|
||||||
|
className="rounded-xl bg-white p-5 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"
|
||||||
|
>
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
|
{draft.id ? 'Channel bearbeiten' : 'Channel anlegen'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-4 sm:grid-cols-2">
|
||||||
|
<label className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Channelname
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(event) => updateDraft('name', event.target.value)}
|
||||||
|
placeholder="Zabbix"
|
||||||
|
className={`${inputClassName} mt-2`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Webhook-Slug
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={draft.slug}
|
||||||
|
onChange={(event) => updateDraft('slug', event.target.value)}
|
||||||
|
placeholder="zabbix"
|
||||||
|
pattern="[a-z0-9]+(?:-[a-z0-9]+)*"
|
||||||
|
className={`${inputClassName} mt-2`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="text-sm font-medium text-gray-900 dark:text-white sm:col-span-2">
|
||||||
|
Angezeigter Absender
|
||||||
|
<input
|
||||||
|
value={draft.sourceName}
|
||||||
|
onChange={(event) => updateDraft('sourceName', event.target.value)}
|
||||||
|
placeholder={draft.name || 'Zabbix'}
|
||||||
|
className={`${inputClassName} mt-2`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Channel-Bild
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-center gap-4">
|
||||||
|
<Avatar
|
||||||
|
src={draft.image}
|
||||||
|
name={draft.name || 'Channel'}
|
||||||
|
size={14}
|
||||||
|
shape="rounded"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref={imageInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
className="sr-only"
|
||||||
|
onChange={handleImageFileChange}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => imageInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
Bild hochladen
|
||||||
|
</Button>
|
||||||
|
{draft.image && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="red"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateDraft('image', '')}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Optional: PNG, JPG, WEBP oder GIF bis maximal 2 MB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 space-y-4 border-t border-gray-200 pt-5 dark:border-white/10">
|
||||||
|
<Switch
|
||||||
|
checked={draft.webhookEnabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateDraft('webhookEnabled', event.target.checked)
|
||||||
|
}
|
||||||
|
label="Webhook aktiviert"
|
||||||
|
description="Nur aktivierte Channels nehmen externe Nachrichten an."
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checked={draft.allUsers}
|
||||||
|
onChange={(event) => updateDraft('allUsers', event.target.checked)}
|
||||||
|
label="Für alle Benutzer"
|
||||||
|
description="Neue Benutzer werden automatisch Mitglied dieses Channels."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!draft.allUsers && (
|
||||||
|
<div className="mt-5">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Channelmitglieder
|
||||||
|
</h3>
|
||||||
|
<div className="mt-3 grid max-h-64 gap-2 overflow-y-auto rounded-lg border border-gray-200 p-3 sm:grid-cols-2 dark:border-white/10">
|
||||||
|
{users.map((user) => (
|
||||||
|
<Checkbox
|
||||||
|
key={user.id}
|
||||||
|
checked={draft.userIds.includes(user.id)}
|
||||||
|
onChange={(event) =>
|
||||||
|
toggleUser(user.id, event.target.checked)
|
||||||
|
}
|
||||||
|
label={userDisplayName(user)}
|
||||||
|
description={user.unit || user.email}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end">
|
||||||
|
<Button type="submit" color="indigo" isLoading={isSaving}>
|
||||||
|
Channel speichern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{draft.id && (
|
||||||
|
<section className="rounded-xl bg-white p-5 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="flex items-center gap-2 text-base font-semibold text-gray-900 dark:text-white">
|
||||||
|
<KeyIcon className="size-5 text-indigo-500" />
|
||||||
|
Webhook für Zabbix 7.2.15
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Verwende den Medientyp „Webhook“. Das Bearer-Token wird nur nach
|
||||||
|
dem Erzeugen vollständig angezeigt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="indigo"
|
||||||
|
isLoading={isRotatingToken}
|
||||||
|
onClick={() => void rotateToken()}
|
||||||
|
>
|
||||||
|
{draft.tokenConfigured ? 'Token erneuern' : 'Token erzeugen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 space-y-4">
|
||||||
|
<ol className="list-decimal space-y-1.5 pl-5 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<li>
|
||||||
|
Unter „Alerts > Media types“ einen Medientyp vom Typ
|
||||||
|
„Webhook“ anlegen.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Die Parameter und das JavaScript unten übernehmen und den
|
||||||
|
Medientyp über „Test“ prüfen.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Den Medientyp einem Zabbix-Benutzer als Medium zuweisen. Bei
|
||||||
|
„Send to“ genügt beispielsweise „teg-channel“.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Diesen Benutzer in der gewünschten Action unter „Send to
|
||||||
|
users“ als Empfänger auswählen.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<CopyField
|
||||||
|
label="URL"
|
||||||
|
value={webhookUrl}
|
||||||
|
onCopy={() => void copyText(webhookUrl, 'URL')}
|
||||||
|
/>
|
||||||
|
{newToken && (
|
||||||
|
<CopyField
|
||||||
|
label="Token"
|
||||||
|
value={newToken}
|
||||||
|
onCopy={() => void copyText(newToken, 'Token')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Parameter im Zabbix-Medientyp
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2 overflow-hidden rounded-lg border border-gray-200 text-sm dark:border-white/10">
|
||||||
|
{[
|
||||||
|
['URL', webhookUrl],
|
||||||
|
['Token', newToken || '<erzeugtes Token>'],
|
||||||
|
['To', '{ALERT.SENDTO}'],
|
||||||
|
['Subject', '{ALERT.SUBJECT}'],
|
||||||
|
['Message', '{ALERT.MESSAGE}'],
|
||||||
|
['Host', '{HOST.NAME}'],
|
||||||
|
['Severity', '{EVENT.SEVERITY}'],
|
||||||
|
['Status', '{EVENT.STATUS}'],
|
||||||
|
['EventId', '{EVENT.ID}'],
|
||||||
|
['EventUrl', '{TRIGGER.URL}'],
|
||||||
|
].map(([name, value]) => (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
className="grid grid-cols-[7rem_minmax(0,1fr)] border-b border-gray-200 last:border-b-0 dark:border-white/10"
|
||||||
|
>
|
||||||
|
<span className="bg-gray-50 px-3 py-2 font-medium text-gray-700 dark:bg-white/5 dark:text-gray-300">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<code className="break-all px-3 py-2 text-gray-600 dark:text-gray-300">
|
||||||
|
{value}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
JavaScript
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
leadingIcon={<ClipboardDocumentIcon className="size-4" />}
|
||||||
|
onClick={() => void copyText(zabbixScript, 'JavaScript')}
|
||||||
|
>
|
||||||
|
Kopieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<pre className="mt-2 max-h-96 overflow-auto rounded-lg bg-gray-950 p-4 text-xs leading-5 text-gray-100">
|
||||||
|
<code>{zabbixScript}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onCopy,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
onCopy: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<input readOnly value={value} className={inputClassName} />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="gray"
|
||||||
|
leadingIcon={<ClipboardDocumentIcon className="size-4" />}
|
||||||
|
onClick={onCopy}
|
||||||
|
>
|
||||||
|
Kopieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,13 +1,22 @@
|
|||||||
// frontend/src/pages/administration/feedback/FeedbackAdministration.tsx
|
// frontend/src/pages/administration/feedback/FeedbackAdministration.tsx
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
|
MagnifyingGlassIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from '@heroicons/react/20/solid'
|
} from '@heroicons/react/20/solid'
|
||||||
|
import {
|
||||||
|
ComputerDesktopIcon,
|
||||||
|
CursorArrowRaysIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
GlobeAltIcon,
|
||||||
|
} from '@heroicons/react/24/outline'
|
||||||
import Button from '../../../components/Button'
|
import Button from '../../../components/Button'
|
||||||
import LoadingSpinner from '../../../components/LoadingSpinner'
|
import LoadingSpinner from '../../../components/LoadingSpinner'
|
||||||
import Avatar from '../../../components/Avatar'
|
import Avatar from '../../../components/Avatar'
|
||||||
|
import ActivityLogView from '../../../components/ActivityLogView'
|
||||||
|
import type { ActivityLogEntry, ActivityLogType } from '../../../utils/activityLog'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
@ -21,6 +30,14 @@ type FeedbackItem = {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClientMeta = {
|
||||||
|
browser: string
|
||||||
|
operatingSystem: string
|
||||||
|
deviceType: string
|
||||||
|
url: string
|
||||||
|
viewport: string
|
||||||
|
}
|
||||||
|
|
||||||
type NotificationLike = {
|
type NotificationLike = {
|
||||||
type?: string
|
type?: string
|
||||||
entityType?: string
|
entityType?: string
|
||||||
@ -39,32 +56,82 @@ function formatDate(value: string) {
|
|||||||
|
|
||||||
function getInitials(name: string, email: string) {
|
function getInitials(name: string, email: string) {
|
||||||
const source = name || email
|
const source = name || email
|
||||||
|
|
||||||
if (!source) {
|
if (!source) {
|
||||||
return '?'
|
return '?'
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts = source
|
return source
|
||||||
.replace(/@.*/, '')
|
.replace(/@.*/, '')
|
||||||
.split(/[.\s_-]+/)
|
.split(/[.\s_-]+/)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
||||||
return parts
|
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
.map((part) => part.charAt(0))
|
.map((part) => part.charAt(0))
|
||||||
.join('')
|
.join('')
|
||||||
.toUpperCase()
|
.toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activityTypes: ActivityLogType[] = ['action', 'navigation', 'error']
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(record: Record<string, unknown> | null, key: string) {
|
||||||
|
const value = record?.[key]
|
||||||
|
return typeof value === 'string' ? value : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceActivity(value: unknown): ActivityLogEntry[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: ActivityLogEntry[] = []
|
||||||
|
|
||||||
|
for (const item of value) {
|
||||||
|
const record = asRecord(item)
|
||||||
|
if (!record || typeof record.message !== 'string') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = activityTypes.includes(record.type as ActivityLogType)
|
||||||
|
? (record.type as ActivityLogType)
|
||||||
|
: 'action'
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
id: typeof record.id === 'string' ? record.id : `${entries.length}`,
|
||||||
|
type,
|
||||||
|
timestamp: readString(record, 'timestamp'),
|
||||||
|
path: readString(record, 'path'),
|
||||||
|
message: record.message,
|
||||||
|
details: asRecord(record.details) ?? undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseClientMeta(record: Record<string, unknown> | null): ClientMeta | null {
|
||||||
|
const client = asRecord(record?.client)
|
||||||
|
if (!client) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
browser: readString(client, 'browser'),
|
||||||
|
operatingSystem: readString(client, 'operatingSystem'),
|
||||||
|
deviceType: readString(client, 'deviceType'),
|
||||||
|
url: readString(client, 'url'),
|
||||||
|
viewport: readString(client, 'viewport'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function extractNotification(eventData: string): NotificationLike | null {
|
function extractNotification(eventData: string): NotificationLike | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(eventData)
|
const parsed = JSON.parse(eventData)
|
||||||
|
return parsed?.notification ?? parsed
|
||||||
if (parsed?.notification) {
|
|
||||||
return parsed.notification
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed
|
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@ -83,6 +150,21 @@ function isFeedbackEvent(notification: NotificationLike | null) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MetaChip({
|
||||||
|
icon: Icon,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: typeof GlobeAltIcon
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600 dark:bg-white/10 dark:text-gray-300">
|
||||||
|
<Icon className="size-3.5 text-gray-400" />
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function FeedbackAdministration() {
|
export default function FeedbackAdministration() {
|
||||||
const [feedback, setFeedback] = useState<FeedbackItem[]>([])
|
const [feedback, setFeedback] = useState<FeedbackItem[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
@ -90,17 +172,28 @@ export default function FeedbackAdministration() {
|
|||||||
const [deletingFeedbackIds, setDeletingFeedbackIds] = useState<Set<string>>(
|
const [deletingFeedbackIds, setDeletingFeedbackIds] = useState<Set<string>>(
|
||||||
() => new Set(),
|
() => new Set(),
|
||||||
)
|
)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const sortedFeedback = useMemo(
|
const visibleFeedback = useMemo(() => {
|
||||||
() =>
|
const sorted = [...feedback].sort(
|
||||||
[...feedback].sort(
|
(firstItem, secondItem) =>
|
||||||
(firstItem, secondItem) =>
|
new Date(secondItem.createdAt).getTime() -
|
||||||
new Date(secondItem.createdAt).getTime() -
|
new Date(firstItem.createdAt).getTime(),
|
||||||
new Date(firstItem.createdAt).getTime(),
|
)
|
||||||
),
|
|
||||||
[feedback],
|
const normalizedSearch = search.trim().toLowerCase()
|
||||||
)
|
if (!normalizedSearch) {
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted.filter((item) =>
|
||||||
|
[item.userName, item.userEmail, item.message]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(normalizedSearch),
|
||||||
|
)
|
||||||
|
}, [feedback, search])
|
||||||
|
|
||||||
const loadFeedback = useCallback(async (options?: { silent?: boolean }) => {
|
const loadFeedback = useCallback(async (options?: { silent?: boolean }) => {
|
||||||
if (options?.silent) {
|
if (options?.silent) {
|
||||||
@ -122,12 +215,11 @@ export default function FeedbackAdministration() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
setFeedback(Array.isArray(data.feedback) ? data.feedback : [])
|
setFeedback(Array.isArray(data.feedback) ? data.feedback : [])
|
||||||
} catch (error) {
|
} catch (loadError) {
|
||||||
setError(
|
setError(
|
||||||
error instanceof Error
|
loadError instanceof Error
|
||||||
? error.message
|
? loadError.message
|
||||||
: 'Feedback konnte nicht geladen werden',
|
: 'Feedback konnte nicht geladen werden',
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
@ -146,9 +238,7 @@ export default function FeedbackAdministration() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
events.onmessage = (event) => {
|
events.onmessage = (event) => {
|
||||||
const notification = extractNotification(event.data)
|
if (isFeedbackEvent(extractNotification(event.data))) {
|
||||||
|
|
||||||
if (isFeedbackEvent(notification)) {
|
|
||||||
void loadFeedback({ silent: true })
|
void loadFeedback({ silent: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -166,17 +256,11 @@ export default function FeedbackAdministration() {
|
|||||||
const confirmed = window.confirm(
|
const confirmed = window.confirm(
|
||||||
`Feedback von ${feedbackItem.userName || feedbackItem.userEmail || 'Unbekannt'} wirklich löschen?`,
|
`Feedback von ${feedbackItem.userName || feedbackItem.userEmail || 'Unbekannt'} wirklich löschen?`,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setDeletingFeedbackIds((currentIds) => {
|
setDeletingFeedbackIds((currentIds) => new Set(currentIds).add(feedbackItem.id))
|
||||||
const nextIds = new Set(currentIds)
|
|
||||||
nextIds.add(feedbackItem.id)
|
|
||||||
return nextIds
|
|
||||||
})
|
|
||||||
|
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -193,10 +277,10 @@ export default function FeedbackAdministration() {
|
|||||||
setFeedback((currentFeedback) =>
|
setFeedback((currentFeedback) =>
|
||||||
currentFeedback.filter((item) => item.id !== feedbackItem.id),
|
currentFeedback.filter((item) => item.id !== feedbackItem.id),
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (deleteError) {
|
||||||
setError(
|
setError(
|
||||||
error instanceof Error
|
deleteError instanceof Error
|
||||||
? error.message
|
? deleteError.message
|
||||||
: 'Feedback konnte nicht gelöscht werden',
|
: 'Feedback konnte nicht gelöscht werden',
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
@ -223,75 +307,95 @@ export default function FeedbackAdministration() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-6 flex items-start justify-between gap-x-4">
|
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
|
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
Feedback
|
Feedback
|
||||||
|
<span className="ml-2 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-white/10 dark:text-gray-300">
|
||||||
|
{feedback.length}
|
||||||
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Eingereichtes Feedback inklusive technischem Log einsehen und verwalten.
|
Eingereichtes Feedback inklusive Aktivitäts- und Fehlerprotokoll.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<div className="relative">
|
||||||
variant="secondary"
|
<MagnifyingGlassIcon
|
||||||
color="gray"
|
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-gray-400"
|
||||||
size="sm"
|
aria-hidden="true"
|
||||||
disabled={isRefreshing}
|
/>
|
||||||
isLoading={isRefreshing}
|
<input
|
||||||
leadingIcon={<ArrowPathIcon aria-hidden="true" className="size-4" />}
|
value={search}
|
||||||
onClick={() => void loadFeedback({ silent: true })}
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
>
|
placeholder="Suchen..."
|
||||||
Aktualisieren
|
className="block w-44 rounded-lg border-0 bg-white py-2 pr-3 pl-9 text-sm text-gray-900 ring-1 ring-gray-200 outline-none focus:ring-2 focus:ring-indigo-500 sm:w-56 dark:bg-white/5 dark:text-white dark:ring-white/10"
|
||||||
</Button>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
disabled={isRefreshing}
|
||||||
|
isLoading={isRefreshing}
|
||||||
|
leadingIcon={<ArrowPathIcon aria-hidden="true" className="size-4" />}
|
||||||
|
onClick={() => void loadFeedback({ silent: true })}
|
||||||
|
>
|
||||||
|
Aktualisieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-6 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
<div className="mb-6 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-300">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sortedFeedback.length === 0 ? (
|
{visibleFeedback.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed border-gray-300 bg-white p-8 text-center dark:border-white/10 dark:bg-gray-900">
|
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-10 text-center dark:border-white/10 dark:bg-gray-900">
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
Es wurde noch kein Feedback abgegeben.
|
{search.trim()
|
||||||
|
? 'Kein Feedback passt zur Suche.'
|
||||||
|
: 'Es wurde noch kein Feedback abgegeben.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{sortedFeedback.map((feedbackItem) => {
|
{visibleFeedback.map((feedbackItem) => {
|
||||||
const isDeleting = deletingFeedbackIds.has(feedbackItem.id)
|
const isDeleting = deletingFeedbackIds.has(feedbackItem.id)
|
||||||
|
const logRecord = asRecord(feedbackItem.log)
|
||||||
|
const activity = coerceActivity(logRecord?.activity)
|
||||||
|
const client = parseClientMeta(logRecord)
|
||||||
|
const errorCount = activity.filter((entry) => entry.type === 'error').length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
key={feedbackItem.id}
|
key={feedbackItem.id}
|
||||||
className="rounded-lg border border-gray-200 bg-white p-5 shadow-xs dark:border-white/10 dark:bg-gray-900"
|
className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-x-4">
|
<div className="flex items-start justify-between gap-4 border-b border-gray-100 p-5 dark:border-white/10">
|
||||||
<div className="flex min-w-0 items-start gap-x-3">
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
<Avatar
|
<Avatar
|
||||||
initials={getInitials(feedbackItem.userName, feedbackItem.userEmail)}
|
initials={getInitials(feedbackItem.userName, feedbackItem.userEmail)}
|
||||||
name={feedbackItem.userName || feedbackItem.userEmail}
|
name={feedbackItem.userName || feedbackItem.userEmail}
|
||||||
size={10}
|
size={10}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
{feedbackItem.userName || 'Unbekannter Benutzer'}
|
{feedbackItem.userName || 'Unbekannter Benutzer'}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{feedbackItem.userEmail && (
|
{feedbackItem.userEmail && (
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{feedbackItem.userEmail}
|
{feedbackItem.userEmail}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{formatDate(feedbackItem.createdAt)}
|
{formatDate(feedbackItem.createdAt)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -311,19 +415,61 @@ export default function FeedbackAdministration() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-4 whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">
|
<div className="p-5">
|
||||||
{feedbackItem.message}
|
<p className="rounded-xl bg-gray-50 p-4 text-sm whitespace-pre-wrap text-gray-700 dark:bg-white/5 dark:text-gray-200">
|
||||||
</p>
|
{feedbackItem.message}
|
||||||
|
</p>
|
||||||
|
|
||||||
<details className="mt-4 rounded-md bg-gray-50 p-3 dark:bg-white/5">
|
{client && (
|
||||||
<summary className="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300">
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
Technisches Log anzeigen
|
{client.browser && (
|
||||||
</summary>
|
<MetaChip icon={GlobeAltIcon}>{client.browser}</MetaChip>
|
||||||
|
)}
|
||||||
|
{client.operatingSystem && (
|
||||||
|
<MetaChip icon={ComputerDesktopIcon}>
|
||||||
|
{client.operatingSystem}
|
||||||
|
{client.deviceType ? ` · ${client.deviceType}` : ''}
|
||||||
|
</MetaChip>
|
||||||
|
)}
|
||||||
|
{client.viewport && (
|
||||||
|
<MetaChip icon={CursorArrowRaysIcon}>{client.viewport}</MetaChip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<pre className="mt-3 max-h-96 overflow-auto rounded bg-gray-950 p-3 text-xs text-gray-100">
|
<details className="group mt-4 overflow-hidden rounded-xl border border-gray-200 dark:border-white/10">
|
||||||
{JSON.stringify(feedbackItem.log, null, 2)}
|
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 select-none hover:bg-gray-100 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10">
|
||||||
</pre>
|
<span className="flex items-center gap-2">
|
||||||
</details>
|
<CursorArrowRaysIcon className="size-4 text-gray-400" />
|
||||||
|
Aktivitätsprotokoll
|
||||||
|
<span className="text-xs font-normal text-gray-400">
|
||||||
|
{activity.length} Einträge
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{errorCount > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-500/15 dark:text-red-300">
|
||||||
|
<ExclamationTriangleIcon className="size-3.5" />
|
||||||
|
{errorCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</summary>
|
||||||
|
<div className="max-h-96 overflow-y-auto p-2">
|
||||||
|
<ActivityLogView
|
||||||
|
entries={activity}
|
||||||
|
emptyLabel="Für dieses Feedback wurde kein clientseitiges Protokoll übermittelt."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details className="mt-2 overflow-hidden rounded-xl border border-gray-200 dark:border-white/10">
|
||||||
|
<summary className="cursor-pointer list-none bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 select-none hover:bg-gray-100 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10">
|
||||||
|
Rohdaten (technisches Log)
|
||||||
|
</summary>
|
||||||
|
<pre className="max-h-96 overflow-auto bg-gray-950 p-3 text-xs text-gray-100">
|
||||||
|
{JSON.stringify(feedbackItem.log, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -331,4 +477,4 @@ export default function FeedbackAdministration() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,6 +45,9 @@ type AdminUser = {
|
|||||||
group: string
|
group: string
|
||||||
rights: string[]
|
rights: string[]
|
||||||
teams: AdminTeam[]
|
teams: AdminTeam[]
|
||||||
|
onlineStatus: boolean
|
||||||
|
presenceStatus: 'online' | 'away' | 'offline'
|
||||||
|
lastSeenAt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminRightId = 'admin'
|
const adminRightId = 'admin'
|
||||||
@ -201,13 +204,6 @@ function normalizeRights(rights: string[]) {
|
|||||||
return [...nextRights]
|
return [...nextRights]
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelectedValues(formData: FormData, name: string) {
|
|
||||||
return formData
|
|
||||||
.getAll(name)
|
|
||||||
.map((value) => String(value))
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserDisplayName(user: AdminUser) {
|
function getUserDisplayName(user: AdminUser) {
|
||||||
return user.displayName || user.username || user.email
|
return user.displayName || user.username || user.email
|
||||||
}
|
}
|
||||||
@ -934,6 +930,8 @@ export default function UsersAdministration() {
|
|||||||
name={displayName}
|
name={displayName}
|
||||||
initials={getUserInitials(user)}
|
initials={getUserInitials(user)}
|
||||||
size={9}
|
size={9}
|
||||||
|
presenceStatus={user.presenceStatus}
|
||||||
|
wrapperClassName="self-start"
|
||||||
className={isSelected && !user.avatar ? 'bg-indigo-600' : undefined}
|
className={isSelected && !user.avatar ? 'bg-indigo-600' : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -1349,4 +1347,4 @@ export default function UsersAdministration() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
2636
frontend/src/pages/chat/ChatPage.tsx
Normal file
2636
frontend/src/pages/chat/ChatPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -44,13 +44,13 @@ type DeviceFormFieldsProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const inputClassName =
|
export const inputClassName =
|
||||||
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
|
'block w-full rounded-xl border-0 bg-gray-50 px-3.5 py-2 text-base text-gray-900 shadow-sm ring-1 ring-gray-200 ring-inset placeholder:text-gray-400 transition focus:bg-white focus:ring-2 focus:ring-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:ring-white/10 dark:placeholder:text-gray-500 dark:focus:bg-white/10 dark:focus:ring-indigo-400'
|
||||||
|
|
||||||
export const selectClassName =
|
export const selectClassName =
|
||||||
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500'
|
'block w-full rounded-xl border-0 bg-gray-50 px-3.5 py-2 text-base text-gray-900 shadow-sm ring-1 ring-gray-200 ring-inset transition focus:bg-white focus:ring-2 focus:ring-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:ring-white/10 dark:*:bg-gray-800 dark:focus:bg-white/10 dark:focus:ring-indigo-400'
|
||||||
|
|
||||||
export const sectionClassName =
|
export const sectionClassName =
|
||||||
'flex min-h-0 flex-col rounded-lg border border-gray-200 bg-white p-4 shadow-xs dark:border-white/10 dark:bg-white/5'
|
'flex min-h-0 flex-col rounded-2xl border border-gray-200/80 bg-white p-5 shadow-sm ring-1 ring-black/[0.02] dark:border-white/10 dark:bg-gray-900/70 dark:ring-white/5'
|
||||||
|
|
||||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||||
return classes.filter(Boolean).join(' ')
|
return classes.filter(Boolean).join(' ')
|
||||||
@ -64,8 +64,9 @@ export function SectionHeader({
|
|||||||
description?: string
|
description?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-gray-200 pb-3 dark:border-white/10">
|
<div className="relative border-b border-gray-200 pb-4 pl-3 dark:border-white/10">
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
<span className="absolute inset-y-0 left-0 w-1 rounded-full bg-indigo-500" />
|
||||||
|
<h3 className="text-sm font-semibold text-gray-950 dark:text-white">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@ -402,7 +403,7 @@ export default function DeviceFormFields({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-full grid-cols-1 gap-4 sm:grid-cols-6">
|
<div className="grid min-h-full grid-cols-1 gap-5 sm:grid-cols-6">
|
||||||
{lookupError && (
|
{lookupError && (
|
||||||
<div className="sm:col-span-6 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
<div className="sm:col-span-6 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||||
{lookupError}
|
{lookupError}
|
||||||
@ -589,7 +590,7 @@ export default function DeviceFormFields({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isMilestoneDevice && (
|
{isMilestoneDevice && (
|
||||||
<section className="sm:col-span-6 rounded-lg border border-indigo-200 bg-indigo-50/50 p-4 dark:border-indigo-400/20 dark:bg-indigo-400/10">
|
<section className="sm:col-span-6 rounded-2xl border border-indigo-200 bg-gradient-to-br from-indigo-50/80 to-violet-50/60 p-5 shadow-sm dark:border-indigo-400/20 dark:from-indigo-500/10 dark:to-violet-500/5">
|
||||||
<input type="hidden" name="isMilestoneDevice" value="true" />
|
<input type="hidden" name="isMilestoneDevice" value="true" />
|
||||||
|
|
||||||
<div className="border-b border-indigo-200 pb-3 dark:border-indigo-400/20">
|
<div className="border-b border-indigo-200 pb-3 dark:border-indigo-400/20">
|
||||||
@ -672,7 +673,7 @@ export default function DeviceFormFields({
|
|||||||
Milestone-Status
|
Milestone-Status
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="mt-2 flex min-h-[38px] items-center gap-3 rounded-md border border-gray-200 bg-white px-3 py-1.5 dark:border-white/10 dark:bg-white/5">
|
<div className="mt-2 flex min-h-[42px] items-center gap-3 rounded-xl border border-gray-200 bg-white px-3.5 py-2 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||||
<VideoCameraIcon
|
<VideoCameraIcon
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="size-4 shrink-0 text-indigo-500 dark:text-indigo-400"
|
className="size-4 shrink-0 text-indigo-500 dark:text-indigo-400"
|
||||||
@ -838,7 +839,7 @@ export default function DeviceFormFields({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-6">
|
<div className="sm:col-span-6">
|
||||||
<div className="rounded-md border border-gray-200 px-3 py-3 dark:border-white/10">
|
<div className="rounded-xl border border-gray-200 bg-gray-50/70 px-4 py-3.5 dark:border-white/10 dark:bg-white/[0.03]">
|
||||||
<Switch
|
<Switch
|
||||||
id="isBaoDevice"
|
id="isBaoDevice"
|
||||||
name="isBaoDevice"
|
name="isBaoDevice"
|
||||||
@ -901,7 +902,7 @@ export default function DeviceFormFields({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 max-h-56 overflow-y-auto rounded-md border border-gray-200 p-3 dark:border-white/10">
|
<div className="mt-4 max-h-64 overflow-y-auto rounded-xl border border-gray-200 bg-gray-50/60 p-3 dark:border-white/10 dark:bg-white/[0.03]">
|
||||||
{assignableDevices.length === 0 ? (
|
{assignableDevices.length === 0 ? (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
Noch keine anderen Geräte vorhanden.
|
Noch keine anderen Geräte vorhanden.
|
||||||
@ -952,7 +953,7 @@ export default function DeviceFormFields({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{!isMilestoneDevice ? (
|
{!isMilestoneDevice ? (
|
||||||
<div className="mt-4 rounded-md border border-dashed border-gray-200 p-4 text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
|
<div className="mt-4 rounded-xl border border-dashed border-gray-300 bg-gray-50/60 p-5 text-sm text-gray-500 dark:border-white/15 dark:bg-white/[0.03] dark:text-gray-400">
|
||||||
Dieses Gerät ist kein Milestone-Gerät.
|
Dieses Gerät ist kein Milestone-Gerät.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -1003,4 +1004,4 @@ export default function DeviceFormFields({
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,10 +34,10 @@ export default function DeviceModalTabs({
|
|||||||
onChange,
|
onChange,
|
||||||
}: DeviceModalTabsProps) {
|
}: DeviceModalTabsProps) {
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-gray-200 bg-white px-4 sm:px-6 dark:border-white/10 dark:bg-gray-900">
|
<div className="border-b border-gray-200 bg-white/90 px-4 py-3 sm:px-6 dark:border-white/10 dark:bg-gray-900/90">
|
||||||
<nav
|
<nav
|
||||||
aria-label="Geräteformular"
|
aria-label="Geräteformular"
|
||||||
className="-mb-px flex gap-x-6 overflow-x-auto"
|
className="flex gap-1.5 overflow-x-auto rounded-2xl bg-gray-100/80 p-1.5 dark:bg-white/5"
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const isActive = activeTab === tab.id
|
const isActive = activeTab === tab.id
|
||||||
@ -50,10 +50,10 @@ export default function DeviceModalTabs({
|
|||||||
disabled={tab.disabled}
|
disabled={tab.disabled}
|
||||||
onClick={() => onChange(tab.id)}
|
onClick={() => onChange(tab.id)}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'inline-flex items-center gap-x-2 whitespace-nowrap border-b-2 px-1 py-3 text-sm font-medium',
|
'inline-flex items-center gap-x-2 whitespace-nowrap rounded-xl px-3 py-2 text-sm font-medium transition',
|
||||||
isActive
|
isActive
|
||||||
? 'border-indigo-600 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
? 'bg-white text-indigo-700 shadow-sm ring-1 ring-black/5 dark:bg-white/10 dark:text-indigo-300 dark:ring-white/10'
|
||||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-200',
|
: 'text-gray-500 hover:bg-white/70 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200',
|
||||||
tab.disabled && 'cursor-not-allowed opacity-50',
|
tab.disabled && 'cursor-not-allowed opacity-50',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -71,4 +71,4 @@ export default function DeviceModalTabs({
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import {
|
|||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
ExclamationTriangleIcon,
|
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
WrenchScrewdriverIcon,
|
WrenchScrewdriverIcon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
@ -110,20 +109,6 @@ function formatFirmwareCheckRemaining(nextAllowedAt?: string | null, now = Date.
|
|||||||
return `in ${hours} Std. ${remainingMinutes} Min.`
|
return `in ${hours} Std. ${remainingMinutes} Min.`
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLatestFirmwareCheckedAt(devices: Device[]) {
|
|
||||||
const timestamps = devices
|
|
||||||
.map((device) => device.firmwareUpdate?.checkedAt)
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((value) => new Date(value as string).getTime())
|
|
||||||
.filter((value) => Number.isFinite(value))
|
|
||||||
|
|
||||||
if (timestamps.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(Math.max(...timestamps)).toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
type BadgeTone = NonNullable<ComponentProps<typeof Badge>['tone']>
|
type BadgeTone = NonNullable<ComponentProps<typeof Badge>['tone']>
|
||||||
|
|
||||||
function getLoanStatusBadgeTone(status: string): BadgeTone {
|
function getLoanStatusBadgeTone(status: string): BadgeTone {
|
||||||
@ -769,7 +754,7 @@ export default function DevicesPage() {
|
|||||||
recordKeyframesOnly: formData.get('recordKeyframesOnly') === 'on',
|
recordKeyframesOnly: formData.get('recordKeyframesOnly') === 'on',
|
||||||
recordingStorageId:
|
recordingStorageId:
|
||||||
String(formData.get('recordingStorageId') ?? '').trim() || null,
|
String(formData.get('recordingStorageId') ?? '').trim() || null,
|
||||||
streams: cameraDetails.streams.map((stream, index) => ({
|
streams: cameraDetails.streams.map((_stream, index) => ({
|
||||||
index,
|
index,
|
||||||
codec: String(formData.get(`streamCodec-${index}`) ?? ''),
|
codec: String(formData.get(`streamCodec-${index}`) ?? ''),
|
||||||
fps: Number(formData.get(`streamFps-${index}`) ?? 0) || null,
|
fps: Number(formData.get(`streamFps-${index}`) ?? 0) || null,
|
||||||
@ -1028,9 +1013,6 @@ export default function DevicesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestFirmwareCheckedAt =
|
|
||||||
firmwareCheckState?.lastCheckedAt ?? getLatestFirmwareCheckedAt(devices)
|
|
||||||
|
|
||||||
const firmwareCheckNextAllowedAt = firmwareCheckState?.nextAllowedAt
|
const firmwareCheckNextAllowedAt = firmwareCheckState?.nextAllowedAt
|
||||||
const isFirmwareCheckLocked =
|
const isFirmwareCheckLocked =
|
||||||
Boolean(firmwareCheckNextAllowedAt) &&
|
Boolean(firmwareCheckNextAllowedAt) &&
|
||||||
@ -1250,12 +1232,16 @@ export default function DevicesPage() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full min-h-0 overflow-y-auto px-4 py-10 sm:px-6 lg:px-8 [scrollbar-gutter:stable]">
|
<div className="relative h-full min-h-0 overflow-y-auto bg-gradient-to-br from-slate-50 via-white to-indigo-50/50 px-4 py-8 sm:px-6 lg:px-8 dark:from-gray-950 dark:via-gray-950 dark:to-indigo-950/20 [scrollbar-gutter:stable]">
|
||||||
|
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||||
|
<div className="absolute -top-36 right-0 size-96 rounded-full bg-indigo-200/25 blur-3xl dark:bg-indigo-500/10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Tables
|
<Tables
|
||||||
title="Geräte"
|
title="Geräte"
|
||||||
description="Eine Liste aller Geräte inklusive Inventur-Nr., Hersteller, Modell, Seriennummer, MAC-Adresse, IP-Adresse, Firmware, Standort, Verleih-Status und Zubehör."
|
description="Eine Liste aller Geräte inklusive Inventur-Nr., Hersteller, Modell, Seriennummer, MAC-Adresse, IP-Adresse, Firmware, Standort, Verleih-Status und Zubehör."
|
||||||
headerAction={
|
headerAction={
|
||||||
<div className="flex flex-col items-start gap-1.5">
|
<div className="flex flex-col items-start gap-2 sm:items-end">
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -1283,7 +1269,7 @@ export default function DevicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{firmwareCheckNextAllowedLabel && (
|
{firmwareCheckNextAllowedLabel && (
|
||||||
<div className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-xs text-gray-600 shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
<div className="inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-white/80 px-3 py-1.5 text-xs font-medium text-gray-600 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
|
||||||
<ClockIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
<ClockIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
||||||
<span>{firmwareCheckNextAllowedLabel}</span>
|
<span>{firmwareCheckNextAllowedLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -1291,6 +1277,7 @@ export default function DevicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
onAction={openCreateModal}
|
onAction={openCreateModal}
|
||||||
|
className="relative overflow-hidden rounded-3xl border border-white/80 bg-white/85 p-5 shadow-xl shadow-slate-900/5 ring-1 ring-slate-900/5 backdrop-blur-xl sm:p-6 dark:border-white/10 dark:bg-gray-900/80 dark:shadow-black/20 dark:ring-white/5"
|
||||||
rows={devices}
|
rows={devices}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
getRowId={(device) => device.id}
|
getRowId={(device) => device.id}
|
||||||
@ -1397,4 +1384,4 @@ export default function DevicesPage() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import {
|
|||||||
type ComponentType,
|
type ComponentType,
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
type ReactNode,
|
|
||||||
type SVGProps,
|
type SVGProps,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { DialogTitle } from '@headlessui/react'
|
import { DialogTitle } from '@headlessui/react'
|
||||||
@ -25,14 +24,11 @@ import {
|
|||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
WrenchScrewdriverIcon,
|
WrenchScrewdriverIcon,
|
||||||
} from '@heroicons/react/20/solid'
|
} from '@heroicons/react/20/solid'
|
||||||
import {
|
|
||||||
CircleMarker,
|
|
||||||
MapContainer,
|
|
||||||
TileLayer,
|
|
||||||
} from 'react-leaflet'
|
|
||||||
import Modal from '../../components/Modal'
|
import Modal from '../../components/Modal'
|
||||||
import Button from '../../components/Button'
|
import Button from '../../components/Button'
|
||||||
import AddressCombobox from '../../components/AddressCombobox'
|
import AddressCombobox, {
|
||||||
|
type AddressArea,
|
||||||
|
} from '../../components/AddressCombobox'
|
||||||
import Textarea from '../../components/Textarea'
|
import Textarea from '../../components/Textarea'
|
||||||
import Combobox, { type ComboboxItem } from '../../components/Combobox'
|
import Combobox, { type ComboboxItem } from '../../components/Combobox'
|
||||||
import MultiCombobox from '../../components/MultiCombobox'
|
import MultiCombobox from '../../components/MultiCombobox'
|
||||||
@ -43,6 +39,8 @@ import ServerFolderPicker, {
|
|||||||
joinServerFolderPath,
|
joinServerFolderPath,
|
||||||
} from '../../components/ServerFolderPicker'
|
} from '../../components/ServerFolderPicker'
|
||||||
import { formatOperationNumberInput, isCompleteOperationNumber } from '../../components/formatter'
|
import { formatOperationNumberInput, isCompleteOperationNumber } from '../../components/formatter'
|
||||||
|
import type { OperationCoordinates } from '../../utils/operationGeometry'
|
||||||
|
import OperationGeometryEditor from './OperationGeometryEditor'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
const OPERATION_UNIT = 'TEG'
|
const OPERATION_UNIT = 'TEG'
|
||||||
@ -227,7 +225,7 @@ function hasInvalidWindowsFolderNameCharacters(value: string) {
|
|||||||
type SetupStepIcon = ComponentType<SVGProps<SVGSVGElement>>
|
type SetupStepIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||||
|
|
||||||
const inputClassName =
|
const inputClassName =
|
||||||
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
|
'block w-full rounded-xl border-0 bg-gray-50 px-3.5 py-2 text-base text-gray-900 shadow-sm ring-1 ring-gray-200 ring-inset placeholder:text-gray-400 transition focus:bg-white focus:ring-2 focus:ring-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:ring-white/10 dark:placeholder:text-gray-500 dark:focus:bg-white/10 dark:focus:ring-indigo-400'
|
||||||
|
|
||||||
const readOnlyInputClassName =
|
const readOnlyInputClassName =
|
||||||
'!cursor-not-allowed !bg-gray-50 !text-gray-500 !outline-gray-200 dark:!bg-white/10 dark:!text-gray-400 dark:!outline-white/10'
|
'!cursor-not-allowed !bg-gray-50 !text-gray-500 !outline-gray-200 dark:!bg-white/10 dark:!text-gray-400 dark:!outline-white/10'
|
||||||
@ -447,269 +445,6 @@ function ReviewTeamItem({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReviewSection({
|
|
||||||
icon: Icon,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
icon: SetupStepIcon
|
|
||||||
title: string
|
|
||||||
description?: string
|
|
||||||
children: ReactNode
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className={classNames(
|
|
||||||
'overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xs dark:border-white/10 dark:bg-white/[0.03]',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="border-b border-gray-200 bg-gray-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.03]">
|
|
||||||
<div className="flex items-start gap-x-3">
|
|
||||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-300">
|
|
||||||
<Icon aria-hidden="true" className="size-5" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
|
||||||
{title}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
{description && (
|
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReviewField({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
title,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
title?: string
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
'min-w-0 rounded-lg bg-gray-50 px-3 py-2 ring-1 ring-gray-200 ring-inset dark:bg-white/5 dark:ring-white/10',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<dt className="text-[11px] font-medium tracking-wide text-gray-500 uppercase dark:text-gray-400">
|
|
||||||
{label}
|
|
||||||
</dt>
|
|
||||||
|
|
||||||
<dd
|
|
||||||
title={title || value || undefined}
|
|
||||||
className="mt-1 truncate text-sm font-medium text-gray-900 dark:text-white"
|
|
||||||
>
|
|
||||||
{getDisplayValue(value)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReviewLongField({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
}) {
|
|
||||||
const displayValue = value.trim()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-w-0 rounded-lg bg-gray-50 px-3 py-2 ring-1 ring-gray-200 ring-inset dark:bg-white/5 dark:ring-white/10">
|
|
||||||
<dt className="text-[11px] font-medium tracking-wide text-gray-500 uppercase dark:text-gray-400">
|
|
||||||
{label}
|
|
||||||
</dt>
|
|
||||||
|
|
||||||
<dd className="mt-1">
|
|
||||||
{displayValue ? (
|
|
||||||
<p
|
|
||||||
title={displayValue}
|
|
||||||
className="line-clamp-4 whitespace-pre-wrap text-sm text-gray-900 dark:text-white"
|
|
||||||
>
|
|
||||||
{displayValue}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-gray-400 dark:text-gray-500">—</span>
|
|
||||||
)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReviewMapCoordinates = {
|
|
||||||
lat: number
|
|
||||||
lon: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function toCoordinateNumber(value: unknown) {
|
|
||||||
if (typeof value === 'number') {
|
|
||||||
return Number.isFinite(value) ? value : NaN
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const parsed = Number(value)
|
|
||||||
return Number.isFinite(parsed) ? parsed : NaN
|
|
||||||
}
|
|
||||||
|
|
||||||
return NaN
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReviewAddressMap({ address }: { address: string }) {
|
|
||||||
const [coordinates, setCoordinates] = useState<ReviewMapCoordinates | null>(null)
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const trimmedAddress = address.trim()
|
|
||||||
|
|
||||||
if (trimmedAddress.length < 3) {
|
|
||||||
setCoordinates(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const abortController = new AbortController()
|
|
||||||
|
|
||||||
async function loadCoordinates() {
|
|
||||||
setIsLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`${API_URL}/address-search?q=${encodeURIComponent(trimmedAddress)}`,
|
|
||||||
{
|
|
||||||
credentials: 'include',
|
|
||||||
signal: abortController.signal,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
setCoordinates(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
const firstSuggestion = data.suggestions?.[0]
|
|
||||||
|
|
||||||
if (!firstSuggestion) {
|
|
||||||
setCoordinates(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const lat = toCoordinateNumber(firstSuggestion.lat)
|
|
||||||
const lon = toCoordinateNumber(firstSuggestion.lon)
|
|
||||||
|
|
||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
|
||||||
setCoordinates(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setCoordinates({
|
|
||||||
lat,
|
|
||||||
lon,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setCoordinates(null)
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void loadCoordinates()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
abortController.abort()
|
|
||||||
}
|
|
||||||
}, [address])
|
|
||||||
|
|
||||||
if (!address.trim()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="mt-2 flex h-28 items-center justify-center rounded-md border border-gray-200 bg-white text-xs text-gray-500 dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
|
|
||||||
Karte wird geladen...
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!coordinates) {
|
|
||||||
return (
|
|
||||||
<div className="mt-2 flex h-28 items-center justify-center rounded-md border border-gray-200 bg-white text-xs text-gray-500 dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
|
|
||||||
Keine Kartenposition gefunden.
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-2 overflow-hidden rounded-md border border-gray-200 bg-white dark:border-white/10 dark:bg-white/5">
|
|
||||||
<MapContainer
|
|
||||||
center={[coordinates.lat, coordinates.lon]}
|
|
||||||
zoom={16}
|
|
||||||
scrollWheelZoom={false}
|
|
||||||
dragging={false}
|
|
||||||
doubleClickZoom={false}
|
|
||||||
zoomControl={false}
|
|
||||||
attributionControl={false}
|
|
||||||
className="h-28 w-full"
|
|
||||||
>
|
|
||||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
|
||||||
|
|
||||||
<CircleMarker
|
|
||||||
center={[coordinates.lat, coordinates.lon]}
|
|
||||||
radius={7}
|
|
||||||
pathOptions={{
|
|
||||||
fillOpacity: 0.9,
|
|
||||||
weight: 2,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</MapContainer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReviewAddressItem({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="min-w-0">
|
|
||||||
<ReviewItem
|
|
||||||
label={label}
|
|
||||||
value={value}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ReviewAddressMap address={value} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepHeader({
|
function StepHeader({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@ -718,8 +453,9 @@ function StepHeader({
|
|||||||
description: string
|
description: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="relative rounded-2xl border border-gray-200/80 bg-white p-5 pl-7 shadow-sm dark:border-white/10 dark:bg-gray-900/70">
|
||||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
|
<span className="absolute inset-y-5 left-4 w-1 rounded-full bg-indigo-500" />
|
||||||
|
<h3 className="text-base font-semibold text-gray-950 dark:text-white">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
@ -739,6 +475,12 @@ export default function CreateOperationModal({
|
|||||||
const [activeStep, setActiveStep] = useState<SetupStepId>('masterData')
|
const [activeStep, setActiveStep] = useState<SetupStepId>('masterData')
|
||||||
const [formValues, setFormValues] = useState<OperationFormValues>(initialFormValues)
|
const [formValues, setFormValues] = useState<OperationFormValues>(initialFormValues)
|
||||||
const [localError, setLocalError] = useState<string | null>(null)
|
const [localError, setLocalError] = useState<string | null>(null)
|
||||||
|
const [kwPosition, setKwPosition] = useState<OperationCoordinates | null>(null)
|
||||||
|
const [targetPosition, setTargetPosition] = useState<OperationCoordinates | null>(null)
|
||||||
|
const [kwArea, setKwArea] = useState<AddressArea | null>(null)
|
||||||
|
const [targetArea, setTargetArea] = useState<AddressArea | null>(null)
|
||||||
|
const [viewConeFov, setViewConeFov] = useState(36)
|
||||||
|
const [viewConeLength, setViewConeLength] = useState(0)
|
||||||
|
|
||||||
const [operationLeaderOptions, setOperationLeaderOptions] = useState<ComboboxItem[]>([])
|
const [operationLeaderOptions, setOperationLeaderOptions] = useState<ComboboxItem[]>([])
|
||||||
const [operationTeamOptions, setOperationTeamOptions] = useState<ComboboxItem[]>([])
|
const [operationTeamOptions, setOperationTeamOptions] = useState<ComboboxItem[]>([])
|
||||||
@ -781,6 +523,12 @@ export default function CreateOperationModal({
|
|||||||
setFormValues(initialFormValues)
|
setFormValues(initialFormValues)
|
||||||
setSelectedOperationLeader(null)
|
setSelectedOperationLeader(null)
|
||||||
setSelectedOperationTeam([])
|
setSelectedOperationTeam([])
|
||||||
|
setKwPosition(null)
|
||||||
|
setTargetPosition(null)
|
||||||
|
setKwArea(null)
|
||||||
|
setTargetArea(null)
|
||||||
|
setViewConeFov(36)
|
||||||
|
setViewConeLength(0)
|
||||||
setLocalError(null)
|
setLocalError(null)
|
||||||
setMilestoneStorages([])
|
setMilestoneStorages([])
|
||||||
setSelectedMilestoneStorage(null)
|
setSelectedMilestoneStorage(null)
|
||||||
@ -1074,7 +822,7 @@ export default function CreateOperationModal({
|
|||||||
open={open}
|
open={open}
|
||||||
setOpen={handleOpenChange}
|
setOpen={handleOpenChange}
|
||||||
zIndexClassName="z-[9999]"
|
zIndexClassName="z-[9999]"
|
||||||
panelClassName="max-h-[calc(100dvh-2rem)] max-w-4xl bg-white dark:bg-gray-900"
|
panelClassName="max-h-[calc(100dvh-2rem)] max-w-4xl rounded-3xl bg-white ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10"
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
@ -1086,6 +834,12 @@ export default function CreateOperationModal({
|
|||||||
<input type="hidden" name="caseWorker" value={formValues.caseWorker} />
|
<input type="hidden" name="caseWorker" value={formValues.caseWorker} />
|
||||||
<input type="hidden" name="targetObject" value={formValues.targetObject} />
|
<input type="hidden" name="targetObject" value={formValues.targetObject} />
|
||||||
<input type="hidden" name="kwAddress" value={formValues.kwAddress} />
|
<input type="hidden" name="kwAddress" value={formValues.kwAddress} />
|
||||||
|
<input type="hidden" name="kwLatitude" value={kwPosition?.lat ?? ''} />
|
||||||
|
<input type="hidden" name="kwLongitude" value={kwPosition?.lon ?? ''} />
|
||||||
|
<input type="hidden" name="targetLatitude" value={targetPosition?.lat ?? ''} />
|
||||||
|
<input type="hidden" name="targetLongitude" value={targetPosition?.lon ?? ''} />
|
||||||
|
<input type="hidden" name="viewConeFov" value={viewConeFov} />
|
||||||
|
<input type="hidden" name="viewConeLength" value={viewConeLength} />
|
||||||
<input type="hidden" name="operationLeader" value={formValues.operationLeader} />
|
<input type="hidden" name="operationLeader" value={formValues.operationLeader} />
|
||||||
<input type="hidden" name="operationTeam" value={formValues.operationTeam} />
|
<input type="hidden" name="operationTeam" value={formValues.operationTeam} />
|
||||||
<input type="hidden" name="legend" value={formValues.legend} />
|
<input type="hidden" name="legend" value={formValues.legend} />
|
||||||
@ -1098,15 +852,21 @@ export default function CreateOperationModal({
|
|||||||
<input type="hidden" name="remark" value={formValues.remark} />
|
<input type="hidden" name="remark" value={formValues.remark} />
|
||||||
<input type="hidden" name="milestoneStorage" value={selectedMilestoneStorage ? (selectedMilestoneStorage.displayName || selectedMilestoneStorage.name) : ''} />
|
<input type="hidden" name="milestoneStorage" value={selectedMilestoneStorage ? (selectedMilestoneStorage.displayName || selectedMilestoneStorage.name) : ''} />
|
||||||
|
|
||||||
<div className="flex items-start justify-between border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
|
<div className="relative flex items-start justify-between overflow-hidden border-b border-indigo-100 bg-gradient-to-r from-indigo-50 via-white to-violet-50 px-4 py-5 sm:px-6 dark:border-white/10 dark:from-indigo-950/50 dark:via-gray-900 dark:to-violet-950/30">
|
||||||
<div>
|
<div className="flex min-w-0 items-start gap-3.5">
|
||||||
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
|
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-indigo-600 text-white shadow-lg shadow-indigo-600/20 dark:bg-indigo-500">
|
||||||
Einsatz anlegen
|
<ClipboardDocumentListIcon aria-hidden="true" className="size-5" />
|
||||||
</DialogTitle>
|
</div>
|
||||||
|
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
<div className="min-w-0">
|
||||||
|
<DialogTitle className="text-base font-semibold text-gray-950 dark:text-white">
|
||||||
|
Einsatz anlegen
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Folge den Schritten oder überspringe optionale Bereiche.
|
Folge den Schritten oder überspringe optionale Bereiche.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@ -1122,9 +882,10 @@ export default function CreateOperationModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-b border-gray-200 px-4 py-4 sm:px-6 dark:border-white/10">
|
<div className="border-b border-gray-200 bg-white/90 px-4 py-3 sm:px-6 dark:border-white/10 dark:bg-gray-900/90">
|
||||||
<nav aria-label="Einrichtungsschritte" className="grid grid-cols-2 gap-2 md:grid-cols-7">
|
<nav aria-label="Einrichtungsschritte" className="overflow-x-auto pb-1">
|
||||||
{setupSteps.map((step, index) => {
|
<div className="grid min-w-[52rem] grid-cols-7 gap-2">
|
||||||
|
{setupSteps.map((step, index) => {
|
||||||
const isActive = step.id === activeStep
|
const isActive = step.id === activeStep
|
||||||
const isDone = index < activeStepIndex
|
const isDone = index < activeStepIndex
|
||||||
const Icon = step.icon
|
const Icon = step.icon
|
||||||
@ -1135,12 +896,12 @@ export default function CreateOperationModal({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => goToStep(step.id)}
|
onClick={() => goToStep(step.id)}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'rounded-lg border px-3 py-2 text-left transition',
|
'rounded-xl border px-3 py-2.5 text-left transition',
|
||||||
isActive
|
isActive
|
||||||
? 'border-indigo-600 bg-indigo-50 text-indigo-700 dark:border-indigo-400 dark:bg-indigo-500/10 dark:text-indigo-300'
|
? 'border-indigo-500 bg-indigo-50 text-indigo-700 shadow-sm ring-1 ring-indigo-500/10 dark:border-indigo-400/50 dark:bg-indigo-500/15 dark:text-indigo-200'
|
||||||
: isDone
|
: isDone
|
||||||
? 'border-green-200 bg-green-50 text-green-700 dark:border-green-500/20 dark:bg-green-500/10 dark:text-green-300'
|
? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300'
|
||||||
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50 dark:border-white/10 dark:bg-white/5 dark:text-gray-300 dark:hover:bg-white/10',
|
: 'border-transparent bg-gray-100/70 text-gray-600 hover:border-gray-200 hover:bg-white dark:bg-white/5 dark:text-gray-400 dark:hover:border-white/10 dark:hover:bg-white/10',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-x-1.5 text-[11px] font-medium">
|
<span className="flex items-center gap-x-1.5 text-[11px] font-medium">
|
||||||
@ -1153,17 +914,18 @@ export default function CreateOperationModal({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{combinedError && (
|
{combinedError && (
|
||||||
<div className="mx-4 mt-4 rounded-md bg-red-50 p-4 text-sm text-red-700 sm:mx-6 dark:bg-red-900/30 dark:text-red-300">
|
<div className="mx-4 mt-4 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 sm:mx-6 dark:border-red-500/20 dark:bg-red-950/30 dark:text-red-300">
|
||||||
{combinedError}
|
{combinedError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-6 sm:px-6">
|
<div className="min-h-0 flex-1 overflow-y-auto bg-gray-50/70 px-4 py-6 sm:px-6 dark:bg-gray-950/40">
|
||||||
{activeStep === 'masterData' && (
|
{activeStep === 'masterData' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<StepHeader
|
<StepHeader
|
||||||
@ -1231,14 +993,18 @@ export default function CreateOperationModal({
|
|||||||
description="KW und Zielobjekt werden mit AddressCombobox und Karten-Vorschau erfasst."
|
description="KW und Zielobjekt werden mit AddressCombobox und Karten-Vorschau erfasst."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 xl:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<AddressCombobox
|
<AddressCombobox
|
||||||
id="createKwAddress"
|
id="createKwAddress"
|
||||||
name="createKwAddressDisplay"
|
name="createKwAddressDisplay"
|
||||||
label="KW"
|
label="KW"
|
||||||
value={formValues.kwAddress}
|
value={formValues.kwAddress}
|
||||||
onChange={(value) => updateField('kwAddress', value)}
|
onChange={(value) => updateField('kwAddress', value)}
|
||||||
|
position={kwPosition}
|
||||||
|
onPositionChange={setKwPosition}
|
||||||
|
onAreaChange={setKwArea}
|
||||||
placeholder="KW-Adresse suchen oder eingeben"
|
placeholder="KW-Adresse suchen oder eingeben"
|
||||||
|
showMap={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddressCombobox
|
<AddressCombobox
|
||||||
@ -1247,9 +1013,27 @@ export default function CreateOperationModal({
|
|||||||
label="Zielobjekt"
|
label="Zielobjekt"
|
||||||
value={formValues.targetObject}
|
value={formValues.targetObject}
|
||||||
onChange={(value) => updateField('targetObject', value)}
|
onChange={(value) => updateField('targetObject', value)}
|
||||||
|
position={targetPosition}
|
||||||
|
onPositionChange={setTargetPosition}
|
||||||
|
onAreaChange={setTargetArea}
|
||||||
placeholder="Adresse suchen oder Zielobjekt eingeben"
|
placeholder="Adresse suchen oder Zielobjekt eingeben"
|
||||||
|
showMap={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<OperationGeometryEditor
|
||||||
|
kwPosition={kwPosition}
|
||||||
|
targetPosition={targetPosition}
|
||||||
|
kwArea={kwArea}
|
||||||
|
targetArea={targetArea}
|
||||||
|
fov={viewConeFov}
|
||||||
|
length={viewConeLength}
|
||||||
|
visible={activeStep === 'addresses'}
|
||||||
|
onKwPositionChange={setKwPosition}
|
||||||
|
onTargetPositionChange={setTargetPosition}
|
||||||
|
onFovChange={setViewConeFov}
|
||||||
|
onLengthChange={setViewConeLength}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -1775,16 +1559,28 @@ export default function CreateOperationModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<ReviewAddressItem
|
<ReviewItem label="KW" value={formValues.kwAddress} />
|
||||||
label="KW"
|
<ReviewItem
|
||||||
value={formValues.kwAddress}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ReviewAddressItem
|
|
||||||
label="Zielobjekt"
|
label="Zielobjekt"
|
||||||
value={formValues.targetObject}
|
value={formValues.targetObject}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<OperationGeometryEditor
|
||||||
|
kwPosition={kwPosition}
|
||||||
|
targetPosition={targetPosition}
|
||||||
|
kwArea={kwArea}
|
||||||
|
targetArea={targetArea}
|
||||||
|
fov={viewConeFov}
|
||||||
|
length={viewConeLength}
|
||||||
|
visible={activeStep === 'review'}
|
||||||
|
onKwPositionChange={setKwPosition}
|
||||||
|
onTargetPositionChange={setTargetPosition}
|
||||||
|
onFovChange={setViewConeFov}
|
||||||
|
onLengthChange={setViewConeLength}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -1855,9 +1651,18 @@ export default function CreateOperationModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 border-t border-gray-200 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6 dark:border-white/10">
|
<div className="flex flex-col gap-3 border-t border-gray-200 bg-white/90 px-4 py-4 backdrop-blur sm:flex-row sm:items-center sm:justify-between sm:px-6 dark:border-white/10 dark:bg-gray-900/90">
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="min-w-36">
|
||||||
Schritt {activeStepIndex + 1} von {setupSteps.length}
|
<div className="flex items-center justify-between gap-3 text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
<span>Schritt {activeStepIndex + 1} von {setupSteps.length}</span>
|
||||||
|
<span>{Math.round(((activeStepIndex + 1) / setupSteps.length) * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-gradient-to-r from-indigo-500 to-violet-500 transition-all"
|
||||||
|
style={{ width: `${((activeStepIndex + 1) / setupSteps.length) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-x-3">
|
<div className="flex justify-end gap-x-3">
|
||||||
@ -1922,4 +1727,4 @@ export default function CreateOperationModal({
|
|||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
858
frontend/src/pages/operations/OperationGeometryEditor.tsx
Normal file
858
frontend/src/pages/operations/OperationGeometryEditor.tsx
Normal file
@ -0,0 +1,858 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
|
import {
|
||||||
|
Circle,
|
||||||
|
GeoJSON,
|
||||||
|
MapContainer,
|
||||||
|
Marker,
|
||||||
|
Polygon,
|
||||||
|
Polyline,
|
||||||
|
TileLayer,
|
||||||
|
Tooltip,
|
||||||
|
useMap,
|
||||||
|
} from 'react-leaflet'
|
||||||
|
import type {
|
||||||
|
Feature,
|
||||||
|
FeatureCollection,
|
||||||
|
GeoJsonObject,
|
||||||
|
Geometry,
|
||||||
|
GeometryCollection,
|
||||||
|
MultiPolygon,
|
||||||
|
Polygon as GeoJsonPolygon,
|
||||||
|
} from 'geojson'
|
||||||
|
import L from 'leaflet'
|
||||||
|
import 'leaflet/dist/leaflet.css'
|
||||||
|
import type { AddressArea } from '../../components/AddressCombobox'
|
||||||
|
import {
|
||||||
|
createOperationViewCone,
|
||||||
|
getOperationBearingDegrees,
|
||||||
|
getOperationDestinationPoint,
|
||||||
|
getOperationDistanceMeters,
|
||||||
|
getOperationSignedAngleDifferenceDegrees,
|
||||||
|
isValidOperationCoordinates,
|
||||||
|
type OperationCoordinates,
|
||||||
|
} from '../../utils/operationGeometry'
|
||||||
|
|
||||||
|
type MarkerType = 'kw' | 'target'
|
||||||
|
|
||||||
|
type OperationGeometryEditorProps = {
|
||||||
|
kwPosition: OperationCoordinates | null
|
||||||
|
targetPosition: OperationCoordinates | null
|
||||||
|
kwArea: AddressArea | null
|
||||||
|
targetArea: AddressArea | null
|
||||||
|
fov: number
|
||||||
|
length: number
|
||||||
|
visible?: boolean
|
||||||
|
onKwPositionChange: (position: OperationCoordinates) => void
|
||||||
|
onTargetPositionChange: (position: OperationCoordinates) => void
|
||||||
|
onFovChange: (value: number) => void
|
||||||
|
onLengthChange: (value: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type PolygonCoordinates = GeoJsonPolygon['coordinates']
|
||||||
|
|
||||||
|
const defaultCenter: OperationCoordinates = {
|
||||||
|
lat: 51.1657,
|
||||||
|
lon: 10.4515,
|
||||||
|
}
|
||||||
|
|
||||||
|
const addressFallbackRadiusMeters = 60
|
||||||
|
|
||||||
|
function createMarkerIcon(color: string, label: string) {
|
||||||
|
return L.divIcon({
|
||||||
|
className: '',
|
||||||
|
iconSize: [36, 42],
|
||||||
|
iconAnchor: [18, 42],
|
||||||
|
tooltipAnchor: [0, -38],
|
||||||
|
html: `
|
||||||
|
<span style="
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:center;
|
||||||
|
width:36px;
|
||||||
|
height:36px;
|
||||||
|
border-radius:9999px 9999px 9999px 0;
|
||||||
|
transform:rotate(-45deg);
|
||||||
|
background:${color};
|
||||||
|
border:3px solid white;
|
||||||
|
box-shadow:0 8px 18px rgb(15 23 42 / .28);
|
||||||
|
">
|
||||||
|
<strong style="
|
||||||
|
transform:rotate(45deg);
|
||||||
|
color:white;
|
||||||
|
font:700 11px/1 system-ui,sans-serif;
|
||||||
|
">${label}</strong>
|
||||||
|
</span>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createControlHandleIcon(color: string, size: number) {
|
||||||
|
return L.divIcon({
|
||||||
|
className: '',
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size / 2, size / 2],
|
||||||
|
html: `
|
||||||
|
<span style="
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:center;
|
||||||
|
width:${size}px;
|
||||||
|
height:${size}px;
|
||||||
|
border-radius:9999px;
|
||||||
|
background:white;
|
||||||
|
border:3px solid ${color};
|
||||||
|
box-shadow:0 4px 12px rgb(15 23 42 / .3);
|
||||||
|
cursor:grab;
|
||||||
|
">
|
||||||
|
<span style="
|
||||||
|
width:6px;
|
||||||
|
height:6px;
|
||||||
|
border-radius:9999px;
|
||||||
|
background:${color};
|
||||||
|
"></span>
|
||||||
|
</span>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const kwIcon = createMarkerIcon('#4f46e5', 'KW')
|
||||||
|
const targetIcon = createMarkerIcon('#e11d48', 'ZO')
|
||||||
|
const fovHandleIcon = createControlHandleIcon('#7c3aed', 20)
|
||||||
|
const lengthHandleIcon = createControlHandleIcon('#4f46e5', 24)
|
||||||
|
|
||||||
|
function collectPolygonCoordinates(
|
||||||
|
value: GeoJsonObject | Geometry | null | undefined,
|
||||||
|
result: PolygonCoordinates[],
|
||||||
|
) {
|
||||||
|
if (!value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.type === 'Polygon') {
|
||||||
|
result.push((value as GeoJsonPolygon).coordinates)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.type === 'MultiPolygon') {
|
||||||
|
result.push(...(value as MultiPolygon).coordinates)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.type === 'Feature') {
|
||||||
|
collectPolygonCoordinates((value as Feature).geometry, result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.type === 'FeatureCollection') {
|
||||||
|
for (const feature of (value as FeatureCollection).features) {
|
||||||
|
collectPolygonCoordinates(feature.geometry, result)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.type === 'GeometryCollection') {
|
||||||
|
for (const geometry of (value as GeometryCollection).geometries) {
|
||||||
|
collectPolygonCoordinates(geometry, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPolygonCoordinates(value: GeoJsonObject | null) {
|
||||||
|
const result: PolygonCoordinates[] = []
|
||||||
|
collectPolygonCoordinates(value, result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPointOnSegment(
|
||||||
|
pointX: number,
|
||||||
|
pointY: number,
|
||||||
|
startX: number,
|
||||||
|
startY: number,
|
||||||
|
endX: number,
|
||||||
|
endY: number,
|
||||||
|
) {
|
||||||
|
const crossProduct =
|
||||||
|
(pointY - startY) * (endX - startX) -
|
||||||
|
(pointX - startX) * (endY - startY)
|
||||||
|
|
||||||
|
if (Math.abs(crossProduct) > 1e-10) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
pointX >= Math.min(startX, endX) - 1e-10 &&
|
||||||
|
pointX <= Math.max(startX, endX) + 1e-10 &&
|
||||||
|
pointY >= Math.min(startY, endY) - 1e-10 &&
|
||||||
|
pointY <= Math.max(startY, endY) + 1e-10
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPointInRing(
|
||||||
|
position: OperationCoordinates,
|
||||||
|
ring: PolygonCoordinates[number],
|
||||||
|
) {
|
||||||
|
let inside = false
|
||||||
|
const pointX = position.lon
|
||||||
|
const pointY = position.lat
|
||||||
|
|
||||||
|
for (
|
||||||
|
let currentIndex = 0, previousIndex = ring.length - 1;
|
||||||
|
currentIndex < ring.length;
|
||||||
|
previousIndex = currentIndex, currentIndex += 1
|
||||||
|
) {
|
||||||
|
const current = ring[currentIndex]
|
||||||
|
const previous = ring[previousIndex]
|
||||||
|
const currentX = current[0]
|
||||||
|
const currentY = current[1]
|
||||||
|
const previousX = previous[0]
|
||||||
|
const previousY = previous[1]
|
||||||
|
|
||||||
|
if (
|
||||||
|
isPointOnSegment(
|
||||||
|
pointX,
|
||||||
|
pointY,
|
||||||
|
previousX,
|
||||||
|
previousY,
|
||||||
|
currentX,
|
||||||
|
currentY,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const intersects =
|
||||||
|
currentY > pointY !== previousY > pointY &&
|
||||||
|
pointX <
|
||||||
|
((previousX - currentX) * (pointY - currentY)) /
|
||||||
|
(previousY - currentY) +
|
||||||
|
currentX
|
||||||
|
|
||||||
|
if (intersects) {
|
||||||
|
inside = !inside
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inside
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPointInPolygon(
|
||||||
|
position: OperationCoordinates,
|
||||||
|
polygon: PolygonCoordinates,
|
||||||
|
) {
|
||||||
|
if (polygon.length === 0 || !isPointInRing(position, polygon[0])) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return !polygon
|
||||||
|
.slice(1)
|
||||||
|
.some((hole) => isPointInRing(position, hole))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAreaPolygons(area: AddressArea | null) {
|
||||||
|
if (!area?.geojson) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return getPolygonCoordinates(area.geojson)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPolygonInteriorPosition(polygon: PolygonCoordinates) {
|
||||||
|
const outerRing = polygon[0]
|
||||||
|
|
||||||
|
if (!outerRing || outerRing.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueCoordinates =
|
||||||
|
outerRing.length > 1 &&
|
||||||
|
outerRing[0][0] === outerRing[outerRing.length - 1][0] &&
|
||||||
|
outerRing[0][1] === outerRing[outerRing.length - 1][1]
|
||||||
|
? outerRing.slice(0, -1)
|
||||||
|
: outerRing
|
||||||
|
const average = uniqueCoordinates.reduce(
|
||||||
|
(result, coordinate) => ({
|
||||||
|
lat: result.lat + coordinate[1],
|
||||||
|
lon: result.lon + coordinate[0],
|
||||||
|
}),
|
||||||
|
{ lat: 0, lon: 0 },
|
||||||
|
)
|
||||||
|
const candidate = {
|
||||||
|
lat: average.lat / uniqueCoordinates.length,
|
||||||
|
lon: average.lon / uniqueCoordinates.length,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPointInPolygon(candidate, polygon)) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundaryPosition = {
|
||||||
|
lat: outerRing[0][1],
|
||||||
|
lon: outerRing[0][0],
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let step = 1; step <= 20; step += 1) {
|
||||||
|
const factor = step / 20
|
||||||
|
const position = {
|
||||||
|
lat:
|
||||||
|
boundaryPosition.lat +
|
||||||
|
(candidate.lat - boundaryPosition.lat) * factor,
|
||||||
|
lon:
|
||||||
|
boundaryPosition.lon +
|
||||||
|
(candidate.lon - boundaryPosition.lon) * factor,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPointInPolygon(position, polygon)) {
|
||||||
|
return position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return boundaryPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialPositionInsideAddressArea(
|
||||||
|
position: OperationCoordinates,
|
||||||
|
area: AddressArea | null,
|
||||||
|
) {
|
||||||
|
if (!area?.hasHouseNumber) {
|
||||||
|
return position
|
||||||
|
}
|
||||||
|
|
||||||
|
const polygons = getAreaPolygons(area)
|
||||||
|
|
||||||
|
if (
|
||||||
|
polygons.length === 0 ||
|
||||||
|
polygons.some((polygon) => isPointInPolygon(position, polygon))
|
||||||
|
) {
|
||||||
|
return position
|
||||||
|
}
|
||||||
|
|
||||||
|
return polygons
|
||||||
|
.map(getPolygonInteriorPosition)
|
||||||
|
.filter(isValidOperationCoordinates)
|
||||||
|
.sort(
|
||||||
|
(first, second) =>
|
||||||
|
getOperationDistanceMeters(position, first) -
|
||||||
|
getOperationDistanceMeters(position, second),
|
||||||
|
)[0] ?? position
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPositionInsideAddressArea(
|
||||||
|
position: OperationCoordinates,
|
||||||
|
area: AddressArea | null,
|
||||||
|
) {
|
||||||
|
if (!area) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!area.hasHouseNumber) {
|
||||||
|
return (
|
||||||
|
getOperationDistanceMeters(area.center, position) <=
|
||||||
|
addressFallbackRadiusMeters
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const polygons = getAreaPolygons(area)
|
||||||
|
|
||||||
|
if (polygons.length > 0) {
|
||||||
|
return polygons.some((polygon) => isPointInPolygon(position, polygon))
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapViewport({
|
||||||
|
kwPosition,
|
||||||
|
targetPosition,
|
||||||
|
visible,
|
||||||
|
}: {
|
||||||
|
kwPosition: OperationCoordinates | null
|
||||||
|
targetPosition: OperationCoordinates | null
|
||||||
|
visible: boolean
|
||||||
|
}) {
|
||||||
|
const map = useMap()
|
||||||
|
const previousPositionCountRef = useRef(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame = window.requestAnimationFrame(() => {
|
||||||
|
map.invalidateSize({ pan: false })
|
||||||
|
|
||||||
|
const positions = [kwPosition, targetPosition].filter(
|
||||||
|
isValidOperationCoordinates,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (positions.length > previousPositionCountRef.current) {
|
||||||
|
if (positions.length === 2) {
|
||||||
|
map.fitBounds(
|
||||||
|
positions.map((position) => [position.lat, position.lon]),
|
||||||
|
{ padding: [64, 64], maxZoom: 19 },
|
||||||
|
)
|
||||||
|
} else if (positions.length === 1) {
|
||||||
|
map.setView([positions[0].lat, positions[0].lon], 18)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousPositionCountRef.current = positions.length
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => window.cancelAnimationFrame(frame)
|
||||||
|
}, [kwPosition, map, targetPosition, visible])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddressAreaOverlay({
|
||||||
|
area,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
area: AddressArea | null
|
||||||
|
color: string
|
||||||
|
}) {
|
||||||
|
if (!area) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
area.hasHouseNumber &&
|
||||||
|
getAreaPolygons(area).length > 0 &&
|
||||||
|
area.geojson
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<GeoJSON
|
||||||
|
key={`${color}-${JSON.stringify(area.geojson)}`}
|
||||||
|
data={area.geojson}
|
||||||
|
interactive={false}
|
||||||
|
style={{
|
||||||
|
color,
|
||||||
|
fillColor: color,
|
||||||
|
fillOpacity: 0.18,
|
||||||
|
opacity: 0.9,
|
||||||
|
weight: 3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!area.hasHouseNumber) {
|
||||||
|
return (
|
||||||
|
<Circle
|
||||||
|
center={[area.center.lat, area.center.lon]}
|
||||||
|
radius={addressFallbackRadiusMeters}
|
||||||
|
interactive={false}
|
||||||
|
pathOptions={{
|
||||||
|
color,
|
||||||
|
fillColor: color,
|
||||||
|
fillOpacity: 0.06,
|
||||||
|
opacity: 0.6,
|
||||||
|
weight: 1.5,
|
||||||
|
dashArray: '5 5',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraggableOperationMarker({
|
||||||
|
type,
|
||||||
|
position,
|
||||||
|
area,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
type: MarkerType
|
||||||
|
position: OperationCoordinates
|
||||||
|
area: AddressArea | null
|
||||||
|
onChange: (type: MarkerType, position: OperationCoordinates) => void
|
||||||
|
}) {
|
||||||
|
const markerRef = useRef<L.Marker | null>(null)
|
||||||
|
const lastValidPositionRef = useRef(position)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const constrainedPosition = getInitialPositionInsideAddressArea(
|
||||||
|
position,
|
||||||
|
area,
|
||||||
|
)
|
||||||
|
|
||||||
|
lastValidPositionRef.current = constrainedPosition
|
||||||
|
markerRef.current?.setLatLng([
|
||||||
|
constrainedPosition.lat,
|
||||||
|
constrainedPosition.lon,
|
||||||
|
])
|
||||||
|
|
||||||
|
if (
|
||||||
|
constrainedPosition.lat !== position.lat ||
|
||||||
|
constrainedPosition.lon !== position.lon
|
||||||
|
) {
|
||||||
|
onChange(type, constrainedPosition)
|
||||||
|
}
|
||||||
|
}, [area, onChange, position, type])
|
||||||
|
|
||||||
|
const eventHandlers = useMemo(() => {
|
||||||
|
function handleDrag() {
|
||||||
|
const marker = markerRef.current
|
||||||
|
const latLng = marker?.getLatLng()
|
||||||
|
|
||||||
|
if (!marker || !latLng) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPosition = {
|
||||||
|
lat: latLng.lat,
|
||||||
|
lon: latLng.lng,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPositionInsideAddressArea(nextPosition, area)) {
|
||||||
|
const previousPosition = lastValidPositionRef.current
|
||||||
|
marker.setLatLng([previousPosition.lat, previousPosition.lon])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastValidPositionRef.current = nextPosition
|
||||||
|
onChange(type, nextPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
drag: handleDrag,
|
||||||
|
dragend: handleDrag,
|
||||||
|
}
|
||||||
|
}, [area, onChange, type])
|
||||||
|
const canDrag =
|
||||||
|
area !== null &&
|
||||||
|
(!area.hasHouseNumber || getAreaPolygons(area).length > 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
ref={markerRef}
|
||||||
|
draggable={canDrag}
|
||||||
|
autoPan
|
||||||
|
position={[position.lat, position.lon]}
|
||||||
|
icon={type === 'kw' ? kwIcon : targetIcon}
|
||||||
|
eventHandlers={eventHandlers}
|
||||||
|
>
|
||||||
|
<Tooltip permanent direction="top">
|
||||||
|
{type === 'kw' ? 'KW' : 'Zielobjekt'}
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConeControlMarker({
|
||||||
|
position,
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
onDrag,
|
||||||
|
}: {
|
||||||
|
position: [number, number]
|
||||||
|
icon: L.DivIcon
|
||||||
|
label: string
|
||||||
|
onDrag: (position: OperationCoordinates, marker: L.Marker) => void
|
||||||
|
}) {
|
||||||
|
const markerRef = useRef<L.Marker | null>(null)
|
||||||
|
const eventHandlers = useMemo(() => {
|
||||||
|
function handleDrag() {
|
||||||
|
const marker = markerRef.current
|
||||||
|
const latLng = marker?.getLatLng()
|
||||||
|
|
||||||
|
if (!marker || !latLng) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onDrag(
|
||||||
|
{
|
||||||
|
lat: latLng.lat,
|
||||||
|
lon: latLng.lng,
|
||||||
|
},
|
||||||
|
marker,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
drag: handleDrag,
|
||||||
|
dragend: handleDrag,
|
||||||
|
}
|
||||||
|
}, [onDrag])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
ref={markerRef}
|
||||||
|
draggable
|
||||||
|
autoPan
|
||||||
|
position={position}
|
||||||
|
icon={icon}
|
||||||
|
eventHandlers={eventHandlers}
|
||||||
|
zIndexOffset={500}
|
||||||
|
>
|
||||||
|
<Tooltip direction="top">{label}</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDistance(value: number) {
|
||||||
|
if (value >= 1000) {
|
||||||
|
return `${(value / 1000).toLocaleString('de-DE', {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})} km`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Math.round(value)} m`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OperationGeometryEditor({
|
||||||
|
kwPosition,
|
||||||
|
targetPosition,
|
||||||
|
kwArea,
|
||||||
|
targetArea,
|
||||||
|
fov,
|
||||||
|
length,
|
||||||
|
visible = true,
|
||||||
|
onKwPositionChange,
|
||||||
|
onTargetPositionChange,
|
||||||
|
onFovChange,
|
||||||
|
onLengthChange,
|
||||||
|
}: OperationGeometryEditorProps) {
|
||||||
|
const validKwPosition = isValidOperationCoordinates(kwPosition)
|
||||||
|
? kwPosition
|
||||||
|
: null
|
||||||
|
const validTargetPosition = isValidOperationCoordinates(targetPosition)
|
||||||
|
? targetPosition
|
||||||
|
: null
|
||||||
|
const geometry =
|
||||||
|
validKwPosition && validTargetPosition
|
||||||
|
? createOperationViewCone({
|
||||||
|
from: validKwPosition,
|
||||||
|
to: validTargetPosition,
|
||||||
|
fov,
|
||||||
|
length,
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
const center = validKwPosition ?? validTargetPosition ?? defaultCenter
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
geometry &&
|
||||||
|
length > 0 &&
|
||||||
|
length > geometry.targetDistance
|
||||||
|
) {
|
||||||
|
onLengthChange(geometry.targetDistance)
|
||||||
|
}
|
||||||
|
}, [geometry, length, onLengthChange])
|
||||||
|
|
||||||
|
function handlePositionChange(
|
||||||
|
type: MarkerType,
|
||||||
|
position: OperationCoordinates,
|
||||||
|
) {
|
||||||
|
onLengthChange(0)
|
||||||
|
|
||||||
|
if (type === 'kw') {
|
||||||
|
onKwPositionChange(position)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onTargetPositionChange(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFovDrag(
|
||||||
|
position: OperationCoordinates,
|
||||||
|
marker: L.Marker,
|
||||||
|
side: -1 | 1,
|
||||||
|
) {
|
||||||
|
if (!geometry || !validKwPosition) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBearing = getOperationBearingDegrees(
|
||||||
|
validKwPosition,
|
||||||
|
position,
|
||||||
|
)
|
||||||
|
const halfAngle = Math.min(
|
||||||
|
90,
|
||||||
|
Math.max(
|
||||||
|
0.5,
|
||||||
|
Math.abs(
|
||||||
|
getOperationSignedAngleDifferenceDegrees(
|
||||||
|
handleBearing,
|
||||||
|
geometry.bearing,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const projectedPosition = getOperationDestinationPoint(
|
||||||
|
validKwPosition,
|
||||||
|
geometry.coneLength,
|
||||||
|
geometry.bearing + side * halfAngle,
|
||||||
|
)
|
||||||
|
|
||||||
|
marker.setLatLng(projectedPosition)
|
||||||
|
onFovChange(halfAngle * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLengthDrag(
|
||||||
|
position: OperationCoordinates,
|
||||||
|
marker: L.Marker,
|
||||||
|
) {
|
||||||
|
if (!geometry || !validKwPosition) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLength = Math.min(
|
||||||
|
geometry.targetDistance,
|
||||||
|
Math.max(1, getOperationDistanceMeters(validKwPosition, position)),
|
||||||
|
)
|
||||||
|
const projectedPosition = getOperationDestinationPoint(
|
||||||
|
validKwPosition,
|
||||||
|
nextLength,
|
||||||
|
geometry.bearing,
|
||||||
|
)
|
||||||
|
|
||||||
|
marker.setLatLng(projectedPosition)
|
||||||
|
onLengthChange(nextLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftCorner = geometry?.positions[1]
|
||||||
|
const rightCorner = geometry?.positions[geometry.positions.length - 2]
|
||||||
|
const centerEndpoint = geometry?.centerLine[1]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/70">
|
||||||
|
<div className="border-b border-gray-200 bg-gray-50/80 px-4 py-3 dark:border-white/10 dark:bg-white/[0.03]">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-950 dark:text-white">
|
||||||
|
Position und Sichtwinkel
|
||||||
|
</h4>
|
||||||
|
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
KW und ZO innerhalb ihrer markierten Adressfläche ziehen. Die
|
||||||
|
Eckgriffe ändern das FOV, der mittlere Griff die Länge.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-0 h-96">
|
||||||
|
<MapContainer
|
||||||
|
center={[center.lat, center.lon]}
|
||||||
|
zoom={validKwPosition || validTargetPosition ? 18 : 6}
|
||||||
|
scrollWheelZoom
|
||||||
|
className="h-full w-full"
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution="© OpenStreetMap"
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MapViewport
|
||||||
|
kwPosition={validKwPosition}
|
||||||
|
targetPosition={validTargetPosition}
|
||||||
|
visible={visible}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddressAreaOverlay area={kwArea} color="#4f46e5" />
|
||||||
|
<AddressAreaOverlay area={targetArea} color="#e11d48" />
|
||||||
|
|
||||||
|
{geometry && (
|
||||||
|
<>
|
||||||
|
<Polygon
|
||||||
|
positions={geometry.positions}
|
||||||
|
interactive={false}
|
||||||
|
pathOptions={{
|
||||||
|
color: '#4f46e5',
|
||||||
|
fillColor: '#6366f1',
|
||||||
|
fillOpacity: 0.18,
|
||||||
|
opacity: 0.9,
|
||||||
|
weight: 1.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Polyline
|
||||||
|
positions={geometry.centerLine}
|
||||||
|
interactive={false}
|
||||||
|
pathOptions={{
|
||||||
|
color: '#4f46e5',
|
||||||
|
opacity: 0.95,
|
||||||
|
weight: 2.5,
|
||||||
|
dashArray: '7 6',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Polyline
|
||||||
|
positions={geometry.targetLine}
|
||||||
|
interactive={false}
|
||||||
|
pathOptions={{
|
||||||
|
color: '#e11d48',
|
||||||
|
opacity: 0.7,
|
||||||
|
weight: 1.5,
|
||||||
|
dashArray: '3 7',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{validKwPosition && (
|
||||||
|
<DraggableOperationMarker
|
||||||
|
type="kw"
|
||||||
|
position={validKwPosition}
|
||||||
|
area={kwArea}
|
||||||
|
onChange={handlePositionChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{validTargetPosition && (
|
||||||
|
<DraggableOperationMarker
|
||||||
|
type="target"
|
||||||
|
position={validTargetPosition}
|
||||||
|
area={targetArea}
|
||||||
|
onChange={handlePositionChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{geometry && leftCorner && (
|
||||||
|
<ConeControlMarker
|
||||||
|
position={leftCorner}
|
||||||
|
icon={fovHandleIcon}
|
||||||
|
label="Linke Kegelkante ziehen"
|
||||||
|
onDrag={(position, marker) =>
|
||||||
|
handleFovDrag(position, marker, -1)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{geometry && rightCorner && (
|
||||||
|
<ConeControlMarker
|
||||||
|
position={rightCorner}
|
||||||
|
icon={fovHandleIcon}
|
||||||
|
label="Rechte Kegelkante ziehen"
|
||||||
|
onDrag={(position, marker) =>
|
||||||
|
handleFovDrag(position, marker, 1)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{geometry && centerEndpoint && (
|
||||||
|
<ConeControlMarker
|
||||||
|
position={centerEndpoint}
|
||||||
|
icon={lengthHandleIcon}
|
||||||
|
label="Kegellänge ziehen"
|
||||||
|
onDrag={handleLengthDrag}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-gray-200 px-4 py-3 text-xs text-gray-500 dark:border-white/10 dark:text-gray-400">
|
||||||
|
<span>
|
||||||
|
FOV:{' '}
|
||||||
|
<strong className="font-semibold text-gray-800 dark:text-gray-200">
|
||||||
|
{Math.round(fov)}°
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Kegellänge:{' '}
|
||||||
|
<strong className="font-semibold text-gray-800 dark:text-gray-200">
|
||||||
|
{geometry ? formatDistance(geometry.coneLength) : '–'}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
KW–ZO:{' '}
|
||||||
|
<strong className="font-semibold text-gray-800 dark:text-gray-200">
|
||||||
|
{geometry ? formatDistance(geometry.targetDistance) : '–'}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -8,6 +8,11 @@ import AppMap, { type MapHeatPoint, type MapMarker } from '../../components/Map'
|
|||||||
import Button from '../../components/Button'
|
import Button from '../../components/Button'
|
||||||
import LoadingSpinner from '../../components/LoadingSpinner'
|
import LoadingSpinner from '../../components/LoadingSpinner'
|
||||||
import type { Operation } from '../../components/types'
|
import type { Operation } from '../../components/types'
|
||||||
|
import {
|
||||||
|
createOperationViewCone,
|
||||||
|
isValidOperationCoordinates,
|
||||||
|
type OperationCoordinates,
|
||||||
|
} from '../../utils/operationGeometry'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
@ -21,8 +26,6 @@ type AddressSuggestion = {
|
|||||||
|
|
||||||
type OperationMarkerType = 'kwAddress' | 'targetObject'
|
type OperationMarkerType = 'kwAddress' | 'targetObject'
|
||||||
|
|
||||||
type OperationMapMode = 'markers' | 'heatmap'
|
|
||||||
|
|
||||||
type OperationMarker = {
|
type OperationMarker = {
|
||||||
id: string
|
id: string
|
||||||
operation: Operation
|
operation: Operation
|
||||||
@ -40,6 +43,7 @@ type OperationViewCone = {
|
|||||||
color: string
|
color: string
|
||||||
positions: Array<[number, number]>
|
positions: Array<[number, number]>
|
||||||
centerLine: [[number, number], [number, number]]
|
centerLine: [[number, number], [number, number]]
|
||||||
|
targetLine: [[number, number], [number, number]]
|
||||||
}
|
}
|
||||||
|
|
||||||
type OperationMapProps = {
|
type OperationMapProps = {
|
||||||
@ -218,22 +222,40 @@ function getOperationAddresses(operation: Operation) {
|
|||||||
const addresses: Array<{
|
const addresses: Array<{
|
||||||
type: OperationMarkerType
|
type: OperationMarkerType
|
||||||
address: string
|
address: string
|
||||||
|
position: OperationCoordinates | null
|
||||||
}> = []
|
}> = []
|
||||||
|
|
||||||
const kwAddress = operation.kwAddress?.trim()
|
const kwAddress = operation.kwAddress?.trim()
|
||||||
const targetObject = operation.targetObject?.trim()
|
const targetObject = operation.targetObject?.trim()
|
||||||
|
|
||||||
if (kwAddress) {
|
const kwPosition =
|
||||||
|
operation.kwLatitude != null && operation.kwLongitude != null
|
||||||
|
? {
|
||||||
|
lat: operation.kwLatitude,
|
||||||
|
lon: operation.kwLongitude,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
const targetPosition =
|
||||||
|
operation.targetLatitude != null && operation.targetLongitude != null
|
||||||
|
? {
|
||||||
|
lat: operation.targetLatitude,
|
||||||
|
lon: operation.targetLongitude,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (kwAddress || isValidOperationCoordinates(kwPosition)) {
|
||||||
addresses.push({
|
addresses.push({
|
||||||
type: 'kwAddress',
|
type: 'kwAddress',
|
||||||
address: kwAddress,
|
address: kwAddress || 'KW',
|
||||||
|
position: kwPosition,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetObject) {
|
if (targetObject || isValidOperationCoordinates(targetPosition)) {
|
||||||
addresses.push({
|
addresses.push({
|
||||||
type: 'targetObject',
|
type: 'targetObject',
|
||||||
address: targetObject,
|
address: targetObject || 'Zielobjekt',
|
||||||
|
position: targetPosition,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -315,110 +337,6 @@ function renderOperationPopup(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toRadians(value: number) {
|
|
||||||
return (value * Math.PI) / 180
|
|
||||||
}
|
|
||||||
|
|
||||||
function toDegrees(value: number) {
|
|
||||||
return (value * 180) / Math.PI
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDistanceMeters(
|
|
||||||
start: { lat: number; lon: number },
|
|
||||||
end: { lat: number; lon: number },
|
|
||||||
) {
|
|
||||||
const earthRadius = 6371000
|
|
||||||
|
|
||||||
const startLat = toRadians(start.lat)
|
|
||||||
const endLat = toRadians(end.lat)
|
|
||||||
const deltaLat = toRadians(end.lat - start.lat)
|
|
||||||
const deltaLon = toRadians(end.lon - start.lon)
|
|
||||||
|
|
||||||
const a =
|
|
||||||
Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
|
|
||||||
Math.cos(startLat) *
|
|
||||||
Math.cos(endLat) *
|
|
||||||
Math.sin(deltaLon / 2) *
|
|
||||||
Math.sin(deltaLon / 2)
|
|
||||||
|
|
||||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
|
||||||
|
|
||||||
return earthRadius * c
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBearingDegrees(
|
|
||||||
start: { lat: number; lon: number },
|
|
||||||
end: { lat: number; lon: number },
|
|
||||||
) {
|
|
||||||
const startLat = toRadians(start.lat)
|
|
||||||
const endLat = toRadians(end.lat)
|
|
||||||
const deltaLon = toRadians(end.lon - start.lon)
|
|
||||||
|
|
||||||
const y = Math.sin(deltaLon) * Math.cos(endLat)
|
|
||||||
const x =
|
|
||||||
Math.cos(startLat) * Math.sin(endLat) -
|
|
||||||
Math.sin(startLat) * Math.cos(endLat) * Math.cos(deltaLon)
|
|
||||||
|
|
||||||
return (toDegrees(Math.atan2(y, x)) + 360) % 360
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDestinationPoint(
|
|
||||||
start: { lat: number; lon: number },
|
|
||||||
distanceMeters: number,
|
|
||||||
bearingDegrees: number,
|
|
||||||
): [number, number] {
|
|
||||||
const earthRadius = 6371000
|
|
||||||
|
|
||||||
const bearing = toRadians(bearingDegrees)
|
|
||||||
const startLat = toRadians(start.lat)
|
|
||||||
const startLon = toRadians(start.lon)
|
|
||||||
const angularDistance = distanceMeters / earthRadius
|
|
||||||
|
|
||||||
const lat = Math.asin(
|
|
||||||
Math.sin(startLat) * Math.cos(angularDistance) +
|
|
||||||
Math.cos(startLat) * Math.sin(angularDistance) * Math.cos(bearing),
|
|
||||||
)
|
|
||||||
|
|
||||||
const lon =
|
|
||||||
startLon +
|
|
||||||
Math.atan2(
|
|
||||||
Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(startLat),
|
|
||||||
Math.cos(angularDistance) - Math.sin(startLat) * Math.sin(lat),
|
|
||||||
)
|
|
||||||
|
|
||||||
return [toDegrees(lat), ((toDegrees(lon) + 540) % 360) - 180]
|
|
||||||
}
|
|
||||||
|
|
||||||
function createViewConePositions({
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
halfAngle = 18,
|
|
||||||
steps = 16,
|
|
||||||
}: {
|
|
||||||
from: { lat: number; lon: number }
|
|
||||||
to: { lat: number; lon: number }
|
|
||||||
halfAngle?: number
|
|
||||||
steps?: number
|
|
||||||
}) {
|
|
||||||
const distance = getDistanceMeters(from, to)
|
|
||||||
|
|
||||||
if (distance < 2) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const bearing = getBearingDegrees(from, to)
|
|
||||||
const positions: Array<[number, number]> = [[from.lat, from.lon]]
|
|
||||||
|
|
||||||
for (let step = 0; step <= steps; step += 1) {
|
|
||||||
const angle = bearing - halfAngle + (halfAngle * 2 * step) / steps
|
|
||||||
positions.push(getDestinationPoint(from, distance, angle))
|
|
||||||
}
|
|
||||||
|
|
||||||
positions.push([from.lat, from.lon])
|
|
||||||
|
|
||||||
return positions
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOperationViewCones(markers: OperationMarker[]) {
|
function getOperationViewCones(markers: OperationMarker[]) {
|
||||||
const markersByOperation = new globalThis.Map<string, OperationMarker[]>()
|
const markersByOperation = new globalThis.Map<string, OperationMarker[]>()
|
||||||
|
|
||||||
@ -438,12 +356,14 @@ function getOperationViewCones(markers: OperationMarker[]) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const positions = createViewConePositions({
|
const geometry = createOperationViewCone({
|
||||||
from: kwMarker,
|
from: kwMarker,
|
||||||
to: targetMarker,
|
to: targetMarker,
|
||||||
|
fov: kwMarker.operation.viewConeFov || 36,
|
||||||
|
length: kwMarker.operation.viewConeLength || 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (positions.length === 0) {
|
if (!geometry) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -451,11 +371,9 @@ function getOperationViewCones(markers: OperationMarker[]) {
|
|||||||
id: `${operationId}-view-cone`,
|
id: `${operationId}-view-cone`,
|
||||||
operation: kwMarker.operation,
|
operation: kwMarker.operation,
|
||||||
color: kwMarker.color,
|
color: kwMarker.color,
|
||||||
positions,
|
positions: geometry.positions,
|
||||||
centerLine: [
|
centerLine: geometry.centerLine,
|
||||||
[kwMarker.lat, kwMarker.lon],
|
targetLine: geometry.targetLine,
|
||||||
[targetMarker.lat, targetMarker.lon],
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -503,7 +421,12 @@ export default function OperationMap({
|
|||||||
new Set(
|
new Set(
|
||||||
operationsWithAddresses
|
operationsWithAddresses
|
||||||
.flatMap((operation) =>
|
.flatMap((operation) =>
|
||||||
getOperationAddresses(operation).map((item) => item.address),
|
getOperationAddresses(operation)
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
!isValidOperationCoordinates(item.position),
|
||||||
|
)
|
||||||
|
.map((item) => item.address),
|
||||||
)
|
)
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
),
|
),
|
||||||
@ -537,7 +460,15 @@ export default function OperationMap({
|
|||||||
const nextMarkers = operationsWithAddresses
|
const nextMarkers = operationsWithAddresses
|
||||||
.flatMap((operation) =>
|
.flatMap((operation) =>
|
||||||
getOperationAddresses(operation).map((addressItem) => {
|
getOperationAddresses(operation).map((addressItem) => {
|
||||||
const suggestion = geocodedByAddress.get(addressItem.address)
|
const suggestion = isValidOperationCoordinates(
|
||||||
|
addressItem.position,
|
||||||
|
)
|
||||||
|
? {
|
||||||
|
lat: addressItem.position.lat,
|
||||||
|
lon: addressItem.position.lon,
|
||||||
|
label: addressItem.address,
|
||||||
|
}
|
||||||
|
: geocodedByAddress.get(addressItem.address)
|
||||||
|
|
||||||
if (!suggestion) {
|
if (!suggestion) {
|
||||||
return null
|
return null
|
||||||
@ -713,8 +644,21 @@ export default function OperationMap({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{visibleViewCones.map((cone) => (
|
||||||
|
<Polyline
|
||||||
|
key={`${cone.id}-target-line`}
|
||||||
|
positions={cone.targetLine}
|
||||||
|
pathOptions={{
|
||||||
|
color: cone.color,
|
||||||
|
opacity: 0.55,
|
||||||
|
weight: 1.5,
|
||||||
|
dashArray: '3 8',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</AppMap>
|
</AppMap>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,13 +28,17 @@ import Modal from '../../components/Modal'
|
|||||||
import Journal, { type JournalItem } from '../../components/Journal'
|
import Journal, { type JournalItem } from '../../components/Journal'
|
||||||
import Button from '../../components/Button'
|
import Button from '../../components/Button'
|
||||||
import LoadingSpinner from '../../components/LoadingSpinner'
|
import LoadingSpinner from '../../components/LoadingSpinner'
|
||||||
import AddressCombobox from '../../components/AddressCombobox'
|
import AddressCombobox, {
|
||||||
import type { Operation, OperationHistoryChange, OperationHistoryEntry } from '../../components/types'
|
type AddressArea,
|
||||||
|
} from '../../components/AddressCombobox'
|
||||||
|
import type { Operation, OperationHistoryEntry } from '../../components/types'
|
||||||
import { formatOperationNumberInput } from '../../components/formatter'
|
import { formatOperationNumberInput } from '../../components/formatter'
|
||||||
import NotificationDot from '../../components/NotificationDot'
|
import NotificationDot from '../../components/NotificationDot'
|
||||||
import Combobox, { type ComboboxItem } from '../../components/Combobox'
|
import Combobox, { type ComboboxItem } from '../../components/Combobox'
|
||||||
import MultiCombobox from '../../components/MultiCombobox'
|
import MultiCombobox from '../../components/MultiCombobox'
|
||||||
import Tabs from '../../components/Tabs'
|
import Tabs from '../../components/Tabs'
|
||||||
|
import type { OperationCoordinates } from '../../utils/operationGeometry'
|
||||||
|
import OperationGeometryEditor from './OperationGeometryEditor'
|
||||||
|
|
||||||
type OperationModalProps = {
|
type OperationModalProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
@ -65,10 +69,10 @@ type OperationTabId =
|
|||||||
type OperationTabIcon = ComponentType<SVGProps<SVGSVGElement>>
|
type OperationTabIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||||
|
|
||||||
const inputClassName =
|
const inputClassName =
|
||||||
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
|
'block w-full rounded-xl border-0 bg-gray-50 px-3.5 py-2 text-base text-gray-900 shadow-sm ring-1 ring-gray-200 ring-inset placeholder:text-gray-400 transition focus:bg-white focus:ring-2 focus:ring-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:ring-white/10 dark:placeholder:text-gray-500 dark:focus:bg-white/10 dark:focus:ring-indigo-400'
|
||||||
|
|
||||||
const sectionClassName =
|
const sectionClassName =
|
||||||
'rounded-lg border border-gray-200 bg-white p-4 shadow-xs dark:border-white/10 dark:bg-white/5'
|
'rounded-2xl border border-gray-200/80 bg-white p-5 shadow-sm ring-1 ring-black/[0.02] dark:border-white/10 dark:bg-gray-900/70 dark:ring-white/5'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
const OPERATION_UNIT = 'TEG'
|
const OPERATION_UNIT = 'TEG'
|
||||||
@ -354,8 +358,9 @@ function SectionHeader({
|
|||||||
description?: string
|
description?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-gray-200 pb-3 dark:border-white/10">
|
<div className="relative border-b border-gray-200 pb-4 pl-3 dark:border-white/10">
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
<span className="absolute inset-y-0 left-0 w-1 rounded-full bg-indigo-500" />
|
||||||
|
<h3 className="text-sm font-semibold text-gray-950 dark:text-white">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@ -415,6 +420,12 @@ export default function OperationModal({
|
|||||||
const [activeTab, setActiveTab] = useState<OperationTabId>('masterData')
|
const [activeTab, setActiveTab] = useState<OperationTabId>('masterData')
|
||||||
const [targetObject, setTargetObject] = useState('')
|
const [targetObject, setTargetObject] = useState('')
|
||||||
const [kwAddress, setKwAddress] = useState('')
|
const [kwAddress, setKwAddress] = useState('')
|
||||||
|
const [kwPosition, setKwPosition] = useState<OperationCoordinates | null>(null)
|
||||||
|
const [targetPosition, setTargetPosition] = useState<OperationCoordinates | null>(null)
|
||||||
|
const [kwArea, setKwArea] = useState<AddressArea | null>(null)
|
||||||
|
const [targetArea, setTargetArea] = useState<AddressArea | null>(null)
|
||||||
|
const [viewConeFov, setViewConeFov] = useState(36)
|
||||||
|
const [viewConeLength, setViewConeLength] = useState(0)
|
||||||
|
|
||||||
const [operationLeaderOptions, setOperationLeaderOptions] = useState<ComboboxItem[]>([])
|
const [operationLeaderOptions, setOperationLeaderOptions] = useState<ComboboxItem[]>([])
|
||||||
const [operationTeamOptions, setOperationTeamOptions] = useState<ComboboxItem[]>([])
|
const [operationTeamOptions, setOperationTeamOptions] = useState<ComboboxItem[]>([])
|
||||||
@ -458,8 +469,30 @@ export default function OperationModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setActiveTab('masterData')
|
setActiveTab('masterData')
|
||||||
|
setKwArea(null)
|
||||||
|
setTargetArea(null)
|
||||||
setTargetObject(editingOperation?.targetObject ?? '')
|
setTargetObject(editingOperation?.targetObject ?? '')
|
||||||
setKwAddress(editingOperation?.kwAddress ?? '')
|
setKwAddress(editingOperation?.kwAddress ?? '')
|
||||||
|
setKwPosition(
|
||||||
|
editingOperation?.kwLatitude != null &&
|
||||||
|
editingOperation.kwLongitude != null
|
||||||
|
? {
|
||||||
|
lat: editingOperation.kwLatitude,
|
||||||
|
lon: editingOperation.kwLongitude,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
setTargetPosition(
|
||||||
|
editingOperation?.targetLatitude != null &&
|
||||||
|
editingOperation.targetLongitude != null
|
||||||
|
? {
|
||||||
|
lat: editingOperation.targetLatitude,
|
||||||
|
lon: editingOperation.targetLongitude,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
setViewConeFov(editingOperation?.viewConeFov || 36)
|
||||||
|
setViewConeLength(editingOperation?.viewConeLength || 0)
|
||||||
setMilestoneStorages([])
|
setMilestoneStorages([])
|
||||||
setSelectedMilestoneStorage(null)
|
setSelectedMilestoneStorage(null)
|
||||||
setShowCreateStorageForm(false)
|
setShowCreateStorageForm(false)
|
||||||
@ -471,6 +504,12 @@ export default function OperationModal({
|
|||||||
editingOperation?.id,
|
editingOperation?.id,
|
||||||
editingOperation?.targetObject,
|
editingOperation?.targetObject,
|
||||||
editingOperation?.kwAddress,
|
editingOperation?.kwAddress,
|
||||||
|
editingOperation?.kwLatitude,
|
||||||
|
editingOperation?.kwLongitude,
|
||||||
|
editingOperation?.targetLatitude,
|
||||||
|
editingOperation?.targetLongitude,
|
||||||
|
editingOperation?.viewConeFov,
|
||||||
|
editingOperation?.viewConeLength,
|
||||||
])
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -856,7 +895,7 @@ export default function OperationModal({
|
|||||||
open={open}
|
open={open}
|
||||||
setOpen={handleOpenChange}
|
setOpen={handleOpenChange}
|
||||||
zIndexClassName="z-[9999]"
|
zIndexClassName="z-[9999]"
|
||||||
panelClassName="max-h-[calc(100dvh-2rem)] max-w-4xl bg-white dark:bg-gray-900"
|
panelClassName="max-h-[calc(100dvh-2rem)] max-w-4xl rounded-3xl bg-white ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10"
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
key={editingOperation?.id ?? 'create'}
|
key={editingOperation?.id ?? 'create'}
|
||||||
@ -898,18 +937,30 @@ export default function OperationModal({
|
|||||||
name="milestoneStorage"
|
name="milestoneStorage"
|
||||||
value={selectedMilestoneStorage ? (selectedMilestoneStorage.displayName || selectedMilestoneStorage.name) : ''}
|
value={selectedMilestoneStorage ? (selectedMilestoneStorage.displayName || selectedMilestoneStorage.name) : ''}
|
||||||
/>
|
/>
|
||||||
|
<input type="hidden" name="kwLatitude" value={kwPosition?.lat ?? ''} />
|
||||||
|
<input type="hidden" name="kwLongitude" value={kwPosition?.lon ?? ''} />
|
||||||
|
<input type="hidden" name="targetLatitude" value={targetPosition?.lat ?? ''} />
|
||||||
|
<input type="hidden" name="targetLongitude" value={targetPosition?.lon ?? ''} />
|
||||||
|
<input type="hidden" name="viewConeFov" value={viewConeFov} />
|
||||||
|
<input type="hidden" name="viewConeLength" value={viewConeLength} />
|
||||||
|
|
||||||
<div className="flex items-start justify-between border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
|
<div className="relative flex items-start justify-between overflow-hidden border-b border-indigo-100 bg-gradient-to-r from-indigo-50 via-white to-violet-50 px-4 py-5 sm:px-6 dark:border-white/10 dark:from-indigo-950/50 dark:via-gray-900 dark:to-violet-950/30">
|
||||||
<div>
|
<div className="flex min-w-0 items-start gap-3.5">
|
||||||
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
|
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-indigo-600 text-white shadow-lg shadow-indigo-600/20 dark:bg-indigo-500">
|
||||||
|
<ClipboardDocumentListIcon aria-hidden="true" className="size-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<DialogTitle className="text-base font-semibold text-gray-950 dark:text-white">
|
||||||
{isEditing ? 'Einsatz bearbeiten' : 'Einsatz hinzufügen'}
|
{isEditing ? 'Einsatz bearbeiten' : 'Einsatz hinzufügen'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
{isEditing
|
{isEditing
|
||||||
? 'Bearbeite Einsatzdaten, Zuständigkeiten, Zielobjekt und eingesetzte Technik.'
|
? 'Bearbeite Einsatzdaten, Zuständigkeiten, Zielobjekt und eingesetzte Technik.'
|
||||||
: 'Erfasse einen neuen Einsatz inklusive Einsatznummer, Einsatzname und Technik.'}
|
: 'Erfasse einen neuen Einsatz inklusive Einsatznummer, Einsatzname und Technik.'}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@ -924,8 +975,8 @@ export default function OperationModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 border-b border-gray-200 bg-white px-4 sm:px-6 dark:border-white/10 dark:bg-gray-900">
|
<div className="shrink-0 border-b border-gray-200 bg-white/90 px-4 py-3 sm:px-6 dark:border-white/10 dark:bg-gray-900/90">
|
||||||
<div className="-mb-px flex gap-x-6 overflow-x-auto">
|
<div className="flex gap-1.5 overflow-x-auto rounded-2xl bg-gray-100/80 p-1.5 dark:bg-white/5">
|
||||||
{visibleTabs.map((tab) => {
|
{visibleTabs.map((tab) => {
|
||||||
const Icon = tab.icon
|
const Icon = tab.icon
|
||||||
|
|
||||||
@ -941,10 +992,10 @@ export default function OperationModal({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium',
|
'whitespace-nowrap rounded-xl px-3 py-2 text-sm font-medium transition',
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'border-indigo-600 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
? 'bg-white text-indigo-700 shadow-sm ring-1 ring-black/5 dark:bg-white/10 dark:text-indigo-300 dark:ring-white/10'
|
||||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-200',
|
: 'text-gray-500 hover:bg-white/70 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-x-2">
|
<span className="inline-flex items-center gap-x-2">
|
||||||
@ -966,13 +1017,13 @@ export default function OperationModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mx-4 mt-4 rounded-md bg-red-50 p-4 text-sm text-red-700 sm:mx-6 dark:bg-red-900/30 dark:text-red-300">
|
<div className="mx-4 mt-4 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 sm:mx-6 dark:border-red-500/20 dark:bg-red-950/30 dark:text-red-300">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="px-4 py-6 sm:px-6">
|
<div className="min-h-full bg-gray-50/70 px-4 py-6 sm:px-6 dark:bg-gray-950/40">
|
||||||
|
|
||||||
{/* Stammdaten */}
|
{/* Stammdaten */}
|
||||||
<div className={activeTab === 'masterData' ? 'block' : 'hidden'}>
|
<div className={activeTab === 'masterData' ? 'block' : 'hidden'}>
|
||||||
@ -1045,25 +1096,51 @@ export default function OperationModal({
|
|||||||
description="Zielobjekt und KW-Adresse mit Adresssuche und Karten-Vorschau."
|
description="Zielobjekt und KW-Adresse mit Adresssuche und Karten-Vorschau."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-2 gap-4 xl:grid-cols-2">
|
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<AddressCombobox
|
|
||||||
id="targetObject"
|
|
||||||
name="targetObject"
|
|
||||||
label="Zielobjekt"
|
|
||||||
value={targetObject}
|
|
||||||
onChange={setTargetObject}
|
|
||||||
placeholder="Adresse suchen oder Zielobjekt eingeben"
|
|
||||||
mapVisible={activeTab === 'addresses'}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AddressCombobox
|
<AddressCombobox
|
||||||
|
key={`kw-${editingOperation?.id ?? 'new'}-${open}`}
|
||||||
id="kwAddress"
|
id="kwAddress"
|
||||||
name="kwAddress"
|
name="kwAddress"
|
||||||
label="KW"
|
label="KW"
|
||||||
value={kwAddress}
|
value={kwAddress}
|
||||||
onChange={setKwAddress}
|
onChange={setKwAddress}
|
||||||
|
position={kwPosition}
|
||||||
|
onPositionChange={setKwPosition}
|
||||||
|
onAreaChange={setKwArea}
|
||||||
placeholder="KW-Adresse suchen oder eingeben"
|
placeholder="KW-Adresse suchen oder eingeben"
|
||||||
mapVisible={activeTab === 'addresses'}
|
mapVisible={activeTab === 'addresses'}
|
||||||
|
showMap={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddressCombobox
|
||||||
|
key={`target-${editingOperation?.id ?? 'new'}-${open}`}
|
||||||
|
id="targetObject"
|
||||||
|
name="targetObject"
|
||||||
|
label="Zielobjekt"
|
||||||
|
value={targetObject}
|
||||||
|
onChange={setTargetObject}
|
||||||
|
position={targetPosition}
|
||||||
|
onPositionChange={setTargetPosition}
|
||||||
|
onAreaChange={setTargetArea}
|
||||||
|
placeholder="Adresse suchen oder Zielobjekt eingeben"
|
||||||
|
mapVisible={activeTab === 'addresses'}
|
||||||
|
showMap={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<OperationGeometryEditor
|
||||||
|
kwPosition={kwPosition}
|
||||||
|
targetPosition={targetPosition}
|
||||||
|
kwArea={kwArea}
|
||||||
|
targetArea={targetArea}
|
||||||
|
fov={viewConeFov}
|
||||||
|
length={viewConeLength}
|
||||||
|
visible={activeTab === 'addresses'}
|
||||||
|
onKwPositionChange={setKwPosition}
|
||||||
|
onTargetPositionChange={setTargetPosition}
|
||||||
|
onFovChange={setViewConeFov}
|
||||||
|
onLengthChange={setViewConeLength}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -1796,7 +1873,7 @@ export default function OperationModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-x-3 border-t border-gray-200 px-4 py-4 sm:px-6 dark:border-white/10">
|
<div className="flex justify-end gap-x-3 border-t border-gray-200 bg-white/90 px-4 py-4 backdrop-blur sm:px-6 dark:border-white/10 dark:bg-gray-900/90">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|||||||
@ -908,6 +908,15 @@ export default function OperationsPage({
|
|||||||
const form = event.currentTarget
|
const form = event.currentTarget
|
||||||
const formData = new FormData(form)
|
const formData = new FormData(form)
|
||||||
const operationToEdit = editingOperation
|
const operationToEdit = editingOperation
|
||||||
|
const optionalNumber = (name: string) => {
|
||||||
|
const rawValue = String(formData.get(name) ?? '').trim()
|
||||||
|
if (!rawValue) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = Number(rawValue)
|
||||||
|
return Number.isFinite(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
setSaveError(null)
|
setSaveError(null)
|
||||||
@ -919,6 +928,12 @@ export default function OperationsPage({
|
|||||||
caseWorker: String(formData.get('caseWorker') ?? ''),
|
caseWorker: String(formData.get('caseWorker') ?? ''),
|
||||||
targetObject: String(formData.get('targetObject') ?? ''),
|
targetObject: String(formData.get('targetObject') ?? ''),
|
||||||
kwAddress: String(formData.get('kwAddress') ?? ''),
|
kwAddress: String(formData.get('kwAddress') ?? ''),
|
||||||
|
kwLatitude: optionalNumber('kwLatitude'),
|
||||||
|
kwLongitude: optionalNumber('kwLongitude'),
|
||||||
|
targetLatitude: optionalNumber('targetLatitude'),
|
||||||
|
targetLongitude: optionalNumber('targetLongitude'),
|
||||||
|
viewConeFov: optionalNumber('viewConeFov') ?? 36,
|
||||||
|
viewConeLength: optionalNumber('viewConeLength') ?? 0,
|
||||||
operationLeader: String(formData.get('operationLeader') ?? ''),
|
operationLeader: String(formData.get('operationLeader') ?? ''),
|
||||||
operationTeam: String(formData.get('operationTeam') ?? ''),
|
operationTeam: String(formData.get('operationTeam') ?? ''),
|
||||||
legend: String(formData.get('legend') ?? ''),
|
legend: String(formData.get('legend') ?? ''),
|
||||||
@ -929,6 +944,7 @@ export default function OperationsPage({
|
|||||||
hardDrive: String(formData.get('hardDrive') ?? ''),
|
hardDrive: String(formData.get('hardDrive') ?? ''),
|
||||||
laptop: String(formData.get('laptop') ?? ''),
|
laptop: String(formData.get('laptop') ?? ''),
|
||||||
remark: String(formData.get('remark') ?? ''),
|
remark: String(formData.get('remark') ?? ''),
|
||||||
|
milestoneStorage: String(formData.get('milestoneStorage') ?? ''),
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -1934,4 +1950,4 @@ export default function OperationsPage({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,8 +4,7 @@ import type { ReactNode } from 'react'
|
|||||||
import { useLocation } from 'react-router'
|
import { useLocation } from 'react-router'
|
||||||
import {
|
import {
|
||||||
BellIcon,
|
BellIcon,
|
||||||
CreditCardIcon,
|
ShieldCheckIcon,
|
||||||
PuzzlePieceIcon,
|
|
||||||
UserIcon,
|
UserIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from '@heroicons/react/20/solid'
|
} from '@heroicons/react/20/solid'
|
||||||
@ -14,6 +13,7 @@ import Container from '../../components/Container'
|
|||||||
import type { User } from '../../components/types'
|
import type { User } from '../../components/types'
|
||||||
import Konto from './konto/Konto'
|
import Konto from './konto/Konto'
|
||||||
import Benachrichtigungen from './benachrichtigungen/Benachrichtigungen'
|
import Benachrichtigungen from './benachrichtigungen/Benachrichtigungen'
|
||||||
|
import PresenceSettings from './presence/PresenceSettings'
|
||||||
|
|
||||||
type SettingsPageProps = {
|
type SettingsPageProps = {
|
||||||
user?: User
|
user?: User
|
||||||
@ -79,6 +79,14 @@ export default function SettingsPage({
|
|||||||
icon: UserIcon,
|
icon: UserIcon,
|
||||||
current: pathname === '/einstellungen/konto',
|
current: pathname === '/einstellungen/konto',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Datenschutz',
|
||||||
|
href: '/einstellungen/datenschutz',
|
||||||
|
icon: ShieldCheckIcon,
|
||||||
|
current:
|
||||||
|
pathname === '/einstellungen/datenschutz' ||
|
||||||
|
pathname === '/einstellungen/praesenz',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Benachrichtigungen',
|
name: 'Benachrichtigungen',
|
||||||
href: '/einstellungen/benachrichtigungen',
|
href: '/einstellungen/benachrichtigungen',
|
||||||
@ -118,10 +126,17 @@ export default function SettingsPage({
|
|||||||
<Benachrichtigungen />
|
<Benachrichtigungen />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(section === 'datenschutz' || section === 'praesenz') && (
|
||||||
|
<PresenceSettings
|
||||||
|
user={user}
|
||||||
|
onUserUpdated={onUserUpdated}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{section === 'teams' && (
|
{section === 'teams' && (
|
||||||
<PlaceholderSection title="Teams" />
|
<PlaceholderSection title="Teams" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,8 @@ type NotificationSettings = {
|
|||||||
deviceUpdates: boolean
|
deviceUpdates: boolean
|
||||||
deviceJournals: boolean
|
deviceJournals: boolean
|
||||||
systemMessages: boolean
|
systemMessages: boolean
|
||||||
|
chatMessages: boolean
|
||||||
|
desktopEnabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSettings: NotificationSettings = {
|
const defaultSettings: NotificationSettings = {
|
||||||
@ -44,6 +46,8 @@ const defaultSettings: NotificationSettings = {
|
|||||||
deviceUpdates: true,
|
deviceUpdates: true,
|
||||||
deviceJournals: true,
|
deviceJournals: true,
|
||||||
systemMessages: true,
|
systemMessages: true,
|
||||||
|
chatMessages: true,
|
||||||
|
desktopEnabled: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
const notificationSounds: Array<{
|
const notificationSounds: Array<{
|
||||||
@ -267,6 +271,32 @@ export default function Benachrichtigungen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function enableDesktopNotifications() {
|
||||||
|
if (!('Notification' in window)) {
|
||||||
|
showToast({
|
||||||
|
title: 'Nicht unterstützt',
|
||||||
|
message: 'Dieser Browser unterstützt keine Windows-Benachrichtigungen.',
|
||||||
|
variant: 'warning',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await Notification.requestPermission()
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
showToast({
|
||||||
|
title: 'Berechtigung fehlt',
|
||||||
|
message: 'Windows-Benachrichtigungen wurden nicht freigegeben.',
|
||||||
|
variant: 'warning',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSetting('desktopEnabled', true)
|
||||||
|
new Notification('TEG-Benachrichtigungen aktiviert', {
|
||||||
|
body: 'Neue Chatnachrichten können jetzt auf Windows-Ebene angezeigt werden.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
@ -293,11 +323,29 @@ export default function Benachrichtigungen() {
|
|||||||
description="Lege fest, welche Hinweise du in der Anwendung erhalten möchtest."
|
description="Lege fest, welche Hinweise du in der Anwendung erhalten möchtest."
|
||||||
>
|
>
|
||||||
<div className="space-y-6 sm:max-w-2xl">
|
<div className="space-y-6 sm:max-w-2xl">
|
||||||
|
{isSaving && (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
className="text-sm text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
Einstellungen werden gespeichert...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<NotificationCard
|
<NotificationCard
|
||||||
icon={<BellIcon aria-hidden="true" className="size-5" />}
|
icon={<BellIcon aria-hidden="true" className="size-5" />}
|
||||||
title="In-App-Benachrichtigungen"
|
title="In-App-Benachrichtigungen"
|
||||||
description="Steuere Hinweise, die direkt in der Anwendung angezeigt werden."
|
description="Steuere Hinweise, die direkt in der Anwendung angezeigt werden."
|
||||||
>
|
>
|
||||||
|
<Switch
|
||||||
|
checked={settings.inAppEnabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSetting('inAppEnabled', event.target.checked)
|
||||||
|
}
|
||||||
|
label="In der Anwendung anzeigen"
|
||||||
|
description="Zeigt Hinweise im Benachrichtigungscenter an."
|
||||||
|
/>
|
||||||
|
|
||||||
<Switch
|
<Switch
|
||||||
checked={settings.soundEnabled}
|
checked={settings.soundEnabled}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@ -355,11 +403,52 @@ export default function Benachrichtigungen() {
|
|||||||
</div>
|
</div>
|
||||||
</NotificationCard>
|
</NotificationCard>
|
||||||
|
|
||||||
|
<NotificationCard
|
||||||
|
icon={<EnvelopeIcon aria-hidden="true" className="size-5" />}
|
||||||
|
title="Windows-Benachrichtigungen"
|
||||||
|
description="Zeigt neue Chatnachrichten außerhalb des Browserfensters an."
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={settings.desktopEnabled}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (event.target.checked) {
|
||||||
|
void enableDesktopNotifications()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateSetting('desktopEnabled', false)
|
||||||
|
}}
|
||||||
|
label="Auf Windows-Ebene anzeigen"
|
||||||
|
description="Benötigt die Benachrichtigungsfreigabe des Browsers."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{typeof Notification !== 'undefined' &&
|
||||||
|
Notification.permission !== 'granted' && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="indigo"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void enableDesktopNotifications()}
|
||||||
|
>
|
||||||
|
Berechtigung erteilen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</NotificationCard>
|
||||||
|
|
||||||
<NotificationCard
|
<NotificationCard
|
||||||
icon={<SpeakerWaveIcon aria-hidden="true" className="size-5" />}
|
icon={<SpeakerWaveIcon aria-hidden="true" className="size-5" />}
|
||||||
title="Ereignisse"
|
title="Ereignisse"
|
||||||
description="Wähle aus, bei welchen Ereignissen du benachrichtigt werden möchtest."
|
description="Wähle aus, bei welchen Ereignissen du benachrichtigt werden möchtest."
|
||||||
>
|
>
|
||||||
|
<Switch
|
||||||
|
checked={settings.chatMessages}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSetting('chatMessages', event.target.checked)
|
||||||
|
}
|
||||||
|
label="Chatnachrichten"
|
||||||
|
description="Zeigt eine Browser-Schnellantwort bei neuen Team- und Direktnachrichten."
|
||||||
|
/>
|
||||||
|
|
||||||
<Switch
|
<Switch
|
||||||
checked={settings.operationUpdates}
|
checked={settings.operationUpdates}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@ -380,26 +469,6 @@ export default function Benachrichtigungen() {
|
|||||||
description="Benachrichtigt dich bei neuen Journal-Einträgen zu Einsätzen."
|
description="Benachrichtigt dich bei neuen Journal-Einträgen zu Einsätzen."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Switch
|
|
||||||
checked={settings.deviceUpdates}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateSetting('deviceUpdates', event.target.checked)
|
|
||||||
}
|
|
||||||
disabled={!settings.inAppEnabled && !settings.emailEnabled}
|
|
||||||
label="Geräteänderungen"
|
|
||||||
description="Benachrichtigt dich, wenn Geräte erstellt oder geändert werden."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Switch
|
|
||||||
checked={settings.deviceJournals}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateSetting('deviceJournals', event.target.checked)
|
|
||||||
}
|
|
||||||
disabled={!settings.inAppEnabled && !settings.emailEnabled}
|
|
||||||
label="Geräte-Journal"
|
|
||||||
description="Benachrichtigt dich bei neuen Journal-Einträgen zu Geräten."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Switch
|
<Switch
|
||||||
checked={settings.systemMessages}
|
checked={settings.systemMessages}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@ -415,4 +484,4 @@ export default function Benachrichtigungen() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
} from '@heroicons/react/20/solid'
|
} from '@heroicons/react/20/solid'
|
||||||
import { useNotifications } from '../../../components/Notifications'
|
import { useNotifications } from '../../../components/Notifications'
|
||||||
import Avatar from '../../../components/Avatar'
|
import Avatar from '../../../components/Avatar'
|
||||||
|
import Combobox, { type ComboboxItem } from '../../../components/Combobox'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
@ -232,8 +233,6 @@ export default function Konto({
|
|||||||
username: String(formData.get('username') ?? ''),
|
username: String(formData.get('username') ?? ''),
|
||||||
email: String(formData.get('email') ?? ''),
|
email: String(formData.get('email') ?? ''),
|
||||||
avatar: String(formData.get('avatar') ?? ''),
|
avatar: String(formData.get('avatar') ?? ''),
|
||||||
unit: String(formData.get('unit') ?? ''),
|
|
||||||
group: String(formData.get('group') ?? ''),
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -540,42 +539,29 @@ export default function Konto({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-3">
|
<div className="col-span-full">
|
||||||
<label
|
<Combobox
|
||||||
htmlFor="unit"
|
label="Einheit"
|
||||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
items={
|
||||||
>
|
currentUser?.unit
|
||||||
Einheit
|
? [{ id: currentUser.unit, name: currentUser.unit }]
|
||||||
</label>
|
: []
|
||||||
|
}
|
||||||
<div className="mt-2">
|
value={
|
||||||
<input
|
currentUser?.unit
|
||||||
id="unit"
|
? ({
|
||||||
name="unit"
|
id: currentUser.unit,
|
||||||
type="text"
|
name: currentUser.unit,
|
||||||
defaultValue={currentUser?.unit ?? ''}
|
} satisfies ComboboxItem)
|
||||||
className={inputClassName}
|
: null
|
||||||
/>
|
}
|
||||||
</div>
|
onChange={() => undefined}
|
||||||
</div>
|
placeholder="Keine Einheit zugeordnet"
|
||||||
|
disabled
|
||||||
<div className="sm:col-span-3">
|
/>
|
||||||
<label
|
<p className="mt-2 text-xs/5 text-gray-500 dark:text-gray-400">
|
||||||
htmlFor="group"
|
Die Einheit kann nur in der Benutzeradministration geändert werden.
|
||||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
</p>
|
||||||
>
|
|
||||||
Gruppe
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="mt-2">
|
|
||||||
<input
|
|
||||||
id="group"
|
|
||||||
name="group"
|
|
||||||
type="text"
|
|
||||||
defaultValue={currentUser?.group ?? ''}
|
|
||||||
className={inputClassName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -841,4 +827,4 @@ export default function Konto({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
151
frontend/src/pages/settings/presence/PresenceSettings.tsx
Normal file
151
frontend/src/pages/settings/presence/PresenceSettings.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import Container from '../../../components/Container'
|
||||||
|
import Button from '../../../components/Button'
|
||||||
|
import Switch from '../../../components/Switch'
|
||||||
|
import type { User } from '../../../components/types'
|
||||||
|
import { useNotifications } from '../../../components/Notifications'
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
|
type PresenceSettingsProps = {
|
||||||
|
user?: User
|
||||||
|
onUserUpdated?: (user: User) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type PresenceSettingsData = {
|
||||||
|
awayAfterMinutes: number
|
||||||
|
showLastSeen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PresenceSettings({
|
||||||
|
user,
|
||||||
|
onUserUpdated,
|
||||||
|
}: PresenceSettingsProps) {
|
||||||
|
const { showToast, showErrorToast } = useNotifications()
|
||||||
|
const [settings, setSettings] = useState<PresenceSettingsData>({
|
||||||
|
awayAfterMinutes: user?.awayAfterMinutes ?? 3,
|
||||||
|
showLastSeen: user?.showLastSeen ?? false,
|
||||||
|
})
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetch(`${API_URL}/settings/presence`, {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.settings) {
|
||||||
|
setSettings(data.settings)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/settings/presence`, {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
awayAfterMinutes: settings.awayAfterMinutes,
|
||||||
|
showLastSeen: settings.showLastSeen,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data?.error || 'Datenschutz konnte nicht gespeichert werden')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSettings(data.settings)
|
||||||
|
if (data.user) {
|
||||||
|
onUserUpdated?.(data.user)
|
||||||
|
}
|
||||||
|
showToast({
|
||||||
|
title: 'Datenschutz gespeichert',
|
||||||
|
message: 'Deine Datenschutz- und Inaktivitätseinstellungen wurden aktualisiert.',
|
||||||
|
variant: 'success',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast({
|
||||||
|
title: 'Datenschutz konnte nicht gespeichert werden',
|
||||||
|
error,
|
||||||
|
fallback: 'Datenschutz konnte nicht gespeichert werden',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container variant="constrained" className="py-16">
|
||||||
|
<div className="grid grid-cols-1 gap-x-8 gap-y-10 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base/7 font-semibold text-gray-900 dark:text-white">
|
||||||
|
Datenschutz
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
||||||
|
Bestimme, welche Statusinformationen andere Personen im Chat sehen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="space-y-6 md:col-span-2 sm:max-w-xl"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="away-after"
|
||||||
|
className="block text-sm font-medium text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
Automatisch abwesend nach
|
||||||
|
</label>
|
||||||
|
<div className="mt-2 flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
id="away-after"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={120}
|
||||||
|
value={settings.awayAfterMinutes}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
awayAfterMinutes: Number(event.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="w-28 rounded-md bg-white px-3 py-2 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 disabled:opacity-50 focus:outline-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-500">Minuten Inaktivität</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Die automatische Abwesenheit greift nur, wenn dein manueller
|
||||||
|
Status in der Topbar auf Online steht.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-200 pt-6 dark:border-white/10">
|
||||||
|
<Switch
|
||||||
|
checked={settings.showLastSeen}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
showLastSeen: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
label="Letzten Online-Zeitpunkt anzeigen"
|
||||||
|
description="Wenn aktiv, teilst du deinen Status (zuletzt online, Abwesenheitsdauer) in Einzelchats – und siehst im Gegenzug auch den der anderen."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" isLoading={saving}>
|
||||||
|
Datenschutz speichern
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
256
frontend/src/utils/activityLog.ts
Normal file
256
frontend/src/utils/activityLog.ts
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
// frontend/src/utils/activityLog.ts
|
||||||
|
//
|
||||||
|
// Clientseitiges Aktivitäts- und Fehlerprotokoll. Erfasst app-weit, was der
|
||||||
|
// Nutzer anklickt, welche Seiten er aufruft und welche Fehler im Browser
|
||||||
|
// auftreten. Wird auf der Feedback-Seite sichtbar gemacht und ans Feedback
|
||||||
|
// angehängt, damit Administratoren Probleme nachvollziehen können.
|
||||||
|
|
||||||
|
export type ActivityLogType = 'action' | 'navigation' | 'error'
|
||||||
|
|
||||||
|
export type ActivityLogEntry = {
|
||||||
|
id: string
|
||||||
|
type: ActivityLogType
|
||||||
|
timestamp: string
|
||||||
|
path: string
|
||||||
|
message: string
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ENTRIES = 200
|
||||||
|
const STORAGE_KEY = 'teg.activityLog'
|
||||||
|
const MAX_STACK_LENGTH = 4000
|
||||||
|
|
||||||
|
let entries: ActivityLogEntry[] = loadEntries()
|
||||||
|
let initialized = false
|
||||||
|
const listeners = new Set<(entries: ActivityLogEntry[]) => void>()
|
||||||
|
|
||||||
|
function loadEntries(): ActivityLogEntry[] {
|
||||||
|
if (typeof sessionStorage === 'undefined') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return Array.isArray(parsed) ? parsed.slice(-MAX_ENTRIES) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist() {
|
||||||
|
if (typeof sessionStorage === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
|
||||||
|
} catch {
|
||||||
|
// Speicher voll oder im privaten Modus – Protokoll bleibt nur im Speicher.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createId() {
|
||||||
|
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||||
|
return crypto.randomUUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify() {
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener(entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function push(
|
||||||
|
type: ActivityLogType,
|
||||||
|
message: string,
|
||||||
|
details?: Record<string, unknown>,
|
||||||
|
path?: string,
|
||||||
|
) {
|
||||||
|
const entry: ActivityLogEntry = {
|
||||||
|
id: createId(),
|
||||||
|
type,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: path ?? window.location.pathname,
|
||||||
|
message: message.slice(0, 500),
|
||||||
|
details,
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = [...entries, entry].slice(-MAX_ENTRIES)
|
||||||
|
persist()
|
||||||
|
notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordAction(message: string, details?: Record<string, unknown>) {
|
||||||
|
push('action', message, details)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordNavigation(path: string) {
|
||||||
|
push('navigation', `Seitenaufruf: ${path}`, undefined, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordError(message: string, details?: Record<string, unknown>) {
|
||||||
|
push('error', message, details)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActivityLog(): ActivityLogEntry[] {
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearActivityLog() {
|
||||||
|
entries = []
|
||||||
|
persist()
|
||||||
|
notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeActivityLog(
|
||||||
|
listener: (entries: ActivityLogEntry[]) => void,
|
||||||
|
) {
|
||||||
|
listeners.add(listener)
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateStack(stack?: string) {
|
||||||
|
if (!stack) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return stack.length > MAX_STACK_LENGTH ? stack.slice(0, MAX_STACK_LENGTH) : stack
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeElement(target: Element): string | null {
|
||||||
|
const actionable = target.closest(
|
||||||
|
'button, a, [role="button"], input, select, textarea, label, summary, [role="menuitem"], [role="tab"], [data-log-label]',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!actionable) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = actionable as HTMLElement
|
||||||
|
const tag = element.tagName.toLowerCase()
|
||||||
|
|
||||||
|
const role =
|
||||||
|
element.getAttribute('data-log-role') ||
|
||||||
|
(tag === 'a'
|
||||||
|
? 'Link'
|
||||||
|
: tag === 'button' || element.getAttribute('role') === 'button'
|
||||||
|
? 'Schaltfläche'
|
||||||
|
: tag === 'input'
|
||||||
|
? `Eingabe (${(element as HTMLInputElement).type || 'text'})`
|
||||||
|
: tag === 'select'
|
||||||
|
? 'Auswahl'
|
||||||
|
: tag === 'textarea'
|
||||||
|
? 'Textfeld'
|
||||||
|
: tag === 'summary'
|
||||||
|
? 'Aufklappen'
|
||||||
|
: 'Element')
|
||||||
|
|
||||||
|
const rawLabel =
|
||||||
|
element.getAttribute('data-log-label') ||
|
||||||
|
element.getAttribute('aria-label') ||
|
||||||
|
element.getAttribute('title') ||
|
||||||
|
(element as HTMLInputElement).placeholder ||
|
||||||
|
(element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||||
|
|
||||||
|
if (!rawLabel) {
|
||||||
|
return role
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = rawLabel.length > 80 ? `${rawLabel.slice(0, 80)}…` : rawLabel
|
||||||
|
return `${role}: „${label}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(event: MouseEvent) {
|
||||||
|
const target = event.target
|
||||||
|
if (!(target instanceof Element)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = describeElement(target)
|
||||||
|
if (description) {
|
||||||
|
recordAction(`Klick – ${description}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError(event: ErrorEvent) {
|
||||||
|
if (!event.message && event.target && event.target !== window) {
|
||||||
|
const element = event.target as HTMLElement & { src?: string; href?: string }
|
||||||
|
const source = element.src || element.href || ''
|
||||||
|
recordError(
|
||||||
|
`Ressource konnte nicht geladen werden${source ? `: ${source}` : ''}`,
|
||||||
|
{ element: element.tagName?.toLowerCase() },
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
recordError(event.message || 'Unbekannter Fehler', {
|
||||||
|
source: event.filename,
|
||||||
|
line: event.lineno,
|
||||||
|
column: event.colno,
|
||||||
|
stack: truncateStack(event.error?.stack),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRejection(event: PromiseRejectionEvent) {
|
||||||
|
const reason = event.reason
|
||||||
|
const message = reason instanceof Error ? reason.message : String(reason)
|
||||||
|
|
||||||
|
recordError(`Unbehandelte Ablehnung: ${message}`, {
|
||||||
|
stack: reason instanceof Error ? truncateStack(reason.stack) : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeStringify(value: unknown) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
} catch {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initActivityLog() {
|
||||||
|
if (initialized || typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
initialized = true
|
||||||
|
|
||||||
|
document.addEventListener('click', handleClick, { capture: true })
|
||||||
|
window.addEventListener('error', handleError, true)
|
||||||
|
window.addEventListener('unhandledrejection', handleRejection)
|
||||||
|
|
||||||
|
const originalConsoleError = console.error.bind(console)
|
||||||
|
console.error = (...args: unknown[]) => {
|
||||||
|
try {
|
||||||
|
const errorArg = args.find((arg): arg is Error => arg instanceof Error)
|
||||||
|
const message = args
|
||||||
|
.map((arg) =>
|
||||||
|
arg instanceof Error
|
||||||
|
? arg.message
|
||||||
|
: typeof arg === 'string'
|
||||||
|
? arg
|
||||||
|
: safeStringify(arg),
|
||||||
|
)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
// React-Entwicklungswarnungen sind kein echter Nutzerfehler.
|
||||||
|
if (!message.startsWith('Warning:')) {
|
||||||
|
recordError(`console.error: ${message}`, {
|
||||||
|
stack: truncateStack(errorArg?.stack),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Logging darf die App niemals stören.
|
||||||
|
}
|
||||||
|
|
||||||
|
originalConsoleError(...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
147
frontend/src/utils/operationGeometry.ts
Normal file
147
frontend/src/utils/operationGeometry.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
export type OperationCoordinates = {
|
||||||
|
lat: number
|
||||||
|
lon: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidOperationCoordinates(
|
||||||
|
value: OperationCoordinates | null | undefined,
|
||||||
|
): value is OperationCoordinates {
|
||||||
|
return (
|
||||||
|
Boolean(value) &&
|
||||||
|
Number.isFinite(value?.lat) &&
|
||||||
|
Number.isFinite(value?.lon) &&
|
||||||
|
value!.lat >= -90 &&
|
||||||
|
value!.lat <= 90 &&
|
||||||
|
value!.lon >= -180 &&
|
||||||
|
value!.lon <= 180
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRadians(value: number) {
|
||||||
|
return (value * Math.PI) / 180
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDegrees(value: number) {
|
||||||
|
return (value * 180) / Math.PI
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOperationDistanceMeters(
|
||||||
|
start: OperationCoordinates,
|
||||||
|
end: OperationCoordinates,
|
||||||
|
) {
|
||||||
|
const earthRadius = 6371000
|
||||||
|
const startLat = toRadians(start.lat)
|
||||||
|
const endLat = toRadians(end.lat)
|
||||||
|
const deltaLat = toRadians(end.lat - start.lat)
|
||||||
|
const deltaLon = toRadians(end.lon - start.lon)
|
||||||
|
|
||||||
|
const a =
|
||||||
|
Math.sin(deltaLat / 2) ** 2 +
|
||||||
|
Math.cos(startLat) *
|
||||||
|
Math.cos(endLat) *
|
||||||
|
Math.sin(deltaLon / 2) ** 2
|
||||||
|
|
||||||
|
return earthRadius * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOperationBearingDegrees(
|
||||||
|
start: OperationCoordinates,
|
||||||
|
end: OperationCoordinates,
|
||||||
|
) {
|
||||||
|
const startLat = toRadians(start.lat)
|
||||||
|
const endLat = toRadians(end.lat)
|
||||||
|
const deltaLon = toRadians(end.lon - start.lon)
|
||||||
|
|
||||||
|
const y = Math.sin(deltaLon) * Math.cos(endLat)
|
||||||
|
const x =
|
||||||
|
Math.cos(startLat) * Math.sin(endLat) -
|
||||||
|
Math.sin(startLat) * Math.cos(endLat) * Math.cos(deltaLon)
|
||||||
|
|
||||||
|
return (toDegrees(Math.atan2(y, x)) + 360) % 360
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOperationSignedAngleDifferenceDegrees(
|
||||||
|
first: number,
|
||||||
|
second: number,
|
||||||
|
) {
|
||||||
|
return ((first - second + 540) % 360) - 180
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOperationDestinationPoint(
|
||||||
|
start: OperationCoordinates,
|
||||||
|
distanceMeters: number,
|
||||||
|
bearingDegrees: number,
|
||||||
|
): [number, number] {
|
||||||
|
const earthRadius = 6371000
|
||||||
|
const bearing = toRadians(bearingDegrees)
|
||||||
|
const startLat = toRadians(start.lat)
|
||||||
|
const startLon = toRadians(start.lon)
|
||||||
|
const angularDistance = distanceMeters / earthRadius
|
||||||
|
|
||||||
|
const lat = Math.asin(
|
||||||
|
Math.sin(startLat) * Math.cos(angularDistance) +
|
||||||
|
Math.cos(startLat) * Math.sin(angularDistance) * Math.cos(bearing),
|
||||||
|
)
|
||||||
|
|
||||||
|
const lon =
|
||||||
|
startLon +
|
||||||
|
Math.atan2(
|
||||||
|
Math.sin(bearing) *
|
||||||
|
Math.sin(angularDistance) *
|
||||||
|
Math.cos(startLat),
|
||||||
|
Math.cos(angularDistance) - Math.sin(startLat) * Math.sin(lat),
|
||||||
|
)
|
||||||
|
|
||||||
|
return [toDegrees(lat), ((toDegrees(lon) + 540) % 360) - 180]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOperationViewCone({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
fov = 36,
|
||||||
|
length = 0,
|
||||||
|
steps = 24,
|
||||||
|
}: {
|
||||||
|
from: OperationCoordinates
|
||||||
|
to: OperationCoordinates
|
||||||
|
fov?: number
|
||||||
|
length?: number
|
||||||
|
steps?: number
|
||||||
|
}) {
|
||||||
|
const targetDistance = getOperationDistanceMeters(from, to)
|
||||||
|
|
||||||
|
if (targetDistance < 1) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const coneLength =
|
||||||
|
length > 0 ? Math.min(length, targetDistance) : targetDistance
|
||||||
|
const bearing = getOperationBearingDegrees(from, to)
|
||||||
|
const safeFov = Math.min(180, Math.max(1, fov))
|
||||||
|
const halfAngle = safeFov / 2
|
||||||
|
const positions: Array<[number, number]> = [[from.lat, from.lon]]
|
||||||
|
|
||||||
|
for (let step = 0; step <= steps; step += 1) {
|
||||||
|
const angle = bearing - halfAngle + (safeFov * step) / steps
|
||||||
|
positions.push(
|
||||||
|
getOperationDestinationPoint(from, coneLength, angle),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
positions.push([from.lat, from.lon])
|
||||||
|
|
||||||
|
return {
|
||||||
|
positions,
|
||||||
|
centerLine: [
|
||||||
|
[from.lat, from.lon],
|
||||||
|
getOperationDestinationPoint(from, coneLength, bearing),
|
||||||
|
] as [[number, number], [number, number]],
|
||||||
|
targetLine: [
|
||||||
|
[from.lat, from.lon],
|
||||||
|
[to.lat, to.lon],
|
||||||
|
] as [[number, number], [number, number]],
|
||||||
|
bearing,
|
||||||
|
targetDistance,
|
||||||
|
coneLength,
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user