mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-03 22:02:57 +00:00
feat(ui,api,db): implement notifications and custom templates with hand-drawn aesthetic
This commit introduces a significant update to both the frontend and backend, focusing on enhanced user engagement and a consistent visual identity. Key changes include: - **Frontend UI/UX Refactor**: - Implemented a "hand-drawn" aesthetic across the entire application using CSS overrides, custom SVG charts, and specific border/shadow styles to match the Excalidraw experience. - Added a new notification system in the Header to display user updates. - Enhanced the Template Picker with more variety and improved interaction models. - Added a "Presentation Mode" in the Editor. - Improved Dashboard visualizations with hand-drawn style sparklines and charts. - Added modal dialogs for creating drawings and templates with custom names. - **Backend & API Enhancements**: - Implemented full CRUD support for custom templates, allowing users to save their drawings as reusable templates. - Added a notification service with endpoints to list, mark as read, and mark all as read. - Updated the API client to handle more robust JSON responses and error states. - Improved CORS/Origin validation in the HTTP middleware to handle proxy headers (`X-Forwarded-Host`, `X-Forwarded-Proto`) more reliably. - **Database & Infrastructure**: - Added a new PostgreSQL migration for the `notifications` table. - Updated the data models in the workspace to support templates (including snapshot storage) and notifications. - Updated `.gitignore` to exclude graphify cache and AST files.
This commit is contained in:
+99
-6
@@ -72,12 +72,17 @@ func (a *API) Routes() chi.Router {
|
||||
r.Post("/drawings/{drawingID}/links", a.handleCreateLink)
|
||||
r.Get("/drawings/{drawingID}/thumbnail", a.handleThumbnail)
|
||||
r.Get("/templates", a.handleListTemplates)
|
||||
r.Post("/templates", a.handleCreateTemplate)
|
||||
r.Delete("/templates/{templateID}", a.handleDeleteTemplate)
|
||||
r.Get("/activity", a.handleListActivity)
|
||||
r.Get("/stats", a.handleStats)
|
||||
r.Get("/folders", a.handleListFolders)
|
||||
r.Post("/folders", a.handleCreateFolder)
|
||||
r.Get("/projects", a.handleListProjects)
|
||||
r.Post("/projects", a.handleCreateProject)
|
||||
r.Get("/notifications", a.handleListNotifications)
|
||||
r.Post("/notifications/{notificationID}/read", a.handleMarkNotificationRead)
|
||||
r.Post("/notifications/read-all", a.handleMarkAllNotificationsRead)
|
||||
})
|
||||
|
||||
return r
|
||||
@@ -134,11 +139,26 @@ func requireSameOriginMutation(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
expectedHTTP := "http://" + r.Host
|
||||
expectedHTTPS := "https://" + r.Host
|
||||
if origin != expectedHTTP && origin != expectedHTTPS {
|
||||
writeError(w, http.StatusForbidden, "Cross-origin mutation denied")
|
||||
return
|
||||
host := r.Host
|
||||
if fwd := r.Header.Get("X-Forwarded-Host"); fwd != "" {
|
||||
host = fwd
|
||||
}
|
||||
proto := "http"
|
||||
if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" {
|
||||
proto = fwd
|
||||
} else if r.TLS != nil {
|
||||
proto = "https"
|
||||
}
|
||||
expected := proto + "://" + host
|
||||
if origin != expected {
|
||||
// also allow without port in case proxy strips it
|
||||
expectedNoPort := proto + "://" + strings.SplitN(host, ":", 2)[0]
|
||||
originNoPort := strings.SplitN(origin, "://", 2)[1]
|
||||
originNoPort = strings.SplitN(originNoPort, ":", 2)[0]
|
||||
if originNoPort != expectedNoPort {
|
||||
writeError(w, http.StatusForbidden, "Cross-origin mutation denied")
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
@@ -342,7 +362,7 @@ func (a *API) handleArchiveDrawing(w http.ResponseWriter, r *http.Request) {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *API) handleListRevisions(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -493,6 +513,30 @@ func (a *API) handleListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, templates)
|
||||
}
|
||||
|
||||
func (a *API) handleCreateTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
var req CreateTemplateRequest
|
||||
if !decodeJSON(w, r, &req, 5<<20) {
|
||||
return
|
||||
}
|
||||
template, err := a.store.CreateTemplate(r.Context(), user.ID, req)
|
||||
if err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, template)
|
||||
}
|
||||
|
||||
func (a *API) handleDeleteTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
templateID := chi.URLParam(r, "templateID")
|
||||
if err := a.store.DeleteTemplate(r.Context(), user.ID, templateID); err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *API) handleListActivity(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
|
||||
@@ -644,6 +688,55 @@ func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// Notification handlers
|
||||
func (a *API) handleListNotifications(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
if user == nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
notifications, err := a.store.ListNotifications(ctx, user.ID, 50)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, notifications)
|
||||
}
|
||||
|
||||
func (a *API) handleMarkNotificationRead(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
if user == nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
notificationID := chi.URLParam(r, "notificationID")
|
||||
if err := a.store.MarkNotificationRead(ctx, user.ID, notificationID); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"error": "not found"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *API) handleMarkAllNotificationsRead(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
if user == nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
if err := a.store.MarkAllNotificationsRead(ctx, user.ID); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func isSecureRequest(r *http.Request) bool {
|
||||
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
|
||||
@@ -143,6 +143,14 @@ type Template struct {
|
||||
PreviewURL *string `json:"preview_url,omitempty"`
|
||||
}
|
||||
|
||||
type CreateTemplateRequest struct {
|
||||
TeamID string `json:"team_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Snapshot json.RawMessage `json:"snapshot"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
|
||||
type ActivityEvent struct {
|
||||
ID string `json:"id"`
|
||||
ActorUserID *string `json:"actor_user_id"`
|
||||
@@ -204,6 +212,19 @@ type LinkReference struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ResourceType string `json:"resource_type,omitempty"`
|
||||
ResourceID string `json:"resource_id,omitempty"`
|
||||
Read bool `json:"read"`
|
||||
MetadataJSON map[string]any `json:"metadata_json"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type WorkspaceStats struct {
|
||||
Teams int `json:"teams"`
|
||||
Members int `json:"members"`
|
||||
|
||||
+157
-1
@@ -586,7 +586,7 @@ func (s *Store) GetDrawing(ctx context.Context, userID, drawingID string) (*Draw
|
||||
u.id, u.name, u.username, u.email, u.avatar_url, u.locale, u.timezone, u.created_at, u.updated_at
|
||||
FROM workspace_drawings d
|
||||
JOIN workspace_users u ON u.id = d.owner_user_id
|
||||
WHERE d.id = ? AND d.deleted_at IS NULL`, drawingID)
|
||||
WHERE d.id = ?`, drawingID)
|
||||
return scanDrawing(row)
|
||||
}
|
||||
|
||||
@@ -733,6 +733,82 @@ func (s *Store) ListTemplates(ctx context.Context, userID, teamID string) ([]Tem
|
||||
return templates, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateTemplate(ctx context.Context, userID string, req CreateTemplateRequest) (*Template, error) {
|
||||
teamID := strings.TrimSpace(req.TeamID)
|
||||
if teamID == "" {
|
||||
teamID, _ = s.defaultTeamID(ctx, userID)
|
||||
}
|
||||
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" || len(name) > 120 {
|
||||
return nil, fmt.Errorf("template name must be between 1 and 120 characters")
|
||||
}
|
||||
|
||||
if len(req.Snapshot) == 0 || !json.Valid(req.Snapshot) {
|
||||
return nil, fmt.Errorf("snapshot must be valid JSON")
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
template := &Template{
|
||||
ID: newID(),
|
||||
TeamID: &teamID,
|
||||
Scope: "team",
|
||||
Type: "custom",
|
||||
Name: name,
|
||||
Description: ptr(strings.TrimSpace(req.Description)),
|
||||
SnapshotPath: fmt.Sprintf("teams/%s/templates/%s.json", teamID, newID()),
|
||||
MetadataJSON: req.Metadata,
|
||||
CreatedBy: userID,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
metadata, _ := json.Marshal(template.MetadataJSON)
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO workspace_templates
|
||||
(id, team_id, scope, type, name, description, snapshot_path, metadata_json, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
template.ID, template.TeamID, template.Scope, template.Type, template.Name, template.Description,
|
||||
template.SnapshotPath, string(metadata), template.CreatedBy, template.CreatedAt, template.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = s.insertActivity(ctx, &userID, &teamID, "template", template.ID, "template_created", map[string]any{"name": template.Name})
|
||||
return template, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteTemplate(ctx context.Context, userID, templateID string) error {
|
||||
var teamID, createdBy string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT team_id, created_by FROM workspace_templates WHERE id = ?`, templateID).Scan(&teamID, &createdBy)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
|
||||
return ErrForbidden
|
||||
}
|
||||
|
||||
// Only creator or team admin can delete
|
||||
if createdBy != userID {
|
||||
// Check if user is admin
|
||||
var role string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT role FROM workspace_team_memberships WHERE team_id = ? AND user_id = ?`, teamID, userID).Scan(&role)
|
||||
if err != nil || (role != "admin" && role != "owner") {
|
||||
return ErrForbidden
|
||||
}
|
||||
}
|
||||
|
||||
_, err = s.db.ExecContext(ctx, `DELETE FROM workspace_templates WHERE id = ?`, templateID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListActivity(ctx context.Context, userID, teamID string, limit int) ([]ActivityEvent, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
@@ -1162,6 +1238,86 @@ func uniqueTeamSlug(ctx context.Context, tx *dbpostgres.Tx, base string) string
|
||||
|
||||
var nonSlugChars = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// Notifications
|
||||
func (s *Store) ListNotifications(ctx context.Context, userID string, limit int) ([]Notification, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, user_id, type, title, description, resource_type, resource_id, read, metadata_json, created_at
|
||||
FROM workspace_notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
notifications := []Notification{}
|
||||
for rows.Next() {
|
||||
var n Notification
|
||||
var metadata string
|
||||
var resourceType sql.NullString
|
||||
var resourceID sql.NullString
|
||||
if err := rows.Scan(&n.ID, &n.UserID, &n.Type, &n.Title, &n.Description, &resourceType, &resourceID, &n.Read, &metadata, &n.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resourceType.Valid {
|
||||
n.ResourceType = resourceType.String
|
||||
}
|
||||
if resourceID.Valid {
|
||||
n.ResourceID = resourceID.String
|
||||
}
|
||||
_ = json.Unmarshal([]byte(metadata), &n.MetadataJSON)
|
||||
if n.MetadataJSON == nil {
|
||||
n.MetadataJSON = map[string]any{}
|
||||
}
|
||||
notifications = append(notifications, n)
|
||||
}
|
||||
return notifications, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) MarkNotificationRead(ctx context.Context, userID, notificationID string) error {
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE workspace_notifications SET read = TRUE WHERE id = ? AND user_id = ?`, notificationID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkAllNotificationsRead(ctx context.Context, userID string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE workspace_notifications SET read = TRUE WHERE user_id = ? AND read = FALSE`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) CreateNotification(ctx context.Context, userID, nType, title, description, resourceType, resourceID string, metadata map[string]any) (*Notification, error) {
|
||||
metadataJSON := []byte("{}")
|
||||
if metadata != nil {
|
||||
b, _ := json.Marshal(metadata)
|
||||
metadataJSON = b
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
n := &Notification{
|
||||
ID: newID(),
|
||||
UserID: userID,
|
||||
Type: nType,
|
||||
Title: title,
|
||||
Description: description,
|
||||
ResourceType: resourceType,
|
||||
ResourceID: resourceID,
|
||||
Read: false,
|
||||
MetadataJSON: map[string]any{},
|
||||
CreatedAt: now,
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO workspace_notifications (id, user_id, type, title, description, resource_type, resource_id, read, metadata_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
n.ID, n.UserID, n.Type, n.Title, n.Description, resourceType, resourceID, false, string(metadataJSON), now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal(metadataJSON, &n.MetadataJSON)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func slugify(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
value = nonSlugChars.ReplaceAllString(value, "-")
|
||||
|
||||
Reference in New Issue
Block a user