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.
- package/dist/index.js +936 -149
- 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: "
|
|
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
|
-
|
|
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 =
|
|
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 [
|
|
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
|
|
5229
|
-
const
|
|
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
|
|
5404
|
+
if (!search) return allSections;
|
|
5275
5405
|
const q = search.toLowerCase();
|
|
5276
|
-
const flat =
|
|
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
|
-
}, [
|
|
5409
|
+
return flat.length > 0 ? [{ header: "Search Results", items: flat }] : [];
|
|
5410
|
+
}, [allSections, search]);
|
|
5281
5411
|
|
|
5282
|
-
//
|
|
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
|
-
|
|
5291
|
-
|
|
5414
|
+
if (search) return;
|
|
5415
|
+
const container = scrollRef.current;
|
|
5416
|
+
if (!container) return;
|
|
5292
5417
|
|
|
5293
5418
|
const observer = new IntersectionObserver(
|
|
5294
|
-
(
|
|
5295
|
-
|
|
5296
|
-
|
|
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:
|
|
5427
|
+
{ root: container, rootMargin: "-10% 0px -80% 0px", threshold: 0 }
|
|
5300
5428
|
);
|
|
5301
5429
|
|
|
5302
|
-
observer.observe(
|
|
5430
|
+
sectionRefs.current.forEach((el) => observer.observe(el));
|
|
5303
5431
|
return () => observer.disconnect();
|
|
5304
|
-
}, [
|
|
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
|
-
|
|
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-
|
|
5429
|
-
{
|
|
5430
|
-
|
|
5431
|
-
<
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
5435
|
-
|
|
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
|
-
<
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
|
|
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
|
-
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
|
|
5468
|
-
|
|
5469
|
-
|
|
5470
|
-
|
|
5471
|
-
|
|
5472
|
-
|
|
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
|
-
|
|
5476
|
-
|
|
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
|
-
|
|
5479
|
-
|
|
5480
|
-
|
|
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
|
-
|
|
5483
|
-
|
|
5484
|
-
|
|
5485
|
-
|
|
5486
|
-
|
|
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
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5665
|
+
))}
|
|
5666
|
+
</div>
|
|
5667
|
+
</div>
|
|
5525
5668
|
|
|
5526
|
-
{/*
|
|
5527
|
-
|
|
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);
|