updated
This commit is contained in:
parent
b62c1273c3
commit
b3c07355a8
@ -1,7 +1,7 @@
|
||||
PORT=8080
|
||||
DATABASE_URL=postgres://postgres:Timmy0104199%3F@localhost:5432/teg?sslmode=disable
|
||||
JWT_SECRET=tegvideo7010!
|
||||
FRONTEND_ORIGIN=http://localhost:5173
|
||||
FRONTEND_ORIGIN=http://10.0.1.25:5173
|
||||
ADDRESS_SEARCH_PROVIDER=photon
|
||||
ADDRESS_SEARCH_URL=https://photon.komoot.io/api
|
||||
ADDRESS_SEARCH_LANGUAGE=de
|
||||
|
||||
@ -3,8 +3,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@ -13,12 +15,13 @@ import (
|
||||
)
|
||||
|
||||
type reverseAddressSuggestion struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
Type string `json:"type,omitempty"`
|
||||
GeoJSON json.RawMessage `json:"geojson,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
Type string `json:"type,omitempty"`
|
||||
HasHouseNumber bool `json:"hasHouseNumber"`
|
||||
GeoJSON json.RawMessage `json:"geojson,omitempty"`
|
||||
}
|
||||
|
||||
type nominatimReverseAddress struct {
|
||||
@ -112,6 +115,215 @@ func formatReverseAddressLabel(result nominatimReverseResponse) string {
|
||||
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) {
|
||||
latRaw := strings.TrimSpace(r.URL.Query().Get("lat"))
|
||||
lonRaw := strings.TrimSpace(r.URL.Query().Get("lon"))
|
||||
@ -179,14 +391,42 @@ func (s *Server) handleAddressReverse(w http.ResponseWriter, r *http.Request) {
|
||||
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{
|
||||
"suggestion": reverseAddressSuggestion{
|
||||
ID: fmt.Sprintf("reverse-%d-%d", result.PlaceID, time.Now().UnixNano()),
|
||||
Label: label,
|
||||
Lat: lat,
|
||||
Lon: lon,
|
||||
Type: result.Type,
|
||||
GeoJSON: result.GeoJSON,
|
||||
ID: fmt.Sprintf("reverse-%d-%d", result.PlaceID, time.Now().UnixNano()),
|
||||
Label: label,
|
||||
Lat: lat,
|
||||
Lon: lon,
|
||||
Type: result.Type,
|
||||
HasHouseNumber: hasHouseNumber,
|
||||
GeoJSON: geoJSON,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -13,11 +13,13 @@ import (
|
||||
)
|
||||
|
||||
type AddressSuggestion struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
Type string `json:"type"`
|
||||
HasHouseNumber bool `json:"hasHouseNumber"`
|
||||
GeoJSON json.RawMessage `json:"geojson,omitempty"`
|
||||
}
|
||||
|
||||
type photonResponse struct {
|
||||
@ -55,6 +57,7 @@ type nominatimSearchResult struct {
|
||||
Lon string `json:"lon"`
|
||||
Type string `json:"type"`
|
||||
Address nominatimReverseAddress `json:"address"`
|
||||
GeoJSON json.RawMessage `json:"geojson,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAddressSearch(w http.ResponseWriter, r *http.Request) {
|
||||
@ -164,11 +167,12 @@ func (s *Server) handlePhotonAddressSearch(
|
||||
}
|
||||
|
||||
suggestions = append(suggestions, AddressSuggestion{
|
||||
ID: buildPhotonID(feature.Properties, label),
|
||||
Label: label,
|
||||
Lat: lat,
|
||||
Lon: lon,
|
||||
Type: buildPhotonType(feature.Properties),
|
||||
ID: buildPhotonID(feature.Properties, label),
|
||||
Label: label,
|
||||
Lat: lat,
|
||||
Lon: lon,
|
||||
Type: buildPhotonType(feature.Properties),
|
||||
HasHouseNumber: strings.TrimSpace(feature.Properties.HouseNumber) != "",
|
||||
})
|
||||
}
|
||||
|
||||
@ -196,6 +200,7 @@ func (s *Server) handleNominatimAddressSearch(
|
||||
values.Set("q", query)
|
||||
values.Set("format", "jsonv2")
|
||||
values.Set("addressdetails", "1")
|
||||
values.Set("polygon_geojson", "1")
|
||||
values.Set("limit", strconv.Itoa(limit))
|
||||
|
||||
if countryCode != "" && values.Get("countrycodes") == "" {
|
||||
@ -252,11 +257,13 @@ func (s *Server) handleNominatimAddressSearch(
|
||||
}
|
||||
|
||||
suggestions = append(suggestions, AddressSuggestion{
|
||||
ID: strconv.FormatInt(result.PlaceID, 10),
|
||||
Label: label,
|
||||
Lat: lat,
|
||||
Lon: lon,
|
||||
Type: result.Type,
|
||||
ID: strconv.FormatInt(result.PlaceID, 10),
|
||||
Label: label,
|
||||
Lat: lat,
|
||||
Lon: lon,
|
||||
Type: result.Type,
|
||||
HasHouseNumber: strings.TrimSpace(result.Address.HouseNumber) != "",
|
||||
GeoJSON: result.GeoJSON,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -24,17 +24,20 @@ type AdminTeam struct {
|
||||
}
|
||||
|
||||
type AdminUser struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Avatar string `json:"avatar"`
|
||||
Unit string `json:"unit"`
|
||||
Group string `json:"group"`
|
||||
Rights []string `json:"rights"`
|
||||
Teams []AdminTeam `json:"teams"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Avatar string `json:"avatar"`
|
||||
Unit string `json:"unit"`
|
||||
Group string `json:"group"`
|
||||
Rights []string `json:"rights"`
|
||||
Teams []AdminTeam `json:"teams"`
|
||||
Online bool `json:"onlineStatus"`
|
||||
PresenceStatus string `json:"presenceStatus"`
|
||||
LastSeenAt *time.Time `json:"lastSeenAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type AdminUserRequest struct {
|
||||
@ -120,6 +123,8 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
COALESCE(unit, ''),
|
||||
COALESCE(user_group, ''),
|
||||
rights,
|
||||
`+presenceStatusSQL+`,
|
||||
last_seen_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM users
|
||||
@ -148,6 +153,8 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
&user.Unit,
|
||||
&user.Group,
|
||||
&user.Rights,
|
||||
&user.PresenceStatus,
|
||||
&user.LastSeenAt,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
); err != nil {
|
||||
@ -156,6 +163,7 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
user.Teams = []AdminTeam{}
|
||||
user.Online = user.PresenceStatus == "online"
|
||||
|
||||
users = append(users, user)
|
||||
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 {
|
||||
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 {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
@ -63,6 +72,16 @@ func (s *Server) handleCreateFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
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{
|
||||
"user": buildUserLogSnapshot(user),
|
||||
"request": mergeLogFields(
|
||||
@ -74,6 +93,8 @@ func (s *Server) handleCreateFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
"server": map[string]any{
|
||||
"createdAt": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
},
|
||||
"client": clientMeta,
|
||||
"activity": clientActivity,
|
||||
}
|
||||
|
||||
logJSON, err := json.Marshal(feedbackLog)
|
||||
|
||||
@ -5,13 +5,12 @@ module main
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.14
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
golang.org/x/crypto v0.51.0
|
||||
)
|
||||
|
||||
require github.com/coder/websocket v1.8.14 // indirect
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
|
||||
@ -37,6 +37,7 @@ func main() {
|
||||
}
|
||||
|
||||
server := NewServer(db, jwtSecret, frontendOrigin)
|
||||
server.startPresenceMonitor(ctx)
|
||||
|
||||
log.Printf("Backend läuft auf http://localhost:%s", port)
|
||||
|
||||
|
||||
@ -21,6 +21,8 @@ type notificationPreferencesResponse struct {
|
||||
DeviceUpdates bool `json:"deviceUpdates"`
|
||||
DeviceJournals bool `json:"deviceJournals"`
|
||||
SystemMessages bool `json:"systemMessages"`
|
||||
ChatMessages bool `json:"chatMessages"`
|
||||
DesktopEnabled bool `json:"desktopEnabled"`
|
||||
}
|
||||
|
||||
func defaultNotificationPreferences() notificationPreferencesResponse {
|
||||
@ -34,6 +36,8 @@ func defaultNotificationPreferences() notificationPreferencesResponse {
|
||||
DeviceUpdates: true,
|
||||
DeviceJournals: true,
|
||||
SystemMessages: true,
|
||||
ChatMessages: true,
|
||||
DesktopEnabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,9 +106,11 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
|
||||
operation_journals,
|
||||
device_updates,
|
||||
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)
|
||||
DO UPDATE SET
|
||||
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_journals = EXCLUDED.device_journals,
|
||||
system_messages = EXCLUDED.system_messages,
|
||||
chat_messages = EXCLUDED.chat_messages,
|
||||
desktop_enabled = EXCLUDED.desktop_enabled,
|
||||
updated_at = now()
|
||||
`,
|
||||
user.ID,
|
||||
@ -128,6 +136,8 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
|
||||
input.DeviceUpdates,
|
||||
input.DeviceJournals,
|
||||
input.SystemMessages,
|
||||
input.ChatMessages,
|
||||
input.DesktopEnabled,
|
||||
)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen konnten nicht gespeichert werden")
|
||||
@ -160,7 +170,9 @@ func (s *Server) getNotificationPreferences(ctx context.Context, userID string)
|
||||
operation_journals,
|
||||
device_updates,
|
||||
device_journals,
|
||||
system_messages
|
||||
system_messages,
|
||||
chat_messages,
|
||||
desktop_enabled
|
||||
FROM notification_preferences
|
||||
WHERE user_id = $1
|
||||
LIMIT 1
|
||||
@ -176,6 +188,8 @@ func (s *Server) getNotificationPreferences(ctx context.Context, userID string)
|
||||
&settings.DeviceUpdates,
|
||||
&settings.DeviceJournals,
|
||||
&settings.SystemMessages,
|
||||
&settings.ChatMessages,
|
||||
&settings.DesktopEnabled,
|
||||
)
|
||||
|
||||
settings.SoundName = normalizeNotificationSoundName(settings.SoundName)
|
||||
|
||||
@ -24,6 +24,8 @@ type Notification struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
Silent bool `json:"silent"`
|
||||
SoundName string `json:"soundName,omitempty"`
|
||||
Desktop bool `json:"desktop,omitempty"`
|
||||
InApp bool `json:"inAppEnabled"`
|
||||
ReadAt *time.Time `json:"readAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
@ -95,11 +97,13 @@ func shouldDeliverNotification(
|
||||
notificationType string,
|
||||
entityType string,
|
||||
) bool {
|
||||
if !preferences.InAppEnabled {
|
||||
return false
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(notificationType, "chat.") || entityType == "chat":
|
||||
return preferences.ChatMessages
|
||||
|
||||
case !preferences.InAppEnabled:
|
||||
return false
|
||||
|
||||
case notificationType == "operation.journal":
|
||||
return preferences.OperationJournals
|
||||
|
||||
@ -150,6 +154,8 @@ func (s *Server) createAndPublishNotification(
|
||||
return nil
|
||||
}
|
||||
|
||||
inAppVisible := silent || (preferences.InAppEnabled && entityType != "chat")
|
||||
|
||||
var notification Notification
|
||||
var rawData []byte
|
||||
|
||||
@ -164,7 +170,8 @@ func (s *Server) createAndPublishNotification(
|
||||
entity_type,
|
||||
entity_id,
|
||||
data,
|
||||
silent
|
||||
silent,
|
||||
in_app_visible
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
@ -174,7 +181,8 @@ func (s *Server) createAndPublishNotification(
|
||||
$5,
|
||||
NULLIF($6, '')::UUID,
|
||||
$7::JSONB,
|
||||
$8
|
||||
$8,
|
||||
$9
|
||||
)
|
||||
RETURNING
|
||||
id::TEXT,
|
||||
@ -196,6 +204,7 @@ func (s *Server) createAndPublishNotification(
|
||||
entityID,
|
||||
string(dataJSON),
|
||||
silent,
|
||||
inAppVisible,
|
||||
).Scan(
|
||||
¬ification.ID,
|
||||
¬ification.UserID,
|
||||
@ -216,6 +225,11 @@ func (s *Server) createAndPublishNotification(
|
||||
|
||||
if !notification.Silent {
|
||||
notification.SoundName = notificationSoundName(preferences)
|
||||
notification.Desktop = preferences.DesktopEnabled
|
||||
notification.InApp = preferences.InAppEnabled
|
||||
if notification.EntityType == "chat" {
|
||||
notification.InApp = preferences.ChatMessages
|
||||
}
|
||||
}
|
||||
|
||||
notificationsBroker.publish(notification)
|
||||
@ -306,6 +320,8 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
||||
FROM notifications
|
||||
WHERE user_id = $1
|
||||
AND silent = false
|
||||
AND in_app_visible = true
|
||||
AND entity_type <> 'chat'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50
|
||||
`,
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@ -16,16 +17,22 @@ import (
|
||||
)
|
||||
|
||||
type Operation struct {
|
||||
ID string `json:"id"`
|
||||
OperationNumber string `json:"operationNumber"`
|
||||
OperationName string `json:"operationName"`
|
||||
Offense string `json:"offense"`
|
||||
CaseWorker string `json:"caseWorker"`
|
||||
TargetObject string `json:"targetObject"`
|
||||
KwAddress string `json:"kwAddress"`
|
||||
OperationLeader string `json:"operationLeader"`
|
||||
OperationTeam string `json:"operationTeam"`
|
||||
Legend string `json:"legend"`
|
||||
ID string `json:"id"`
|
||||
OperationNumber string `json:"operationNumber"`
|
||||
OperationName string `json:"operationName"`
|
||||
Offense string `json:"offense"`
|
||||
CaseWorker string `json:"caseWorker"`
|
||||
TargetObject string `json:"targetObject"`
|
||||
KwAddress string `json:"kwAddress"`
|
||||
KwLatitude *float64 `json:"kwLatitude"`
|
||||
KwLongitude *float64 `json:"kwLongitude"`
|
||||
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"`
|
||||
Router string `json:"router"`
|
||||
Technology string `json:"technology"`
|
||||
@ -47,23 +54,29 @@ type UpdateOperationJournalEntryRequest struct {
|
||||
}
|
||||
|
||||
type CreateOperationRequest struct {
|
||||
OperationNumber string `json:"operationNumber"`
|
||||
OperationName string `json:"operationName"`
|
||||
Offense string `json:"offense"`
|
||||
CaseWorker string `json:"caseWorker"`
|
||||
TargetObject string `json:"targetObject"`
|
||||
KwAddress string `json:"kwAddress"`
|
||||
OperationLeader string `json:"operationLeader"`
|
||||
OperationTeam string `json:"operationTeam"`
|
||||
Legend string `json:"legend"`
|
||||
Camera string `json:"camera"`
|
||||
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"`
|
||||
OperationNumber string `json:"operationNumber"`
|
||||
OperationName string `json:"operationName"`
|
||||
Offense string `json:"offense"`
|
||||
CaseWorker string `json:"caseWorker"`
|
||||
TargetObject string `json:"targetObject"`
|
||||
KwAddress string `json:"kwAddress"`
|
||||
KwLatitude *float64 `json:"kwLatitude"`
|
||||
KwLongitude *float64 `json:"kwLongitude"`
|
||||
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"`
|
||||
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
|
||||
@ -119,6 +132,12 @@ func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) {
|
||||
case_worker,
|
||||
target_object,
|
||||
kw_address,
|
||||
kw_latitude,
|
||||
kw_longitude,
|
||||
target_latitude,
|
||||
target_longitude,
|
||||
view_cone_fov,
|
||||
view_cone_length,
|
||||
operation_leader,
|
||||
operation_team,
|
||||
legend,
|
||||
@ -156,6 +175,12 @@ func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) {
|
||||
&operation.CaseWorker,
|
||||
&operation.TargetObject,
|
||||
&operation.KwAddress,
|
||||
&operation.KwLatitude,
|
||||
&operation.KwLongitude,
|
||||
&operation.TargetLatitude,
|
||||
&operation.TargetLongitude,
|
||||
&operation.ViewConeFOV,
|
||||
&operation.ViewConeLength,
|
||||
&operation.OperationLeader,
|
||||
&operation.OperationTeam,
|
||||
&operation.Legend,
|
||||
@ -201,6 +226,10 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "Einsatznummer ist erforderlich")
|
||||
return
|
||||
}
|
||||
if err := validateOperationGeometry(input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := userFromContext(r.Context())
|
||||
if !ok {
|
||||
@ -227,6 +256,12 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
|
||||
case_worker,
|
||||
target_object,
|
||||
kw_address,
|
||||
kw_latitude,
|
||||
kw_longitude,
|
||||
target_latitude,
|
||||
target_longitude,
|
||||
view_cone_fov,
|
||||
view_cone_length,
|
||||
operation_leader,
|
||||
operation_team,
|
||||
legend,
|
||||
@ -239,7 +274,7 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
|
||||
remark,
|
||||
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
|
||||
id,
|
||||
operation_number,
|
||||
@ -248,6 +283,12 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
|
||||
case_worker,
|
||||
target_object,
|
||||
kw_address,
|
||||
kw_latitude,
|
||||
kw_longitude,
|
||||
target_latitude,
|
||||
target_longitude,
|
||||
view_cone_fov,
|
||||
view_cone_length,
|
||||
operation_leader,
|
||||
operation_team,
|
||||
legend,
|
||||
@ -268,6 +309,12 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
|
||||
input.CaseWorker,
|
||||
input.TargetObject,
|
||||
input.KwAddress,
|
||||
input.KwLatitude,
|
||||
input.KwLongitude,
|
||||
input.TargetLatitude,
|
||||
input.TargetLongitude,
|
||||
input.ViewConeFOV,
|
||||
input.ViewConeLength,
|
||||
input.OperationLeader,
|
||||
input.OperationTeam,
|
||||
input.Legend,
|
||||
@ -287,6 +334,12 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
|
||||
&operation.CaseWorker,
|
||||
&operation.TargetObject,
|
||||
&operation.KwAddress,
|
||||
&operation.KwLatitude,
|
||||
&operation.KwLongitude,
|
||||
&operation.TargetLatitude,
|
||||
&operation.TargetLongitude,
|
||||
&operation.ViewConeFOV,
|
||||
&operation.ViewConeLength,
|
||||
&operation.OperationLeader,
|
||||
&operation.OperationTeam,
|
||||
&operation.Legend,
|
||||
@ -380,6 +433,10 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "Einsatznummer ist erforderlich")
|
||||
return
|
||||
}
|
||||
if err := validateOperationGeometry(input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := userFromContext(r.Context())
|
||||
if !ok {
|
||||
@ -418,17 +475,23 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
|
||||
case_worker = $5,
|
||||
target_object = $6,
|
||||
kw_address = $7,
|
||||
operation_leader = $8,
|
||||
operation_team = $9,
|
||||
legend = $10,
|
||||
camera = $11,
|
||||
router = $12,
|
||||
technology = $13,
|
||||
switchbox = $14,
|
||||
hard_drive = $15,
|
||||
laptop = $16,
|
||||
remark = $17,
|
||||
milestone_storage = $18,
|
||||
kw_latitude = $8,
|
||||
kw_longitude = $9,
|
||||
target_latitude = $10,
|
||||
target_longitude = $11,
|
||||
view_cone_fov = $12,
|
||||
view_cone_length = $13,
|
||||
operation_leader = $14,
|
||||
operation_team = $15,
|
||||
legend = $16,
|
||||
camera = $17,
|
||||
router = $18,
|
||||
technology = $19,
|
||||
switchbox = $20,
|
||||
hard_drive = $21,
|
||||
laptop = $22,
|
||||
remark = $23,
|
||||
milestone_storage = $24,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING
|
||||
@ -439,6 +502,12 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
|
||||
case_worker,
|
||||
target_object,
|
||||
kw_address,
|
||||
kw_latitude,
|
||||
kw_longitude,
|
||||
target_latitude,
|
||||
target_longitude,
|
||||
view_cone_fov,
|
||||
view_cone_length,
|
||||
operation_leader,
|
||||
operation_team,
|
||||
legend,
|
||||
@ -460,6 +529,12 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
|
||||
input.CaseWorker,
|
||||
input.TargetObject,
|
||||
input.KwAddress,
|
||||
input.KwLatitude,
|
||||
input.KwLongitude,
|
||||
input.TargetLatitude,
|
||||
input.TargetLongitude,
|
||||
input.ViewConeFOV,
|
||||
input.ViewConeLength,
|
||||
input.OperationLeader,
|
||||
input.OperationTeam,
|
||||
input.Legend,
|
||||
@ -479,6 +554,12 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
|
||||
&operation.CaseWorker,
|
||||
&operation.TargetObject,
|
||||
&operation.KwAddress,
|
||||
&operation.KwLatitude,
|
||||
&operation.KwLongitude,
|
||||
&operation.TargetLatitude,
|
||||
&operation.TargetLongitude,
|
||||
&operation.ViewConeFOV,
|
||||
&operation.ViewConeLength,
|
||||
&operation.OperationLeader,
|
||||
&operation.OperationTeam,
|
||||
&operation.Legend,
|
||||
@ -644,6 +725,12 @@ func getOperationForHistory(ctx context.Context, tx pgx.Tx, operationID string)
|
||||
case_worker,
|
||||
target_object,
|
||||
kw_address,
|
||||
kw_latitude,
|
||||
kw_longitude,
|
||||
target_latitude,
|
||||
target_longitude,
|
||||
view_cone_fov,
|
||||
view_cone_length,
|
||||
operation_leader,
|
||||
operation_team,
|
||||
legend,
|
||||
@ -670,6 +757,12 @@ func getOperationForHistory(ctx context.Context, tx pgx.Tx, operationID string)
|
||||
&operation.CaseWorker,
|
||||
&operation.TargetObject,
|
||||
&operation.KwAddress,
|
||||
&operation.KwLatitude,
|
||||
&operation.KwLongitude,
|
||||
&operation.TargetLatitude,
|
||||
&operation.TargetLongitude,
|
||||
&operation.ViewConeFOV,
|
||||
&operation.ViewConeLength,
|
||||
&operation.OperationLeader,
|
||||
&operation.OperationTeam,
|
||||
&operation.Legend,
|
||||
@ -730,6 +823,12 @@ func buildOperationCreateHistoryChanges(input CreateOperationRequest) []Operatio
|
||||
changes = appendOperationHistoryChange(changes, "caseWorker", "Sachbearbeitung", "", input.CaseWorker)
|
||||
changes = appendOperationHistoryChange(changes, "targetObject", "Zielobjekt", "", input.TargetObject)
|
||||
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, "operationTeam", "Einsatzteam", "", input.OperationTeam)
|
||||
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, "targetObject", "Zielobjekt", oldOperation.TargetObject, input.TargetObject)
|
||||
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, "operationTeam", "Einsatzteam", oldOperation.OperationTeam, input.OperationTeam)
|
||||
changes = appendOperationHistoryChange(changes, "legend", "Legende", oldOperation.Legend, input.Legend)
|
||||
@ -809,6 +912,56 @@ func normalizeOperationInput(input *CreateOperationRequest) {
|
||||
input.Laptop = strings.TrimSpace(input.Laptop)
|
||||
input.Remark = strings.TrimSpace(input.Remark)
|
||||
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) {
|
||||
|
||||
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.HandleFunc("GET /health", s.handleHealth)
|
||||
mux.HandleFunc("POST /webhooks/channels/{slug}", s.handleChannelWebhook)
|
||||
|
||||
mux.HandleFunc("POST /auth/register", s.handleRegister)
|
||||
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("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)))
|
||||
|
||||
@ -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("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 */
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@ -27,19 +28,27 @@ type Server struct {
|
||||
db *pgxpool.Pool
|
||||
jwtSecret []byte
|
||||
frontendOrigin string
|
||||
presenceMu sync.Mutex
|
||||
presenceStatus map[string]string
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Avatar string `json:"avatar"`
|
||||
Unit string `json:"unit"`
|
||||
Group string `json:"group"`
|
||||
Rights []string `json:"rights"`
|
||||
Teams []Team `json:"teams"`
|
||||
TokenVersion int `json:"-"`
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Avatar string `json:"avatar"`
|
||||
Unit string `json:"unit"`
|
||||
Group string `json:"group"`
|
||||
Rights []string `json:"rights"`
|
||||
Teams []Team `json:"teams"`
|
||||
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 {
|
||||
@ -87,6 +96,7 @@ func NewServer(db *pgxpool.Pool, jwtSecret string, frontendOrigin string) *Serve
|
||||
db: db,
|
||||
jwtSecret: []byte(jwtSecret),
|
||||
frontendOrigin: frontendOrigin,
|
||||
presenceStatus: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
@ -196,6 +206,14 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
writeError(w, http.StatusInternalServerError, "Could not create session")
|
||||
return
|
||||
@ -249,6 +267,11 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
writeError(w, http.StatusInternalServerError, "Could not create session")
|
||||
return
|
||||
@ -264,10 +287,22 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = s.db.Exec(
|
||||
r.Context(),
|
||||
`
|
||||
UPDATE user_sessions
|
||||
SET revoked_at = now()
|
||||
WHERE id = $1
|
||||
AND revoked_at IS NULL
|
||||
WITH revoked_session AS (
|
||||
UPDATE user_sessions
|
||||
SET revoked_at = now()
|
||||
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,
|
||||
)
|
||||
@ -381,6 +416,11 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
|
||||
_ = 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(ctx, sessionContextKey, claims.SessionID)
|
||||
@ -408,6 +448,11 @@ func (s *Server) getUserByIdentifier(ctx context.Context, identifier string) (Us
|
||||
COALESCE(unit, ''),
|
||||
COALESCE(user_group, ''),
|
||||
COALESCE(rights, '{}'::TEXT[]),
|
||||
COALESCE(presence_mode, 'online'),
|
||||
away_after_minutes,
|
||||
show_last_seen,
|
||||
`+presenceStatusSQL+`,
|
||||
last_seen_at,
|
||||
token_version
|
||||
FROM users
|
||||
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.Group,
|
||||
&user.Rights,
|
||||
&user.PresenceMode,
|
||||
&user.AwayAfterMinutes,
|
||||
&user.ShowLastSeen,
|
||||
&user.PresenceStatus,
|
||||
&user.LastSeenAt,
|
||||
&user.TokenVersion,
|
||||
)
|
||||
user.Online = user.PresenceStatus == "online"
|
||||
|
||||
if err != nil {
|
||||
return user, passwordHash, err
|
||||
@ -456,6 +507,11 @@ func (s *Server) getUserByID(ctx context.Context, userID string) (User, error) {
|
||||
COALESCE(unit, ''),
|
||||
COALESCE(user_group, ''),
|
||||
COALESCE(rights, '{}'::TEXT[]),
|
||||
COALESCE(presence_mode, 'online'),
|
||||
away_after_minutes,
|
||||
show_last_seen,
|
||||
`+presenceStatusSQL+`,
|
||||
last_seen_at,
|
||||
token_version
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
@ -471,8 +527,14 @@ func (s *Server) getUserByID(ctx context.Context, userID string) (User, error) {
|
||||
&user.Unit,
|
||||
&user.Group,
|
||||
&user.Rights,
|
||||
&user.PresenceMode,
|
||||
&user.AwayAfterMinutes,
|
||||
&user.ShowLastSeen,
|
||||
&user.PresenceStatus,
|
||||
&user.LastSeenAt,
|
||||
&user.TokenVersion,
|
||||
)
|
||||
user.Online = user.PresenceStatus == "online"
|
||||
|
||||
if err != nil {
|
||||
return user, err
|
||||
|
||||
@ -229,6 +229,13 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||
token_version INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
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(),
|
||||
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
|
||||
// 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_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 (
|
||||
@ -436,6 +495,12 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||
case_worker TEXT NOT NULL DEFAULT '',
|
||||
target_object 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_team 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 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 (
|
||||
@ -485,12 +556,15 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||
entity_id UUID,
|
||||
|
||||
data JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||
in_app_visible BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
read_at TIMESTAMPTZ,
|
||||
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 (
|
||||
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_journals 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(),
|
||||
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 (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@ -619,15 +698,14 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||
`,
|
||||
|
||||
// ── Chat ────────────────────────────────────────────────────────────
|
||||
// Eine Konversation ist entweder ein Team-Gruppenchat (type='team',
|
||||
// genau einer pro Team), ein Einzelchat zwischen zwei Personen
|
||||
// (type='direct') oder ein freier Gruppenchat (type='group').
|
||||
// Eine Konversation ist entweder ein Team-Gruppenchat, Einzelchat,
|
||||
// freier Gruppenchat oder ein schreibgeschützter Integrations-Channel.
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
type TEXT NOT NULL DEFAULT 'direct'
|
||||
CHECK (type IN ('team', 'direct', 'group')),
|
||||
CHECK (type IN ('team', 'direct', 'group', 'channel')),
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
|
||||
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),
|
||||
-- damit pro Personenpaar nur eine Konversation existiert.
|
||||
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,
|
||||
|
||||
@ -644,6 +729,15 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||
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 (
|
||||
@ -651,6 +745,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
last_read_at TIMESTAMPTZ,
|
||||
muted BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
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 (
|
||||
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,
|
||||
|
||||
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(),
|
||||
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_username ON users(username)`,
|
||||
`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.
|
||||
`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_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_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_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_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 {
|
||||
|
||||
@ -9,6 +9,10 @@ import (
|
||||
)
|
||||
|
||||
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{
|
||||
"user": user,
|
||||
})
|
||||
|
||||
@ -54,6 +54,16 @@ func (s *Server) createUserSession(
|
||||
err := s.db.QueryRow(
|
||||
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 (
|
||||
user_id,
|
||||
browser,
|
||||
@ -144,11 +154,18 @@ func (s *Server) touchUserSession(
|
||||
_, err := s.db.Exec(
|
||||
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()
|
||||
WHERE id = $1
|
||||
AND user_id = $2
|
||||
AND revoked_at IS NULL
|
||||
FROM touched_session
|
||||
WHERE users.id = touched_session.user_id
|
||||
`,
|
||||
sessionID,
|
||||
userID,
|
||||
|
||||
@ -18,8 +18,6 @@ type updateSettingsAccountRequest struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Avatar string `json:"avatar"`
|
||||
Unit string `json:"unit"`
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
type updateSettingsPasswordRequest struct {
|
||||
@ -49,8 +47,6 @@ func (s *Server) handleUpdateSettingsAccount(w http.ResponseWriter, r *http.Requ
|
||||
email := normalizeEmail(payload.Email)
|
||||
displayName := strings.TrimSpace(payload.DisplayName)
|
||||
avatar := strings.TrimSpace(payload.Avatar)
|
||||
unit := strings.TrimSpace(payload.Unit)
|
||||
group := strings.TrimSpace(payload.Group)
|
||||
|
||||
if username == "" {
|
||||
writeSettingsError(w, http.StatusBadRequest, "Benutzername ist erforderlich")
|
||||
@ -66,10 +62,6 @@ func (s *Server) handleUpdateSettingsAccount(w http.ResponseWriter, r *http.Requ
|
||||
displayName = username
|
||||
}
|
||||
|
||||
if group == "" {
|
||||
group = "user"
|
||||
}
|
||||
|
||||
commandTag, err := s.db.Exec(
|
||||
r.Context(),
|
||||
`
|
||||
@ -79,17 +71,13 @@ func (s *Server) handleUpdateSettingsAccount(w http.ResponseWriter, r *http.Requ
|
||||
display_name = $2,
|
||||
email = $3,
|
||||
avatar = $4,
|
||||
unit = $5,
|
||||
user_group = $6,
|
||||
updated_at = now()
|
||||
WHERE id = $7
|
||||
WHERE id = $5
|
||||
`,
|
||||
username,
|
||||
displayName,
|
||||
email,
|
||||
avatar,
|
||||
unit,
|
||||
group,
|
||||
userID,
|
||||
)
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
// frontend/src/App.tsx
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Navigate, Route, Routes, useNavigate } from 'react-router'
|
||||
import { NotificationProvider } from './components/Notifications'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'
|
||||
import { useNotifications } from './components/Notifications'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import AppLayout from './components/AppLayout'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
@ -21,6 +21,8 @@ import Feedback from './components/Feedback'
|
||||
import MilestoneStoragePage from './pages/operations/milestone-storage/MilestoneStoragePage'
|
||||
import MilestoneDevicesPage from './pages/operations/milestone-devices/MilestoneDevicesPage'
|
||||
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'
|
||||
|
||||
@ -65,6 +67,23 @@ function getUserFromNotification(notification: Notification) {
|
||||
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) {
|
||||
if (!soundName) {
|
||||
return
|
||||
@ -96,31 +115,57 @@ function RequireRight({
|
||||
|
||||
function App() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { showToast } = useNotifications()
|
||||
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
const [unreadChatCount, setUnreadChatCount] = useState(0)
|
||||
const [unreadJournalOperationIds, setUnreadJournalOperationIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
)
|
||||
const currentUserId = user?.id
|
||||
const awayAfterMinutes = user?.awayAfterMinutes
|
||||
|
||||
const unreadNotificationCount = notifications.filter(
|
||||
(notification) => !notification.readAt,
|
||||
).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(() => {
|
||||
void loadUser()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
recordNavigation(location.pathname)
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUserId) {
|
||||
setNotifications([])
|
||||
setUnreadJournalOperationIds(new Set())
|
||||
return
|
||||
}
|
||||
|
||||
void loadNotifications()
|
||||
void loadUnreadChatCount()
|
||||
void loadUnreadJournalOperations()
|
||||
|
||||
const eventSource = new EventSource(`${API_URL}/events`, {
|
||||
@ -132,6 +177,89 @@ function App() {
|
||||
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)
|
||||
|
||||
setNotifications((currentNotifications) => [
|
||||
@ -167,6 +295,10 @@ function App() {
|
||||
|
||||
addVisibleNotification(notification)
|
||||
dispatchEntityEvent(notification)
|
||||
|
||||
if (notification.entityType === 'chat') {
|
||||
void loadUnreadChatCount()
|
||||
}
|
||||
}
|
||||
|
||||
function handleOperationEvent(event: MessageEvent) {
|
||||
@ -248,7 +380,103 @@ function App() {
|
||||
eventSource.removeEventListener('user-event', handleUserEvent)
|
||||
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() {
|
||||
try {
|
||||
@ -352,6 +580,16 @@ function App() {
|
||||
}
|
||||
|
||||
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 (
|
||||
notification.type === 'operation.created' ||
|
||||
notification.entityType === 'operation'
|
||||
@ -369,6 +607,7 @@ function App() {
|
||||
} finally {
|
||||
setUser(null)
|
||||
setNotifications([])
|
||||
setUnreadChatCount(0)
|
||||
setUnreadJournalOperationIds(new Set())
|
||||
}
|
||||
}
|
||||
@ -396,10 +635,11 @@ function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<NotificationProvider>
|
||||
<AppLayout
|
||||
<AppLayout
|
||||
user={user}
|
||||
onUserUpdated={setUser}
|
||||
onLogout={handleLogout}
|
||||
unreadChatCount={unreadChatCount}
|
||||
notificationCenter={
|
||||
<NotificationCenter
|
||||
notifications={notifications}
|
||||
@ -511,12 +751,24 @@ function App() {
|
||||
/>
|
||||
|
||||
<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 />} />
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
</NotificationProvider>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
lon: number
|
||||
type?: string
|
||||
hasHouseNumber?: boolean
|
||||
geojson?: GeoJsonObject | null
|
||||
}
|
||||
|
||||
type Coordinates = {
|
||||
export type AddressCoordinates = {
|
||||
lat: number
|
||||
lon: number
|
||||
}
|
||||
|
||||
export type AddressArea = {
|
||||
center: AddressCoordinates
|
||||
hasHouseNumber: boolean
|
||||
geojson: GeoJsonObject | null
|
||||
}
|
||||
|
||||
type ReverseGeocodeResponse = {
|
||||
suggestion?: AddressSuggestion
|
||||
}
|
||||
@ -39,8 +46,12 @@ type AddressComboboxProps = {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
position?: AddressCoordinates | null
|
||||
onPositionChange?: (position: AddressCoordinates | null) => void
|
||||
onAreaChange?: (area: AddressArea | null) => void
|
||||
placeholder?: string
|
||||
mapVisible?: boolean
|
||||
showMap?: boolean
|
||||
}
|
||||
|
||||
const inputClassName =
|
||||
@ -73,6 +84,18 @@ function normalizeAddressSuggestion(
|
||||
...suggestion,
|
||||
lat,
|
||||
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,
|
||||
value,
|
||||
onChange,
|
||||
position,
|
||||
onPositionChange,
|
||||
onAreaChange,
|
||||
placeholder = 'Adresse suchen',
|
||||
mapVisible = true,
|
||||
showMap = true,
|
||||
}: AddressComboboxProps) {
|
||||
const [query, setQuery] = useState(value)
|
||||
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([])
|
||||
@ -96,6 +123,21 @@ export default function AddressCombobox({
|
||||
const [mapFocusKey, setMapFocusKey] = useState(0)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
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 initializedAddressRef = useRef('')
|
||||
@ -104,6 +146,26 @@ export default function AddressCombobox({
|
||||
const reverseGeocodeAbortRef = useRef<AbortController | null>(null)
|
||||
const internalValueUpdateRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!highlightedSuggestion?.geojson ||
|
||||
!effectiveMarkerPosition
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
onAreaChange?.(
|
||||
createAddressArea(highlightedSuggestion, {
|
||||
lat: highlightedSuggestion.lat,
|
||||
lon: highlightedSuggestion.lon,
|
||||
}),
|
||||
)
|
||||
}, [
|
||||
effectiveMarkerPosition,
|
||||
highlightedSuggestion,
|
||||
onAreaChange,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== previousValueRef.current) {
|
||||
setQuery(value)
|
||||
@ -136,7 +198,7 @@ export default function AddressCombobox({
|
||||
return
|
||||
}
|
||||
|
||||
if (markerPosition) {
|
||||
if (effectiveMarkerPosition) {
|
||||
setMapFocusKey((currentKey) => currentKey + 1)
|
||||
return
|
||||
}
|
||||
@ -144,7 +206,7 @@ export default function AddressCombobox({
|
||||
initializedAddressRef.current = ''
|
||||
|
||||
void loadInitialMarkerForAddress(value)
|
||||
}, [mapVisible, markerPosition, value])
|
||||
}, [mapVisible, effectiveMarkerPosition, value])
|
||||
|
||||
useEffect(() => {
|
||||
const trimmedValue = value.trim()
|
||||
@ -154,7 +216,7 @@ export default function AddressCombobox({
|
||||
return
|
||||
}
|
||||
|
||||
if (markerPosition) {
|
||||
if (effectiveMarkerPosition) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -165,7 +227,63 @@ export default function AddressCombobox({
|
||||
return () => {
|
||||
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(
|
||||
address: string,
|
||||
@ -177,7 +295,10 @@ export default function AddressCombobox({
|
||||
return
|
||||
}
|
||||
|
||||
if (initializedAddressRef.current === trimmedAddress && markerPosition) {
|
||||
if (
|
||||
initializedAddressRef.current === trimmedAddress &&
|
||||
effectiveMarkerPosition
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -207,7 +328,7 @@ export default function AddressCombobox({
|
||||
const reverseSuggestion = await reverseGeocodeCoordinates({
|
||||
lat: firstSuggestion.lat,
|
||||
lon: firstSuggestion.lon,
|
||||
})
|
||||
}, trimmedAddress, firstSuggestion.id)
|
||||
|
||||
const nextSuggestion: AddressSuggestion = {
|
||||
...firstSuggestion,
|
||||
@ -222,6 +343,16 @@ export default function AddressCombobox({
|
||||
lat: nextSuggestion.lat,
|
||||
lon: nextSuggestion.lon,
|
||||
})
|
||||
onPositionChange?.({
|
||||
lat: nextSuggestion.lat,
|
||||
lon: nextSuggestion.lon,
|
||||
})
|
||||
onAreaChange?.(
|
||||
createAddressArea(nextSuggestion, {
|
||||
lat: nextSuggestion.lat,
|
||||
lon: nextSuggestion.lon,
|
||||
}),
|
||||
)
|
||||
setMapFocusKey((currentKey) => currentKey + 1)
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
@ -231,7 +362,9 @@ export default function AddressCombobox({
|
||||
}
|
||||
|
||||
async function reverseGeocodeCoordinates(
|
||||
coordinates: Coordinates,
|
||||
coordinates: AddressCoordinates,
|
||||
address = value,
|
||||
osmIdentifier = '',
|
||||
): Promise<AddressSuggestion | null> {
|
||||
reverseGeocodeAbortRef.current?.abort()
|
||||
|
||||
@ -242,7 +375,11 @@ export default function AddressCombobox({
|
||||
const response = await fetch(
|
||||
`${API_URL}/address-reverse?lat=${encodeURIComponent(
|
||||
coordinates.lat,
|
||||
)}&lon=${encodeURIComponent(coordinates.lon)}`,
|
||||
)}&lon=${encodeURIComponent(
|
||||
coordinates.lon,
|
||||
)}&q=${encodeURIComponent(
|
||||
address.trim(),
|
||||
)}&osm_id=${encodeURIComponent(osmIdentifier)}`,
|
||||
{
|
||||
credentials: 'include',
|
||||
signal: abortController.signal,
|
||||
@ -279,14 +416,16 @@ export default function AddressCombobox({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMapPositionChange(coordinates: Coordinates) {
|
||||
async function handleMapPositionChange(coordinates: AddressCoordinates) {
|
||||
setMarkerPosition(coordinates)
|
||||
onPositionChange?.(coordinates)
|
||||
setSelectedSuggestion(null)
|
||||
setPreviewSuggestion(null)
|
||||
|
||||
const suggestion = await reverseGeocodeCoordinates(coordinates)
|
||||
|
||||
if (!suggestion) {
|
||||
onAreaChange?.(createAddressArea(null, coordinates))
|
||||
return
|
||||
}
|
||||
|
||||
@ -296,6 +435,7 @@ export default function AddressCombobox({
|
||||
setQuery(suggestion.label)
|
||||
setSelectedSuggestion(suggestion)
|
||||
setPreviewSuggestion(suggestion)
|
||||
onAreaChange?.(createAddressArea(suggestion, coordinates))
|
||||
|
||||
onChange(suggestion.label)
|
||||
}
|
||||
@ -372,6 +512,8 @@ export default function AddressCombobox({
|
||||
setSelectedSuggestion(null)
|
||||
setPreviewSuggestion(null)
|
||||
setMarkerPosition(null)
|
||||
onPositionChange?.(null)
|
||||
onAreaChange?.(null)
|
||||
onChange(nextValue)
|
||||
}
|
||||
|
||||
@ -390,12 +532,22 @@ export default function AddressCombobox({
|
||||
lat: normalizedSuggestion.lat,
|
||||
lon: normalizedSuggestion.lon,
|
||||
})
|
||||
onPositionChange?.({
|
||||
lat: normalizedSuggestion.lat,
|
||||
lon: normalizedSuggestion.lon,
|
||||
})
|
||||
onAreaChange?.(
|
||||
createAddressArea(normalizedSuggestion, {
|
||||
lat: normalizedSuggestion.lat,
|
||||
lon: normalizedSuggestion.lon,
|
||||
}),
|
||||
)
|
||||
setMapFocusKey((currentKey) => currentKey + 1)
|
||||
|
||||
const reverseSuggestion = await reverseGeocodeCoordinates({
|
||||
lat: normalizedSuggestion.lat,
|
||||
lon: normalizedSuggestion.lon,
|
||||
})
|
||||
}, normalizedSuggestion.label, normalizedSuggestion.id)
|
||||
|
||||
const nextSuggestion: AddressSuggestion = {
|
||||
...normalizedSuggestion,
|
||||
@ -409,6 +561,12 @@ export default function AddressCombobox({
|
||||
setQuery(nextSuggestion.label)
|
||||
setSelectedSuggestion(nextSuggestion)
|
||||
setPreviewSuggestion(nextSuggestion)
|
||||
onAreaChange?.(
|
||||
createAddressArea(nextSuggestion, {
|
||||
lat: nextSuggestion.lat,
|
||||
lon: nextSuggestion.lon,
|
||||
}),
|
||||
)
|
||||
|
||||
onChange(nextSuggestion.label)
|
||||
}
|
||||
@ -472,14 +630,16 @@ export default function AddressCombobox({
|
||||
</div>
|
||||
</HeadlessCombobox>
|
||||
|
||||
<AddressMapPicker
|
||||
key={mapKey}
|
||||
value={markerPosition}
|
||||
focusKey={mapFocusKey}
|
||||
visible={mapVisible}
|
||||
highlightGeoJson={previewSuggestion?.geojson ?? null}
|
||||
onChange={handleMapPositionChange}
|
||||
/>
|
||||
{showMap && (
|
||||
<AddressMapPicker
|
||||
key={mapKey}
|
||||
value={effectiveMarkerPosition}
|
||||
focusKey={mapFocusKey}
|
||||
visible={mapVisible}
|
||||
highlightGeoJson={previewSuggestion?.geojson ?? null}
|
||||
onChange={handleMapPositionChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,16 +10,20 @@ import type { User } from './types'
|
||||
|
||||
type AppLayoutProps = {
|
||||
user: User
|
||||
onUserUpdated: (user: User) => void
|
||||
onLogout: () => void | Promise<void>
|
||||
children: ReactNode
|
||||
notificationCenter?: ReactNode
|
||||
unreadChatCount?: number
|
||||
}
|
||||
|
||||
export default function AppLayout({
|
||||
user,
|
||||
onUserUpdated,
|
||||
onLogout,
|
||||
children,
|
||||
notificationCenter,
|
||||
unreadChatCount = 0,
|
||||
}: AppLayoutProps) {
|
||||
const location = useLocation()
|
||||
|
||||
@ -48,11 +52,13 @@ export default function AppLayout({
|
||||
sidebarOpen={sidebarOpen}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
onNavigate={handleNavigation}
|
||||
unreadChatCount={unreadChatCount}
|
||||
/>
|
||||
|
||||
<div className="relative z-0 flex h-dvh min-w-0 flex-col lg:pl-72">
|
||||
<Topbar
|
||||
user={user}
|
||||
onUserUpdated={onUserUpdated}
|
||||
onLogout={onLogout}
|
||||
onOpenSidebar={() => setSidebarOpen(true)}
|
||||
notificationCenter={notificationCenter}
|
||||
@ -85,4 +91,4 @@ export default function AppLayout({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,8 @@ import type { ImgHTMLAttributes } from 'react'
|
||||
|
||||
export type AvatarSize = 6 | 8 | 9 | 10 | 12 | 14 | 16
|
||||
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 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).
|
||||
* Wird ignoriert, wenn `status` explizit gesetzt ist.
|
||||
*/
|
||||
onlineStatus?: boolean
|
||||
presenceStatus?: PresenceStatus
|
||||
/** @deprecated Nutze `onlineStatus`. */
|
||||
online?: boolean
|
||||
showPlaceholderIcon?: boolean
|
||||
wrapperClassName?: string
|
||||
@ -62,6 +66,7 @@ const statusColorClassNames: Record<AvatarStatus, string> = {
|
||||
gray: 'bg-gray-300 dark:bg-gray-500',
|
||||
red: 'bg-red-400 dark:bg-red-500',
|
||||
green: 'bg-green-400 dark:bg-green-500',
|
||||
amber: 'bg-amber-400 dark:bg-amber-500',
|
||||
}
|
||||
|
||||
function getInitials(name?: string, initials?: string) {
|
||||
@ -104,6 +109,8 @@ export default function Avatar({
|
||||
shape = 'circle',
|
||||
status,
|
||||
statusPosition = 'bottom',
|
||||
onlineStatus,
|
||||
presenceStatus,
|
||||
online,
|
||||
showPlaceholderIcon = true,
|
||||
wrapperClassName,
|
||||
@ -114,10 +121,35 @@ export default function Avatar({
|
||||
const avatarInitials = getInitials(name, initials)
|
||||
|
||||
// `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 =
|
||||
status ?? (online === undefined ? undefined : online ? 'green' : 'gray')
|
||||
status ??
|
||||
presenceAvatarStatus ??
|
||||
(resolvedOnlineStatus === undefined
|
||||
? undefined
|
||||
: resolvedOnlineStatus
|
||||
? 'green'
|
||||
: 'gray')
|
||||
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 ? (
|
||||
<img
|
||||
@ -171,7 +203,12 @@ export default function Avatar({
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={classNames('relative inline-block', wrapperClassName)}>
|
||||
<span
|
||||
className={classNames(
|
||||
'relative inline-flex shrink-0 align-middle leading-none',
|
||||
wrapperClassName,
|
||||
)}
|
||||
>
|
||||
{avatar}
|
||||
|
||||
<span
|
||||
@ -193,4 +230,4 @@ export default function Avatar({
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,54 +1,96 @@
|
||||
// frontend/src/pages/Feedback.tsx
|
||||
// frontend/src/components/Feedback.tsx
|
||||
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import Textarea from '../components/Textarea'
|
||||
import { useMemo, useState, type FormEvent } from 'react'
|
||||
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 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() {
|
||||
const [logSnapshot, setLogSnapshot] = useState<ActivityLogEntry[]>(() =>
|
||||
getActivityLog(),
|
||||
)
|
||||
const [message, setMessage] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
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>) {
|
||||
event.preventDefault()
|
||||
|
||||
const trimmedMessage = message.trim()
|
||||
|
||||
if (!trimmedMessage) {
|
||||
setError('Bitte gib dein Feedback ein.')
|
||||
setSuccessMessage(null)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
setSuccessMessage(null)
|
||||
|
||||
try {
|
||||
const client = await collectClientMeta()
|
||||
|
||||
const response = await fetch(`${API_URL}/feedback`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
message: trimmedMessage,
|
||||
clientLog: logSnapshot,
|
||||
client,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
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('')
|
||||
setSuccessMessage('Danke, dein Feedback wurde gespeichert.')
|
||||
} catch (error) {
|
||||
setSubmitted(true)
|
||||
} catch (submitError) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
submitError instanceof Error
|
||||
? submitError.message
|
||||
: 'Feedback konnte nicht gesendet werden',
|
||||
)
|
||||
} finally {
|
||||
@ -56,56 +98,141 @@ export default function Feedback() {
|
||||
}
|
||||
}
|
||||
|
||||
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="mx-auto max-w-3xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
Feedback
|
||||
</h1>
|
||||
function startNewFeedback() {
|
||||
setSubmitted(false)
|
||||
setMessage('')
|
||||
setError(null)
|
||||
setLogSnapshot(getActivityLog())
|
||||
}
|
||||
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Teile uns mit, wenn dir etwas auffällt, etwas nicht funktioniert oder du eine Idee zur Verbesserung hast.
|
||||
Beim Absenden wird automatisch ein technisches Log mit deinem Benutzerkonto angehängt.
|
||||
</p>
|
||||
return (
|
||||
<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]">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<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>
|
||||
|
||||
{successMessage && (
|
||||
<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">
|
||||
{successMessage}
|
||||
{submitted ? (
|
||||
<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">
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,11 +2,15 @@
|
||||
|
||||
import { type ComponentType, type SVGProps, useState } from 'react'
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
ClockIcon,
|
||||
PaperAirplaneIcon,
|
||||
PencilSquareIcon,
|
||||
PlusCircleIcon,
|
||||
UserCircleIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import Avatar from './Avatar'
|
||||
import { Badge, type BadgeTone } from './Badge'
|
||||
import Button from './Button'
|
||||
import NotificationDot from './NotificationDot'
|
||||
|
||||
@ -126,6 +130,25 @@ function getJournalIconBackground(item: JournalItem) {
|
||||
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({
|
||||
items = [],
|
||||
currentUserAvatar,
|
||||
@ -177,151 +200,189 @@ export default function Journal({
|
||||
return (
|
||||
<div className={className}>
|
||||
{showEntryForm && (
|
||||
<div className="mb-8 flex gap-x-3">
|
||||
{currentUserAvatar ? (
|
||||
<img
|
||||
alt=""
|
||||
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
|
||||
<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">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<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">
|
||||
<ChatBubbleLeftEllipsisIcon
|
||||
aria-hidden="true"
|
||||
className="size-6 text-gray-500 dark:text-gray-400"
|
||||
/>
|
||||
</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"
|
||||
className="size-5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 flex justify-end py-2 pr-2 pl-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
color="indigo"
|
||||
size="sm"
|
||||
isLoading={isSubmitting}
|
||||
disabled={!entry.trim() || isSubmitting}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
Neuer Journal-Eintrag
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Notizen und wichtige Informationen nachvollziehbar festhalten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{emptyText}
|
||||
</p>
|
||||
<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]">
|
||||
<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">
|
||||
<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">
|
||||
<ul role="list" className="-mb-8">
|
||||
<ul role="list" className="space-y-4">
|
||||
{items.map((item, itemIndex) => {
|
||||
const personName = getItemPersonName(item)
|
||||
const imageUrl = getItemImageUrl(item)
|
||||
const dateTime = getItemDateTime(item)
|
||||
const Icon = getJournalIcon(item)
|
||||
const itemType = getJournalType(item)
|
||||
const isManualEntry = item.type === 'journal' || item.type === 'comment'
|
||||
const changes = item.changes ?? []
|
||||
const hasChanges = !isManualEntry && changes.length > 0
|
||||
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<div className="relative pb-8">
|
||||
{itemIndex !== items.length - 1 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute top-4 left-4 z-0 -ml-px h-full w-0.5 bg-gray-200 dark:bg-white/10"
|
||||
<li key={item.id} className="relative">
|
||||
{itemIndex !== items.length - 1 && (
|
||||
<span
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
|
||||
<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">
|
||||
<div className="relative z-10 size-8 flex-none self-start">
|
||||
{imageUrl ? (
|
||||
<img
|
||||
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>
|
||||
<span
|
||||
className={classNames(
|
||||
getJournalIconBackground(item),
|
||||
'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',
|
||||
)}
|
||||
>
|
||||
<Icon aria-hidden="true" className="size-3 text-white" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={classNames(
|
||||
getJournalIconBackground(item),
|
||||
'absolute -right-1 -bottom-1 z-20 flex size-5 items-center justify-center rounded-full ring-2 ring-white dark:ring-gray-900',
|
||||
)}
|
||||
>
|
||||
<Icon aria-hidden="true" className="size-3.5 text-white" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 pt-1.5">
|
||||
<div className="flex justify-between gap-x-4">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="inline-flex items-center gap-x-2 font-medium text-gray-900 dark:text-white">
|
||||
<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)}
|
||||
<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">
|
||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{item.person?.href ? (
|
||||
<a
|
||||
href={item.person.href}
|
||||
className="truncate text-sm font-semibold text-gray-950 hover:text-indigo-600 dark:text-white dark:hover:text-indigo-300"
|
||||
>
|
||||
{personName}
|
||||
</a>
|
||||
) : (
|
||||
<span className="truncate text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{personName}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="text-right whitespace-nowrap">
|
||||
<time
|
||||
dateTime={dateTime}
|
||||
className="block text-sm text-gray-500 dark:text-gray-400"
|
||||
<Badge
|
||||
tone={itemType.tone}
|
||||
variant="border"
|
||||
shape="pill"
|
||||
size="small"
|
||||
>
|
||||
{item.date}
|
||||
</time>
|
||||
{itemType.label}
|
||||
</Badge>
|
||||
|
||||
{item.hasNotification && (
|
||||
<NotificationDot
|
||||
title="Neuer Journal-Eintrag"
|
||||
label="Neuer Journal-Eintrag"
|
||||
className="size-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isManualEntry && (
|
||||
<p className="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
{getItemDescription(item)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isManualEntry && editingItemId === item.id ? (
|
||||
<div className="mt-2">
|
||||
<time
|
||||
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
|
||||
rows={3}
|
||||
value={editEntry}
|
||||
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
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@ -345,77 +406,87 @@ export default function Journal({
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : 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="flex items-start gap-x-3">
|
||||
<div className="min-w-0 flex-1 py-1 whitespace-pre-wrap break-words">
|
||||
{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>
|
||||
) : isManualEntry ? (
|
||||
<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 flex-col gap-3 sm:flex-row sm:items-start">
|
||||
<div className="min-w-0 flex-1 whitespace-pre-wrap break-words leading-6">
|
||||
{getItemDescription(item)}
|
||||
</div>
|
||||
|
||||
{item.editedDate && (
|
||||
<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">
|
||||
<PencilSquareIcon aria-hidden="true" className="size-3.5" />
|
||||
<span>
|
||||
Zuletzt bearbeitet:{' '}
|
||||
<time dateTime={getItemEditedDateTime(item)}>
|
||||
{item.editedDate}
|
||||
</time>
|
||||
</span>
|
||||
</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>
|
||||
) : hasChanges ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
{changes.map((change, changeIndex) => (
|
||||
<div
|
||||
key={getChangeKey(change, changeIndex)}
|
||||
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"
|
||||
>
|
||||
<div className="text-xs font-semibold text-gray-900 dark:text-white">
|
||||
{change.label}
|
||||
|
||||
{item.editedDate && (
|
||||
<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">
|
||||
<PencilSquareIcon aria-hidden="true" className="size-3.5" />
|
||||
<span>
|
||||
Zuletzt bearbeitet:{' '}
|
||||
<time dateTime={getItemEditedDateTime(item)}>
|
||||
{item.editedDate}
|
||||
</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 className="mt-2 grid grid-cols-1 gap-2 lg:grid-cols-2">
|
||||
<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">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-400 dark:text-gray-500">
|
||||
Vorher
|
||||
</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="hidden items-center lg:flex">
|
||||
<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">
|
||||
<ArrowRightIcon aria-hidden="true" className="size-3.5" />
|
||||
</span>
|
||||
</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="text-[11px] font-medium uppercase tracking-wide text-indigo-500 dark:text-indigo-300">
|
||||
Nachher
|
||||
</div>
|
||||
<div className="mt-1 whitespace-pre-wrap break-words text-sm font-medium text-gray-900 dark:text-white">
|
||||
{getChangeValue(change.newValue)}
|
||||
</div>
|
||||
<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-[10px] font-semibold tracking-wider text-indigo-600 uppercase dark:text-indigo-300">
|
||||
Nachher
|
||||
</div>
|
||||
<div className="mt-1.5 whitespace-pre-wrap break-words text-sm font-medium text-gray-950 dark:text-white">
|
||||
{getChangeValue(change.newValue)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
@ -425,4 +496,4 @@ export default function Journal({
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
@ -18,6 +19,7 @@ import {
|
||||
XCircleIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { XMarkIcon } from '@heroicons/react/20/solid'
|
||||
import Avatar from './Avatar'
|
||||
|
||||
export type NotificationVariant =
|
||||
| 'success'
|
||||
@ -34,6 +36,11 @@ export type ToastNotification = {
|
||||
onAction?: () => void
|
||||
autoClose?: boolean
|
||||
duration?: number
|
||||
imageUrl?: string
|
||||
imageName?: string
|
||||
replyPlaceholder?: string
|
||||
replyActionLabel?: string
|
||||
onReply?: (message: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
type NotificationsProps = {
|
||||
@ -119,9 +126,42 @@ function NotificationToast({
|
||||
onAction,
|
||||
autoClose = true,
|
||||
duration = 3000,
|
||||
imageUrl,
|
||||
imageName,
|
||||
replyPlaceholder,
|
||||
replyActionLabel = 'Antworten',
|
||||
onReply,
|
||||
} = notification
|
||||
|
||||
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(() => {
|
||||
if (!autoClose) {
|
||||
@ -143,10 +183,18 @@ function NotificationToast({
|
||||
<div className="p-4">
|
||||
<div className="flex items-start">
|
||||
<div className="shrink-0">
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className={classNames('size-6', iconClassName)}
|
||||
/>
|
||||
{imageUrl || imageName ? (
|
||||
<Avatar
|
||||
src={imageUrl}
|
||||
name={imageName || title}
|
||||
size={10}
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className={classNames('size-6', iconClassName)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-3 w-0 flex-1 pt-0.5">
|
||||
@ -160,11 +208,40 @@ function NotificationToast({
|
||||
</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 && (
|
||||
<div className="mt-3">
|
||||
<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"
|
||||
>
|
||||
{actionLabel}
|
||||
@ -311,4 +388,4 @@ export function useNotifications() {
|
||||
return context
|
||||
}
|
||||
|
||||
export default NotificationsViewport
|
||||
export default NotificationsViewport
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
ExclamationTriangleIcon,
|
||||
MagnifyingGlassIcon,
|
||||
UserGroupIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import type { User } from './types'
|
||||
import Checkbox from './Checkbox'
|
||||
@ -139,7 +140,7 @@ function HighlightedText({
|
||||
return (
|
||||
<mark
|
||||
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}
|
||||
</mark>
|
||||
@ -284,13 +285,18 @@ export default function SearchOverlay({
|
||||
search.length >= 2 && !isLoading && !error && !hasResults
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto bg-white/95 backdrop-blur-sm [scrollbar-gutter:stable]">
|
||||
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<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="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 */}
|
||||
<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 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
|
||||
aria-hidden="true"
|
||||
className="size-6"
|
||||
@ -298,11 +304,11 @@ export default function SearchOverlay({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-base font-semibold text-gray-900">
|
||||
<p className="text-base font-semibold text-gray-950 dark:text-white">
|
||||
Suche
|
||||
</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
|
||||
? `${resultCount} Treffer für „${query}“`
|
||||
: 'Gib mindestens 2 Zeichen ein.'}
|
||||
@ -312,16 +318,25 @@ export default function SearchOverlay({
|
||||
|
||||
<div className="flex flex-col gap-3 sm:items-end">
|
||||
{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 && (
|
||||
<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`}
|
||||
</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
|
||||
checked={effectiveExactPhrase}
|
||||
disabled={!canUseExactPhrase}
|
||||
@ -330,8 +345,8 @@ export default function SearchOverlay({
|
||||
wrapperClassName={[
|
||||
'items-center gap-2 rounded-full px-3 py-1.5 ring-1 ring-inset',
|
||||
canUseExactPhrase
|
||||
? 'cursor-pointer bg-white text-gray-700 ring-gray-200 hover:bg-gray-50'
|
||||
: 'cursor-not-allowed bg-gray-50 text-gray-400 ring-gray-200',
|
||||
? '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/80 text-gray-400 ring-gray-200 dark:bg-white/[0.03] dark:text-gray-600 dark:ring-white/5',
|
||||
].join(' ')}
|
||||
labelClassName="text-xs/5 font-medium text-inherit"
|
||||
inputClassName="size-4"
|
||||
@ -341,7 +356,7 @@ export default function SearchOverlay({
|
||||
checked={caseSensitive}
|
||||
onChange={(event) => setCaseSensitive(event.target.checked)}
|
||||
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"
|
||||
inputClassName="size-4"
|
||||
/>
|
||||
@ -351,72 +366,72 @@ export default function SearchOverlay({
|
||||
</div>
|
||||
|
||||
{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
|
||||
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
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFullLoading && (
|
||||
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-10 text-center shadow-sm">
|
||||
<div className="mx-auto size-8 animate-spin rounded-full border-2 border-gray-200 border-t-indigo-600" />
|
||||
<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 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...
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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
|
||||
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
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-sm text-red-700">
|
||||
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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
|
||||
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
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && groups.length > 0 && (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-10">
|
||||
{groups.map((group) => {
|
||||
const Icon = group.icon
|
||||
|
||||
@ -424,7 +439,7 @@ export default function SearchOverlay({
|
||||
<section key={group.id}>
|
||||
<div className="mb-4 flex items-center justify-between gap-x-4">
|
||||
<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
|
||||
aria-hidden="true"
|
||||
className="size-5"
|
||||
@ -432,11 +447,11 @@ export default function SearchOverlay({
|
||||
</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}
|
||||
</h3>
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{group.items.length} Treffer
|
||||
</p>
|
||||
</div>
|
||||
@ -449,15 +464,15 @@ export default function SearchOverlay({
|
||||
key={`${group.id}-${item.id}`}
|
||||
to={item.href}
|
||||
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
|
||||
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>
|
||||
<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
|
||||
value={item.title}
|
||||
search={search}
|
||||
@ -467,7 +482,7 @@ export default function SearchOverlay({
|
||||
</p>
|
||||
|
||||
{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
|
||||
value={item.subtitle}
|
||||
search={search}
|
||||
@ -488,4 +503,4 @@ export default function SearchOverlay({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@ import {
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router'
|
||||
import { hasRight } from './permissions'
|
||||
import type { User, Team } from './types'
|
||||
import type { User } from './types'
|
||||
|
||||
type NavigationItem = {
|
||||
name: string
|
||||
@ -82,6 +82,11 @@ const navigationSections: NavigationSection[] = [
|
||||
{
|
||||
name: 'Organisation',
|
||||
items: [
|
||||
{
|
||||
name: 'Chats',
|
||||
href: '/chat',
|
||||
icon: ChatBubbleLeftRightIcon,
|
||||
},
|
||||
{
|
||||
name: 'Kalender',
|
||||
href: '/kalender',
|
||||
@ -109,20 +114,13 @@ type SidebarProps = {
|
||||
sidebarOpen: boolean
|
||||
setSidebarOpen: (open: boolean) => void
|
||||
onNavigate?: () => void
|
||||
unreadChatCount?: number
|
||||
}
|
||||
|
||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
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) {
|
||||
if (href === '/') {
|
||||
return pathname === '/'
|
||||
@ -170,12 +168,13 @@ function SidebarContent({
|
||||
user,
|
||||
setSidebarOpen,
|
||||
onNavigate,
|
||||
unreadChatCount = 0,
|
||||
}: {
|
||||
user: User
|
||||
setSidebarOpen: (open: boolean) => void
|
||||
onNavigate?: () => void
|
||||
unreadChatCount?: number
|
||||
}) {
|
||||
const teams = user.teams ?? []
|
||||
const location = useLocation()
|
||||
|
||||
const visibleNavigationSections = getVisibleNavigationSections(user)
|
||||
@ -222,6 +221,11 @@ function SidebarContent({
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{showChildren && (
|
||||
@ -298,42 +302,6 @@ function SidebarContent({
|
||||
</div>
|
||||
</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">
|
||||
<h2 className="mb-2 px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
|
||||
Hilfe & System
|
||||
@ -413,6 +381,7 @@ export default function Sidebar({
|
||||
sidebarOpen,
|
||||
setSidebarOpen,
|
||||
onNavigate,
|
||||
unreadChatCount = 0,
|
||||
}: SidebarProps) {
|
||||
return (
|
||||
<>
|
||||
@ -449,6 +418,7 @@ export default function Sidebar({
|
||||
user={user}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
onNavigate={onNavigate}
|
||||
unreadChatCount={unreadChatCount}
|
||||
/>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
@ -461,6 +431,7 @@ export default function Sidebar({
|
||||
user={user}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
onNavigate={onNavigate}
|
||||
unreadChatCount={unreadChatCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
// frontend/src/components/Topbar.tsx
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import {
|
||||
Menu,
|
||||
MenuButton,
|
||||
@ -11,15 +11,19 @@ import {
|
||||
Bars3Icon,
|
||||
BellIcon,
|
||||
} 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 Search from './Search'
|
||||
import Camera from './Camera'
|
||||
import Avatar from './Avatar'
|
||||
import type { User } from './types'
|
||||
import { useNotifications } from './Notifications'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
type TopbarProps = {
|
||||
user: User
|
||||
onUserUpdated: (user: User) => void
|
||||
onLogout: () => void | Promise<void>
|
||||
onOpenSidebar: () => void
|
||||
notificationCenter?: ReactNode
|
||||
@ -34,6 +38,7 @@ const userNavigation = [
|
||||
|
||||
export default function Topbar({
|
||||
user,
|
||||
onUserUpdated,
|
||||
onLogout,
|
||||
onOpenSidebar,
|
||||
notificationCenter,
|
||||
@ -41,10 +46,44 @@ export default function Topbar({
|
||||
onSearchQueryChange,
|
||||
onNavigate,
|
||||
}: TopbarProps) {
|
||||
const { showErrorToast } = useNotifications()
|
||||
const [updatingPresenceMode, setUpdatingPresenceMode] = useState<
|
||||
User['presenceMode'] | null
|
||||
>(null)
|
||||
|
||||
function handleQrScan(value: string) {
|
||||
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 (
|
||||
<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
|
||||
@ -98,6 +137,7 @@ export default function Topbar({
|
||||
src={user.avatar}
|
||||
name={user.displayName || user.username || user.email}
|
||||
size={8}
|
||||
presenceStatus={user.presenceStatus}
|
||||
/>
|
||||
|
||||
<span className="hidden lg:flex lg:items-center">
|
||||
@ -116,8 +156,36 @@ export default function Topbar({
|
||||
|
||||
<MenuItems
|
||||
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) => (
|
||||
<MenuItem key={item.name}>
|
||||
<Link
|
||||
@ -148,4 +216,4 @@ export default function Topbar({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,6 +18,12 @@ export type User = {
|
||||
group: string
|
||||
rights: string[]
|
||||
teams: Team[]
|
||||
onlineStatus: boolean
|
||||
presenceMode: 'online' | 'away' | 'offline'
|
||||
presenceStatus: 'online' | 'away' | 'offline'
|
||||
awayAfterMinutes: number
|
||||
showLastSeen: boolean
|
||||
lastSeenAt?: string | null
|
||||
}
|
||||
|
||||
export type DeviceHistoryChange = {
|
||||
@ -149,6 +155,12 @@ export type Operation = {
|
||||
caseWorker: string
|
||||
targetObject: string
|
||||
kwAddress: string
|
||||
kwLatitude?: number | null
|
||||
kwLongitude?: number | null
|
||||
targetLatitude?: number | null
|
||||
targetLongitude?: number | null
|
||||
viewConeFov?: number
|
||||
viewConeLength?: number
|
||||
operationLeader: string
|
||||
operationTeam: string
|
||||
legend: string
|
||||
@ -194,6 +206,8 @@ export type Notification = {
|
||||
data: unknown
|
||||
silent: boolean
|
||||
soundName?: string
|
||||
desktop?: boolean
|
||||
inAppEnabled?: boolean
|
||||
readAt?: string | null
|
||||
createdAt: string
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,11 +5,17 @@ import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { NotificationProvider } from './components/Notifications.tsx'
|
||||
import { initActivityLog } from './utils/activityLog.ts'
|
||||
|
||||
initActivityLog()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<NotificationProvider>
|
||||
<App />
|
||||
</NotificationProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
)
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useLocation, Navigate } from 'react-router'
|
||||
import {
|
||||
ChatBubbleLeftRightIcon,
|
||||
HashtagIcon,
|
||||
UserGroupIcon,
|
||||
UsersIcon,
|
||||
VideoCameraIcon,
|
||||
@ -12,6 +13,7 @@ import UsersAdministration from './users/UsersAdministration'
|
||||
import TeamsAdministration from './teams/TeamsAdministration'
|
||||
import FeedbackAdministration from './feedback/FeedbackAdministration'
|
||||
import Milestone from './milestone/Milestone'
|
||||
import ChannelsAdministration from './channels/ChannelsAdministration'
|
||||
|
||||
export default function AdministrationPage() {
|
||||
const location = useLocation()
|
||||
@ -33,6 +35,12 @@ export default function AdministrationPage() {
|
||||
icon: UserGroupIcon,
|
||||
current: section === 'teams',
|
||||
},
|
||||
{
|
||||
name: 'Channels',
|
||||
href: '/administration/channels',
|
||||
icon: HashtagIcon,
|
||||
current: section === 'channels',
|
||||
},
|
||||
{
|
||||
name: '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 />
|
||||
}
|
||||
|
||||
@ -78,10 +86,11 @@ export default function AdministrationPage() {
|
||||
|
||||
{section === 'benutzer' && <UsersAdministration />}
|
||||
{section === 'teams' && <TeamsAdministration />}
|
||||
{section === 'channels' && <ChannelsAdministration />}
|
||||
{section === 'milestone' && <Milestone />}
|
||||
{section === 'feedback' && <FeedbackAdministration />}
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
MagnifyingGlassIcon,
|
||||
TrashIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import {
|
||||
ComputerDesktopIcon,
|
||||
CursorArrowRaysIcon,
|
||||
ExclamationTriangleIcon,
|
||||
GlobeAltIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import Button from '../../../components/Button'
|
||||
import LoadingSpinner from '../../../components/LoadingSpinner'
|
||||
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'
|
||||
|
||||
@ -21,6 +30,14 @@ type FeedbackItem = {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type ClientMeta = {
|
||||
browser: string
|
||||
operatingSystem: string
|
||||
deviceType: string
|
||||
url: string
|
||||
viewport: string
|
||||
}
|
||||
|
||||
type NotificationLike = {
|
||||
type?: string
|
||||
entityType?: string
|
||||
@ -39,32 +56,82 @@ function formatDate(value: string) {
|
||||
|
||||
function getInitials(name: string, email: string) {
|
||||
const source = name || email
|
||||
|
||||
if (!source) {
|
||||
return '?'
|
||||
}
|
||||
|
||||
const parts = source
|
||||
return source
|
||||
.replace(/@.*/, '')
|
||||
.split(/[.\s_-]+/)
|
||||
.filter(Boolean)
|
||||
|
||||
return parts
|
||||
.slice(0, 2)
|
||||
.map((part) => part.charAt(0))
|
||||
.join('')
|
||||
.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 {
|
||||
try {
|
||||
const parsed = JSON.parse(eventData)
|
||||
|
||||
if (parsed?.notification) {
|
||||
return parsed.notification
|
||||
}
|
||||
|
||||
return parsed
|
||||
return parsed?.notification ?? parsed
|
||||
} catch {
|
||||
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() {
|
||||
const [feedback, setFeedback] = useState<FeedbackItem[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@ -90,17 +172,28 @@ export default function FeedbackAdministration() {
|
||||
const [deletingFeedbackIds, setDeletingFeedbackIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
)
|
||||
const [search, setSearch] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const sortedFeedback = useMemo(
|
||||
() =>
|
||||
[...feedback].sort(
|
||||
(firstItem, secondItem) =>
|
||||
new Date(secondItem.createdAt).getTime() -
|
||||
new Date(firstItem.createdAt).getTime(),
|
||||
),
|
||||
[feedback],
|
||||
)
|
||||
const visibleFeedback = useMemo(() => {
|
||||
const sorted = [...feedback].sort(
|
||||
(firstItem, secondItem) =>
|
||||
new Date(secondItem.createdAt).getTime() -
|
||||
new Date(firstItem.createdAt).getTime(),
|
||||
)
|
||||
|
||||
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 }) => {
|
||||
if (options?.silent) {
|
||||
@ -122,12 +215,11 @@ export default function FeedbackAdministration() {
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
setFeedback(Array.isArray(data.feedback) ? data.feedback : [])
|
||||
} catch (error) {
|
||||
} catch (loadError) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
loadError instanceof Error
|
||||
? loadError.message
|
||||
: 'Feedback konnte nicht geladen werden',
|
||||
)
|
||||
} finally {
|
||||
@ -146,9 +238,7 @@ export default function FeedbackAdministration() {
|
||||
})
|
||||
|
||||
events.onmessage = (event) => {
|
||||
const notification = extractNotification(event.data)
|
||||
|
||||
if (isFeedbackEvent(notification)) {
|
||||
if (isFeedbackEvent(extractNotification(event.data))) {
|
||||
void loadFeedback({ silent: true })
|
||||
}
|
||||
}
|
||||
@ -166,17 +256,11 @@ export default function FeedbackAdministration() {
|
||||
const confirmed = window.confirm(
|
||||
`Feedback von ${feedbackItem.userName || feedbackItem.userEmail || 'Unbekannt'} wirklich löschen?`,
|
||||
)
|
||||
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
setDeletingFeedbackIds((currentIds) => {
|
||||
const nextIds = new Set(currentIds)
|
||||
nextIds.add(feedbackItem.id)
|
||||
return nextIds
|
||||
})
|
||||
|
||||
setDeletingFeedbackIds((currentIds) => new Set(currentIds).add(feedbackItem.id))
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
@ -193,10 +277,10 @@ export default function FeedbackAdministration() {
|
||||
setFeedback((currentFeedback) =>
|
||||
currentFeedback.filter((item) => item.id !== feedbackItem.id),
|
||||
)
|
||||
} catch (error) {
|
||||
} catch (deleteError) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
deleteError instanceof Error
|
||||
? deleteError.message
|
||||
: 'Feedback konnte nicht gelöscht werden',
|
||||
)
|
||||
} finally {
|
||||
@ -223,75 +307,95 @@ export default function FeedbackAdministration() {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
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>
|
||||
|
||||
<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>
|
||||
</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 className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon
|
||||
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Suchen..."
|
||||
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"
|
||||
/>
|
||||
</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>
|
||||
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedFeedback.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">
|
||||
{visibleFeedback.length === 0 ? (
|
||||
<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">
|
||||
Es wurde noch kein Feedback abgegeben.
|
||||
{search.trim()
|
||||
? 'Kein Feedback passt zur Suche.'
|
||||
: 'Es wurde noch kein Feedback abgegeben.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{sortedFeedback.map((feedbackItem) => {
|
||||
{visibleFeedback.map((feedbackItem) => {
|
||||
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 (
|
||||
<article
|
||||
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 min-w-0 items-start gap-x-3">
|
||||
<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-3">
|
||||
<Avatar
|
||||
initials={getInitials(feedbackItem.userName, feedbackItem.userEmail)}
|
||||
name={feedbackItem.userName || feedbackItem.userEmail}
|
||||
size={10}
|
||||
/>
|
||||
|
||||
<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">
|
||||
{feedbackItem.userName || 'Unbekannter Benutzer'}
|
||||
</h3>
|
||||
|
||||
{feedbackItem.userEmail && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{feedbackItem.userEmail}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatDate(feedbackItem.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
@ -311,19 +415,61 @@ export default function FeedbackAdministration() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">
|
||||
{feedbackItem.message}
|
||||
</p>
|
||||
<div className="p-5">
|
||||
<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">
|
||||
{feedbackItem.message}
|
||||
</p>
|
||||
|
||||
<details className="mt-4 rounded-md bg-gray-50 p-3 dark:bg-white/5">
|
||||
<summary className="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Technisches Log anzeigen
|
||||
</summary>
|
||||
{client && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{client.browser && (
|
||||
<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">
|
||||
{JSON.stringify(feedbackItem.log, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
<details className="group mt-4 overflow-hidden rounded-xl border border-gray-200 dark:border-white/10">
|
||||
<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">
|
||||
<span className="flex items-center gap-2">
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
@ -331,4 +477,4 @@ export default function FeedbackAdministration() {
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,6 +45,9 @@ type AdminUser = {
|
||||
group: string
|
||||
rights: string[]
|
||||
teams: AdminTeam[]
|
||||
onlineStatus: boolean
|
||||
presenceStatus: 'online' | 'away' | 'offline'
|
||||
lastSeenAt?: string | null
|
||||
}
|
||||
|
||||
const adminRightId = 'admin'
|
||||
@ -201,13 +204,6 @@ function normalizeRights(rights: string[]) {
|
||||
return [...nextRights]
|
||||
}
|
||||
|
||||
function getSelectedValues(formData: FormData, name: string) {
|
||||
return formData
|
||||
.getAll(name)
|
||||
.map((value) => String(value))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function getUserDisplayName(user: AdminUser) {
|
||||
return user.displayName || user.username || user.email
|
||||
}
|
||||
@ -934,6 +930,8 @@ export default function UsersAdministration() {
|
||||
name={displayName}
|
||||
initials={getUserInitials(user)}
|
||||
size={9}
|
||||
presenceStatus={user.presenceStatus}
|
||||
wrapperClassName="self-start"
|
||||
className={isSelected && !user.avatar ? 'bg-indigo-600' : undefined}
|
||||
/>
|
||||
|
||||
@ -1349,4 +1347,4 @@ export default function UsersAdministration() {
|
||||
</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 =
|
||||
'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 =
|
||||
'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 =
|
||||
'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>) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
@ -64,8 +64,9 @@ export function SectionHeader({
|
||||
description?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-gray-200 pb-3 dark:border-white/10">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<div className="relative border-b border-gray-200 pb-4 pl-3 dark:border-white/10">
|
||||
<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}
|
||||
</h3>
|
||||
|
||||
@ -402,7 +403,7 @@ export default function DeviceFormFields({
|
||||
}
|
||||
|
||||
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 && (
|
||||
<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}
|
||||
@ -589,7 +590,7 @@ export default function DeviceFormFields({
|
||||
)}
|
||||
|
||||
{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" />
|
||||
|
||||
<div className="border-b border-indigo-200 pb-3 dark:border-indigo-400/20">
|
||||
@ -672,7 +673,7 @@ export default function DeviceFormFields({
|
||||
Milestone-Status
|
||||
</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
|
||||
aria-hidden="true"
|
||||
className="size-4 shrink-0 text-indigo-500 dark:text-indigo-400"
|
||||
@ -838,7 +839,7 @@ export default function DeviceFormFields({
|
||||
</div>
|
||||
|
||||
<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
|
||||
id="isBaoDevice"
|
||||
name="isBaoDevice"
|
||||
@ -901,7 +902,7 @@ export default function DeviceFormFields({
|
||||
</span>
|
||||
</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 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Noch keine anderen Geräte vorhanden.
|
||||
@ -952,7 +953,7 @@ export default function DeviceFormFields({
|
||||
/>
|
||||
|
||||
{!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.
|
||||
</div>
|
||||
) : (
|
||||
@ -1003,4 +1004,4 @@ export default function DeviceFormFields({
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,10 +34,10 @@ export default function DeviceModalTabs({
|
||||
onChange,
|
||||
}: DeviceModalTabsProps) {
|
||||
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
|
||||
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) => {
|
||||
const isActive = activeTab === tab.id
|
||||
@ -50,10 +50,10 @@ export default function DeviceModalTabs({
|
||||
disabled={tab.disabled}
|
||||
onClick={() => onChange(tab.id)}
|
||||
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
|
||||
? 'border-indigo-600 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||
: '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',
|
||||
? 'bg-white text-indigo-700 shadow-sm ring-1 ring-black/5 dark:bg-white/10 dark:text-indigo-300 dark:ring-white/10'
|
||||
: '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',
|
||||
)}
|
||||
>
|
||||
@ -71,4 +71,4 @@ export default function DeviceModalTabs({
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,6 @@ import {
|
||||
ArrowPathIcon,
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
ExclamationTriangleIcon,
|
||||
PlusIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
XCircleIcon,
|
||||
@ -110,20 +109,6 @@ function formatFirmwareCheckRemaining(nextAllowedAt?: string | null, now = Date.
|
||||
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']>
|
||||
|
||||
function getLoanStatusBadgeTone(status: string): BadgeTone {
|
||||
@ -769,7 +754,7 @@ export default function DevicesPage() {
|
||||
recordKeyframesOnly: formData.get('recordKeyframesOnly') === 'on',
|
||||
recordingStorageId:
|
||||
String(formData.get('recordingStorageId') ?? '').trim() || null,
|
||||
streams: cameraDetails.streams.map((stream, index) => ({
|
||||
streams: cameraDetails.streams.map((_stream, index) => ({
|
||||
index,
|
||||
codec: String(formData.get(`streamCodec-${index}`) ?? ''),
|
||||
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 isFirmwareCheckLocked =
|
||||
Boolean(firmwareCheckNextAllowedAt) &&
|
||||
@ -1250,12 +1232,16 @@ export default function DevicesPage() {
|
||||
]
|
||||
|
||||
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
|
||||
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."
|
||||
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">
|
||||
<Button
|
||||
type="button"
|
||||
@ -1283,7 +1269,7 @@ export default function DevicesPage() {
|
||||
</div>
|
||||
|
||||
{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" />
|
||||
<span>{firmwareCheckNextAllowedLabel}</span>
|
||||
</div>
|
||||
@ -1291,6 +1277,7 @@ export default function DevicesPage() {
|
||||
</div>
|
||||
}
|
||||
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}
|
||||
columns={columns}
|
||||
getRowId={(device) => device.id}
|
||||
@ -1397,4 +1384,4 @@ export default function DevicesPage() {
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,7 +7,6 @@ import {
|
||||
type ComponentType,
|
||||
type FormEvent,
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
type SVGProps,
|
||||
} from 'react'
|
||||
import { DialogTitle } from '@headlessui/react'
|
||||
@ -25,14 +24,11 @@ import {
|
||||
UserGroupIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import {
|
||||
CircleMarker,
|
||||
MapContainer,
|
||||
TileLayer,
|
||||
} from 'react-leaflet'
|
||||
import Modal from '../../components/Modal'
|
||||
import Button from '../../components/Button'
|
||||
import AddressCombobox from '../../components/AddressCombobox'
|
||||
import AddressCombobox, {
|
||||
type AddressArea,
|
||||
} from '../../components/AddressCombobox'
|
||||
import Textarea from '../../components/Textarea'
|
||||
import Combobox, { type ComboboxItem } from '../../components/Combobox'
|
||||
import MultiCombobox from '../../components/MultiCombobox'
|
||||
@ -43,6 +39,8 @@ import ServerFolderPicker, {
|
||||
joinServerFolderPath,
|
||||
} from '../../components/ServerFolderPicker'
|
||||
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 OPERATION_UNIT = 'TEG'
|
||||
@ -227,7 +225,7 @@ function hasInvalidWindowsFolderNameCharacters(value: string) {
|
||||
type SetupStepIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
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 =
|
||||
'!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({
|
||||
title,
|
||||
description,
|
||||
@ -718,8 +453,9 @@ function StepHeader({
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
<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">
|
||||
<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}
|
||||
</h3>
|
||||
<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 [formValues, setFormValues] = useState<OperationFormValues>(initialFormValues)
|
||||
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 [operationTeamOptions, setOperationTeamOptions] = useState<ComboboxItem[]>([])
|
||||
@ -781,6 +523,12 @@ export default function CreateOperationModal({
|
||||
setFormValues(initialFormValues)
|
||||
setSelectedOperationLeader(null)
|
||||
setSelectedOperationTeam([])
|
||||
setKwPosition(null)
|
||||
setTargetPosition(null)
|
||||
setKwArea(null)
|
||||
setTargetArea(null)
|
||||
setViewConeFov(36)
|
||||
setViewConeLength(0)
|
||||
setLocalError(null)
|
||||
setMilestoneStorages([])
|
||||
setSelectedMilestoneStorage(null)
|
||||
@ -1074,7 +822,7 @@ export default function CreateOperationModal({
|
||||
open={open}
|
||||
setOpen={handleOpenChange}
|
||||
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
|
||||
onSubmit={handleSubmit}
|
||||
@ -1086,6 +834,12 @@ export default function CreateOperationModal({
|
||||
<input type="hidden" name="caseWorker" value={formValues.caseWorker} />
|
||||
<input type="hidden" name="targetObject" value={formValues.targetObject} />
|
||||
<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="operationTeam" value={formValues.operationTeam} />
|
||||
<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="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>
|
||||
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
Einsatz anlegen
|
||||
</DialogTitle>
|
||||
<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 className="flex min-w-0 items-start gap-3.5">
|
||||
<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>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@ -1122,9 +882,10 @@ export default function CreateOperationModal({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-gray-200 px-4 py-4 sm:px-6 dark:border-white/10">
|
||||
<nav aria-label="Einrichtungsschritte" className="grid grid-cols-2 gap-2 md:grid-cols-7">
|
||||
{setupSteps.map((step, index) => {
|
||||
<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="overflow-x-auto pb-1">
|
||||
<div className="grid min-w-[52rem] grid-cols-7 gap-2">
|
||||
{setupSteps.map((step, index) => {
|
||||
const isActive = step.id === activeStep
|
||||
const isDone = index < activeStepIndex
|
||||
const Icon = step.icon
|
||||
@ -1135,12 +896,12 @@ export default function CreateOperationModal({
|
||||
type="button"
|
||||
onClick={() => goToStep(step.id)}
|
||||
className={classNames(
|
||||
'rounded-lg border px-3 py-2 text-left transition',
|
||||
'rounded-xl border px-3 py-2.5 text-left transition',
|
||||
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
|
||||
? 'border-green-200 bg-green-50 text-green-700 dark:border-green-500/20 dark:bg-green-500/10 dark:text-green-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-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300'
|
||||
: '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">
|
||||
@ -1153,17 +914,18 @@ export default function CreateOperationModal({
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{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}
|
||||
</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' && (
|
||||
<div className="space-y-6">
|
||||
<StepHeader
|
||||
@ -1231,14 +993,18 @@ export default function CreateOperationModal({
|
||||
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
|
||||
id="createKwAddress"
|
||||
name="createKwAddressDisplay"
|
||||
label="KW"
|
||||
value={formValues.kwAddress}
|
||||
onChange={(value) => updateField('kwAddress', value)}
|
||||
position={kwPosition}
|
||||
onPositionChange={setKwPosition}
|
||||
onAreaChange={setKwArea}
|
||||
placeholder="KW-Adresse suchen oder eingeben"
|
||||
showMap={false}
|
||||
/>
|
||||
|
||||
<AddressCombobox
|
||||
@ -1247,9 +1013,27 @@ export default function CreateOperationModal({
|
||||
label="Zielobjekt"
|
||||
value={formValues.targetObject}
|
||||
onChange={(value) => updateField('targetObject', value)}
|
||||
position={targetPosition}
|
||||
onPositionChange={setTargetPosition}
|
||||
onAreaChange={setTargetArea}
|
||||
placeholder="Adresse suchen oder Zielobjekt eingeben"
|
||||
showMap={false}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@ -1775,16 +1559,28 @@ export default function CreateOperationModal({
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<ReviewAddressItem
|
||||
label="KW"
|
||||
value={formValues.kwAddress}
|
||||
/>
|
||||
|
||||
<ReviewAddressItem
|
||||
<ReviewItem label="KW" value={formValues.kwAddress} />
|
||||
<ReviewItem
|
||||
label="Zielobjekt"
|
||||
value={formValues.targetObject}
|
||||
/>
|
||||
</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>
|
||||
@ -1855,9 +1651,18 @@ export default function CreateOperationModal({
|
||||
)}
|
||||
</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="text-sm text-gray-500 dark:text-gray-400">
|
||||
Schritt {activeStepIndex + 1} von {setupSteps.length}
|
||||
<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="min-w-36">
|
||||
<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 className="flex justify-end gap-x-3">
|
||||
@ -1922,4 +1727,4 @@ export default function CreateOperationModal({
|
||||
</form>
|
||||
</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 LoadingSpinner from '../../components/LoadingSpinner'
|
||||
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'
|
||||
|
||||
@ -21,8 +26,6 @@ type AddressSuggestion = {
|
||||
|
||||
type OperationMarkerType = 'kwAddress' | 'targetObject'
|
||||
|
||||
type OperationMapMode = 'markers' | 'heatmap'
|
||||
|
||||
type OperationMarker = {
|
||||
id: string
|
||||
operation: Operation
|
||||
@ -40,6 +43,7 @@ type OperationViewCone = {
|
||||
color: string
|
||||
positions: Array<[number, number]>
|
||||
centerLine: [[number, number], [number, number]]
|
||||
targetLine: [[number, number], [number, number]]
|
||||
}
|
||||
|
||||
type OperationMapProps = {
|
||||
@ -218,22 +222,40 @@ function getOperationAddresses(operation: Operation) {
|
||||
const addresses: Array<{
|
||||
type: OperationMarkerType
|
||||
address: string
|
||||
position: OperationCoordinates | null
|
||||
}> = []
|
||||
|
||||
const kwAddress = operation.kwAddress?.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({
|
||||
type: 'kwAddress',
|
||||
address: kwAddress,
|
||||
address: kwAddress || 'KW',
|
||||
position: kwPosition,
|
||||
})
|
||||
}
|
||||
|
||||
if (targetObject) {
|
||||
if (targetObject || isValidOperationCoordinates(targetPosition)) {
|
||||
addresses.push({
|
||||
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[]) {
|
||||
const markersByOperation = new globalThis.Map<string, OperationMarker[]>()
|
||||
|
||||
@ -438,12 +356,14 @@ function getOperationViewCones(markers: OperationMarker[]) {
|
||||
return
|
||||
}
|
||||
|
||||
const positions = createViewConePositions({
|
||||
const geometry = createOperationViewCone({
|
||||
from: kwMarker,
|
||||
to: targetMarker,
|
||||
fov: kwMarker.operation.viewConeFov || 36,
|
||||
length: kwMarker.operation.viewConeLength || 0,
|
||||
})
|
||||
|
||||
if (positions.length === 0) {
|
||||
if (!geometry) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -451,11 +371,9 @@ function getOperationViewCones(markers: OperationMarker[]) {
|
||||
id: `${operationId}-view-cone`,
|
||||
operation: kwMarker.operation,
|
||||
color: kwMarker.color,
|
||||
positions,
|
||||
centerLine: [
|
||||
[kwMarker.lat, kwMarker.lon],
|
||||
[targetMarker.lat, targetMarker.lon],
|
||||
],
|
||||
positions: geometry.positions,
|
||||
centerLine: geometry.centerLine,
|
||||
targetLine: geometry.targetLine,
|
||||
})
|
||||
})
|
||||
|
||||
@ -503,7 +421,12 @@ export default function OperationMap({
|
||||
new Set(
|
||||
operationsWithAddresses
|
||||
.flatMap((operation) =>
|
||||
getOperationAddresses(operation).map((item) => item.address),
|
||||
getOperationAddresses(operation)
|
||||
.filter(
|
||||
(item) =>
|
||||
!isValidOperationCoordinates(item.position),
|
||||
)
|
||||
.map((item) => item.address),
|
||||
)
|
||||
.filter(Boolean),
|
||||
),
|
||||
@ -537,7 +460,15 @@ export default function OperationMap({
|
||||
const nextMarkers = operationsWithAddresses
|
||||
.flatMap((operation) =>
|
||||
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) {
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,13 +28,17 @@ import Modal from '../../components/Modal'
|
||||
import Journal, { type JournalItem } from '../../components/Journal'
|
||||
import Button from '../../components/Button'
|
||||
import LoadingSpinner from '../../components/LoadingSpinner'
|
||||
import AddressCombobox from '../../components/AddressCombobox'
|
||||
import type { Operation, OperationHistoryChange, OperationHistoryEntry } from '../../components/types'
|
||||
import AddressCombobox, {
|
||||
type AddressArea,
|
||||
} from '../../components/AddressCombobox'
|
||||
import type { Operation, OperationHistoryEntry } from '../../components/types'
|
||||
import { formatOperationNumberInput } from '../../components/formatter'
|
||||
import NotificationDot from '../../components/NotificationDot'
|
||||
import Combobox, { type ComboboxItem } from '../../components/Combobox'
|
||||
import MultiCombobox from '../../components/MultiCombobox'
|
||||
import Tabs from '../../components/Tabs'
|
||||
import type { OperationCoordinates } from '../../utils/operationGeometry'
|
||||
import OperationGeometryEditor from './OperationGeometryEditor'
|
||||
|
||||
type OperationModalProps = {
|
||||
open: boolean
|
||||
@ -65,10 +69,10 @@ type OperationTabId =
|
||||
type OperationTabIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
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 =
|
||||
'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 OPERATION_UNIT = 'TEG'
|
||||
@ -354,8 +358,9 @@ function SectionHeader({
|
||||
description?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-gray-200 pb-3 dark:border-white/10">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<div className="relative border-b border-gray-200 pb-4 pl-3 dark:border-white/10">
|
||||
<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}
|
||||
</h3>
|
||||
|
||||
@ -415,6 +420,12 @@ export default function OperationModal({
|
||||
const [activeTab, setActiveTab] = useState<OperationTabId>('masterData')
|
||||
const [targetObject, setTargetObject] = 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 [operationTeamOptions, setOperationTeamOptions] = useState<ComboboxItem[]>([])
|
||||
@ -458,8 +469,30 @@ export default function OperationModal({
|
||||
}
|
||||
|
||||
setActiveTab('masterData')
|
||||
setKwArea(null)
|
||||
setTargetArea(null)
|
||||
setTargetObject(editingOperation?.targetObject ?? '')
|
||||
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([])
|
||||
setSelectedMilestoneStorage(null)
|
||||
setShowCreateStorageForm(false)
|
||||
@ -471,6 +504,12 @@ export default function OperationModal({
|
||||
editingOperation?.id,
|
||||
editingOperation?.targetObject,
|
||||
editingOperation?.kwAddress,
|
||||
editingOperation?.kwLatitude,
|
||||
editingOperation?.kwLongitude,
|
||||
editingOperation?.targetLatitude,
|
||||
editingOperation?.targetLongitude,
|
||||
editingOperation?.viewConeFov,
|
||||
editingOperation?.viewConeLength,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
@ -856,7 +895,7 @@ export default function OperationModal({
|
||||
open={open}
|
||||
setOpen={handleOpenChange}
|
||||
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
|
||||
key={editingOperation?.id ?? 'create'}
|
||||
@ -898,18 +937,30 @@ export default function OperationModal({
|
||||
name="milestoneStorage"
|
||||
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>
|
||||
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
<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 className="flex min-w-0 items-start gap-3.5">
|
||||
<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'}
|
||||
</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
|
||||
? 'Bearbeite Einsatzdaten, Zuständigkeiten, Zielobjekt und eingesetzte Technik.'
|
||||
: 'Erfasse einen neuen Einsatz inklusive Einsatznummer, Einsatzname und Technik.'}
|
||||
</p>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@ -924,8 +975,8 @@ export default function OperationModal({
|
||||
</Button>
|
||||
</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="-mb-px flex gap-x-6 overflow-x-auto">
|
||||
<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="flex gap-1.5 overflow-x-auto rounded-2xl bg-gray-100/80 p-1.5 dark:bg-white/5">
|
||||
{visibleTabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
|
||||
@ -941,10 +992,10 @@ export default function OperationModal({
|
||||
}
|
||||
}}
|
||||
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
|
||||
? 'border-indigo-600 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||
: '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',
|
||||
? 'bg-white text-indigo-700 shadow-sm ring-1 ring-black/5 dark:bg-white/10 dark:text-indigo-300 dark:ring-white/10'
|
||||
: '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">
|
||||
@ -966,13 +1017,13 @@ export default function OperationModal({
|
||||
</div>
|
||||
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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 */}
|
||||
<div className={activeTab === 'masterData' ? 'block' : 'hidden'}>
|
||||
@ -1045,25 +1096,51 @@ export default function OperationModal({
|
||||
description="Zielobjekt und KW-Adresse mit Adresssuche und Karten-Vorschau."
|
||||
/>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 xl:grid-cols-2">
|
||||
<AddressCombobox
|
||||
id="targetObject"
|
||||
name="targetObject"
|
||||
label="Zielobjekt"
|
||||
value={targetObject}
|
||||
onChange={setTargetObject}
|
||||
placeholder="Adresse suchen oder Zielobjekt eingeben"
|
||||
mapVisible={activeTab === 'addresses'}
|
||||
/>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<AddressCombobox
|
||||
key={`kw-${editingOperation?.id ?? 'new'}-${open}`}
|
||||
id="kwAddress"
|
||||
name="kwAddress"
|
||||
label="KW"
|
||||
value={kwAddress}
|
||||
onChange={setKwAddress}
|
||||
position={kwPosition}
|
||||
onPositionChange={setKwPosition}
|
||||
onAreaChange={setKwArea}
|
||||
placeholder="KW-Adresse suchen oder eingeben"
|
||||
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>
|
||||
</section>
|
||||
@ -1796,7 +1873,7 @@ export default function OperationModal({
|
||||
</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
|
||||
type="button"
|
||||
variant="secondary"
|
||||
|
||||
@ -908,6 +908,15 @@ export default function OperationsPage({
|
||||
const form = event.currentTarget
|
||||
const formData = new FormData(form)
|
||||
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)
|
||||
setSaveError(null)
|
||||
@ -919,6 +928,12 @@ export default function OperationsPage({
|
||||
caseWorker: String(formData.get('caseWorker') ?? ''),
|
||||
targetObject: String(formData.get('targetObject') ?? ''),
|
||||
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') ?? ''),
|
||||
operationTeam: String(formData.get('operationTeam') ?? ''),
|
||||
legend: String(formData.get('legend') ?? ''),
|
||||
@ -929,6 +944,7 @@ export default function OperationsPage({
|
||||
hardDrive: String(formData.get('hardDrive') ?? ''),
|
||||
laptop: String(formData.get('laptop') ?? ''),
|
||||
remark: String(formData.get('remark') ?? ''),
|
||||
milestoneStorage: String(formData.get('milestoneStorage') ?? ''),
|
||||
}
|
||||
|
||||
try {
|
||||
@ -1934,4 +1950,4 @@ export default function OperationsPage({
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,8 +4,7 @@ import type { ReactNode } from 'react'
|
||||
import { useLocation } from 'react-router'
|
||||
import {
|
||||
BellIcon,
|
||||
CreditCardIcon,
|
||||
PuzzlePieceIcon,
|
||||
ShieldCheckIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
@ -14,6 +13,7 @@ import Container from '../../components/Container'
|
||||
import type { User } from '../../components/types'
|
||||
import Konto from './konto/Konto'
|
||||
import Benachrichtigungen from './benachrichtigungen/Benachrichtigungen'
|
||||
import PresenceSettings from './presence/PresenceSettings'
|
||||
|
||||
type SettingsPageProps = {
|
||||
user?: User
|
||||
@ -79,6 +79,14 @@ export default function SettingsPage({
|
||||
icon: UserIcon,
|
||||
current: pathname === '/einstellungen/konto',
|
||||
},
|
||||
{
|
||||
name: 'Datenschutz',
|
||||
href: '/einstellungen/datenschutz',
|
||||
icon: ShieldCheckIcon,
|
||||
current:
|
||||
pathname === '/einstellungen/datenschutz' ||
|
||||
pathname === '/einstellungen/praesenz',
|
||||
},
|
||||
{
|
||||
name: 'Benachrichtigungen',
|
||||
href: '/einstellungen/benachrichtigungen',
|
||||
@ -118,10 +126,17 @@ export default function SettingsPage({
|
||||
<Benachrichtigungen />
|
||||
)}
|
||||
|
||||
{(section === 'datenschutz' || section === 'praesenz') && (
|
||||
<PresenceSettings
|
||||
user={user}
|
||||
onUserUpdated={onUserUpdated}
|
||||
/>
|
||||
)}
|
||||
|
||||
{section === 'teams' && (
|
||||
<PlaceholderSection title="Teams" />
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,6 +32,8 @@ type NotificationSettings = {
|
||||
deviceUpdates: boolean
|
||||
deviceJournals: boolean
|
||||
systemMessages: boolean
|
||||
chatMessages: boolean
|
||||
desktopEnabled: boolean
|
||||
}
|
||||
|
||||
const defaultSettings: NotificationSettings = {
|
||||
@ -44,6 +46,8 @@ const defaultSettings: NotificationSettings = {
|
||||
deviceUpdates: true,
|
||||
deviceJournals: true,
|
||||
systemMessages: true,
|
||||
chatMessages: true,
|
||||
desktopEnabled: false,
|
||||
}
|
||||
|
||||
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) {
|
||||
return (
|
||||
<Section
|
||||
@ -293,11 +323,29 @@ export default function Benachrichtigungen() {
|
||||
description="Lege fest, welche Hinweise du in der Anwendung erhalten möchtest."
|
||||
>
|
||||
<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
|
||||
icon={<BellIcon aria-hidden="true" className="size-5" />}
|
||||
title="In-App-Benachrichtigungen"
|
||||
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
|
||||
checked={settings.soundEnabled}
|
||||
onChange={(event) =>
|
||||
@ -355,11 +403,52 @@ export default function Benachrichtigungen() {
|
||||
</div>
|
||||
</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
|
||||
icon={<SpeakerWaveIcon aria-hidden="true" className="size-5" />}
|
||||
title="Ereignisse"
|
||||
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
|
||||
checked={settings.operationUpdates}
|
||||
onChange={(event) =>
|
||||
@ -380,26 +469,6 @@ export default function Benachrichtigungen() {
|
||||
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
|
||||
checked={settings.systemMessages}
|
||||
onChange={(event) =>
|
||||
@ -415,4 +484,4 @@ export default function Benachrichtigungen() {
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
} from '@heroicons/react/20/solid'
|
||||
import { useNotifications } from '../../../components/Notifications'
|
||||
import Avatar from '../../../components/Avatar'
|
||||
import Combobox, { type ComboboxItem } from '../../../components/Combobox'
|
||||
|
||||
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') ?? ''),
|
||||
email: String(formData.get('email') ?? ''),
|
||||
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 className="sm:col-span-3">
|
||||
<label
|
||||
htmlFor="unit"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Einheit
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="unit"
|
||||
name="unit"
|
||||
type="text"
|
||||
defaultValue={currentUser?.unit ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label
|
||||
htmlFor="group"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Gruppe
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="group"
|
||||
name="group"
|
||||
type="text"
|
||||
defaultValue={currentUser?.group ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-full">
|
||||
<Combobox
|
||||
label="Einheit"
|
||||
items={
|
||||
currentUser?.unit
|
||||
? [{ id: currentUser.unit, name: currentUser.unit }]
|
||||
: []
|
||||
}
|
||||
value={
|
||||
currentUser?.unit
|
||||
? ({
|
||||
id: currentUser.unit,
|
||||
name: currentUser.unit,
|
||||
} satisfies ComboboxItem)
|
||||
: null
|
||||
}
|
||||
onChange={() => undefined}
|
||||
placeholder="Keine Einheit zugeordnet"
|
||||
disabled
|
||||
/>
|
||||
<p className="mt-2 text-xs/5 text-gray-500 dark:text-gray-400">
|
||||
Die Einheit kann nur in der Benutzeradministration geändert werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -841,4 +827,4 @@ export default function Konto({
|
||||
</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