Compare commits

2 Commits

Author SHA1 Message Date
soroush.asadi f1756b491e feat(admin): rich text editor for blog content (TipTap)
CI/CD / CI · API (dotnet build + test) (push) Successful in 3m33s
CI/CD / CI · Admin API (dotnet build) (push) Failing after 3m18s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Failing after 3m43s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Has been skipped
Blog post bodies were plain <textarea>s labelled "Markdown". Replace with a
TipTap rich editor (bold/italic/strike, H1–H3, lists, blockquote, code, links,
undo/redo), RTL-aware, producing HTML.

- New RichTextEditor component (TipTap v2: react + starter-kit + pm + link +
  placeholder), immediatelyRender:false for Next SSR, self-contained content
  styling, external-value sync.
- Wired into the FA/EN content fields of the blog editor; labels no longer say
  "Markdown" (fa/en/ar).
- Website blog page now renders HTML when the body is HTML and falls back to
  MDXRemote for older Markdown posts (backward-compatible). Content is authored
  only by trusted SystemAdmins, so HTML is stored/rendered directly.

Admin build + website typecheck clean.
2026-06-02 22:25:47 +03:30
soroush.asadi 97a9481627 feat(media): content-hash dedup for uploads + media-library endpoint
Uploads previously wrote every file to disk with a fresh GUID name, so the
same image uploaded twice produced two identical files. Now:

- New MediaAsset table records each stored upload (SHA-256 hash, size, type,
  url, kind, scope) + migration. Indexed on (CafeId, ContentHash).
- MediaStorageService computes the content hash on upload; if an identical file
  already exists for that café it returns the existing URL instead of writing a
  duplicate (covers images, videos, 3D models). Dedup lookup/record run via a
  scoped DbContext (the service is a singleton) and never block an upload on
  failure.
- GET /api/cafes/{cafeId}/media lists the café's library (newest first, optional
  ?kind=) so the UI can let users pick an existing file instead of re-uploading.

86 API tests pass.
2026-06-02 22:16:11 +03:30
15 changed files with 4684 additions and 30 deletions
@@ -1,7 +1,9 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Services;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Meezi.Infrastructure.Services.Platform;
using Meezi.Shared;
@@ -81,6 +83,33 @@ public class MediaController : CafeApiControllerBase
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
=> Upload(cafeId, file, tenant, _media.SaveCafeCoverAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
/// <summary>Media library for this café — previously uploaded files so the UI can
/// reuse one instead of re-uploading. Deduplication means each distinct file appears once.</summary>
[HttpGet]
public async Task<IActionResult> ListMedia(
string cafeId,
ITenantContext tenant,
[FromServices] AppDbContext db,
CancellationToken cancellationToken,
[FromQuery] string? kind = null,
[FromQuery] int limit = 60)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var query = db.MediaAssets.AsNoTracking().Where(m => m.CafeId == cafeId);
if (!string.IsNullOrWhiteSpace(kind))
query = query.Where(m => m.Kind == kind);
var items = await query
.OrderByDescending(m => m.CreatedAt)
.Take(Math.Clamp(limit, 1, 200))
.Select(m => new MediaAssetDto(
m.Id, m.Url, m.Kind, m.ContentType, m.SizeBytes, m.OriginalFileName, m.CreatedAt))
.ToListAsync(cancellationToken);
return Ok(new ApiResponse<List<MediaAssetDto>>(true, items));
}
private async Task<IActionResult> Upload(
string cafeId,
IFormFile file,
@@ -103,3 +132,12 @@ public class MediaController : CafeApiControllerBase
}
public record UploadResultDto(string Url);
public record MediaAssetDto(
string Id,
string Url,
string Kind,
string ContentType,
long SizeBytes,
string? OriginalFileName,
DateTime CreatedAt);
+95 -7
View File
@@ -1,3 +1,8 @@
using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using Meezi.Core.Entities;
using Meezi.Infrastructure.Data;
namespace Meezi.API.Services;
public interface IMediaStorageService
@@ -37,11 +42,16 @@ public class MediaStorageService : IMediaStorageService
private readonly IWebHostEnvironment _env;
private readonly ILogger<MediaStorageService> _logger;
private readonly IServiceScopeFactory _scopeFactory;
public MediaStorageService(IWebHostEnvironment env, ILogger<MediaStorageService> logger)
public MediaStorageService(
IWebHostEnvironment env,
ILogger<MediaStorageService> logger,
IServiceScopeFactory scopeFactory)
{
_env = env;
_logger = logger;
_scopeFactory = scopeFactory;
}
public Task<string?> SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
@@ -100,16 +110,29 @@ public class MediaStorageService : IMediaStorageService
|| Model3dMime.Contains(file.ContentType);
if (!isGlb) return null;
await using var buffer = new MemoryStream();
await file.CopyToAsync(buffer, cancellationToken);
var bytes = buffer.ToArray();
var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
var existing = await FindExistingByHashAsync(cafeId, hash, cancellationToken);
if (existing is not null)
{
_logger.LogInformation("Dedup hit for 3D model (cafe {CafeId}); reusing existing file", cafeId);
return existing;
}
var dir = Path.Combine(_env.ContentRootPath, "uploads", cafeId);
Directory.CreateDirectory(dir);
var savedName = $"menu_3d_{Guid.NewGuid():N}.glb";
var path = Path.Combine(dir, savedName);
await using var stream = File.Create(path);
await file.CopyToAsync(stream, cancellationToken);
await File.WriteAllBytesAsync(path, bytes, cancellationToken);
var url = $"/uploads/{cafeId}/{savedName}";
await RecordAsync(cafeId, hash, bytes.LongLength, file.ContentType, url, "menu_3d", file.FileName, cancellationToken);
_logger.LogInformation("Saved 3D model media for cafe {CafeId}", cafeId);
return $"/uploads/{cafeId}/{savedName}";
return url;
}
private async Task<string?> SaveAsync(
@@ -123,6 +146,20 @@ public class MediaStorageService : IMediaStorageService
if (file.Length == 0 || file.Length > maxBytes) return null;
if (!allowedMime.Contains(file.ContentType)) return null;
// Buffer once so we can hash the content and (if new) write it.
await using var buffer = new MemoryStream();
await file.CopyToAsync(buffer, cancellationToken);
var bytes = buffer.ToArray();
var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
// Dedup: an identical file already stored for this scope is reused as-is.
var existing = await FindExistingByHashAsync(cafeId, hash, cancellationToken);
if (existing is not null)
{
_logger.LogInformation("Dedup hit for {Prefix} (cafe {CafeId}); reusing existing file", prefix, cafeId);
return existing;
}
var ext = file.ContentType.ToLowerInvariant() switch
{
"image/png" => ".png",
@@ -138,10 +175,61 @@ public class MediaStorageService : IMediaStorageService
var fileName = $"{prefix}_{Guid.NewGuid():N}{ext}";
var path = Path.Combine(dir, fileName);
await using var stream = File.Create(path);
await file.CopyToAsync(stream, cancellationToken);
await File.WriteAllBytesAsync(path, bytes, cancellationToken);
var url = $"/uploads/{cafeId}/{fileName}";
await RecordAsync(cafeId, hash, bytes.LongLength, file.ContentType, url, prefix, file.FileName, cancellationToken);
_logger.LogInformation("Saved {Prefix} media for cafe {CafeId}", prefix, cafeId);
return $"/uploads/{cafeId}/{fileName}";
return url;
}
// ─── Deduplication helpers ────────────────────────────────────────────────
// MediaStorageService is a singleton; resolve a scoped DbContext per call.
private async Task<string?> FindExistingByHashAsync(string? cafeId, string hash, CancellationToken ct)
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await db.MediaAssets.AsNoTracking()
.Where(m => m.CafeId == cafeId && m.ContentHash == hash)
.Select(m => m.Url)
.FirstOrDefaultAsync(ct);
}
catch (Exception ex)
{
// Never let a dedup-lookup failure block an upload.
_logger.LogWarning(ex, "Media dedup lookup failed; proceeding with a fresh upload");
return null;
}
}
private async Task RecordAsync(
string? cafeId, string hash, long size, string contentType,
string url, string kind, string? originalName, CancellationToken ct)
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.MediaAssets.Add(new MediaAsset
{
CafeId = cafeId,
ContentHash = hash,
SizeBytes = size,
ContentType = contentType,
Url = url,
Kind = kind,
OriginalFileName = originalName,
});
await db.SaveChangesAsync(ct);
}
catch (Exception ex)
{
// The file is already written; a missing dedup record only means a
// future identical upload won't be de-duplicated. Don't fail the upload.
_logger.LogWarning(ex, "Failed to record media asset for cafe {CafeId}", cafeId);
}
}
}
+27
View File
@@ -0,0 +1,27 @@
namespace Meezi.Core.Entities;
/// <summary>
/// A stored upload, recorded so identical files can be de-duplicated and reused
/// instead of written to disk again. Files with the same content hash within a
/// scope (café, or platform when <see cref="CafeId"/> is null) share one stored file.
/// </summary>
public class MediaAsset : BaseEntity
{
/// <summary>Owning café, or null for platform-level (admin) uploads.</summary>
public string? CafeId { get; set; }
/// <summary>SHA-256 of the file content, lowercase hex.</summary>
public string ContentHash { get; set; } = string.Empty;
public long SizeBytes { get; set; }
public string ContentType { get; set; } = string.Empty;
/// <summary>Public URL/path the file is served from (e.g. /uploads/{cafeId}/{name}).</summary>
public string Url { get; set; } = string.Empty;
/// <summary>Logical kind/prefix: menu_img, logo, cover, gallery, review, menu_3d, blog, ...</summary>
public string Kind { get; set; } = string.Empty;
public string? OriginalFileName { get; set; }
}
@@ -85,6 +85,9 @@ public class AppDbContext : DbContext
// Idempotency keys for safe retry of offline-replayed writes.
public DbSet<IdempotencyRecord> IdempotencyRecords => Set<IdempotencyRecord>();
// Uploaded files, recorded for content-hash de-duplication and a media library.
public DbSet<MediaAsset> MediaAssets => Set<MediaAsset>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -113,6 +116,20 @@ public class AppDbContext : DbContext
e.HasQueryFilter(x => x.DeletedAt == null);
});
modelBuilder.Entity<MediaAsset>(e =>
{
e.HasKey(x => x.Id);
// Dedup lookups: same content within a scope (café or platform).
e.HasIndex(x => new { x.CafeId, x.ContentHash });
e.Property(x => x.CafeId).HasMaxLength(64);
e.Property(x => x.ContentHash).HasMaxLength(64).IsRequired();
e.Property(x => x.ContentType).HasMaxLength(100).IsRequired();
e.Property(x => x.Url).HasMaxLength(500).IsRequired();
e.Property(x => x.Kind).HasMaxLength(40).IsRequired();
e.Property(x => x.OriginalFileName).HasMaxLength(260);
e.HasQueryFilter(x => x.DeletedAt == null);
});
modelBuilder.Entity<Cafe>(e =>
{
e.HasKey(x => x.Id);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddMediaAssets : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "MediaAssets",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
CafeId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
ContentHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
ContentType = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Url = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
Kind = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
OriginalFileName = table.Column<string>(type: "character varying(260)", maxLength: 260, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_MediaAssets", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_MediaAssets_CafeId_ContentHash",
table: "MediaAssets",
columns: new[] { "CafeId", "ContentHash" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "MediaAssets");
}
}
}
@@ -1308,6 +1308,55 @@ namespace Meezi.Infrastructure.Data.Migrations
b.ToTable("LeaveRequests");
});
modelBuilder.Entity("Meezi.Core.Entities.MediaAsset", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("CafeId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ContentHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("OriginalFileName")
.HasMaxLength(260)
.HasColumnType("character varying(260)");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.HasKey("Id");
b.HasIndex("CafeId", "ContentHash");
b.ToTable("MediaAssets");
});
modelBuilder.Entity("Meezi.Core.Entities.MenuCategory", b =>
{
b.Property<string>("Id")
+20 -7
View File
@@ -1055,8 +1055,8 @@
"fieldExcerptEn": "الملخص بالإنجليزية",
"fieldCategoryFa": "الفئة بالفارسية",
"fieldCategoryEn": "الفئة بالإنجليزية",
"fieldContentFa": "المحتوى بالفارسية (Markdown)",
"fieldContentEn": "المحتوى بالإنجليزية (Markdown)",
"fieldContentFa": "المحتوى (فارسي)",
"fieldContentEn": "المحتوى (إنجليزي)",
"fieldPublished": "منشور",
"commentsTitle": "إدارة التعليقات",
"noComments": "لا توجد تعليقات",
@@ -1241,8 +1241,13 @@
"instagramLabel": "إنستغرام",
"websiteLabel": "الموقع",
"days": {
"sat": "السبت", "sun": "الأحد", "mon": "الاثنين",
"tue": "الثلاثاء", "wed": "الأربعاء", "thu": "الخميس", "fri": "الجمعة"
"sat": "السبت",
"sun": "الأحد",
"mon": "الاثنين",
"tue": "الثلاثاء",
"wed": "الأربعاء",
"thu": "الخميس",
"fri": "الجمعة"
},
"coffeeAdvisor": {
"title": "مستشار المشروبات",
@@ -1253,7 +1258,10 @@
"notConfigured": "المستشار الذكي غير مفعّل لهذا المقهى بعد",
"failed": "الاقتراحات غير متاحة. حاول لاحقاً"
},
"cities": { "tehran": "طهران", "karaj": "كرج" },
"cities": {
"tehran": "طهران",
"karaj": "كرج"
},
"sort": {
"rating": "الأعلى تقييماً",
"reviews": "الأكثر مراجعات",
@@ -1296,8 +1304,13 @@
"openTime": "يفتح الساعة",
"closeTime": "يغلق الساعة",
"days": {
"sat": "السبت", "sun": "الأحد", "mon": "الاثنين",
"tue": "الثلاثاء", "wed": "الأربعاء", "thu": "الخميس", "fri": "الجمعة"
"sat": "السبت",
"sun": "الأحد",
"mon": "الاثنين",
"tue": "الثلاثاء",
"wed": "الأربعاء",
"thu": "الخميس",
"fri": "الجمعة"
},
"save": "حفظ",
"saved": "تم الحفظ",
+6 -3
View File
@@ -1056,8 +1056,8 @@
"fieldExcerptEn": "Excerpt (English)",
"fieldCategoryFa": "Category (Persian)",
"fieldCategoryEn": "Category (English)",
"fieldContentFa": "Content (Persian, Markdown)",
"fieldContentEn": "Content (English, Markdown)",
"fieldContentFa": "Content (Persian)",
"fieldContentEn": "Content (English)",
"fieldPublished": "Published",
"commentsTitle": "Comment management",
"noComments": "No comments found",
@@ -1219,7 +1219,10 @@
"notFound": "Café not found",
"exploreMore": "Explore more cafés",
"reviewCount": "{count} reviews",
"cities": { "tehran": "Tehran", "karaj": "Karaj" },
"cities": {
"tehran": "Tehran",
"karaj": "Karaj"
},
"sort": {
"rating": "Top rated",
"reviews": "Most reviews",
+2 -2
View File
@@ -1056,8 +1056,8 @@
"fieldExcerptEn": "خلاصه انگلیسی",
"fieldCategoryFa": "دسته‌بندی فارسی",
"fieldCategoryEn": "دسته‌بندی انگلیسی",
"fieldContentFa": "محتوا فارسی (Markdown)",
"fieldContentEn": "محتوا انگلیسی (Markdown)",
"fieldContentFa": "محتوا (فارسی)",
"fieldContentEn": "محتوا (انگلیسی)",
"fieldPublished": "وضعیت انتشار",
"commentsTitle": "مدیریت نظرات",
"noComments": "نظری یافت نشد",
+791 -7
View File
@@ -13,6 +13,11 @@
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@tanstack/react-query": "^5.59.20",
"@tiptap/extension-link": "^2.27.2",
"@tiptap/extension-placeholder": "^2.27.2",
"@tiptap/pm": "^2.27.2",
"@tiptap/react": "^2.27.2",
"@tiptap/starter-kit": "^2.27.2",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
@@ -427,6 +432,16 @@
"node": ">=14"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
@@ -1160,6 +1175,12 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@remirror/core-constants": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
"license": "MIT"
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1216,6 +1237,422 @@
"react": "^18 || ^19"
}
},
"node_modules/@tiptap/core": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz",
"integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.2.tgz",
"integrity": "sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.2.tgz",
"integrity": "sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.2.tgz",
"integrity": "sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w==",
"license": "MIT",
"dependencies": {
"tippy.js": "^6.3.7"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.2.tgz",
"integrity": "sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-code": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.2.tgz",
"integrity": "sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz",
"integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-document": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz",
"integrity": "sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.2.tgz",
"integrity": "sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.2.tgz",
"integrity": "sha512-GUN6gPIGXS7ngRJOwdSmtBRBDt9Kt9CM/9pSwKebhLJ+honFoNA+Y6IpVyDvvDMdVNgBchiJLs6qA5H97gAePQ==",
"license": "MIT",
"dependencies": {
"tippy.js": "^6.3.7"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.2.tgz",
"integrity": "sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.2.tgz",
"integrity": "sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.2.tgz",
"integrity": "sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-history": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz",
"integrity": "sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.2.tgz",
"integrity": "sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz",
"integrity": "sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-link": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz",
"integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz",
"integrity": "sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.2.tgz",
"integrity": "sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.2.tgz",
"integrity": "sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.27.2.tgz",
"integrity": "sha512-IjsgSVYJRjpAKmIoapU0E2R4E2FPY3kpvU7/1i7PUYisylqejSJxmtJPGYw0FOMQY9oxnEEvfZHMBA610tqKpg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.2.tgz",
"integrity": "sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz",
"integrity": "sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text-style": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",
"integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/pm": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz",
"integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.1",
"prosemirror-menu": "^1.2.4",
"prosemirror-model": "^1.23.0",
"prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-trailing-node": "^3.0.0",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.37.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.27.2.tgz",
"integrity": "sha512-0EAs8Cpkfbvben1PZ34JN2Nd79Dhioynm2jML27DBbf1VWPk+FFWFGTMLUT0bu+Np5iVxio8fqV9t0mc4D6thA==",
"license": "MIT",
"dependencies": {
"@tiptap/extension-bubble-menu": "^2.27.2",
"@tiptap/extension-floating-menu": "^2.27.2",
"@types/use-sync-external-store": "^0.0.6",
"fast-deep-equal": "^3",
"use-sync-external-store": "^1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.2.tgz",
"integrity": "sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^2.27.2",
"@tiptap/extension-blockquote": "^2.27.2",
"@tiptap/extension-bold": "^2.27.2",
"@tiptap/extension-bullet-list": "^2.27.2",
"@tiptap/extension-code": "^2.27.2",
"@tiptap/extension-code-block": "^2.27.2",
"@tiptap/extension-document": "^2.27.2",
"@tiptap/extension-dropcursor": "^2.27.2",
"@tiptap/extension-gapcursor": "^2.27.2",
"@tiptap/extension-hard-break": "^2.27.2",
"@tiptap/extension-heading": "^2.27.2",
"@tiptap/extension-history": "^2.27.2",
"@tiptap/extension-horizontal-rule": "^2.27.2",
"@tiptap/extension-italic": "^2.27.2",
"@tiptap/extension-list-item": "^2.27.2",
"@tiptap/extension-ordered-list": "^2.27.2",
"@tiptap/extension-paragraph": "^2.27.2",
"@tiptap/extension-strike": "^2.27.2",
"@tiptap/extension-text": "^2.27.2",
"@tiptap/extension-text-style": "^2.27.2",
"@tiptap/pm": "^2.27.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@types/json5": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
@@ -1223,6 +1660,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
@@ -1237,14 +1696,14 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.29",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz",
"integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -1255,12 +1714,18 @@
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz",
@@ -1687,7 +2152,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
@@ -2302,6 +2766,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2334,7 +2804,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@@ -2547,6 +3017,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-abstract": {
"version": "1.24.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
@@ -2734,7 +3216,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -3177,7 +3658,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
@@ -4471,6 +4951,31 @@
"dev": true,
"license": "MIT"
},
"node_modules/linkify-it": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz",
"integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/linkifyjs": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
"license": "MIT"
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -4522,6 +5027,33 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/markdown-it": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz",
"integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.1",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -4531,6 +5063,12 @@
"node": ">= 0.4"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -5000,6 +5538,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -5373,6 +5917,201 @@
"react-is": "^16.13.1"
}
},
"node_modules/prosemirror-changeset": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-collab": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
"integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-markdown": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz",
"integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==",
"license": "MIT",
"dependencies": {
"@types/markdown-it": "^14.0.0",
"markdown-it": "^14.0.0",
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-menu": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.2.tgz",
"integrity": "sha512-6VgUJTYod0nMBlCaYJGhXGLu7Gt4AvcwcOq0YfJCY/6Uh+3S7UsWhpy6rJFCBFOmonq1hD8KyWOtZhkppd4YPg==",
"license": "MIT",
"dependencies": {
"crelt": "^1.0.0",
"prosemirror-commands": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.7",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.7.tgz",
"integrity": "sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-basic": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
"integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-trailing-node": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
"license": "MIT",
"dependencies": {
"@remirror/core-constants": "3.0.0",
"escape-string-regexp": "^4.0.0"
},
"peerDependencies": {
"prosemirror-model": "^1.22.1",
"prosemirror-state": "^1.4.2",
"prosemirror-view": "^1.33.8"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.8",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -5392,6 +6131,15 @@
"node": ">=6"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5688,6 +6436,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -6420,6 +7174,15 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tippy.js": {
"version": "6.3.7",
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
"integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==",
"license": "MIT",
"dependencies": {
"@popperjs/core": "^2.9.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -6590,6 +7353,12 @@
"node": ">=14.17"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/unbox-primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
@@ -6751,6 +7520,15 @@
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -6758,6 +7536,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+6 -1
View File
@@ -15,10 +15,15 @@
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@tanstack/react-query": "^5.59.20",
"@tiptap/extension-link": "^2.27.2",
"@tiptap/extension-placeholder": "^2.27.2",
"@tiptap/pm": "^2.27.2",
"@tiptap/react": "^2.27.2",
"@tiptap/starter-kit": "^2.27.2",
"axios": "^1.7.7",
"date-fns-jalali": "^4.1.0-0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns-jalali": "^4.1.0-0",
"lucide-react": "^0.454.0",
"next": "14.2.18",
"next-intl": "^3.23.5",
@@ -14,6 +14,7 @@ import type {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { RichTextEditor } from "@/components/ui/rich-text-editor";
import { Badge } from "@/components/ui/badge";
import { notify } from "@/lib/notify";
import { cn } from "@/lib/utils";
@@ -327,8 +328,14 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
<BlogField label={t("fieldCategoryFa")} value={form.categoryFa} onChange={setField("categoryFa")} dir="rtl" />
<BlogField label={t("fieldCategoryEn")} value={form.categoryEn} onChange={setField("categoryEn")} dir="ltr" />
</div>
<BlogField label={t("fieldContentFa")} value={form.contentFa} onChange={setField("contentFa")} dir="rtl" multiline />
<BlogField label={t("fieldContentEn")} value={form.contentEn} onChange={setField("contentEn")} dir="ltr" multiline />
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">{t("fieldContentFa")}</label>
<RichTextEditor value={form.contentFa} onChange={setField("contentFa")} dir="rtl" />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">{t("fieldContentEn")}</label>
<RichTextEditor value={form.contentEn} onChange={setField("contentEn")} dir="ltr" />
</div>
<div className="flex items-center justify-between rounded-lg border border-border/80 px-3 py-2">
<span className="text-sm font-medium">{t("fieldPublished")}</span>
@@ -0,0 +1,158 @@
"use client";
import { useEffect } from "react";
import { useEditor, EditorContent, type Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import {
Bold,
Italic,
Strikethrough,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Code,
Link2,
Link2Off,
Undo2,
Redo2,
} from "lucide-react";
import { cn } from "@/lib/utils";
type Props = {
value: string;
onChange: (html: string) => void;
dir?: "rtl" | "ltr";
placeholder?: string;
};
/** Headless TipTap rich-text editor producing HTML. Used for long-form content
* (blog posts) edited by trusted admins. */
export function RichTextEditor({ value, onChange, dir = "rtl", placeholder }: Props) {
const editor = useEditor({
immediatelyRender: false, // required under Next.js SSR to avoid hydration mismatch
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
Link.configure({ openOnClick: false, autolink: true, HTMLAttributes: { rel: "noopener", target: "_blank" } }),
Placeholder.configure({ placeholder: placeholder ?? "" }),
],
content: value || "",
editorProps: {
attributes: {
dir,
class: "meezi-rte-content min-h-44 px-3 py-2 focus:outline-none",
},
},
onUpdate: ({ editor }) => onChange(editor.getHTML()),
});
// Keep the editor in sync when the external value changes (load existing post / reset).
useEffect(() => {
if (!editor) return;
const current = editor.getHTML();
if (value !== current) {
editor.commands.setContent(value || "", false);
}
}, [value, editor]);
return (
<div className="overflow-hidden rounded-lg border border-border bg-background" dir={dir}>
<Toolbar editor={editor} />
<EditorContent editor={editor} />
<style>{`
.meezi-rte-content { font-size: 0.875rem; line-height: 1.7; }
.meezi-rte-content:focus { outline: none; }
.meezi-rte-content h1 { font-size: 1.5rem; font-weight: 700; margin: 0.6em 0 0.3em; }
.meezi-rte-content h2 { font-size: 1.25rem; font-weight: 700; margin: 0.6em 0 0.3em; }
.meezi-rte-content h3 { font-size: 1.1rem; font-weight: 600; margin: 0.5em 0 0.25em; }
.meezi-rte-content p { margin: 0.4em 0; }
.meezi-rte-content ul { list-style: disc; padding-inline-start: 1.5rem; margin: 0.4em 0; }
.meezi-rte-content ol { list-style: decimal; padding-inline-start: 1.5rem; margin: 0.4em 0; }
.meezi-rte-content blockquote { border-inline-start: 3px solid hsl(var(--primary)); padding-inline-start: 0.75rem; color: hsl(var(--muted-foreground)); margin: 0.5em 0; }
.meezi-rte-content a { color: hsl(var(--primary)); text-decoration: underline; }
.meezi-rte-content code { background: hsl(var(--muted)); padding: 0.1em 0.3em; border-radius: 4px; font-size: 0.85em; }
.meezi-rte-content pre { background: hsl(var(--muted)); padding: 0.6rem 0.8rem; border-radius: 8px; overflow-x: auto; }
.meezi-rte-content p.is-editor-empty:first-child::before { content: attr(data-placeholder); color: hsl(var(--muted-foreground)); float: inline-start; height: 0; pointer-events: none; }
`}</style>
</div>
);
}
function Toolbar({ editor }: { editor: Editor | null }) {
if (!editor) return <div className="h-9 border-b border-border bg-muted/40" />;
const setLink = () => {
const prev = editor.getAttributes("link").href as string | undefined;
const url = window.prompt("URL", prev ?? "https://");
if (url === null) return;
if (url === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
};
return (
<div className="flex flex-wrap items-center gap-0.5 border-b border-border bg-muted/40 px-1.5 py-1">
<Btn onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive("bold")} title="Bold"><Bold className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive("italic")} title="Italic"><Italic className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive("strike")} title="Strikethrough"><Strikethrough className="size-4" /></Btn>
<Sep />
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive("heading", { level: 1 })} title="H1"><Heading1 className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive("heading", { level: 2 })} title="H2"><Heading2 className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive("heading", { level: 3 })} title="H3"><Heading3 className="size-4" /></Btn>
<Sep />
<Btn onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive("bulletList")} title="Bullet list"><List className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive("orderedList")} title="Numbered list"><ListOrdered className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive("blockquote")} title="Quote"><Quote className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive("codeBlock")} title="Code block"><Code className="size-4" /></Btn>
<Sep />
<Btn onClick={setLink} active={editor.isActive("link")} title="Link"><Link2 className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().unsetLink().run()} active={false} title="Remove link" disabled={!editor.isActive("link")}><Link2Off className="size-4" /></Btn>
<Sep />
<Btn onClick={() => editor.chain().focus().undo().run()} active={false} title="Undo" disabled={!editor.can().undo()}><Undo2 className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().redo().run()} active={false} title="Redo" disabled={!editor.can().redo()}><Redo2 className="size-4" /></Btn>
</div>
);
}
function Btn({
onClick,
active,
title,
disabled,
children,
}: {
onClick: () => void;
active: boolean;
title: string;
disabled?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={title}
aria-label={title}
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
disabled={disabled}
className={cn(
"inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-background hover:text-foreground disabled:opacity-40",
active && "bg-primary/15 text-primary"
)}
>
{children}
</button>
);
}
function Sep() {
return <span className="mx-0.5 h-5 w-px bg-border" />;
}
@@ -126,7 +126,12 @@ export default async function BlogPostPage({
{/* Content */}
<div className="mx-auto max-w-3xl px-4 py-12 sm:px-6 lg:px-8">
<div className="prose prose-sm sm:prose-base prose-gray max-w-none prose-headings:font-bold prose-headings:text-gray-900 prose-a:text-brand-700 prose-strong:text-gray-900">
<MDXRemote source={post.content} />
{/* New posts are authored as HTML (rich editor); older posts are MDX/Markdown. */}
{/^\s*</.test(post.content) ? (
<div dangerouslySetInnerHTML={{ __html: post.content }} />
) : (
<MDXRemote source={post.content} />
)}
</div>
{/* Back link */}