mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-04 14:22: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:
+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