calabasas 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +936 -149
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -4733,7 +4733,7 @@ export function useOnlineCount(guildDiscordId: string): number | undefined {
4733
4733
  var emojiPicker = {
4734
4734
  name: "emoji-picker",
4735
4735
  kind: "component",
4736
- description: "Grid-based emoji picker with custom Discord emojis, Unicode emojis, search, and animated GIF support",
4736
+ description: "Discord-style emoji picker with category sidebar, lazy-loaded custom emojis, Unicode emojis, search, and animated GIF support",
4737
4737
  requiredSyncTypes: ["emojis"],
4738
4738
  requiredShadcnComponents: ["popover", "button"],
4739
4739
  generateReactComponent: () => `"use client";
@@ -4765,11 +4765,64 @@ type EmojiPickerProps = {
4765
4765
  onSelect?: (emoji: EmojiData) => void;
4766
4766
  onChange?: (emojis: EmojiData[]) => void;
4767
4767
  columns?: number;
4768
- pageSize?: number;
4769
4768
  placeholder?: string;
4770
4769
  className?: string;
4771
4770
  };
4772
4771
 
4772
+ /** Category icon SVGs for the sidebar (Discord-style) */
4773
+ const CATEGORY_ICONS: Record<string, React.ReactNode> = {
4774
+ "Smileys & Emotion": (
4775
+ <svg viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5">
4776
+ <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-5-6c.78 2.34 2.72 4 5 4s4.22-1.66 5-4H7zm1-2.5c.83 0 1.5-.67 1.5-1.5S8.83 8.5 8 8.5 6.5 9.17 6.5 10 7.17 11.5 8 11.5zm8 0c.83 0 1.5-.67 1.5-1.5S16.83 8.5 16 8.5s-1.5.67-1.5 1.5.67 1.5 1.5 1.5z"/>
4777
+ </svg>
4778
+ ),
4779
+ "People & Body": (
4780
+ <svg viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5">
4781
+ <path d="M7 7c0-1.1.9-2 2-2s2 .9 2 2-.9 2-2 2-2-.9-2-2zm-2 0c0 2.21 1.79 4 4 4s4-1.79 4-4-1.79-4-4-4-4 1.79-4 4zm7.22 4.22L9 14.44l-3.22-3.22L4.37 12.6l4.63 4.63 4.63-4.63-1.41-1.38zM17 7c0-1.1.9-2 2-2s2 .9 2 2-.9 2-2 2-2-.9-2-2zm-2 0c0 2.21 1.79 4 4 4s4-1.79 4-4-1.79-4-4-4-4 1.79-4 4z"/>
4782
+ </svg>
4783
+ ),
4784
+ "Animals & Nature": (
4785
+ <svg viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5">
4786
+ <path d="M6.05 8.05c-2.73 2.73-2.73 7.15-.02 9.88a6.95 6.95 0 004.95 2.05c.78 0 1.57-.13 2.32-.39-.98-.54-1.86-1.27-2.59-2.17-.35-.43-.66-.89-.93-1.37a6.93 6.93 0 01-.8-4.44c.12-.9.42-1.75.85-2.53a7.06 7.06 0 01-3.78-1.03zm4.97.17c-.36.42-.66.89-.9 1.39-.42.9-.63 1.87-.62 2.87.01.95.23 1.88.66 2.72.23.45.5.87.82 1.24.68.8 1.52 1.42 2.46 1.87 2.13-1.05 3.61-3.15 3.61-5.59 0-2.07-1.01-3.9-2.56-5.05a6.93 6.93 0 00-3.47 1.55zM19.95 2a6.97 6.97 0 00-4.93 2.05 7.1 7.1 0 00-.58.65A8.94 8.94 0 0119 10.72c0 3.4-1.94 6.35-4.77 7.82A8.98 8.98 0 0110 20a8.9 8.9 0 01-5.64-2 6.98 6.98 0 005.64 4 6.97 6.97 0 004.95-2.05c2.73-2.73 2.73-7.17 0-9.9.65-.56 1.2-1.23 1.63-1.99A6.93 6.93 0 0020 4c0-.7-.1-1.38-.3-2h.25z"/>
4787
+ </svg>
4788
+ ),
4789
+ "Food & Drink": (
4790
+ <svg viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5">
4791
+ <path d="M18.06 22.99h1.66c.84 0 1.53-.64 1.63-1.46L23 5.05h-5V1h-1.97v4.05h-4.97l.3 2.34c1.71.47 3.31 1.32 4.27 2.26 1.44 1.42 2.43 2.89 2.43 5.29v8.05zM1 21.99V21h15.03v.99c0 .55-.45 1-1.01 1H2.01c-.56 0-1.01-.45-1.01-1zm15.03-7c0-4.5-6.77-5-7.52-5s-7.51.5-7.51 5v1h15.03v-1zM1.02 17h15v2h-15v-2z"/>
4792
+ </svg>
4793
+ ),
4794
+ "Travel & Places": (
4795
+ <svg viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5">
4796
+ <path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
4797
+ </svg>
4798
+ ),
4799
+ "Activities": (
4800
+ <svg viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5">
4801
+ <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-5.5-2.5l7.51-3.49L17.5 6.5 9.99 9.99 6.5 17.5zm5.5-6.6c.61 0 1.1.49 1.1 1.1s-.49 1.1-1.1 1.1-1.1-.49-1.1-1.1.49-1.1 1.1-1.1z"/>
4802
+ </svg>
4803
+ ),
4804
+ "Objects": (
4805
+ <svg viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5">
4806
+ <path d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7zm2.85 11.1l-.85.6V16h-4v-2.3l-.85-.6A4.997 4.997 0 017 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.63-.8 3.16-2.15 4.1z"/>
4807
+ </svg>
4808
+ ),
4809
+ "Symbols": (
4810
+ <svg viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5">
4811
+ <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
4812
+ </svg>
4813
+ ),
4814
+ "Server Emojis": (
4815
+ <svg viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5">
4816
+ <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
4817
+ </svg>
4818
+ ),
4819
+ "App Emojis": (
4820
+ <svg viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5">
4821
+ <path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-7-2h2v-4h4v-2h-4V7h-2v4H8v2h4z"/>
4822
+ </svg>
4823
+ ),
4824
+ };
4825
+
4773
4826
  /** Curated default Unicode emojis organized by Discord-style categories */
4774
4827
  const DEFAULT_EMOJIS: { category: string; emojis: { codepoint: string; name: string }[] }[] = [
4775
4828
  {
@@ -5153,21 +5206,30 @@ const DEFAULT_EMOJIS: { category: string; emojis: { codepoint: string; name: str
5153
5206
  },
5154
5207
  ];
5155
5208
 
5156
- /** Renders a custom Discord emoji with animated hover behavior */
5209
+ const EMOJI_BUTTON_SIZE = 44;
5210
+
5211
+ /** Renders a custom Discord emoji — only loads the image when visible */
5157
5212
  function CustomEmoji({
5158
5213
  discordId,
5159
5214
  name,
5160
5215
  animated,
5161
5216
  selected,
5162
5217
  size = 32,
5218
+ visible = true,
5163
5219
  }: {
5164
5220
  discordId: string;
5165
5221
  name: string;
5166
5222
  animated?: boolean;
5167
5223
  selected?: boolean;
5168
5224
  size?: number;
5225
+ visible?: boolean;
5169
5226
  }) {
5170
5227
  const [hovered, setHovered] = useState(false);
5228
+
5229
+ if (!visible) {
5230
+ return <div style={{ width: size, height: size }} />;
5231
+ }
5232
+
5171
5233
  const ext = animated && (hovered || selected) ? "gif" : "png";
5172
5234
  const url = \`https://cdn.discordapp.com/emojis/\${discordId}.\${ext}?size=\${size}&quality=lossless\`;
5173
5235
 
@@ -5186,6 +5248,75 @@ function CustomEmoji({
5186
5248
  );
5187
5249
  }
5188
5250
 
5251
+ /** Wraps each emoji button and uses IntersectionObserver to only render content when in view */
5252
+ function LazyEmoji({
5253
+ emoji,
5254
+ selected,
5255
+ disabled,
5256
+ onClick,
5257
+ scrollRoot,
5258
+ }: {
5259
+ emoji: EmojiData;
5260
+ selected: boolean;
5261
+ disabled: boolean;
5262
+ onClick: () => void;
5263
+ scrollRoot: HTMLDivElement | null;
5264
+ }) {
5265
+ const ref = useRef<HTMLButtonElement>(null);
5266
+ const [visible, setVisible] = useState(false);
5267
+
5268
+ useEffect(() => {
5269
+ const el = ref.current;
5270
+ if (!el) return;
5271
+
5272
+ const observer = new IntersectionObserver(
5273
+ ([entry]) => {
5274
+ if (entry.isIntersecting) {
5275
+ setVisible(true);
5276
+ observer.disconnect();
5277
+ }
5278
+ },
5279
+ { root: scrollRoot, rootMargin: "100px" }
5280
+ );
5281
+
5282
+ observer.observe(el);
5283
+ return () => observer.disconnect();
5284
+ }, [scrollRoot]);
5285
+
5286
+ return (
5287
+ <button
5288
+ ref={ref}
5289
+ onClick={() => !disabled && onClick()}
5290
+ title={emoji.name}
5291
+ className={cn(
5292
+ "flex items-center justify-center rounded-md transition-colors",
5293
+ selected
5294
+ ? "ring-2 ring-primary bg-accent/50"
5295
+ : "hover:bg-accent",
5296
+ disabled && "opacity-50 cursor-not-allowed"
5297
+ )}
5298
+ style={{ width: EMOJI_BUTTON_SIZE, height: EMOJI_BUTTON_SIZE }}
5299
+ >
5300
+ {emoji.source === "default" ? (
5301
+ visible ? (
5302
+ <span className="text-2xl leading-none">{emoji.id}</span>
5303
+ ) : (
5304
+ <span style={{ width: 28, height: 28 }} />
5305
+ )
5306
+ ) : (
5307
+ <CustomEmoji
5308
+ discordId={emoji.id}
5309
+ name={emoji.name}
5310
+ animated={emoji.animated}
5311
+ selected={selected}
5312
+ size={32}
5313
+ visible={visible}
5314
+ />
5315
+ )}
5316
+ </button>
5317
+ );
5318
+ }
5319
+
5189
5320
  export function EmojiPicker({
5190
5321
  guildDiscordId,
5191
5322
  source = "all",
@@ -5194,18 +5325,17 @@ export function EmojiPicker({
5194
5325
  value,
5195
5326
  onSelect,
5196
5327
  onChange,
5197
- columns = 8,
5198
- pageSize = 64,
5328
+ columns = 9,
5199
5329
  placeholder = "Pick emoji...",
5200
5330
  className,
5201
5331
  }: EmojiPickerProps) {
5202
5332
  const [open, setOpen] = useState(false);
5203
5333
  const [search, setSearch] = useState("");
5204
5334
  const [selected, setSelected] = useState<EmojiData[]>([]);
5205
- const [visibleCount, setVisibleCount] = useState(pageSize);
5206
- const sentinelRef = useRef<HTMLDivElement>(null);
5335
+ const [activeSection, setActiveSection] = useState(0);
5207
5336
  const scrollRef = useRef<HTMLDivElement>(null);
5208
5337
  const searchRef = useRef<HTMLInputElement>(null);
5338
+ const sectionRefs = useRef<Map<number, HTMLDivElement>>(new Map());
5209
5339
 
5210
5340
  const currentSelected = value
5211
5341
  ? selected.filter((e) => value.includes(e.id))
@@ -5225,8 +5355,8 @@ export function EmojiPicker({
5225
5355
  needsApp ? {} : "skip"
5226
5356
  );
5227
5357
 
5228
- // Build flat emoji list for search and infinite scroll
5229
- const allEmojis = useMemo(() => {
5358
+ // Build sections
5359
+ const allSections = useMemo(() => {
5230
5360
  const sections: { header: string; items: EmojiData[] }[] = [];
5231
5361
 
5232
5362
  if (needsGuild && guildEmojis && guildEmojis.length > 0) {
@@ -5271,42 +5401,35 @@ export function EmojiPicker({
5271
5401
 
5272
5402
  // Filter by search
5273
5403
  const filtered = useMemo(() => {
5274
- if (!search) return allEmojis;
5404
+ if (!search) return allSections;
5275
5405
  const q = search.toLowerCase();
5276
- const flat = allEmojis.flatMap((s) => s.items).filter((e) =>
5406
+ const flat = allSections.flatMap((s) => s.items).filter((e) =>
5277
5407
  e.name.toLowerCase().includes(q)
5278
5408
  );
5279
- return flat.length > 0 ? [{ header: "", items: flat }] : [];
5280
- }, [allEmojis, search]);
5409
+ return flat.length > 0 ? [{ header: "Search Results", items: flat }] : [];
5410
+ }, [allSections, search]);
5281
5411
 
5282
- // Flatten for counting
5283
- const totalCount = useMemo(
5284
- () => filtered.reduce((acc, s) => acc + s.items.length, 0),
5285
- [filtered]
5286
- );
5287
-
5288
- // Intersection observer for infinite scroll
5412
+ // Track which section is in view via IntersectionObserver
5289
5413
  useEffect(() => {
5290
- const sentinel = sentinelRef.current;
5291
- if (!sentinel) return;
5414
+ if (search) return;
5415
+ const container = scrollRef.current;
5416
+ if (!container) return;
5292
5417
 
5293
5418
  const observer = new IntersectionObserver(
5294
- ([entry]) => {
5295
- if (entry.isIntersecting && visibleCount < totalCount) {
5296
- setVisibleCount((prev) => prev + pageSize);
5419
+ (entries) => {
5420
+ for (const entry of entries) {
5421
+ if (entry.isIntersecting) {
5422
+ const idx = Number(entry.target.getAttribute("data-section-index"));
5423
+ if (!isNaN(idx)) setActiveSection(idx);
5424
+ }
5297
5425
  }
5298
5426
  },
5299
- { root: scrollRef.current, threshold: 0 }
5427
+ { root: container, rootMargin: "-10% 0px -80% 0px", threshold: 0 }
5300
5428
  );
5301
5429
 
5302
- observer.observe(sentinel);
5430
+ sectionRefs.current.forEach((el) => observer.observe(el));
5303
5431
  return () => observer.disconnect();
5304
- }, [visibleCount, totalCount, pageSize]);
5305
-
5306
- // Reset visible count on search change
5307
- useEffect(() => {
5308
- setVisibleCount(pageSize);
5309
- }, [search, pageSize]);
5432
+ }, [filtered, search]);
5310
5433
 
5311
5434
  // Auto-focus search on open
5312
5435
  useEffect(() => {
@@ -5314,9 +5437,17 @@ export function EmojiPicker({
5314
5437
  setTimeout(() => searchRef.current?.focus(), 0);
5315
5438
  } else {
5316
5439
  setSearch("");
5440
+ setActiveSection(0);
5317
5441
  }
5318
5442
  }, [open]);
5319
5443
 
5444
+ const scrollToSection = useCallback((index: number) => {
5445
+ const el = sectionRefs.current.get(index);
5446
+ if (el) {
5447
+ el.scrollIntoView({ behavior: "smooth", block: "start" });
5448
+ }
5449
+ }, []);
5450
+
5320
5451
  const isSelected = useCallback(
5321
5452
  (id: string) => {
5322
5453
  if (value) return value.includes(id);
@@ -5335,7 +5466,6 @@ export function EmojiPicker({
5335
5466
  return;
5336
5467
  }
5337
5468
 
5338
- // Multi mode
5339
5469
  const alreadySelected = isSelected(emoji.id);
5340
5470
  let next: EmojiData[];
5341
5471
 
@@ -5360,9 +5490,7 @@ export function EmojiPicker({
5360
5490
 
5361
5491
  const atMax = mode === "multi" && maxCount !== undefined && currentSelected.length >= maxCount;
5362
5492
 
5363
- // Render grid items with virtual slicing
5364
- let rendered = 0;
5365
- const gridStyle = { gridTemplateColumns: \`repeat(\${columns}, 1fr)\` };
5493
+ const gridStyle = { gridTemplateColumns: \`repeat(\${columns}, \${EMOJI_BUTTON_SIZE}px)\` };
5366
5494
 
5367
5495
  // Trigger display
5368
5496
  const renderTrigger = () => {
@@ -5384,7 +5512,6 @@ export function EmojiPicker({
5384
5512
  );
5385
5513
  }
5386
5514
 
5387
- // Multi mode
5388
5515
  const shown = currentSelected.slice(0, 5);
5389
5516
  const extra = currentSelected.length - shown.length;
5390
5517
  return (
@@ -5425,125 +5552,138 @@ export function EmojiPicker({
5425
5552
  <span className="ml-2 text-lg opacity-50">\uD83D\uDE00</span>
5426
5553
  </Button>
5427
5554
  </PopoverTrigger>
5428
- <PopoverContent className="w-[352px] p-0" align="start">
5429
- {/* Search */}
5430
- <div className="flex items-center gap-2 border-b px-3 py-2">
5431
- <svg
5432
- className="h-4 w-4 shrink-0 opacity-50"
5433
- fill="none"
5434
- stroke="currentColor"
5435
- viewBox="0 0 24 24"
5436
- >
5437
- <path
5438
- strokeLinecap="round"
5439
- strokeLinejoin="round"
5440
- strokeWidth={2}
5441
- d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
5442
- />
5443
- </svg>
5444
- <input
5445
- ref={searchRef}
5446
- type="text"
5447
- placeholder="Search emojis..."
5448
- value={search}
5449
- onChange={(e) => setSearch(e.target.value)}
5450
- className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
5451
- />
5452
- {search && (
5453
- <button
5454
- onClick={() => setSearch("")}
5455
- className="text-muted-foreground hover:text-foreground"
5555
+ <PopoverContent className="w-auto p-0" align="start">
5556
+ <div className="flex flex-col" style={{ width: columns * EMOJI_BUTTON_SIZE + 52 + 16 }}>
5557
+ {/* Search bar */}
5558
+ <div className="flex items-center gap-2 border-b px-3 py-2">
5559
+ <svg
5560
+ className="h-4 w-4 shrink-0 opacity-50"
5561
+ fill="none"
5562
+ stroke="currentColor"
5563
+ viewBox="0 0 24 24"
5456
5564
  >
5457
- <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
5458
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
5459
- </svg>
5460
- </button>
5461
- )}
5462
- </div>
5565
+ <path
5566
+ strokeLinecap="round"
5567
+ strokeLinejoin="round"
5568
+ strokeWidth={2}
5569
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
5570
+ />
5571
+ </svg>
5572
+ <input
5573
+ ref={searchRef}
5574
+ type="text"
5575
+ placeholder="Search emojis..."
5576
+ value={search}
5577
+ onChange={(e) => setSearch(e.target.value)}
5578
+ className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
5579
+ />
5580
+ {search && (
5581
+ <button
5582
+ onClick={() => setSearch("")}
5583
+ className="text-muted-foreground hover:text-foreground"
5584
+ >
5585
+ <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
5586
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
5587
+ </svg>
5588
+ </button>
5589
+ )}
5590
+ </div>
5463
5591
 
5464
- {/* Scrollable emoji grid */}
5465
- <div
5466
- ref={scrollRef}
5467
- className="max-h-[400px] overflow-y-auto p-2"
5468
- >
5469
- {filtered.length === 0 && (
5470
- <div className="py-8 text-center text-sm text-muted-foreground">
5471
- No emojis found
5472
- </div>
5473
- )}
5592
+ {/* Main body: sidebar + emoji grid */}
5593
+ <div className="flex">
5594
+ {/* Category sidebar */}
5595
+ {!search && (
5596
+ <div className="flex flex-col items-center gap-0.5 border-r py-1.5 px-1 overflow-y-auto" style={{ maxHeight: 420 }}>
5597
+ {filtered.map((section, i) => (
5598
+ <button
5599
+ key={section.header}
5600
+ onClick={() => scrollToSection(i)}
5601
+ title={section.header}
5602
+ className={cn(
5603
+ "flex items-center justify-center rounded-md w-8 h-8 shrink-0 transition-colors",
5604
+ activeSection === i
5605
+ ? "bg-accent text-accent-foreground"
5606
+ : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
5607
+ )}
5608
+ >
5609
+ {CATEGORY_ICONS[section.header] ?? (
5610
+ <span className="text-sm leading-none">
5611
+ {section.items[0]?.source === "default"
5612
+ ? section.items[0].id
5613
+ : "?"}
5614
+ </span>
5615
+ )}
5616
+ </button>
5617
+ ))}
5618
+ </div>
5619
+ )}
5474
5620
 
5475
- {filtered.map((section) => {
5476
- if (rendered >= visibleCount) return null;
5621
+ {/* Scrollable emoji grid */}
5622
+ <div
5623
+ ref={scrollRef}
5624
+ className="overflow-y-auto p-2 flex-1"
5625
+ style={{ maxHeight: 420 }}
5626
+ >
5627
+ {filtered.length === 0 && (
5628
+ <div className="py-8 text-center text-sm text-muted-foreground">
5629
+ No emojis found
5630
+ </div>
5631
+ )}
5477
5632
 
5478
- const remaining = visibleCount - rendered;
5479
- const items = section.items.slice(0, remaining);
5480
- rendered += items.length;
5633
+ {filtered.map((section, sectionIdx) => (
5634
+ <div
5635
+ key={section.header || "search-results"}
5636
+ ref={(el) => {
5637
+ if (el) sectionRefs.current.set(sectionIdx, el);
5638
+ else sectionRefs.current.delete(sectionIdx);
5639
+ }}
5640
+ data-section-index={sectionIdx}
5641
+ >
5642
+ {section.header && (
5643
+ <div className="sticky top-0 z-10 bg-popover px-1 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
5644
+ {section.header}
5645
+ </div>
5646
+ )}
5647
+ <div className="grid" style={gridStyle}>
5648
+ {section.items.map((emoji) => {
5649
+ const sel = isSelected(emoji.id);
5650
+ const disabled = !sel && atMax;
5481
5651
 
5482
- return (
5483
- <div key={section.header || "search-results"}>
5484
- {section.header && (
5485
- <div className="sticky top-0 z-10 bg-popover px-1 py-1.5 text-xs font-semibold text-muted-foreground">
5486
- {section.header}
5652
+ return (
5653
+ <LazyEmoji
5654
+ key={\`\${emoji.source}-\${emoji.id}\`}
5655
+ emoji={emoji}
5656
+ selected={sel}
5657
+ disabled={disabled}
5658
+ onClick={() => handleSelect(emoji)}
5659
+ scrollRoot={scrollRef.current}
5660
+ />
5661
+ );
5662
+ })}
5487
5663
  </div>
5488
- )}
5489
- <div className="grid gap-0.5" style={gridStyle}>
5490
- {items.map((emoji) => {
5491
- const sel = isSelected(emoji.id);
5492
- const disabled = !sel && atMax;
5493
-
5494
- return (
5495
- <button
5496
- key={\`\${emoji.source}-\${emoji.id}\`}
5497
- onClick={() => !disabled && handleSelect(emoji)}
5498
- title={emoji.name}
5499
- className={cn(
5500
- "flex items-center justify-center rounded-md p-1 h-9 w-full transition-colors",
5501
- sel
5502
- ? "ring-2 ring-primary bg-accent/50"
5503
- : "hover:bg-accent",
5504
- disabled && "opacity-50 cursor-not-allowed"
5505
- )}
5506
- >
5507
- {emoji.source === "default" ? (
5508
- <span className="text-xl leading-none">{emoji.id}</span>
5509
- ) : (
5510
- <CustomEmoji
5511
- discordId={emoji.id}
5512
- name={emoji.name}
5513
- animated={emoji.animated}
5514
- selected={sel}
5515
- size={28}
5516
- />
5517
- )}
5518
- </button>
5519
- );
5520
- })}
5521
5664
  </div>
5522
- </div>
5523
- );
5524
- })}
5665
+ ))}
5666
+ </div>
5667
+ </div>
5525
5668
 
5526
- {/* Sentinel for infinite scroll */}
5527
- <div ref={sentinelRef} className="h-1" />
5669
+ {/* Multi-select footer */}
5670
+ {mode === "multi" && (
5671
+ <div className="flex items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground">
5672
+ <span>
5673
+ Selected: {currentSelected.length}
5674
+ {maxCount !== undefined && \` / \${maxCount}\`}
5675
+ </span>
5676
+ {currentSelected.length > 0 && (
5677
+ <button
5678
+ onClick={handleClear}
5679
+ className="text-xs text-muted-foreground hover:text-foreground underline"
5680
+ >
5681
+ Clear
5682
+ </button>
5683
+ )}
5684
+ </div>
5685
+ )}
5528
5686
  </div>
5529
-
5530
- {/* Multi-select footer */}
5531
- {mode === "multi" && (
5532
- <div className="flex items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground">
5533
- <span>
5534
- Selected: {currentSelected.length}
5535
- {maxCount !== undefined && \` / \${maxCount}\`}
5536
- </span>
5537
- {currentSelected.length > 0 && (
5538
- <button
5539
- onClick={handleClear}
5540
- className="text-xs text-muted-foreground hover:text-foreground underline"
5541
- >
5542
- Clear
5543
- </button>
5544
- )}
5545
- </div>
5546
- )}
5547
5687
  </PopoverContent>
5548
5688
  </Popover>
5549
5689
  );
@@ -5601,6 +5741,652 @@ export const listAppEmojis = query({
5601
5741
  });`
5602
5742
  };
5603
5743
 
5744
+ // src/lib/registry/components/role-creator.ts
5745
+ var roleCreator = {
5746
+ name: "role-creator",
5747
+ kind: "component",
5748
+ description: "Discord-style role creation dialog with color picker, member assignment, and position ordering",
5749
+ requiredSyncTypes: ["roles", "members"],
5750
+ requiredShadcnComponents: ["dialog", "button", "popover", "command"],
5751
+ generateReactComponent: () => `"use client";
5752
+
5753
+ import { useState, useMemo } from "react";
5754
+ import { useQuery } from "convex/react";
5755
+ import { api } from "@/convex/_generated/api";
5756
+ import {
5757
+ Dialog,
5758
+ DialogContent,
5759
+ DialogDescription,
5760
+ DialogHeader,
5761
+ DialogTitle,
5762
+ DialogTrigger,
5763
+ } from "@/components/ui/dialog";
5764
+ import {
5765
+ Popover,
5766
+ PopoverContent,
5767
+ PopoverTrigger,
5768
+ } from "@/components/ui/popover";
5769
+ import {
5770
+ Command,
5771
+ CommandEmpty,
5772
+ CommandGroup,
5773
+ CommandInput,
5774
+ CommandItem,
5775
+ CommandList,
5776
+ } from "@/components/ui/command";
5777
+ import { Button } from "@/components/ui/button";
5778
+ import { cn } from "@/lib/utils";
5779
+ import {
5780
+ Check,
5781
+ X,
5782
+ ChevronsUpDown,
5783
+ User,
5784
+ Plus,
5785
+ ChevronUp,
5786
+ ChevronDown,
5787
+ } from "lucide-react";
5788
+
5789
+ /**
5790
+ * Discord's 20 preset role colors arranged in two rows:
5791
+ * Row 1 — lighter tones, Row 2 — darker counterparts.
5792
+ */
5793
+ const PRESET_COLORS: number[] = [
5794
+ 0x1abc9c, 0x2ecc71, 0x3498db, 0x9b59b6, 0xe91e63,
5795
+ 0xf1c40f, 0xe67e22, 0xe74c3c, 0x95a5a6, 0x607d8b,
5796
+ 0x11806a, 0x1f8b4c, 0x206694, 0x71368a, 0xad1457,
5797
+ 0xc27c0e, 0xa84300, 0x992d22, 0x979c9f, 0x546e7a,
5798
+ ];
5799
+
5800
+ /** Convert Discord integer color to hex CSS string */
5801
+ function colorToHex(color: number): string {
5802
+ if (color === 0) return "#99AAB5";
5803
+ return "#" + color.toString(16).padStart(6, "0");
5804
+ }
5805
+
5806
+ /** Convert hex string to Discord integer color */
5807
+ function hexToColor(hex: string): number {
5808
+ const clean = hex.replace("#", "");
5809
+ const parsed = parseInt(clean, 16);
5810
+ return isNaN(parsed) ? 0 : parsed;
5811
+ }
5812
+
5813
+ /** Convert Discord integer color to rgba string */
5814
+ function colorToRgba(color: number, alpha: number): string {
5815
+ if (color === 0) return \`rgba(153, 170, 181, \${alpha})\`;
5816
+ const r = (color >> 16) & 0xff;
5817
+ const g = (color >> 8) & 0xff;
5818
+ const b = color & 0xff;
5819
+ return \`rgba(\${r}, \${g}, \${b}, \${alpha})\`;
5820
+ }
5821
+
5822
+ /** Build Discord CDN avatar URL */
5823
+ function avatarUrl(
5824
+ userId: string,
5825
+ avatar: string | undefined,
5826
+ ): string | null {
5827
+ if (!avatar) return null;
5828
+ return \`https://cdn.discordapp.com/avatars/\${userId}/\${avatar}.png?size=32\`;
5829
+ }
5830
+
5831
+ // ── Types ────────────────────────────────────────────────────────────────────
5832
+
5833
+ type RoleCreatorData = {
5834
+ name: string;
5835
+ color: number;
5836
+ memberIds: string[];
5837
+ position: number;
5838
+ };
5839
+
5840
+ type RoleCreatorProps = {
5841
+ guildDiscordId: string;
5842
+ defaultMemberIds?: string[];
5843
+ open?: boolean;
5844
+ onOpenChange?: (open: boolean) => void;
5845
+ onCreate?: (data: RoleCreatorData) => void;
5846
+ trigger?: React.ReactNode;
5847
+ className?: string;
5848
+ };
5849
+
5850
+ // ── Component ────────────────────────────────────────────────────────────────
5851
+
5852
+ export function RoleCreator({
5853
+ guildDiscordId,
5854
+ defaultMemberIds = [],
5855
+ open: controlledOpen,
5856
+ onOpenChange,
5857
+ onCreate,
5858
+ trigger,
5859
+ className,
5860
+ }: RoleCreatorProps) {
5861
+ const [internalOpen, setInternalOpen] = useState(false);
5862
+ const isControlled = controlledOpen !== undefined;
5863
+ const open = isControlled ? controlledOpen : internalOpen;
5864
+
5865
+ // Form state
5866
+ const [name, setName] = useState("new role");
5867
+ const [color, setColor] = useState(0);
5868
+ const [customHex, setCustomHex] = useState("");
5869
+ const [usingCustom, setUsingCustom] = useState(false);
5870
+ const [selectedMembers, setSelectedMembers] = useState<string[]>(defaultMemberIds);
5871
+ const [memberPickerOpen, setMemberPickerOpen] = useState(false);
5872
+ const [positionIndex, setPositionIndex] = useState(0);
5873
+
5874
+ // Data
5875
+ const roles = useQuery(api.calabasas.queries.listRolesForCreator, {
5876
+ guildDiscordId,
5877
+ });
5878
+ const members = useQuery(api.calabasas.queries.listMembersForCreator, {
5879
+ guildDiscordId,
5880
+ });
5881
+
5882
+ // Sort roles highest-position first, exclude @everyone
5883
+ const sortedRoles = useMemo(() => {
5884
+ if (!roles) return [];
5885
+ return roles
5886
+ .filter((r) => r.name !== "@everyone")
5887
+ .sort((a, b) => b.position - a.position);
5888
+ }, [roles]);
5889
+
5890
+ // Calculate the position value from the insertion index
5891
+ const calculatedPosition = useMemo(() => {
5892
+ if (sortedRoles.length === 0) return 1;
5893
+ if (positionIndex === 0) return sortedRoles[0].position + 1;
5894
+ if (positionIndex >= sortedRoles.length) return 1;
5895
+ return sortedRoles[positionIndex - 1].position;
5896
+ }, [sortedRoles, positionIndex]);
5897
+
5898
+ const reset = () => {
5899
+ setName("new role");
5900
+ setColor(0);
5901
+ setCustomHex("");
5902
+ setUsingCustom(false);
5903
+ setSelectedMembers(defaultMemberIds);
5904
+ setMemberPickerOpen(false);
5905
+ setPositionIndex(0);
5906
+ };
5907
+
5908
+ const setOpen = (v: boolean) => {
5909
+ if (!v) reset();
5910
+ if (!isControlled) setInternalOpen(v);
5911
+ onOpenChange?.(v);
5912
+ };
5913
+
5914
+ const handleCreate = () => {
5915
+ onCreate?.({
5916
+ name,
5917
+ color,
5918
+ memberIds: selectedMembers,
5919
+ position: calculatedPosition,
5920
+ });
5921
+ setOpen(false);
5922
+ };
5923
+
5924
+ const toggleMember = (userId: string) => {
5925
+ setSelectedMembers((prev) =>
5926
+ prev.includes(userId)
5927
+ ? prev.filter((id) => id !== userId)
5928
+ : [...prev, userId],
5929
+ );
5930
+ };
5931
+
5932
+ const selectPreset = (c: number) => {
5933
+ setColor(c);
5934
+ setUsingCustom(false);
5935
+ setCustomHex("");
5936
+ };
5937
+
5938
+ const moveUp = () => setPositionIndex((i) => Math.max(0, i - 1));
5939
+ const moveDown = () =>
5940
+ setPositionIndex((i) => Math.min(sortedRoles.length, i + 1));
5941
+
5942
+ // ── Render ───────────────────────────────────────────────────────────────
5943
+
5944
+ return (
5945
+ <Dialog open={open} onOpenChange={setOpen}>
5946
+ <DialogTrigger asChild>
5947
+ {trigger ?? (
5948
+ <Button variant="outline" className={className}>
5949
+ <Plus className="mr-2 h-4 w-4" />
5950
+ Create Role
5951
+ </Button>
5952
+ )}
5953
+ </DialogTrigger>
5954
+
5955
+ <DialogContent className="sm:max-w-[520px] p-0 gap-0 overflow-hidden">
5956
+ {/* ── Header ──────────────────────────────────────────────── */}
5957
+ <DialogHeader className="px-6 pt-6 pb-2">
5958
+ <DialogTitle>Create Role</DialogTitle>
5959
+ <DialogDescription>
5960
+ Configure the role's appearance, assign members, and set its
5961
+ position in the hierarchy.
5962
+ </DialogDescription>
5963
+ </DialogHeader>
5964
+
5965
+ {/* ── Live preview ────────────────────────────────────────── */}
5966
+ <div className="px-6 py-3">
5967
+ <div className="flex items-center gap-3 rounded-lg border bg-muted/40 px-4 py-3">
5968
+ <span
5969
+ className="h-3.5 w-3.5 rounded-full shrink-0 transition-colors duration-200"
5970
+ style={{ backgroundColor: colorToHex(color) }}
5971
+ />
5972
+ <span className="text-sm font-medium truncate">
5973
+ {name || "new role"}
5974
+ </span>
5975
+ <span
5976
+ className="ml-auto text-[11px] font-medium px-2.5 py-0.5 rounded-full transition-colors duration-200"
5977
+ style={{
5978
+ backgroundColor: colorToRgba(color, 0.12),
5979
+ color: colorToHex(color),
5980
+ border: \`1px solid \${colorToRgba(color, 0.25)}\`,
5981
+ }}
5982
+ >
5983
+ Preview
5984
+ </span>
5985
+ </div>
5986
+ </div>
5987
+
5988
+ {/* ── Scrollable body ─────────────────────────────────────── */}
5989
+ <div className="overflow-y-auto max-h-[60vh] px-6 pb-2 space-y-5">
5990
+ {/* ROLE NAME --------------------------------------------------- */}
5991
+ <fieldset>
5992
+ <legend className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">
5993
+ Role Name
5994
+ </legend>
5995
+ <input
5996
+ type="text"
5997
+ value={name}
5998
+ onChange={(e) => setName(e.target.value)}
5999
+ placeholder="new role"
6000
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
6001
+ />
6002
+ </fieldset>
6003
+
6004
+ {/* ROLE COLOR -------------------------------------------------- */}
6005
+ <fieldset>
6006
+ <legend className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">
6007
+ Role Color
6008
+ </legend>
6009
+
6010
+ {/* Default (no color) toggle */}
6011
+ <button
6012
+ onClick={() => selectPreset(0)}
6013
+ className={cn(
6014
+ "mb-3 inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors",
6015
+ color === 0
6016
+ ? "border-primary bg-primary/10 text-primary"
6017
+ : "border-input text-muted-foreground hover:bg-accent",
6018
+ )}
6019
+ >
6020
+ <span
6021
+ className="h-4 w-4 rounded-full border-2 transition-colors"
6022
+ style={{
6023
+ borderColor: "#99AAB5",
6024
+ backgroundColor: color === 0 ? "#99AAB5" : "transparent",
6025
+ }}
6026
+ />
6027
+ Default
6028
+ </button>
6029
+
6030
+ {/* Preset grid (2 rows of 10) */}
6031
+ <div className="grid grid-cols-10 gap-2 mb-3">
6032
+ {PRESET_COLORS.map((c) => (
6033
+ <button
6034
+ key={c}
6035
+ onClick={() => selectPreset(c)}
6036
+ className={cn(
6037
+ "h-7 w-7 rounded-full transition-all duration-150 hover:scale-110 focus-visible:outline-none relative",
6038
+ color === c &&
6039
+ "ring-2 ring-offset-2 ring-offset-background ring-primary scale-110",
6040
+ )}
6041
+ style={{ backgroundColor: colorToHex(c) }}
6042
+ title={colorToHex(c)}
6043
+ >
6044
+ {color === c && (
6045
+ <Check className="h-3 w-3 text-white absolute inset-0 m-auto drop-shadow-[0_1px_2px_rgba(0,0,0,0.5)]" />
6046
+ )}
6047
+ </button>
6048
+ ))}
6049
+ </div>
6050
+
6051
+ {/* Custom color */}
6052
+ <div className="flex items-center gap-2">
6053
+ <div className="relative">
6054
+ <input
6055
+ type="color"
6056
+ value={color !== 0 ? colorToHex(color) : "#000000"}
6057
+ onChange={(e) => {
6058
+ const hex = e.target.value;
6059
+ setColor(hexToColor(hex));
6060
+ setCustomHex(hex);
6061
+ setUsingCustom(true);
6062
+ }}
6063
+ className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
6064
+ />
6065
+ <div
6066
+ className={cn(
6067
+ "h-7 w-7 rounded-full border-2 flex items-center justify-center cursor-pointer transition-colors",
6068
+ usingCustom
6069
+ ? "border-primary"
6070
+ : "border-dashed border-muted-foreground/40 hover:border-muted-foreground",
6071
+ )}
6072
+ style={
6073
+ usingCustom && color !== 0
6074
+ ? { backgroundColor: colorToHex(color), borderStyle: "solid" }
6075
+ : {}
6076
+ }
6077
+ >
6078
+ {!usingCustom && (
6079
+ <Plus className="h-3 w-3 text-muted-foreground" />
6080
+ )}
6081
+ </div>
6082
+ </div>
6083
+ <input
6084
+ type="text"
6085
+ value={customHex}
6086
+ onChange={(e) => {
6087
+ const v = e.target.value;
6088
+ setCustomHex(v);
6089
+ if (v.match(/^#?[0-9a-fA-F]{6}$/)) {
6090
+ setColor(hexToColor(v));
6091
+ setUsingCustom(true);
6092
+ }
6093
+ }}
6094
+ placeholder="#000000"
6095
+ maxLength={7}
6096
+ className="w-[88px] rounded-md border border-input bg-background px-2 py-1 text-sm font-mono shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
6097
+ />
6098
+ </div>
6099
+ </fieldset>
6100
+
6101
+ {/* ADD MEMBERS ------------------------------------------------- */}
6102
+ <fieldset>
6103
+ <legend className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">
6104
+ Add Members
6105
+ {selectedMembers.length > 0 && (
6106
+ <span className="ml-1.5 text-[11px] font-normal">
6107
+ ({selectedMembers.length})
6108
+ </span>
6109
+ )}
6110
+ </legend>
6111
+
6112
+ {/* Selected member chips */}
6113
+ {selectedMembers.length > 0 && (
6114
+ <div className="flex flex-wrap gap-1.5 mb-2">
6115
+ {selectedMembers.map((userId) => {
6116
+ const member = members?.find(
6117
+ (m) => m.discordUserId === userId,
6118
+ );
6119
+ if (!member) return null;
6120
+ const url = avatarUrl(member.discordUserId, member.avatar);
6121
+ return (
6122
+ <span
6123
+ key={userId}
6124
+ className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 text-primary pl-1 pr-1.5 py-0.5 text-xs font-medium"
6125
+ >
6126
+ {url ? (
6127
+ <img
6128
+ src={url}
6129
+ alt=""
6130
+ className="h-4 w-4 rounded-full shrink-0 object-cover"
6131
+ />
6132
+ ) : (
6133
+ <div className="h-4 w-4 rounded-full bg-primary/20 flex items-center justify-center shrink-0">
6134
+ <User className="h-2.5 w-2.5" />
6135
+ </div>
6136
+ )}
6137
+ {member.displayName ?? member.username}
6138
+ <button
6139
+ onClick={() => toggleMember(userId)}
6140
+ className="rounded-full p-0.5 hover:bg-primary/20 transition-colors"
6141
+ >
6142
+ <X className="h-3 w-3" />
6143
+ </button>
6144
+ </span>
6145
+ );
6146
+ })}
6147
+ </div>
6148
+ )}
6149
+
6150
+ {/* Member combobox */}
6151
+ <Popover open={memberPickerOpen} onOpenChange={setMemberPickerOpen}>
6152
+ <PopoverTrigger asChild>
6153
+ <Button
6154
+ variant="outline"
6155
+ role="combobox"
6156
+ aria-expanded={memberPickerOpen}
6157
+ className="w-full justify-between font-normal"
6158
+ >
6159
+ <span className="text-muted-foreground">
6160
+ Search by name or ID...
6161
+ </span>
6162
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
6163
+ </Button>
6164
+ </PopoverTrigger>
6165
+ <PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
6166
+ <Command>
6167
+ <CommandInput placeholder="Search members..." />
6168
+ <CommandList>
6169
+ <CommandEmpty>No members found.</CommandEmpty>
6170
+ <CommandGroup>
6171
+ {members?.map((member) => {
6172
+ const checked = selectedMembers.includes(
6173
+ member.discordUserId,
6174
+ );
6175
+ const url = avatarUrl(
6176
+ member.discordUserId,
6177
+ member.avatar,
6178
+ );
6179
+ return (
6180
+ <CommandItem
6181
+ key={member.discordUserId}
6182
+ value={\`\${member.discordUserId} \${member.username} \${member.displayName ?? ""} \${member.nick ?? ""}\`}
6183
+ onSelect={() => toggleMember(member.discordUserId)}
6184
+ >
6185
+ {url ? (
6186
+ <img
6187
+ src={url}
6188
+ alt=""
6189
+ className="mr-2 h-5 w-5 rounded-full shrink-0 object-cover"
6190
+ />
6191
+ ) : (
6192
+ <div className="mr-2 h-5 w-5 rounded-full bg-muted flex items-center justify-center shrink-0">
6193
+ <User className="h-3 w-3 text-muted-foreground" />
6194
+ </div>
6195
+ )}
6196
+ <span className="truncate">
6197
+ {member.displayName ?? member.username}
6198
+ </span>
6199
+ <Check
6200
+ className={cn(
6201
+ "ml-auto h-4 w-4",
6202
+ checked ? "opacity-100" : "opacity-0",
6203
+ )}
6204
+ />
6205
+ </CommandItem>
6206
+ );
6207
+ })}
6208
+ </CommandGroup>
6209
+ </CommandList>
6210
+ </Command>
6211
+ </PopoverContent>
6212
+ </Popover>
6213
+ </fieldset>
6214
+
6215
+ {/* ROLE POSITION ------------------------------------------------ */}
6216
+ <fieldset>
6217
+ <div className="flex items-center justify-between mb-2">
6218
+ <legend className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
6219
+ Role Position
6220
+ </legend>
6221
+ <div className="flex items-center gap-0.5">
6222
+ <button
6223
+ onClick={moveUp}
6224
+ disabled={positionIndex === 0}
6225
+ className="rounded-md p-1 hover:bg-accent disabled:opacity-25 disabled:pointer-events-none transition-colors"
6226
+ title="Move up"
6227
+ >
6228
+ <ChevronUp className="h-4 w-4" />
6229
+ </button>
6230
+ <button
6231
+ onClick={moveDown}
6232
+ disabled={positionIndex >= sortedRoles.length}
6233
+ className="rounded-md p-1 hover:bg-accent disabled:opacity-25 disabled:pointer-events-none transition-colors"
6234
+ title="Move down"
6235
+ >
6236
+ <ChevronDown className="h-4 w-4" />
6237
+ </button>
6238
+ </div>
6239
+ </div>
6240
+
6241
+ <div className="max-h-[200px] overflow-y-auto rounded-md border border-input">
6242
+ {sortedRoles.length === 0 && roles !== undefined ? (
6243
+ <>
6244
+ <NewRoleEntry
6245
+ name={name}
6246
+ color={color}
6247
+ />
6248
+ <EveryoneEntry />
6249
+ </>
6250
+ ) : sortedRoles.length === 0 ? (
6251
+ <div className="px-3 py-8 text-center text-sm text-muted-foreground">
6252
+ Loading roles...
6253
+ </div>
6254
+ ) : (
6255
+ <>
6256
+ {sortedRoles.map((role, i) => (
6257
+ <div key={role.discordId}>
6258
+ {positionIndex === i && (
6259
+ <NewRoleEntry
6260
+ name={name}
6261
+ color={color}
6262
+ />
6263
+ )}
6264
+ <div className="flex items-center gap-2.5 px-3 py-2 text-sm">
6265
+ <span
6266
+ className="h-3 w-3 rounded-full shrink-0"
6267
+ style={{ backgroundColor: colorToHex(role.color) }}
6268
+ />
6269
+ <span className="truncate flex-1">{role.name}</span>
6270
+ <span className="text-[11px] tabular-nums text-muted-foreground">
6271
+ {role.position}
6272
+ </span>
6273
+ </div>
6274
+ </div>
6275
+ ))}
6276
+ {positionIndex >= sortedRoles.length && (
6277
+ <NewRoleEntry
6278
+ name={name}
6279
+ color={color}
6280
+ />
6281
+ )}
6282
+ <EveryoneEntry />
6283
+ </>
6284
+ )}
6285
+ </div>
6286
+ </fieldset>
6287
+ </div>
6288
+
6289
+ {/* ── Footer ──────────────────────────────────────────────── */}
6290
+ <div className="flex items-center justify-end gap-3 border-t px-6 py-4 mt-1">
6291
+ <Button variant="ghost" onClick={() => setOpen(false)}>
6292
+ Cancel
6293
+ </Button>
6294
+ <Button
6295
+ onClick={handleCreate}
6296
+ disabled={!name.trim()}
6297
+ className="transition-colors duration-200"
6298
+ style={
6299
+ color !== 0
6300
+ ? { backgroundColor: colorToHex(color), color: "#fff" }
6301
+ : undefined
6302
+ }
6303
+ >
6304
+ Create Role
6305
+ </Button>
6306
+ </div>
6307
+ </DialogContent>
6308
+ </Dialog>
6309
+ );
6310
+ }
6311
+
6312
+ // ── Sub-components ─────────────────────────────────────────────────────────
6313
+
6314
+ function NewRoleEntry({ name, color }: { name: string; color: number }) {
6315
+ return (
6316
+ <div className="flex items-center gap-2.5 px-3 py-2 bg-primary/[0.08] border-l-2 border-l-primary">
6317
+ <span
6318
+ className="h-3 w-3 rounded-full shrink-0 transition-colors duration-200"
6319
+ style={{ backgroundColor: colorToHex(color) }}
6320
+ />
6321
+ <span className="text-sm font-medium truncate flex-1">
6322
+ {name || "new role"}
6323
+ </span>
6324
+ <span className="text-[11px] font-semibold text-primary">NEW</span>
6325
+ </div>
6326
+ );
6327
+ }
6328
+
6329
+ function EveryoneEntry() {
6330
+ return (
6331
+ <div className="flex items-center gap-2.5 px-3 py-2 opacity-40">
6332
+ <span className="h-3 w-3 rounded-full shrink-0 bg-muted-foreground/50" />
6333
+ <span className="text-sm truncate">@everyone</span>
6334
+ <span className="ml-auto text-[11px] tabular-nums">0</span>
6335
+ </div>
6336
+ );
6337
+ }
6338
+ `,
6339
+ generateConvexQueries: () => `export const listRolesForCreator = query({
6340
+ args: { guildDiscordId: v.string() },
6341
+ returns: v.array(
6342
+ v.object({
6343
+ discordId: v.string(),
6344
+ name: v.string(),
6345
+ color: v.number(),
6346
+ position: v.number(),
6347
+ })
6348
+ ),
6349
+ handler: async (ctx, { guildDiscordId }) => {
6350
+ const roles = await ctx.db
6351
+ .query("calabasasRoles")
6352
+ .withIndex("by_guild", (q) => q.eq("guildDiscordId", guildDiscordId))
6353
+ .collect();
6354
+ return roles.map((r) => ({
6355
+ discordId: r.discordId,
6356
+ name: r.name,
6357
+ color: r.color,
6358
+ position: r.position,
6359
+ }));
6360
+ },
6361
+ });
6362
+
6363
+ export const listMembersForCreator = query({
6364
+ args: { guildDiscordId: v.string() },
6365
+ returns: v.array(
6366
+ v.object({
6367
+ discordUserId: v.string(),
6368
+ username: v.string(),
6369
+ displayName: v.optional(v.string()),
6370
+ avatar: v.optional(v.string()),
6371
+ nick: v.optional(v.string()),
6372
+ })
6373
+ ),
6374
+ handler: async (ctx, { guildDiscordId }) => {
6375
+ const members = await ctx.db
6376
+ .query("calabasasMembers")
6377
+ .withIndex("by_guild", (q) => q.eq("guildDiscordId", guildDiscordId))
6378
+ .collect();
6379
+ return members.map((m) => ({
6380
+ discordUserId: m.discordUserId,
6381
+ username: m.username,
6382
+ displayName: m.displayName,
6383
+ avatar: m.avatar,
6384
+ nick: m.nick,
6385
+ }));
6386
+ },
6387
+ });`
6388
+ };
6389
+
5604
6390
  // src/lib/registry/index.ts
5605
6391
  var REGISTRY = [
5606
6392
  channelSelect,
@@ -5614,7 +6400,8 @@ var REGISTRY = [
5614
6400
  channelTree,
5615
6401
  memberRoster,
5616
6402
  useOnlineCount,
5617
- emojiPicker
6403
+ emojiPicker,
6404
+ roleCreator
5618
6405
  ];
5619
6406
  function getComponent(name) {
5620
6407
  return REGISTRY.find((c) => c.name === name);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "calabasas",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "CLI for Calabasas - Discord Gateway as a Service for Convex",
5
5
  "type": "module",
6
6
  "bin": {