[READ-ONLY] Mirror of https://github.com/flo-bit/ui-kit. 🦊 fox ui, svelte 5 and tailwind 4 flo-bit.dev/ui-kit/
svelte tailwindcss ui-components
0

Configure Feed

Select the types of activity you want to include in your feed.

improve css stuff, refactor social stuff, update tailwind (+ add new colors)

Florian (Mar 25, 2026, 4:39 PM +0100) d3b697f8 c84615c7

+2825 -1891
+1 -1
pnpm-lock.yaml
··· 115 115 specifier: ^4.4.4 116 116 version: 4.4.4(picomatch@4.0.3)(svelte@5.53.7)(typescript@5.9.3) 117 117 tailwindcss: 118 - specifier: ^4.2.1 118 + specifier: 4.2.1 119 119 version: 4.2.1 120 120 tw-animate-css: 121 121 specifier: ^1.4.0
+7
.changeset/loose-eagles-serve.md
··· 1 + --- 2 + '@foxui/colors': patch 3 + '@foxui/social': patch 4 + '@foxui/core': patch 5 + --- 6 + 7 + improve css stuff, refactor social stuff, update tailwind (+ add new colors)
+8
.claude/settings.local.json
··· 1 + { 2 + "permissions": { 3 + "allow": [ 4 + "mcp__plugin_svelte_svelte__svelte-autofixer", 5 + "Bash(npx svelte-check:*)" 6 + ] 7 + } 8 + }
+1 -1
apps/docs/package.json
··· 37 37 "shiki": "^4.0.1", 38 38 "svelte": "^5.53.7", 39 39 "svelte-check": "^4.4.4", 40 - "tailwindcss": "^4.2.1", 40 + "tailwindcss": "4.2.1", 41 41 "tw-animate-css": "^1.4.0", 42 42 "typescript": "^5.9.3", 43 43 "typescript-eslint": "^8.56.1",
+4 -2
packages/core/package.json
··· 25 25 ".": { 26 26 "types": "./src/lib/index.ts", 27 27 "svelte": "./src/lib/index.ts" 28 - } 28 + }, 29 + "./theme.css": "./src/lib/theme.css" 29 30 }, 30 31 "types": "./src/lib/index.ts", 31 32 "svelte": "./src/lib/index.ts", ··· 38 39 ".": { 39 40 "types": "./dist/types.d.ts", 40 41 "svelte": "./dist/index.js" 41 - } 42 + }, 43 + "./theme.css": "./dist/theme.css" 42 44 }, 43 45 "types": "./dist/types.d.ts" 44 46 },
+2 -362
apps/docs/src/app.css
··· 3 3 @plugin '@tailwindcss/typography'; 4 4 @import 'tw-animate-css'; 5 5 6 - @source '../../../packages'; 7 - 8 6 @custom-variant dark (&:is(.dark *)); 9 7 10 - @theme inline { 11 - --color-base-50: var(--base-50); 12 - --color-base-100: var(--base-100); 13 - --color-base-200: var(--base-200); 14 - --color-base-300: var(--base-300); 15 - --color-base-400: var(--base-400); 16 - --color-base-500: var(--base-500); 17 - --color-base-600: var(--base-600); 18 - --color-base-700: var(--base-700); 19 - --color-base-800: var(--base-800); 20 - --color-base-900: var(--base-900); 21 - --color-base-950: var(--base-950); 22 - 23 - --color-accent-50: var(--accent-50); 24 - --color-accent-100: var(--accent-100); 25 - --color-accent-200: var(--accent-200); 26 - --color-accent-300: var(--accent-300); 27 - --color-accent-400: var(--accent-400); 28 - --color-accent-500: var(--accent-500); 29 - --color-accent-600: var(--accent-600); 30 - --color-accent-700: var(--accent-700); 31 - --color-accent-800: var(--accent-800); 32 - --color-accent-900: var(--accent-900); 33 - --color-accent-950: var(--accent-950); 34 - } 35 - 36 - @layer base { 37 - :root { 38 - --accent-50: var(--color-pink-50); 39 - --accent-100: var(--color-pink-100); 40 - --accent-200: var(--color-pink-200); 41 - --accent-300: var(--color-pink-300); 42 - --accent-400: var(--color-pink-400); 43 - --accent-500: var(--color-pink-500); 44 - --accent-600: var(--color-pink-600); 45 - --accent-700: var(--color-pink-700); 46 - --accent-800: var(--color-pink-800); 47 - --accent-900: var(--color-pink-900); 48 - --accent-950: var(--color-pink-950); 49 - 50 - --base-50: var(--color-stone-50); 51 - --base-100: var(--color-stone-100); 52 - --base-200: var(--color-stone-200); 53 - --base-300: var(--color-stone-300); 54 - --base-400: var(--color-stone-400); 55 - --base-500: var(--color-stone-500); 56 - --base-600: var(--color-stone-600); 57 - --base-700: var(--color-stone-700); 58 - --base-800: var(--color-stone-800); 59 - --base-900: var(--color-stone-900); 60 - --base-950: var(--color-stone-950); 61 - } 62 - 63 - .red { 64 - --accent-50: var(--color-red-50); 65 - --accent-100: var(--color-red-100); 66 - --accent-200: var(--color-red-200); 67 - --accent-300: var(--color-red-300); 68 - --accent-400: var(--color-red-400); 69 - --accent-500: var(--color-red-500); 70 - --accent-600: var(--color-red-600); 71 - --accent-700: var(--color-red-700); 72 - --accent-800: var(--color-red-800); 73 - --accent-900: var(--color-red-900); 74 - --accent-950: var(--color-red-950); 75 - } 76 - 77 - .orange { 78 - --accent-50: var(--color-orange-50); 79 - --accent-100: var(--color-orange-100); 80 - --accent-200: var(--color-orange-200); 81 - --accent-300: var(--color-orange-300); 82 - --accent-400: var(--color-orange-400); 83 - --accent-500: var(--color-orange-500); 84 - --accent-600: var(--color-orange-600); 85 - --accent-700: var(--color-orange-700); 86 - --accent-800: var(--color-orange-800); 87 - --accent-900: var(--color-orange-900); 88 - --accent-950: var(--color-orange-950); 89 - } 90 - 91 - .amber { 92 - --accent-50: var(--color-amber-50); 93 - --accent-100: var(--color-amber-100); 94 - --accent-200: var(--color-amber-200); 95 - --accent-300: var(--color-amber-300); 96 - --accent-400: var(--color-amber-400); 97 - --accent-500: var(--color-amber-500); 98 - --accent-600: var(--color-amber-600); 99 - --accent-700: var(--color-amber-700); 100 - --accent-800: var(--color-amber-800); 101 - --accent-900: var(--color-amber-900); 102 - --accent-950: var(--color-amber-950); 103 - } 104 - 105 - .yellow { 106 - --accent-50: var(--color-yellow-50); 107 - --accent-100: var(--color-yellow-100); 108 - --accent-200: var(--color-yellow-200); 109 - --accent-300: var(--color-yellow-300); 110 - --accent-400: var(--color-yellow-400); 111 - --accent-500: var(--color-yellow-500); 112 - --accent-600: var(--color-yellow-600); 113 - --accent-700: var(--color-yellow-700); 114 - --accent-800: var(--color-yellow-800); 115 - --accent-900: var(--color-yellow-900); 116 - --accent-950: var(--color-yellow-950); 117 - } 118 - 119 - .lime { 120 - --accent-50: var(--color-lime-50); 121 - --accent-100: var(--color-lime-100); 122 - --accent-200: var(--color-lime-200); 123 - --accent-300: var(--color-lime-300); 124 - --accent-400: var(--color-lime-400); 125 - --accent-500: var(--color-lime-500); 126 - --accent-600: var(--color-lime-600); 127 - --accent-700: var(--color-lime-700); 128 - --accent-800: var(--color-lime-800); 129 - --accent-900: var(--color-lime-900); 130 - --accent-950: var(--color-lime-950); 131 - } 132 - 133 - .green { 134 - --accent-50: var(--color-green-50); 135 - --accent-100: var(--color-green-100); 136 - --accent-200: var(--color-green-200); 137 - --accent-300: var(--color-green-300); 138 - --accent-400: var(--color-green-400); 139 - --accent-500: var(--color-green-500); 140 - --accent-600: var(--color-green-600); 141 - --accent-700: var(--color-green-700); 142 - --accent-800: var(--color-green-800); 143 - --accent-900: var(--color-green-900); 144 - --accent-950: var(--color-green-950); 145 - } 146 - 147 - .emerald { 148 - --accent-50: var(--color-emerald-50); 149 - --accent-100: var(--color-emerald-100); 150 - --accent-200: var(--color-emerald-200); 151 - --accent-300: var(--color-emerald-300); 152 - --accent-400: var(--color-emerald-400); 153 - --accent-500: var(--color-emerald-500); 154 - --accent-600: var(--color-emerald-600); 155 - --accent-700: var(--color-emerald-700); 156 - --accent-800: var(--color-emerald-800); 157 - --accent-900: var(--color-emerald-900); 158 - --accent-950: var(--color-emerald-950); 159 - } 160 - 161 - .teal { 162 - --accent-50: var(--color-teal-50); 163 - --accent-100: var(--color-teal-100); 164 - --accent-200: var(--color-teal-200); 165 - --accent-300: var(--color-teal-300); 166 - --accent-400: var(--color-teal-400); 167 - --accent-500: var(--color-teal-500); 168 - --accent-600: var(--color-teal-600); 169 - --accent-700: var(--color-teal-700); 170 - --accent-800: var(--color-teal-800); 171 - --accent-900: var(--color-teal-900); 172 - --accent-950: var(--color-teal-950); 173 - } 174 - 175 - .cyan { 176 - --accent-50: var(--color-cyan-50); 177 - --accent-100: var(--color-cyan-100); 178 - --accent-200: var(--color-cyan-200); 179 - --accent-300: var(--color-cyan-300); 180 - --accent-400: var(--color-cyan-400); 181 - --accent-500: var(--color-cyan-500); 182 - --accent-600: var(--color-cyan-600); 183 - --accent-700: var(--color-cyan-700); 184 - --accent-800: var(--color-cyan-800); 185 - --accent-900: var(--color-cyan-900); 186 - --accent-950: var(--color-cyan-950); 187 - } 188 - 189 - .sky { 190 - --accent-50: var(--color-sky-50); 191 - --accent-100: var(--color-sky-100); 192 - --accent-200: var(--color-sky-200); 193 - --accent-300: var(--color-sky-300); 194 - --accent-400: var(--color-sky-400); 195 - --accent-500: var(--color-sky-500); 196 - --accent-600: var(--color-sky-600); 197 - --accent-700: var(--color-sky-700); 198 - --accent-800: var(--color-sky-800); 199 - --accent-900: var(--color-sky-900); 200 - --accent-950: var(--color-sky-950); 201 - } 202 - .blue { 203 - --accent-50: var(--color-blue-50); 204 - --accent-100: var(--color-blue-100); 205 - --accent-200: var(--color-blue-200); 206 - --accent-300: var(--color-blue-300); 207 - --accent-400: var(--color-blue-400); 208 - --accent-500: var(--color-blue-500); 209 - --accent-600: var(--color-blue-600); 210 - --accent-700: var(--color-blue-700); 211 - --accent-800: var(--color-blue-800); 212 - --accent-900: var(--color-blue-900); 213 - --accent-950: var(--color-blue-950); 214 - } 215 - 216 - .indigo { 217 - --accent-50: var(--color-indigo-50); 218 - --accent-100: var(--color-indigo-100); 219 - --accent-200: var(--color-indigo-200); 220 - --accent-300: var(--color-indigo-300); 221 - --accent-400: var(--color-indigo-400); 222 - --accent-500: var(--color-indigo-500); 223 - --accent-600: var(--color-indigo-600); 224 - --accent-700: var(--color-indigo-700); 225 - --accent-800: var(--color-indigo-800); 226 - --accent-900: var(--color-indigo-900); 227 - --accent-950: var(--color-indigo-950); 228 - } 229 - 230 - .violet { 231 - --accent-50: var(--color-violet-50); 232 - --accent-100: var(--color-violet-100); 233 - --accent-200: var(--color-violet-200); 234 - --accent-300: var(--color-violet-300); 235 - --accent-400: var(--color-violet-400); 236 - --accent-500: var(--color-violet-500); 237 - --accent-600: var(--color-violet-600); 238 - --accent-700: var(--color-violet-700); 239 - --accent-800: var(--color-violet-800); 240 - --accent-900: var(--color-violet-900); 241 - --accent-950: var(--color-violet-950); 242 - } 243 - 244 - .purple { 245 - --accent-50: var(--color-purple-50); 246 - --accent-100: var(--color-purple-100); 247 - --accent-200: var(--color-purple-200); 248 - --accent-300: var(--color-purple-300); 249 - --accent-400: var(--color-purple-400); 250 - --accent-500: var(--color-purple-500); 251 - --accent-600: var(--color-purple-600); 252 - --accent-700: var(--color-purple-700); 253 - --accent-800: var(--color-purple-800); 254 - --accent-900: var(--color-purple-900); 255 - --accent-950: var(--color-purple-950); 256 - } 257 - 258 - .fuchsia { 259 - --accent-50: var(--color-fuchsia-50); 260 - --accent-100: var(--color-fuchsia-100); 261 - --accent-200: var(--color-fuchsia-200); 262 - --accent-300: var(--color-fuchsia-300); 263 - --accent-400: var(--color-fuchsia-400); 264 - --accent-500: var(--color-fuchsia-500); 265 - --accent-600: var(--color-fuchsia-600); 266 - --accent-700: var(--color-fuchsia-700); 267 - --accent-800: var(--color-fuchsia-800); 268 - --accent-900: var(--color-fuchsia-900); 269 - --accent-950: var(--color-fuchsia-950); 270 - } 271 - 272 - .pink { 273 - --accent-50: var(--color-pink-50); 274 - --accent-100: var(--color-pink-100); 275 - --accent-200: var(--color-pink-200); 276 - --accent-300: var(--color-pink-300); 277 - --accent-400: var(--color-pink-400); 278 - --accent-500: var(--color-pink-500); 279 - --accent-600: var(--color-pink-600); 280 - --accent-700: var(--color-pink-700); 281 - --accent-800: var(--color-pink-800); 282 - --accent-900: var(--color-pink-900); 283 - --accent-950: var(--color-pink-950); 284 - } 285 - 286 - .rose { 287 - --accent-50: var(--color-rose-50); 288 - --accent-100: var(--color-rose-100); 289 - --accent-200: var(--color-rose-200); 290 - --accent-300: var(--color-rose-300); 291 - --accent-400: var(--color-rose-400); 292 - --accent-500: var(--color-rose-500); 293 - --accent-600: var(--color-rose-600); 294 - --accent-700: var(--color-rose-700); 295 - --accent-800: var(--color-rose-800); 296 - --accent-900: var(--color-rose-900); 297 - --accent-950: var(--color-rose-950); 298 - } 299 - 300 - .gray { 301 - --base-50: var(--color-gray-50); 302 - --base-100: var(--color-gray-100); 303 - --base-200: var(--color-gray-200); 304 - --base-300: var(--color-gray-300); 305 - --base-400: var(--color-gray-400); 306 - --base-500: var(--color-gray-500); 307 - --base-600: var(--color-gray-600); 308 - --base-700: var(--color-gray-700); 309 - --base-800: var(--color-gray-800); 310 - --base-900: var(--color-gray-900); 311 - --base-950: var(--color-gray-950); 312 - } 313 - 314 - .zinc { 315 - --base-50: var(--color-zinc-50); 316 - --base-100: var(--color-zinc-100); 317 - --base-200: var(--color-zinc-200); 318 - --base-300: var(--color-zinc-300); 319 - --base-400: var(--color-zinc-400); 320 - --base-500: var(--color-zinc-500); 321 - --base-600: var(--color-zinc-600); 322 - --base-700: var(--color-zinc-700); 323 - --base-800: var(--color-zinc-800); 324 - --base-900: var(--color-zinc-900); 325 - --base-950: var(--color-zinc-950); 326 - } 327 - 328 - .neutral { 329 - --base-50: var(--color-neutral-50); 330 - --base-100: var(--color-neutral-100); 331 - --base-200: var(--color-neutral-200); 332 - --base-300: var(--color-neutral-300); 333 - --base-400: var(--color-neutral-400); 334 - --base-500: var(--color-neutral-500); 335 - --base-600: var(--color-neutral-600); 336 - --base-700: var(--color-neutral-700); 337 - --base-800: var(--color-neutral-800); 338 - --base-900: var(--color-neutral-900); 339 - --base-950: var(--color-neutral-950); 340 - } 341 - 342 - .stone { 343 - --base-50: var(--color-stone-50); 344 - --base-100: var(--color-stone-100); 345 - --base-200: var(--color-stone-200); 346 - --base-300: var(--color-stone-300); 347 - --base-400: var(--color-stone-400); 348 - --base-500: var(--color-stone-500); 349 - --base-600: var(--color-stone-600); 350 - --base-700: var(--color-stone-700); 351 - --base-800: var(--color-stone-800); 352 - --base-900: var(--color-stone-900); 353 - --base-950: var(--color-stone-950); 354 - } 355 - 356 - .slate { 357 - --base-50: var(--color-slate-50); 358 - --base-100: var(--color-slate-100); 359 - --base-200: var(--color-slate-200); 360 - --base-300: var(--color-slate-300); 361 - --base-400: var(--color-slate-400); 362 - --base-500: var(--color-slate-500); 363 - --base-600: var(--color-slate-600); 364 - --base-700: var(--color-slate-700); 365 - --base-800: var(--color-slate-800); 366 - --base-900: var(--color-slate-900); 367 - --base-950: var(--color-slate-950); 368 - } 369 - } 8 + @source '../../../packages'; 9 + @import '../../../packages/core/src/lib/theme.css';
+1 -25
packages/colors/src/app.css
··· 2 2 @plugin '@tailwindcss/forms'; 3 3 @plugin '@tailwindcss/typography'; 4 4 5 - @theme { 6 - --color-base-50: var(--color-zinc-50); 7 - --color-base-100: var(--color-zinc-100); 8 - --color-base-200: var(--color-zinc-200); 9 - --color-base-300: var(--color-zinc-300); 10 - --color-base-400: var(--color-zinc-400); 11 - --color-base-500: var(--color-zinc-500); 12 - --color-base-600: var(--color-zinc-600); 13 - --color-base-700: var(--color-zinc-700); 14 - --color-base-800: var(--color-zinc-800); 15 - --color-base-900: var(--color-zinc-900); 16 - --color-base-950: var(--color-zinc-950); 17 - 18 - --color-accent-50: var(--color-pink-50); 19 - --color-accent-100: var(--color-pink-100); 20 - --color-accent-200: var(--color-pink-200); 21 - --color-accent-300: var(--color-pink-300); 22 - --color-accent-400: var(--color-pink-400); 23 - --color-accent-500: var(--color-pink-500); 24 - --color-accent-600: var(--color-pink-600); 25 - --color-accent-700: var(--color-pink-700); 26 - --color-accent-800: var(--color-pink-800); 27 - --color-accent-900: var(--color-pink-900); 28 - --color-accent-950: var(--color-pink-950); 29 - } 5 + @import '../../core/src/lib/theme.css';
+1 -25
packages/core/src/app.css
··· 2 2 @plugin '@tailwindcss/forms'; 3 3 @plugin '@tailwindcss/typography'; 4 4 5 - @theme { 6 - --color-base-50: var(--color-zinc-50); 7 - --color-base-100: var(--color-zinc-100); 8 - --color-base-200: var(--color-zinc-200); 9 - --color-base-300: var(--color-zinc-300); 10 - --color-base-400: var(--color-zinc-400); 11 - --color-base-500: var(--color-zinc-500); 12 - --color-base-600: var(--color-zinc-600); 13 - --color-base-700: var(--color-zinc-700); 14 - --color-base-800: var(--color-zinc-800); 15 - --color-base-900: var(--color-zinc-900); 16 - --color-base-950: var(--color-zinc-950); 17 - 18 - --color-accent-50: var(--color-pink-50); 19 - --color-accent-100: var(--color-pink-100); 20 - --color-accent-200: var(--color-pink-200); 21 - --color-accent-300: var(--color-pink-300); 22 - --color-accent-400: var(--color-pink-400); 23 - --color-accent-500: var(--color-pink-500); 24 - --color-accent-600: var(--color-pink-600); 25 - --color-accent-700: var(--color-pink-700); 26 - --color-accent-800: var(--color-pink-800); 27 - --color-accent-900: var(--color-pink-900); 28 - --color-accent-950: var(--color-pink-950); 29 - } 5 + @import './lib/theme.css';
+461
packages/core/src/lib/theme.css
··· 1 + @source "../../"; 2 + @source "@foxui/social"; 3 + @source "@foxui/colors"; 4 + @source "@foxui/text"; 5 + @source "@foxui/time"; 6 + @source "@foxui/visual"; 7 + @source "@foxui/3d"; 8 + 9 + @theme inline { 10 + --color-base-50: var(--base-50); 11 + --color-base-100: var(--base-100); 12 + --color-base-200: var(--base-200); 13 + --color-base-300: var(--base-300); 14 + --color-base-400: var(--base-400); 15 + --color-base-500: var(--base-500); 16 + --color-base-600: var(--base-600); 17 + --color-base-700: var(--base-700); 18 + --color-base-800: var(--base-800); 19 + --color-base-900: var(--base-900); 20 + --color-base-950: var(--base-950); 21 + 22 + --color-accent-50: var(--accent-50); 23 + --color-accent-100: var(--accent-100); 24 + --color-accent-200: var(--accent-200); 25 + --color-accent-300: var(--accent-300); 26 + --color-accent-400: var(--accent-400); 27 + --color-accent-500: var(--accent-500); 28 + --color-accent-600: var(--accent-600); 29 + --color-accent-700: var(--accent-700); 30 + --color-accent-800: var(--accent-800); 31 + --color-accent-900: var(--accent-900); 32 + --color-accent-950: var(--accent-950); 33 + } 34 + 35 + @layer base { 36 + :root { 37 + --accent-50: var(--color-pink-50); 38 + --accent-100: var(--color-pink-100); 39 + --accent-200: var(--color-pink-200); 40 + --accent-300: var(--color-pink-300); 41 + --accent-400: var(--color-pink-400); 42 + --accent-500: var(--color-pink-500); 43 + --accent-600: var(--color-pink-600); 44 + --accent-700: var(--color-pink-700); 45 + --accent-800: var(--color-pink-800); 46 + --accent-900: var(--color-pink-900); 47 + --accent-950: var(--color-pink-950); 48 + 49 + --base-50: var(--color-stone-50); 50 + --base-100: var(--color-stone-100); 51 + --base-200: var(--color-stone-200); 52 + --base-300: var(--color-stone-300); 53 + --base-400: var(--color-stone-400); 54 + --base-500: var(--color-stone-500); 55 + --base-600: var(--color-stone-600); 56 + --base-700: var(--color-stone-700); 57 + --base-800: var(--color-stone-800); 58 + --base-900: var(--color-stone-900); 59 + --base-950: var(--color-stone-950); 60 + } 61 + 62 + .red { 63 + --accent-50: var(--color-red-50); 64 + --accent-100: var(--color-red-100); 65 + --accent-200: var(--color-red-200); 66 + --accent-300: var(--color-red-300); 67 + --accent-400: var(--color-red-400); 68 + --accent-500: var(--color-red-500); 69 + --accent-600: var(--color-red-600); 70 + --accent-700: var(--color-red-700); 71 + --accent-800: var(--color-red-800); 72 + --accent-900: var(--color-red-900); 73 + --accent-950: var(--color-red-950); 74 + } 75 + 76 + .orange { 77 + --accent-50: var(--color-orange-50); 78 + --accent-100: var(--color-orange-100); 79 + --accent-200: var(--color-orange-200); 80 + --accent-300: var(--color-orange-300); 81 + --accent-400: var(--color-orange-400); 82 + --accent-500: var(--color-orange-500); 83 + --accent-600: var(--color-orange-600); 84 + --accent-700: var(--color-orange-700); 85 + --accent-800: var(--color-orange-800); 86 + --accent-900: var(--color-orange-900); 87 + --accent-950: var(--color-orange-950); 88 + } 89 + 90 + .amber { 91 + --accent-50: var(--color-amber-50); 92 + --accent-100: var(--color-amber-100); 93 + --accent-200: var(--color-amber-200); 94 + --accent-300: var(--color-amber-300); 95 + --accent-400: var(--color-amber-400); 96 + --accent-500: var(--color-amber-500); 97 + --accent-600: var(--color-amber-600); 98 + --accent-700: var(--color-amber-700); 99 + --accent-800: var(--color-amber-800); 100 + --accent-900: var(--color-amber-900); 101 + --accent-950: var(--color-amber-950); 102 + } 103 + 104 + .yellow { 105 + --accent-50: var(--color-yellow-50); 106 + --accent-100: var(--color-yellow-100); 107 + --accent-200: var(--color-yellow-200); 108 + --accent-300: var(--color-yellow-300); 109 + --accent-400: var(--color-yellow-400); 110 + --accent-500: var(--color-yellow-500); 111 + --accent-600: var(--color-yellow-600); 112 + --accent-700: var(--color-yellow-700); 113 + --accent-800: var(--color-yellow-800); 114 + --accent-900: var(--color-yellow-900); 115 + --accent-950: var(--color-yellow-950); 116 + } 117 + 118 + .lime { 119 + --accent-50: var(--color-lime-50); 120 + --accent-100: var(--color-lime-100); 121 + --accent-200: var(--color-lime-200); 122 + --accent-300: var(--color-lime-300); 123 + --accent-400: var(--color-lime-400); 124 + --accent-500: var(--color-lime-500); 125 + --accent-600: var(--color-lime-600); 126 + --accent-700: var(--color-lime-700); 127 + --accent-800: var(--color-lime-800); 128 + --accent-900: var(--color-lime-900); 129 + --accent-950: var(--color-lime-950); 130 + } 131 + 132 + .green { 133 + --accent-50: var(--color-green-50); 134 + --accent-100: var(--color-green-100); 135 + --accent-200: var(--color-green-200); 136 + --accent-300: var(--color-green-300); 137 + --accent-400: var(--color-green-400); 138 + --accent-500: var(--color-green-500); 139 + --accent-600: var(--color-green-600); 140 + --accent-700: var(--color-green-700); 141 + --accent-800: var(--color-green-800); 142 + --accent-900: var(--color-green-900); 143 + --accent-950: var(--color-green-950); 144 + } 145 + 146 + .emerald { 147 + --accent-50: var(--color-emerald-50); 148 + --accent-100: var(--color-emerald-100); 149 + --accent-200: var(--color-emerald-200); 150 + --accent-300: var(--color-emerald-300); 151 + --accent-400: var(--color-emerald-400); 152 + --accent-500: var(--color-emerald-500); 153 + --accent-600: var(--color-emerald-600); 154 + --accent-700: var(--color-emerald-700); 155 + --accent-800: var(--color-emerald-800); 156 + --accent-900: var(--color-emerald-900); 157 + --accent-950: var(--color-emerald-950); 158 + } 159 + 160 + .teal { 161 + --accent-50: var(--color-teal-50); 162 + --accent-100: var(--color-teal-100); 163 + --accent-200: var(--color-teal-200); 164 + --accent-300: var(--color-teal-300); 165 + --accent-400: var(--color-teal-400); 166 + --accent-500: var(--color-teal-500); 167 + --accent-600: var(--color-teal-600); 168 + --accent-700: var(--color-teal-700); 169 + --accent-800: var(--color-teal-800); 170 + --accent-900: var(--color-teal-900); 171 + --accent-950: var(--color-teal-950); 172 + } 173 + 174 + .cyan { 175 + --accent-50: var(--color-cyan-50); 176 + --accent-100: var(--color-cyan-100); 177 + --accent-200: var(--color-cyan-200); 178 + --accent-300: var(--color-cyan-300); 179 + --accent-400: var(--color-cyan-400); 180 + --accent-500: var(--color-cyan-500); 181 + --accent-600: var(--color-cyan-600); 182 + --accent-700: var(--color-cyan-700); 183 + --accent-800: var(--color-cyan-800); 184 + --accent-900: var(--color-cyan-900); 185 + --accent-950: var(--color-cyan-950); 186 + } 187 + 188 + .sky { 189 + --accent-50: var(--color-sky-50); 190 + --accent-100: var(--color-sky-100); 191 + --accent-200: var(--color-sky-200); 192 + --accent-300: var(--color-sky-300); 193 + --accent-400: var(--color-sky-400); 194 + --accent-500: var(--color-sky-500); 195 + --accent-600: var(--color-sky-600); 196 + --accent-700: var(--color-sky-700); 197 + --accent-800: var(--color-sky-800); 198 + --accent-900: var(--color-sky-900); 199 + --accent-950: var(--color-sky-950); 200 + } 201 + 202 + .blue { 203 + --accent-50: var(--color-blue-50); 204 + --accent-100: var(--color-blue-100); 205 + --accent-200: var(--color-blue-200); 206 + --accent-300: var(--color-blue-300); 207 + --accent-400: var(--color-blue-400); 208 + --accent-500: var(--color-blue-500); 209 + --accent-600: var(--color-blue-600); 210 + --accent-700: var(--color-blue-700); 211 + --accent-800: var(--color-blue-800); 212 + --accent-900: var(--color-blue-900); 213 + --accent-950: var(--color-blue-950); 214 + } 215 + 216 + .indigo { 217 + --accent-50: var(--color-indigo-50); 218 + --accent-100: var(--color-indigo-100); 219 + --accent-200: var(--color-indigo-200); 220 + --accent-300: var(--color-indigo-300); 221 + --accent-400: var(--color-indigo-400); 222 + --accent-500: var(--color-indigo-500); 223 + --accent-600: var(--color-indigo-600); 224 + --accent-700: var(--color-indigo-700); 225 + --accent-800: var(--color-indigo-800); 226 + --accent-900: var(--color-indigo-900); 227 + --accent-950: var(--color-indigo-950); 228 + } 229 + 230 + .violet { 231 + --accent-50: var(--color-violet-50); 232 + --accent-100: var(--color-violet-100); 233 + --accent-200: var(--color-violet-200); 234 + --accent-300: var(--color-violet-300); 235 + --accent-400: var(--color-violet-400); 236 + --accent-500: var(--color-violet-500); 237 + --accent-600: var(--color-violet-600); 238 + --accent-700: var(--color-violet-700); 239 + --accent-800: var(--color-violet-800); 240 + --accent-900: var(--color-violet-900); 241 + --accent-950: var(--color-violet-950); 242 + } 243 + 244 + .purple { 245 + --accent-50: var(--color-purple-50); 246 + --accent-100: var(--color-purple-100); 247 + --accent-200: var(--color-purple-200); 248 + --accent-300: var(--color-purple-300); 249 + --accent-400: var(--color-purple-400); 250 + --accent-500: var(--color-purple-500); 251 + --accent-600: var(--color-purple-600); 252 + --accent-700: var(--color-purple-700); 253 + --accent-800: var(--color-purple-800); 254 + --accent-900: var(--color-purple-900); 255 + --accent-950: var(--color-purple-950); 256 + } 257 + 258 + .fuchsia { 259 + --accent-50: var(--color-fuchsia-50); 260 + --accent-100: var(--color-fuchsia-100); 261 + --accent-200: var(--color-fuchsia-200); 262 + --accent-300: var(--color-fuchsia-300); 263 + --accent-400: var(--color-fuchsia-400); 264 + --accent-500: var(--color-fuchsia-500); 265 + --accent-600: var(--color-fuchsia-600); 266 + --accent-700: var(--color-fuchsia-700); 267 + --accent-800: var(--color-fuchsia-800); 268 + --accent-900: var(--color-fuchsia-900); 269 + --accent-950: var(--color-fuchsia-950); 270 + } 271 + 272 + .pink { 273 + --accent-50: var(--color-pink-50); 274 + --accent-100: var(--color-pink-100); 275 + --accent-200: var(--color-pink-200); 276 + --accent-300: var(--color-pink-300); 277 + --accent-400: var(--color-pink-400); 278 + --accent-500: var(--color-pink-500); 279 + --accent-600: var(--color-pink-600); 280 + --accent-700: var(--color-pink-700); 281 + --accent-800: var(--color-pink-800); 282 + --accent-900: var(--color-pink-900); 283 + --accent-950: var(--color-pink-950); 284 + } 285 + 286 + .rose { 287 + --accent-50: var(--color-rose-50); 288 + --accent-100: var(--color-rose-100); 289 + --accent-200: var(--color-rose-200); 290 + --accent-300: var(--color-rose-300); 291 + --accent-400: var(--color-rose-400); 292 + --accent-500: var(--color-rose-500); 293 + --accent-600: var(--color-rose-600); 294 + --accent-700: var(--color-rose-700); 295 + --accent-800: var(--color-rose-800); 296 + --accent-900: var(--color-rose-900); 297 + --accent-950: var(--color-rose-950); 298 + } 299 + 300 + .gray { 301 + --base-50: var(--color-gray-50); 302 + --base-100: var(--color-gray-100); 303 + --base-200: var(--color-gray-200); 304 + --base-300: var(--color-gray-300); 305 + --base-400: var(--color-gray-400); 306 + --base-500: var(--color-gray-500); 307 + --base-600: var(--color-gray-600); 308 + --base-700: var(--color-gray-700); 309 + --base-800: var(--color-gray-800); 310 + --base-900: var(--color-gray-900); 311 + --base-950: var(--color-gray-950); 312 + } 313 + 314 + .zinc { 315 + --base-50: var(--color-zinc-50); 316 + --base-100: var(--color-zinc-100); 317 + --base-200: var(--color-zinc-200); 318 + --base-300: var(--color-zinc-300); 319 + --base-400: var(--color-zinc-400); 320 + --base-500: var(--color-zinc-500); 321 + --base-600: var(--color-zinc-600); 322 + --base-700: var(--color-zinc-700); 323 + --base-800: var(--color-zinc-800); 324 + --base-900: var(--color-zinc-900); 325 + --base-950: var(--color-zinc-950); 326 + } 327 + 328 + .neutral { 329 + --base-50: var(--color-neutral-50); 330 + --base-100: var(--color-neutral-100); 331 + --base-200: var(--color-neutral-200); 332 + --base-300: var(--color-neutral-300); 333 + --base-400: var(--color-neutral-400); 334 + --base-500: var(--color-neutral-500); 335 + --base-600: var(--color-neutral-600); 336 + --base-700: var(--color-neutral-700); 337 + --base-800: var(--color-neutral-800); 338 + --base-900: var(--color-neutral-900); 339 + --base-950: var(--color-neutral-950); 340 + } 341 + 342 + .stone { 343 + --base-50: var(--color-stone-50); 344 + --base-100: var(--color-stone-100); 345 + --base-200: var(--color-stone-200); 346 + --base-300: var(--color-stone-300); 347 + --base-400: var(--color-stone-400); 348 + --base-500: var(--color-stone-500); 349 + --base-600: var(--color-stone-600); 350 + --base-700: var(--color-stone-700); 351 + --base-800: var(--color-stone-800); 352 + --base-900: var(--color-stone-900); 353 + --base-950: var(--color-stone-950); 354 + } 355 + 356 + .slate { 357 + --base-50: var(--color-slate-50); 358 + --base-100: var(--color-slate-100); 359 + --base-200: var(--color-slate-200); 360 + --base-300: var(--color-slate-300); 361 + --base-400: var(--color-slate-400); 362 + --base-500: var(--color-slate-500); 363 + --base-600: var(--color-slate-600); 364 + --base-700: var(--color-slate-700); 365 + --base-800: var(--color-slate-800); 366 + --base-900: var(--color-slate-900); 367 + --base-950: var(--color-slate-950); 368 + } 369 + 370 + .taupe { 371 + --base-50: var(--color-taupe-50); 372 + --base-100: var(--color-taupe-100); 373 + --base-200: var(--color-taupe-200); 374 + --base-300: var(--color-taupe-300); 375 + --base-400: var(--color-taupe-400); 376 + --base-500: var(--color-taupe-500); 377 + --base-600: var(--color-taupe-600); 378 + --base-700: var(--color-taupe-700); 379 + --base-800: var(--color-taupe-800); 380 + --base-900: var(--color-taupe-900); 381 + --base-950: var(--color-taupe-950); 382 + } 383 + 384 + .mist { 385 + --base-50: var(--color-mist-50); 386 + --base-100: var(--color-mist-100); 387 + --base-200: var(--color-mist-200); 388 + --base-300: var(--color-mist-300); 389 + --base-400: var(--color-mist-400); 390 + --base-500: var(--color-mist-500); 391 + --base-600: var(--color-mist-600); 392 + --base-700: var(--color-mist-700); 393 + --base-800: var(--color-mist-800); 394 + --base-900: var(--color-mist-900); 395 + --base-950: var(--color-mist-950); 396 + } 397 + 398 + .olive { 399 + --base-50: var(--color-olive-50); 400 + --base-100: var(--color-olive-100); 401 + --base-200: var(--color-olive-200); 402 + --base-300: var(--color-olive-300); 403 + --base-400: var(--color-olive-400); 404 + --base-500: var(--color-olive-500); 405 + --base-600: var(--color-olive-600); 406 + --base-700: var(--color-olive-700); 407 + --base-800: var(--color-olive-800); 408 + --base-900: var(--color-olive-900); 409 + --base-950: var(--color-olive-950); 410 + } 411 + 412 + .mauve { 413 + --base-50: var(--color-mauve-50); 414 + --base-100: var(--color-mauve-100); 415 + --base-200: var(--color-mauve-200); 416 + --base-300: var(--color-mauve-300); 417 + --base-400: var(--color-mauve-400); 418 + --base-500: var(--color-mauve-500); 419 + --base-600: var(--color-mauve-600); 420 + --base-700: var(--color-mauve-700); 421 + --base-800: var(--color-mauve-800); 422 + --base-900: var(--color-mauve-900); 423 + --base-950: var(--color-mauve-950); 424 + } 425 + } 426 + 427 + @utility prose-base-color { 428 + --tw-prose-body: var(--color-base-800); 429 + --tw-prose-headings: var(--color-base-900); 430 + --tw-prose-lead: var(--color-base-700); 431 + --tw-prose-links: var(--color-base-900); 432 + --tw-prose-bold: var(--color-base-900); 433 + --tw-prose-counters: var(--color-base-600); 434 + --tw-prose-bullets: var(--color-base-400); 435 + --tw-prose-hr: var(--color-base-300); 436 + --tw-prose-quotes: var(--color-base-900); 437 + --tw-prose-quote-borders: var(--color-base-300); 438 + --tw-prose-captions: var(--color-base-700); 439 + --tw-prose-code: var(--color-base-900); 440 + --tw-prose-pre-code: var(--color-base-100); 441 + --tw-prose-pre-bg: var(--color-base-900); 442 + --tw-prose-th-borders: var(--color-base-300); 443 + --tw-prose-td-borders: var(--color-base-200); 444 + 445 + --tw-prose-invert-body: var(--color-base-200); 446 + --tw-prose-invert-headings: var(--color-white); 447 + --tw-prose-invert-lead: var(--color-base-300); 448 + --tw-prose-invert-links: var(--color-white); 449 + --tw-prose-invert-bold: var(--color-white); 450 + --tw-prose-invert-counters: var(--color-base-400); 451 + --tw-prose-invert-bullets: var(--color-base-600); 452 + --tw-prose-invert-hr: var(--color-base-700); 453 + --tw-prose-invert-quotes: var(--color-base-100); 454 + --tw-prose-invert-quote-borders: var(--color-base-700); 455 + --tw-prose-invert-captions: var(--color-base-400); 456 + --tw-prose-invert-code: var(--color-white); 457 + --tw-prose-invert-pre-code: var(--color-base-300); 458 + --tw-prose-invert-pre-bg: rgb(0 0 0 / 50%); 459 + --tw-prose-invert-th-borders: var(--color-base-600); 460 + --tw-prose-invert-td-borders: var(--color-base-700); 461 + }
+3 -1
packages/social/src/lib/components/index.ts
··· 1 + export * from './action-buttons'; 1 2 export * from './atproto-login'; 3 + export * from './embed'; 2 4 export * from './post'; 3 5 export * from './star-rating'; 4 6 export * from './social-icons'; ··· 12 14 export * from './link-card'; 13 15 export * from './animated-emoji-picker'; 14 16 export * from './atproto-handle-popup'; 15 - export * from './bluesky-post-creator'; 17 + export * from './microblogging-post-creator'; 16 18 17 19 export function numberToHumanReadable(number: number) { 18 20 if (number < 1000) {
+7 -1
packages/colors/src/lib/components/select-theme/SelectTheme.svelte
··· 40 40 { class: 'text-stone-500', label: 'stone' }, 41 41 { class: 'text-zinc-500', label: 'zinc' }, 42 42 { class: 'text-neutral-500', label: 'neutral' }, 43 - { class: 'text-slate-500', label: 'slate' } 43 + { class: 'text-slate-500', label: 'slate' }, 44 + { class: 'text-olive-500', label: 'olive' }, 45 + { class: 'text-mauve-500', label: 'mauve' }, 46 + { class: 'text-mist-500', label: 'mist' }, 47 + { class: 'text-taupe-500', label: 'taupe' } 44 48 ]; 45 49 46 50 let { ··· 120 124 121 125 onchanged?.(color.label, previous.label); 122 126 }} 127 + 128 + class="w-64" 123 129 /> 124 130 {/if}
+1 -1
packages/core/src/lib/components/modal/Modal.svelte
··· 1 1 <script lang="ts" module> 2 2 import { Dialog, type WithoutChild } from 'bits-ui'; 3 - import { cn } from '@foxui/core'; 3 + import { cn } from '../../utils.js'; 4 4 5 5 export type ModalProps = Dialog.RootProps & { 6 6 interactOutsideBehavior?: 'close' | 'ignore';
+2 -67
packages/core/src/lib/components/prose/Prose.svelte
··· 37 37 }: ProseProps = $props(); 38 38 </script> 39 39 40 - <div bind:this={ref} class={cn(proseVariants({ size }), className)} {...restProps}> 40 + <div bind:this={ref} class={cn("prose-base-color", proseVariants({ size }), className)} {...restProps}> 41 41 {@render children?.()} 42 - </div> 43 - 44 - <!-- TODO: this is not actually used right now, we have to fix that --> 45 - <style> 46 - .prose-base-colors { 47 - --tw-prose-body: var(--color-base-700); 48 - --tw-prose-headings: var(--color-base-900); 49 - --tw-prose-lead: var(--color-base-600); 50 - --tw-prose-links: var(--color-base-900); 51 - --tw-prose-bold: var(--color-base-900); 52 - --tw-prose-counters: var(--color-base-500); 53 - --tw-prose-bullets: var(--color-base-300); 54 - --tw-prose-hr: var(--color-base-200); 55 - --tw-prose-quotes: var(--color-base-900); 56 - --tw-prose-quote-borders: var(--color-base-200); 57 - --tw-prose-captions: var(--color-base-500); 58 - --tw-prose-kbd: var(--color-base-900); 59 - --tw-prose-kbd-shadows: var(--color-base-900); 60 - --tw-prose-code: var(--color-base-900); 61 - --tw-prose-pre-code: var(--color-base-200); 62 - --tw-prose-pre-bg: var(--color-base-800); 63 - --tw-prose-th-borders: var(--color-base-300); 64 - --tw-prose-td-borders: var(--color-base-200); 65 - 66 - 67 - --tw-prose-invert-body: var(--color-base-300); 68 - --tw-prose-invert-headings: var(--color-white); 69 - --tw-prose-invert-lead: var(--color-base-400); 70 - --tw-prose-invert-links: var(--color-white); 71 - --tw-prose-invert-bold: var(--color-white); 72 - --tw-prose-invert-counters: var(--color-base-400); 73 - --tw-prose-invert-bullets: var(--color-base-600); 74 - --tw-prose-invert-hr: var(--color-base-700); 75 - --tw-prose-invert-quotes: var(--color-base-100); 76 - --tw-prose-invert-quote-borders: var(--color-base-700); 77 - --tw-prose-invert-captions: var(--color-base-400); 78 - --tw-prose-invert-kbd: var(--color-white); 79 - --tw-prose-invert-kbd-shadows: var(--color-white); 80 - --tw-prose-invert-code: var(--color-white); 81 - --tw-prose-invert-pre-code: var(--color-base-300); 82 - --tw-prose-invert-pre-bg: rgb(0 0 0 / 50%); 83 - --tw-prose-invert-th-borders: var(--color-base-600); 84 - --tw-prose-invert-td-borders: var(--color-base-700); 85 - } 86 - 87 - .prose-base-colors-dark { 88 - --tw-prose-invert-body: var(--color-base-300); 89 - --tw-prose-invert-headings: var(--color-white); 90 - --tw-prose-invert-lead: var(--color-base-400); 91 - --tw-prose-invert-links: var(--color-white); 92 - --tw-prose-invert-bold: var(--color-white); 93 - --tw-prose-invert-counters: var(--color-base-400); 94 - --tw-prose-invert-bullets: var(--color-base-600); 95 - --tw-prose-invert-hr: var(--color-base-700); 96 - --tw-prose-invert-quotes: var(--color-base-100); 97 - --tw-prose-invert-quote-borders: var(--color-base-700); 98 - --tw-prose-invert-captions: var(--color-base-400); 99 - --tw-prose-invert-kbd: var(--color-white); 100 - --tw-prose-invert-kbd-shadows: var(--color-white); 101 - --tw-prose-invert-code: var(--color-white); 102 - --tw-prose-invert-pre-code: var(--color-base-300); 103 - --tw-prose-invert-pre-bg: rgb(0 0 0 / 50%); 104 - --tw-prose-invert-th-borders: var(--color-base-600); 105 - --tw-prose-invert-td-borders: var(--color-base-700); 106 - } 107 - </style> 42 + </div>
+2 -1
packages/core/src/lib/components/toggle/toggle.svelte
··· 8 8 default: 'bg-base-200/20 dark:bg-base-800/20' 9 9 }, 10 10 size: { 11 - default: 'h-10 min-w-10 px-3 [&_svg]:size-4', 11 + xs: 'h-7 min-w-7 px-1.5 [&_svg]:size-3 text-xs rounded-xl', 12 12 sm: 'h-9 min-w-9 px-2.5 [&_svg]:size-3.5', 13 + default: 'h-10 min-w-10 px-3 [&_svg]:size-4', 13 14 lg: 'h-11 min-w-11 px-4 [&_svg]:size-5', 14 15 } 15 16 },
+40
packages/social/src/lib/components/action-buttons/ActionButtons.svelte
··· 1 + <script lang="ts"> 2 + import { cn } from '@foxui/core'; 3 + import type { ActionButtonsProps } from './types'; 4 + import ReplyButton from './ReplyButton.svelte'; 5 + import RepostButton from './RepostButton.svelte'; 6 + import LikeButton from './LikeButton.svelte'; 7 + import BookmarkButton from './BookmarkButton.svelte'; 8 + 9 + let { 10 + reply, 11 + repost, 12 + like, 13 + bookmark, 14 + customActions, 15 + class: className 16 + }: ActionButtonsProps = $props(); 17 + </script> 18 + 19 + {#if reply || repost || like || bookmark || customActions} 20 + <div 21 + class={cn( 22 + 'text-base-500 dark:text-base-400 accent:text-base-900 flex justify-start gap-12', 23 + className 24 + )} 25 + > 26 + {#if reply} 27 + <ReplyButton {...reply} /> 28 + {/if} 29 + {#if repost} 30 + <RepostButton {...repost} /> 31 + {/if} 32 + {#if like} 33 + <LikeButton {...like} /> 34 + {/if} 35 + {#if bookmark} 36 + <BookmarkButton {...bookmark} /> 37 + {/if} 38 + {@render customActions?.()} 39 + </div> 40 + {/if}
+51
packages/social/src/lib/components/action-buttons/BookmarkButton.svelte
··· 1 + <script lang="ts"> 2 + import type { BookmarkButtonProps } from './types'; 3 + 4 + let { active = $bindable(false), onclick }: BookmarkButtonProps = $props(); 5 + </script> 6 + 7 + {#snippet icon()} 8 + <span class="sr-only">Bookmark</span> 9 + {#if active} 10 + <svg 11 + xmlns="http://www.w3.org/2000/svg" 12 + viewBox="0 0 24 24" 13 + fill="currentColor" 14 + class="group-hover/post-action:bg-accent-500/10 text-accent-700 dark:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 15 + > 16 + <path 17 + fill-rule="evenodd" 18 + d="M6.32 2.577a49.255 49.255 0 0 1 11.36 0c1.497.174 2.57 1.46 2.57 2.93V21a.75.75 0 0 1-1.085.67L12 18.089l-7.165 3.583A.75.75 0 0 1 3.75 21V5.507c0-1.47 1.073-2.756 2.57-2.93Z" 19 + clip-rule="evenodd" 20 + /> 21 + </svg> 22 + {:else} 23 + <svg 24 + xmlns="http://www.w3.org/2000/svg" 25 + fill="none" 26 + viewBox="0 0 24 24" 27 + stroke-width="1.5" 28 + stroke="currentColor" 29 + class="group-hover/post-action:bg-accent-500/10 group-hover/post-action:text-accent-700 dark:group-hover/post-action:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 30 + > 31 + <path 32 + stroke-linecap="round" 33 + stroke-linejoin="round" 34 + d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z" 35 + /> 36 + </svg> 37 + {/if} 38 + {/snippet} 39 + 40 + {#if onclick} 41 + <button 42 + class="group/post-action inline-flex cursor-pointer items-center gap-2 text-sm" 43 + {onclick} 44 + > 45 + {@render icon()} 46 + </button> 47 + {:else} 48 + <span class="inline-flex items-center gap-2 text-sm"> 49 + {@render icon()} 50 + </span> 51 + {/if}
+71
packages/social/src/lib/components/action-buttons/LikeButton.svelte
··· 1 + <script lang="ts"> 2 + import type { LikeButtonProps } from './types'; 3 + import { numberToHumanReadable } from '..'; 4 + 5 + let { count, active = $bindable(false), onclick, href }: LikeButtonProps = $props(); 6 + </script> 7 + 8 + {#snippet icon()} 9 + {#if active} 10 + <svg 11 + xmlns="http://www.w3.org/2000/svg" 12 + viewBox="0 0 24 24" 13 + fill="currentColor" 14 + class="group-hover/post-action:bg-accent-500/10 text-accent-700 dark:text-accent-500 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 15 + > 16 + <path 17 + d="m11.645 20.91-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z" 18 + /> 19 + </svg> 20 + {:else} 21 + <svg 22 + xmlns="http://www.w3.org/2000/svg" 23 + fill="none" 24 + viewBox="0 0 24 24" 25 + stroke-width="1.5" 26 + stroke="currentColor" 27 + class="group-hover/post-action:bg-accent-500/10 group-hover/post-action:text-accent-700 dark:group-hover/post-action:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 28 + > 29 + <path 30 + stroke-linecap="round" 31 + stroke-linejoin="round" 32 + d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" 33 + /> 34 + </svg> 35 + {/if} 36 + {#if count} 37 + {numberToHumanReadable(count)} 38 + {/if} 39 + {/snippet} 40 + 41 + {#if onclick} 42 + <button 43 + class={[ 44 + 'group/post-action inline-flex cursor-pointer items-center gap-2 text-sm', 45 + active && 'text-accent-700 dark:text-accent-500 font-semibold' 46 + ]} 47 + {onclick} 48 + > 49 + {@render icon()} 50 + </button> 51 + {:else if href} 52 + <a 53 + class={[ 54 + 'group/post-action inline-flex cursor-pointer items-center gap-2 text-sm', 55 + active && 'text-accent-700 dark:text-accent-500 font-semibold' 56 + ]} 57 + {href} 58 + target="_blank" 59 + > 60 + {@render icon()} 61 + </a> 62 + {:else} 63 + <span 64 + class={[ 65 + 'inline-flex items-center gap-2 text-sm', 66 + active && 'text-accent-700 dark:text-accent-500 font-semibold' 67 + ]} 68 + > 69 + {@render icon()} 70 + </span> 71 + {/if}
+47
packages/social/src/lib/components/action-buttons/ReplyButton.svelte
··· 1 + <script lang="ts"> 2 + import type { ReplyButtonProps } from './types'; 3 + import { numberToHumanReadable } from '..'; 4 + 5 + let { count, onclick, href }: ReplyButtonProps = $props(); 6 + </script> 7 + 8 + {#snippet icon()} 9 + <svg 10 + xmlns="http://www.w3.org/2000/svg" 11 + fill="none" 12 + viewBox="0 0 24 24" 13 + stroke-width="1.5" 14 + stroke="currentColor" 15 + class="group-hover/post-action:bg-accent-500/10 group-hover/post-action:text-accent-700 dark:group-hover/post-action:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 16 + > 17 + <path 18 + stroke-linecap="round" 19 + stroke-linejoin="round" 20 + d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 0 1-.923 1.785A5.969 5.969 0 0 0 6 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337Z" 21 + /> 22 + </svg> 23 + {#if count} 24 + {numberToHumanReadable(count)} 25 + {/if} 26 + {/snippet} 27 + 28 + {#if onclick} 29 + <button 30 + class="group/post-action inline-flex cursor-pointer items-center gap-2 text-sm" 31 + {onclick} 32 + > 33 + {@render icon()} 34 + </button> 35 + {:else if href} 36 + <a 37 + class="group/post-action inline-flex cursor-pointer items-center gap-2 text-sm" 38 + {href} 39 + target="_blank" 40 + > 41 + {@render icon()} 42 + </a> 43 + {:else} 44 + <span class="inline-flex items-center gap-2 text-sm"> 45 + {@render icon()} 46 + </span> 47 + {/if}
+47
packages/social/src/lib/components/action-buttons/RepostButton.svelte
··· 1 + <script lang="ts"> 2 + import type { RepostButtonProps } from './types'; 3 + import { numberToHumanReadable } from '..'; 4 + 5 + let { count, active = $bindable(false), onclick, href }: RepostButtonProps = $props(); 6 + </script> 7 + 8 + {#snippet icon()} 9 + <svg 10 + xmlns="http://www.w3.org/2000/svg" 11 + fill="none" 12 + viewBox="0 0 24 24" 13 + stroke-width="1.5" 14 + stroke="currentColor" 15 + class="group-hover/post-action:bg-accent-500/10 group-hover/post-action:text-accent-700 dark:group-hover/post-action:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 16 + > 17 + <path 18 + stroke-linecap="round" 19 + stroke-linejoin="round" 20 + d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" 21 + /> 22 + </svg> 23 + {#if count} 24 + {numberToHumanReadable(count)} 25 + {/if} 26 + {/snippet} 27 + 28 + {#if onclick} 29 + <button 30 + class="group/post-action inline-flex cursor-pointer items-center gap-2 text-sm" 31 + {onclick} 32 + > 33 + {@render icon()} 34 + </button> 35 + {:else if href} 36 + <a 37 + class="group/post-action inline-flex cursor-pointer items-center gap-2 text-sm" 38 + {href} 39 + target="_blank" 40 + > 41 + {@render icon()} 42 + </a> 43 + {:else} 44 + <span class="inline-flex items-center gap-2 text-sm"> 45 + {@render icon()} 46 + </span> 47 + {/if}
+13
packages/social/src/lib/components/action-buttons/index.ts
··· 1 + export type { 2 + ReplyButtonProps, 3 + RepostButtonProps, 4 + LikeButtonProps, 5 + BookmarkButtonProps, 6 + ActionButtonsProps 7 + } from './types'; 8 + 9 + export { default as ActionButtons } from './ActionButtons.svelte'; 10 + export { default as ReplyButton } from './ReplyButton.svelte'; 11 + export { default as RepostButton } from './RepostButton.svelte'; 12 + export { default as LikeButton } from './LikeButton.svelte'; 13 + export { default as BookmarkButton } from './BookmarkButton.svelte';
+35
packages/social/src/lib/components/action-buttons/types.ts
··· 1 + import type { Snippet } from 'svelte'; 2 + 3 + export type ReplyButtonProps = { 4 + count?: number; 5 + onclick?: () => void; 6 + href?: string; 7 + }; 8 + 9 + export type RepostButtonProps = { 10 + count?: number; 11 + active?: boolean; 12 + onclick?: () => void; 13 + href?: string; 14 + }; 15 + 16 + export type LikeButtonProps = { 17 + count?: number; 18 + active?: boolean; 19 + onclick?: () => void; 20 + href?: string; 21 + }; 22 + 23 + export type BookmarkButtonProps = { 24 + active?: boolean; 25 + onclick?: () => void; 26 + }; 27 + 28 + export type ActionButtonsProps = { 29 + reply?: ReplyButtonProps; 30 + repost?: RepostButtonProps; 31 + like?: LikeButtonProps; 32 + bookmark?: BookmarkButtonProps; 33 + customActions?: Snippet; 34 + class?: string; 35 + };
+2 -2
packages/social/src/lib/components/blog-entry/BlogEntry.svelte
··· 26 26 27 27 <article class="group relative isolate flex w-full flex-col gap-8 md:flex-row"> 28 28 {#if image} 29 - <div class="relative aspect-[16/9] md:w-64 md:shrink-0"> 29 + <div class="relative aspect-video md:w-64 md:shrink-0"> 30 30 <img 31 31 src={image} 32 32 alt="" ··· 39 39 </div> 40 40 {/if} 41 41 <div> 42 - <div class={'flex max-h-8.5 flex-wrap items-center gap-2 overflow-hidden p-1 text-xs'}> 42 + <div class="flex max-h-8.5 flex-wrap items-center gap-2 overflow-hidden p-1 text-xs"> 43 43 <time datetime={date.toISOString()} class="text-base-500 mr-2 shrink-0"> 44 44 {date.toLocaleDateString('en-us', { 45 45 year: 'numeric',
-117
packages/social/src/lib/components/bluesky-post-creator/BlueskyPostCreator.svelte
··· 1 - <script lang="ts"> 2 - import { 3 - CoreRichTextEditor, 4 - MentionNode, 5 - MentionMenu, 6 - HashtagDecoration, 7 - type MentionItem, 8 - type JSONContent 9 - } from '@foxui/text'; 10 - import type { Editor } from '@foxui/text'; 11 - import { cn, Avatar } from '@foxui/core'; 12 - import { searchActorsTypeahead } from '../atproto-handle-popup/helper'; 13 - import { editorJsonToBlueskyPost, type BlueskyPostContent } from './facets'; 14 - import type { Readable } from 'svelte/store'; 15 - 16 - let { 17 - class: className, 18 - placeholder = "What's on your mind?", 19 - content = $bindable<BlueskyPostContent>({ text: '', facets: [] }), 20 - maxLength = 300, 21 - onupdate, 22 - editor = $bindable<Readable<Editor>>(), 23 - ref = $bindable<HTMLDivElement | null>(null), 24 - host 25 - }: { 26 - class?: string; 27 - placeholder?: string; 28 - content?: BlueskyPostContent; 29 - maxLength?: number; 30 - onupdate?: (content: BlueskyPostContent) => void; 31 - editor?: Readable<Editor>; 32 - ref?: HTMLDivElement | null; 33 - host?: string; 34 - } = $props(); 35 - 36 - let charCount = $derived(content.text.length); 37 - let remaining = $derived(maxLength - charCount); 38 - 39 - async function searchMentions(query: string): Promise<MentionItem[]> { 40 - const result = await searchActorsTypeahead(query, 8, host); 41 - return result.actors.map((actor) => ({ 42 - id: actor.did, 43 - label: actor.handle, 44 - avatar: actor.avatar, 45 - data: { displayName: actor.displayName } 46 - })); 47 - } 48 - </script> 49 - 50 - <div class={cn('relative', className)}> 51 - <CoreRichTextEditor 52 - bind:editor 53 - bind:ref 54 - {placeholder} 55 - extraExtensions={[ 56 - MentionNode.configure({ 57 - HTMLAttributes: { 58 - class: 'text-accent-600 dark:text-accent-400 font-medium' 59 - } 60 - }), 61 - HashtagDecoration 62 - ]} 63 - onupdate={(c) => { 64 - content = editorJsonToBlueskyPost(c as JSONContent); 65 - onupdate?.(content); 66 - }} 67 - > 68 - {#if $editor} 69 - <MentionMenu editor={$editor} items={searchMentions}> 70 - {#snippet item({ item: mentionItem, isActive, select })} 71 - <button 72 - onclick={select} 73 - class={cn( 74 - 'text-base-900 dark:text-base-200 flex min-w-48 w-full cursor-pointer items-center gap-3 px-3 py-2 font-medium', 75 - isActive 76 - ? 'text-accent-700 dark:text-accent-400 bg-accent-500/10' 77 - : 'hover:bg-accent-500/10' 78 - )} 79 - > 80 - <Avatar src={mentionItem.avatar} alt="" class="size-6 rounded-full" /> 81 - <span class="flex flex-col items-start"> 82 - <span>{mentionItem.label}</span> 83 - {#if mentionItem.data?.displayName} 84 - <span class="text-base-500 text-xs">{mentionItem.data.displayName}</span> 85 - {/if} 86 - </span> 87 - </button> 88 - {/snippet} 89 - </MentionMenu> 90 - {/if} 91 - </CoreRichTextEditor> 92 - 93 - {#if maxLength} 94 - <div 95 - class={cn( 96 - 'mt-1 text-right text-xs tabular-nums', 97 - remaining < 0 98 - ? 'text-red-500' 99 - : remaining < 20 100 - ? 'text-amber-500' 101 - : 'text-base-400' 102 - )} 103 - > 104 - {remaining} 105 - </div> 106 - {/if} 107 - </div> 108 - 109 - <style> 110 - :global(.tiptap .hashtag) { 111 - color: var(--color-accent-600); 112 - } 113 - 114 - :global(.dark .tiptap .hashtag) { 115 - color: var(--color-accent-400); 116 - } 117 - </style>
-103
packages/social/src/lib/components/bluesky-post-creator/facets.ts
··· 1 - import type { JSONContent } from '@foxui/text'; 2 - 3 - interface MentionFeature { 4 - $type: 'app.bsky.richtext.facet#mention'; 5 - did: string; 6 - } 7 - 8 - interface LinkFeature { 9 - $type: 'app.bsky.richtext.facet#link'; 10 - uri: string; 11 - } 12 - 13 - interface TagFeature { 14 - $type: 'app.bsky.richtext.facet#tag'; 15 - tag: string; 16 - } 17 - 18 - type Feature = MentionFeature | LinkFeature | TagFeature; 19 - 20 - interface FacetIndex { 21 - byteStart: number; 22 - byteEnd: number; 23 - } 24 - 25 - export interface BlueskyFacet { 26 - index: FacetIndex; 27 - features: Feature[]; 28 - } 29 - 30 - export interface BlueskyPostContent { 31 - text: string; 32 - facets: BlueskyFacet[]; 33 - } 34 - 35 - const encoder = new TextEncoder(); 36 - 37 - function byteLength(str: string): number { 38 - return encoder.encode(str).byteLength; 39 - } 40 - 41 - export function editorJsonToBlueskyPost(json: JSONContent): BlueskyPostContent { 42 - let text = ''; 43 - const facets: BlueskyFacet[] = []; 44 - 45 - function processNode(node: JSONContent) { 46 - if (node.type === 'doc') { 47 - for (const child of node.content ?? []) { 48 - processNode(child); 49 - } 50 - } else if (node.type === 'paragraph') { 51 - if (text.length > 0) text += '\n'; 52 - for (const child of node.content ?? []) { 53 - processInline(child); 54 - } 55 - } 56 - } 57 - 58 - function processInline(node: JSONContent) { 59 - if (node.type === 'mention') { 60 - const label = `@${node.attrs?.label ?? node.attrs?.id}`; 61 - const byteStart = byteLength(text); 62 - text += label; 63 - const byteEnd = byteLength(text); 64 - facets.push({ 65 - index: { byteStart, byteEnd }, 66 - features: [{ $type: 'app.bsky.richtext.facet#mention', did: node.attrs?.id }] 67 - }); 68 - } else if (node.type === 'text') { 69 - const nodeText = node.text ?? ''; 70 - const linkMark = node.marks?.find((m) => m.type === 'link'); 71 - if (linkMark) { 72 - const byteStart = byteLength(text); 73 - text += nodeText; 74 - const byteEnd = byteLength(text); 75 - facets.push({ 76 - index: { byteStart, byteEnd }, 77 - features: [{ $type: 'app.bsky.richtext.facet#link', uri: linkMark.attrs?.href }] 78 - }); 79 - } else { 80 - text += nodeText; 81 - } 82 - } 83 - } 84 - 85 - processNode(json); 86 - 87 - // Detect hashtags in the final text 88 - const hashtagRegex = /(?<=^|\s)#([a-zA-Z\u00C0-\u024F\u1E00-\u1EFF][\w\u00C0-\u024F\u1E00-\u1EFF]*)/g; 89 - let match; 90 - while ((match = hashtagRegex.exec(text)) !== null) { 91 - const fullMatch = match[0]; 92 - const tag = match[1]; 93 - const charStart = match.index; 94 - const byteStart = byteLength(text.slice(0, charStart)); 95 - const byteEnd = byteStart + byteLength(fullMatch); 96 - facets.push({ 97 - index: { byteStart, byteEnd }, 98 - features: [{ $type: 'app.bsky.richtext.facet#tag', tag }] 99 - }); 100 - } 101 - 102 - return { text, facets }; 103 - }
-3
packages/social/src/lib/components/bluesky-post-creator/index.ts
··· 1 - export { default as BlueskyPostCreator } from './BlueskyPostCreator.svelte'; 2 - export { editorJsonToBlueskyPost } from './facets'; 3 - export type { BlueskyPostContent, BlueskyFacet } from './facets';
+24 -22
packages/social/src/lib/components/bluesky-post/BlueskyPost.svelte
··· 2 2 import { Post } from '../post'; 3 3 import type { PostProps } from '../post/types'; 4 4 import { blueskyPostToPostData } from '.'; 5 + import type { BlueskyHrefs } from '.'; 5 6 import type { PostView } from '@atcute/bluesky/types/app/feed/defs'; 6 7 7 8 type BlueskyPostProps = Omit<PostProps, 'data'> & { 8 9 data?: PostView; 9 10 // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 11 reason?: any; 12 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 13 + reply?: any; 14 + baseUrl?: string; 15 + hrefs?: BlueskyHrefs; 11 16 showLogo?: boolean; 12 17 }; 13 18 14 19 let { 15 20 data, 16 21 reason, 17 - children, 22 + reply, 23 + baseUrl = 'https://bsky.app', 24 + hrefs, 18 25 showLogo = false, 19 26 logo, 20 27 showAvatar = true, 21 28 compact = false, 22 - showImages = true, 23 - showExternal = true, 24 - showVideo = true, 25 - showQuotes = true, 29 + target = '_blank', 26 30 ...restProps 27 31 }: BlueskyPostProps = $props(); 28 32 29 - const postData = $derived( 30 - data ? blueskyPostToPostData(data, undefined, reason) : undefined 33 + const result = $derived( 34 + data ? blueskyPostToPostData(data, baseUrl, reason, reply, hrefs, target) : undefined 31 35 ); 32 36 </script> 33 37 34 38 {#snippet defaultLogo()} 35 39 <a 36 40 class="text-accent-700 dark:text-accent-400 hover:text-accent-600 dark:hover:text-accent-500 accent:text-accent-900 accent:hover:text-accent-800" 37 - href={postData?.href} 38 - target="_blank" 41 + href={result?.postData.href} 42 + {target} 39 43 > 40 44 <svg xmlns="http://www.w3.org/2000/svg" version="1.1" class="size-4" viewBox="0 0 600 530"> 41 45 <path ··· 47 51 </a> 48 52 {/snippet} 49 53 50 - {#if postData} 54 + {#if result} 51 55 <Post 52 - data={postData} 53 - replyHref={postData?.href} 54 - repostHref={postData?.href} 55 - likeHref={postData?.href} 56 - timestampHref={postData?.href} 56 + data={result.postData} 57 + embeds={result.embeds} 58 + actions={{ 59 + reply: { count: result.postData.replyCount, href: result.postData.href }, 60 + repost: { count: result.postData.repostCount, href: result.postData.href }, 61 + like: { count: result.postData.likeCount, href: result.postData.href } 62 + }} 63 + timestamp={{ href: result.postData.href }} 57 64 logo={logo ?? (showLogo ? defaultLogo : undefined)} 58 65 {showAvatar} 59 66 {compact} 60 - {showImages} 61 - {showExternal} 62 - {showVideo} 63 - {showQuotes} 67 + {target} 64 68 {...restProps} 65 - > 66 - {@render children?.()} 67 - </Post> 69 + /> 68 70 {/if}
+146 -45
packages/social/src/lib/components/bluesky-post/index.ts
··· 1 - import type { PostData, PostEmbed, QuotedPostData } from '../post'; 1 + import type { Embed, EmbedRecordData } from '../embed'; 2 + import type { PostData } from '../post'; 2 3 import type { PostView } from '@atcute/bluesky/types/app/feed/defs'; 3 4 import { segmentize, type Facet, type RichtextSegment } from '@atcute/bluesky-richtext-segmenter'; 5 + 6 + export type BlueskyHrefs = { 7 + profile?: (handle: string, did?: string) => string; 8 + post?: (handle: string, postId: string) => string; 9 + hashtag?: (tag: string) => string; 10 + }; 11 + 12 + function defaultHrefs(baseUrl: string): Required<BlueskyHrefs> { 13 + return { 14 + profile: (handle, did) => `${baseUrl}/profile/${did ?? handle}`, 15 + post: (handle, postId) => `${baseUrl}/profile/${handle}/post/${postId}`, 16 + hashtag: (tag) => `${baseUrl}/hashtag/${tag}` 17 + }; 18 + } 19 + 20 + function resolveHrefs(baseUrl: string, hrefs?: BlueskyHrefs): Required<BlueskyHrefs> { 21 + const defaults = defaultHrefs(baseUrl); 22 + if (!hrefs) return defaults; 23 + return { 24 + profile: hrefs.profile ?? defaults.profile, 25 + post: hrefs.post ?? defaults.post, 26 + hashtag: hrefs.hashtag ?? defaults.hashtag 27 + }; 28 + } 4 29 5 30 function escapeHtml(str: string): string { 6 31 return str ··· 28 53 29 54 type Feature = MentionFeature | LinkFeature | TagFeature; 30 55 31 - const renderSegment = (segment: RichtextSegment, baseUrl: string) => { 56 + const renderSegment = ( 57 + segment: RichtextSegment, 58 + hrefs: Required<BlueskyHrefs>, 59 + target?: string 60 + ) => { 32 61 const { text, features } = segment; 33 62 const escaped = escapeHtml(text); 34 63 ··· 37 66 } 38 67 39 68 const feature = features[0] as Feature; 69 + const targetAttr = target ? ` target="${target}"` : ''; 40 70 41 71 const createLink = (href: string, text: string) => { 42 - return `<a target="_blank" rel="noopener noreferrer nofollow" href="${encodeURI(href)}">${text}</a>`; 72 + return `<a${targetAttr} rel="noopener noreferrer nofollow" href="${encodeURI(href)}">${text}</a>`; 43 73 }; 44 74 45 75 switch (feature.$type) { 46 76 case 'app.bsky.richtext.facet#mention': 47 - return createLink(`${baseUrl}/profile/${feature.did}`, escaped); 77 + return createLink(hrefs.profile(feature.did, feature.did), escaped); 48 78 case 'app.bsky.richtext.facet#link': 49 79 return createLink(feature.uri, escaped); 50 80 case 'app.bsky.richtext.facet#tag': 51 - return createLink(`${baseUrl}/hashtag/${feature.tag}`, escaped); 81 + return createLink(hrefs.hashtag(feature.tag), escaped); 52 82 default: 53 83 return `<span>${escaped}</span>`; 54 84 } 55 85 }; 56 86 57 - const RichText = ({ text, facets }: { text: string; facets?: Facet[] }, baseUrl: string) => { 87 + const RichText = ( 88 + { text, facets }: { text: string; facets?: Facet[] }, 89 + hrefs: Required<BlueskyHrefs>, 90 + target?: string 91 + ) => { 58 92 const segments = segmentize(text, facets); 59 - return segments.map((v) => renderSegment(v, baseUrl)).join(''); 93 + return segments.map((v) => renderSegment(v, hrefs, target)).join(''); 60 94 }; 61 95 62 96 function blueskyEmbedTypeToEmbedType(type: string) { ··· 82 116 } 83 117 84 118 // eslint-disable-next-line @typescript-eslint/no-explicit-any 85 - function extractQuotedPost(recordView: any, baseUrl: string): QuotedPostData | null { 119 + function extractQuotedPost( 120 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 121 + recordView: any, 122 + hrefs: Required<BlueskyHrefs>, 123 + target?: string 124 + ): EmbedRecordData | null { 86 125 if (!recordView?.author) return null; 87 126 88 127 const id = recordView.uri?.split('/').pop(); ··· 92 131 93 132 let htmlContent = ''; 94 133 if (value?.text) { 95 - htmlContent = RichText({ text: value.text, facets: value.facets }, baseUrl).replace( 134 + htmlContent = RichText({ text: value.text, facets: value.facets }, hrefs, target).replace( 96 135 /\n/g, 97 136 '<br>' 98 137 ); 99 138 } 100 139 101 - let embed: PostEmbed | undefined; 102 140 // eslint-disable-next-line @typescript-eslint/no-explicit-any 103 141 const firstEmbed = recordView.embeds?.[0] as any; 142 + let embed: Embed | undefined; 104 143 if (firstEmbed) { 105 144 const embedType = blueskyEmbedTypeToEmbedType(firstEmbed.$type); 106 145 if (embedType !== 'record' && embedType !== 'recordWithMedia' && embedType !== 'unknown') { 107 - embed = convertEmbed(firstEmbed, baseUrl); 146 + embed = convertEmbed(firstEmbed, hrefs, target) ?? undefined; 108 147 } 109 148 } 110 149 ··· 113 152 displayName: author.displayName || '', 114 153 handle: author.handle, 115 154 avatar: author.avatar, 116 - href: `${baseUrl}/profile/${author.did}` 155 + href: hrefs.profile(author.handle, author.did) 117 156 }, 118 - href: `${baseUrl}/profile/${author.handle}/post/${id}`, 157 + href: hrefs.post(author.handle, id), 119 158 htmlContent, 120 159 createdAt: value?.createdAt, 121 160 embed ··· 123 162 } 124 163 125 164 // eslint-disable-next-line @typescript-eslint/no-explicit-any 126 - function convertEmbed(embedView: any, baseUrl: string): PostEmbed { 165 + function convertEmbed( 166 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 167 + embedView: any, 168 + hrefs: Required<BlueskyHrefs>, 169 + target?: string 170 + ): Embed | null { 127 171 const type = blueskyEmbedTypeToEmbedType(embedView?.$type); 128 172 129 173 switch (type) { ··· 149 193 thumb: embedView.external.thumb 150 194 } 151 195 } 152 - : { type: 'unknown' }; 196 + : null; 153 197 case 'video': 154 198 return embedView.playlist 155 199 ? { ··· 161 205 aspectRatio: embedView.aspectRatio 162 206 } 163 207 } 164 - : { type: 'unknown' }; 208 + : null; 165 209 case 'record': { 166 - const record = extractQuotedPost(embedView.record, baseUrl); 167 - return record ? { type: 'record', record } : { type: 'unknown' }; 168 - } 169 - case 'recordWithMedia': { 170 - const record = extractQuotedPost(embedView.record?.record, baseUrl); 171 - const media = embedView.media ? convertEmbed(embedView.media, baseUrl) : undefined; 172 - if (record) { 173 - return { 174 - type: 'recordWithMedia', 175 - record, 176 - media: media ?? { type: 'unknown' } 177 - }; 178 - } 179 - return media ?? { type: 'unknown' }; 210 + const record = extractQuotedPost(embedView.record, hrefs, target); 211 + return record ? { type: 'record', record } : null; 180 212 } 181 213 default: 182 - return { type: 'unknown' }; 214 + return null; 183 215 } 216 + } 217 + 218 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 219 + function convertEmbeds( 220 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 221 + embedView: any, 222 + hrefs: Required<BlueskyHrefs>, 223 + target?: string 224 + ): Embed[] { 225 + const type = blueskyEmbedTypeToEmbedType(embedView?.$type); 226 + 227 + if (type === 'recordWithMedia') { 228 + const embeds: Embed[] = []; 229 + if (embedView.media) { 230 + const media = convertEmbed(embedView.media, hrefs, target); 231 + if (media) embeds.push(media); 232 + } 233 + const record = extractQuotedPost(embedView.record?.record, hrefs, target); 234 + if (record) embeds.push({ type: 'record', record }); 235 + return embeds; 236 + } 237 + 238 + const embed = convertEmbed(embedView, hrefs, target); 239 + return embed ? [embed] : []; 240 + } 241 + 242 + const nsfwLabels = ['porn', 'sexual', 'graphic-media', 'nudity']; 243 + 244 + function hasNSFWLabel(labels?: string[]): boolean { 245 + if (!labels) return false; 246 + return labels.some((label) => nsfwLabels.includes(label)); 247 + } 248 + 249 + function attachSensitive(embeds: Embed[], labels?: string[]): Embed[] { 250 + if (!hasNSFWLabel(labels)) return embeds; 251 + return embeds.map((embed) => { 252 + if (embed.type === 'images') return { ...embed, sensitive: true }; 253 + if (embed.type === 'video') return { ...embed, sensitive: true }; 254 + return embed; 255 + }); 184 256 } 185 257 186 258 export function blueskyPostToPostData( 187 259 data: PostView, 188 260 baseUrl: string = 'https://bsky.app', 189 261 // eslint-disable-next-line @typescript-eslint/no-explicit-any 190 - reason?: any 191 - ): PostData { 262 + reason?: any, 263 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 264 + reply?: any, 265 + hrefs?: BlueskyHrefs, 266 + target?: string 267 + ): { postData: PostData; embeds: Embed[] } { 268 + const resolvedHrefs = resolveHrefs(baseUrl, hrefs); 192 269 const post = data; 193 - const id = post.uri.split('/').pop(); 270 + const id = post.uri.split('/').pop() ?? ''; 194 271 195 272 const reposted = 196 273 reason?.$type === 'app.bsky.feed.defs#reasonRepost' && reason?.by 197 274 ? { 198 275 handle: reason.by.handle as string, 199 - href: `${baseUrl}/profile/${reason.by.did ?? reason.by.handle}` 276 + href: resolvedHrefs.profile(reason.by.handle, reason.by.did) 200 277 } 201 278 : undefined; 202 279 203 - return { 280 + const replyTo = 281 + reply?.parent?.author 282 + ? { 283 + handle: reply.parent.author.handle as string, 284 + href: resolvedHrefs.post( 285 + reply.parent.author.handle, 286 + reply.parent.uri?.split('/').pop() ?? '' 287 + ) 288 + } 289 + : undefined; 290 + 291 + const labels = post.labels ? post.labels.map((label) => label.val) : undefined; 292 + const embeds = post.embed 293 + ? attachSensitive(convertEmbeds(post.embed, resolvedHrefs, target), labels) 294 + : []; 295 + 296 + const postData: PostData = { 204 297 id, 205 - href: `${baseUrl}/profile/${post.author.handle}/post/${id}`, 298 + href: resolvedHrefs.post(post.author.handle, id), 206 299 reposted, 300 + replyTo, 207 301 author: { 208 302 displayName: post.author.displayName || '', 209 303 handle: post.author.handle, 210 304 avatar: post.author.avatar, 211 - href: `${baseUrl}/profile/${post.author.did}` 305 + href: resolvedHrefs.profile(post.author.handle, post.author.did) 212 306 }, 213 307 replyCount: post.replyCount ?? 0, 214 308 repostCount: post.repostCount ?? 0, 215 309 likeCount: post.likeCount ?? 0, 216 310 createdAt: (post.record as { createdAt?: string }).createdAt ?? '', 217 - 218 - embed: post.embed ? convertEmbed(post.embed, baseUrl) : undefined, 219 - 220 - htmlContent: blueskyPostToHTML(post, baseUrl), 221 - labels: post.labels ? post.labels.map((label) => label.val) : undefined 311 + embeds, 312 + htmlContent: blueskyPostToHTML(post, resolvedHrefs, target), 313 + labels 222 314 }; 315 + 316 + return { postData, embeds }; 223 317 } 224 318 225 - export function blueskyPostToHTML(post: PostView, baseUrl: string = 'https://bsky.app') { 319 + export function blueskyPostToHTML( 320 + post: PostView, 321 + hrefsOrBaseUrl: Required<BlueskyHrefs> | string = 'https://bsky.app', 322 + target?: string 323 + ) { 226 324 if (!post?.record) { 227 325 return ''; 228 326 } 229 327 328 + const hrefs = 329 + typeof hrefsOrBaseUrl === 'string' ? defaultHrefs(hrefsOrBaseUrl) : hrefsOrBaseUrl; 230 330 const record = post.record as { text?: string; facets?: Facet[] }; 231 - const html = RichText({ text: record.text ?? '', facets: record.facets }, baseUrl); 331 + const html = RichText({ text: record.text ?? '', facets: record.facets }, hrefs, target); 232 332 233 333 return html.replace(/\n/g, '<br>'); 234 334 } 235 335 236 336 export { default as BlueskyPost } from './BlueskyPost.svelte'; 337 + export type { BlueskyHrefs as BlueskyPostHrefs };
+33
packages/social/src/lib/components/embed/Embed.svelte
··· 1 + <script lang="ts"> 2 + import type { Embed } from './types'; 3 + import External from './External.svelte'; 4 + import Images from './Images.svelte'; 5 + import Video from './Video.svelte'; 6 + import QuotedPost from './QuotedPost.svelte'; 7 + 8 + const { 9 + embed, 10 + showSensitive = true 11 + }: { 12 + embed: Embed; 13 + showSensitive?: boolean; 14 + } = $props(); 15 + </script> 16 + 17 + <div class="flex flex-col gap-2 pt-3 text-sm"> 18 + {#if embed.type === 'images'} 19 + <Images data={embed} {showSensitive} /> 20 + {:else if embed.type === 'external' && embed.external} 21 + <External data={embed} /> 22 + {:else if embed.type === 'video' && embed.video} 23 + <Video data={embed} {showSensitive} /> 24 + {:else if embed.type === 'record' && embed.record} 25 + <QuotedPost record={embed.record} /> 26 + {:else if embed.type === 'unknown'} 27 + <div 28 + class="text-base-700 dark:text-base-300 bg-base-200/50 dark:bg-base-900/50 border-base-300 dark:border-base-600/30 rounded-2xl border p-4 text-sm" 29 + > 30 + Unsupported embed type 31 + </div> 32 + {/if} 33 + </div>
+40
packages/social/src/lib/components/embed/External.svelte
··· 1 + <script lang="ts"> 2 + import type { EmbedExternalData } from './types'; 3 + 4 + const { data }: { data: EmbedExternalData } = $props(); 5 + 6 + // svelte-ignore state_referenced_locally 7 + const domain = new URL(data.external.href).hostname.replace('www.', ''); 8 + </script> 9 + 10 + <article 11 + class={[ 12 + 'group dark:bg-base-900 bg-base-200 border-base-300 dark:border-base-600/30 relative isolate flex max-w-md flex-col justify-end overflow-hidden rounded-2xl border p-4', 13 + data.external.thumb ? 'aspect-video' : '' 14 + ]} 15 + > 16 + {#if data.external.thumb} 17 + <img 18 + src={data.external.thumb} 19 + alt={data.external.description} 20 + class="absolute inset-0 -z-10 size-full object-cover transition-transform duration-500 ease-in-out group-hover:scale-102" 21 + /> 22 + {/if} 23 + <div 24 + class="dark:from-base-950/90 dark:via-base-950/40 from-base-50/90 via-base-50/40 absolute inset-0 -z-10 bg-linear-to-t" 25 + ></div> 26 + 27 + <div 28 + class="text-base-700 dark:text-base-300 flex flex-wrap items-center gap-y-1 overflow-hidden text-sm" 29 + > 30 + <div>{domain}</div> 31 + </div> 32 + <h3 33 + class="dark:text-base-50 text-base-900 group-hover:text-accent-600 dark:group-hover:text-accent-400 mt-1 text-lg/6 font-semibold transition-colors duration-200" 34 + > 35 + <a href={data.external.href} target="_blank" rel="noopener noreferrer nofollow"> 36 + <span class="absolute inset-0"></span> 37 + {data.external.title} 38 + </a> 39 + </h3> 40 + </article>
+70
packages/social/src/lib/components/embed/Images.svelte
··· 1 + <script lang="ts"> 2 + import type { EmbedImageData, ImageData } from './types'; 3 + 4 + const { 5 + data, 6 + showSensitive = true 7 + }: { 8 + data: EmbedImageData; 9 + showSensitive?: boolean; 10 + } = $props(); 11 + 12 + let revealed = $state(false); 13 + </script> 14 + 15 + {#snippet imageSnippet(image: ImageData, className?: string)} 16 + {#if data.onclick} 17 + <button class="cursor-pointer" onclick={() => data.onclick!(image)}> 18 + <img 19 + loading="lazy" 20 + src={image.thumb} 21 + alt={image.alt} 22 + width={image.aspectRatio?.width} 23 + height={image.aspectRatio?.height} 24 + style={image.aspectRatio 25 + ? `aspect-ratio: ${image.aspectRatio.width} / ${image.aspectRatio.height}` 26 + : 'aspect-ratio: 1 / 1'} 27 + class={[ 28 + 'border-base-500/20 dark:border-base-400/20 accent:border-accent-900 max-h-160 w-full max-w-full rounded-2xl border object-cover', 29 + className 30 + ]} 31 + /> 32 + </button> 33 + {:else} 34 + <img 35 + loading="lazy" 36 + src={image.thumb} 37 + alt={image.alt} 38 + width={image.aspectRatio?.width} 39 + height={image.aspectRatio?.height} 40 + style={image.aspectRatio 41 + ? `aspect-ratio: ${image.aspectRatio.width} / ${image.aspectRatio.height}` 42 + : 'aspect-ratio: 1 / 1'} 43 + class={[ 44 + 'border-base-500/20 dark:border-base-400/20 accent:border-accent-900 max-h-160 w-full max-w-full rounded-2xl border object-cover', 45 + className 46 + ]} 47 + /> 48 + {/if} 49 + {/snippet} 50 + 51 + {#if data.sensitive && showSensitive && !revealed} 52 + {@const firstImage = data.images[0]} 53 + <button 54 + onclick={() => (revealed = true)} 55 + style={firstImage?.aspectRatio 56 + ? `aspect-ratio: ${firstImage.aspectRatio.width} / ${firstImage.aspectRatio.height}` 57 + : 'aspect-ratio: 1 / 1'} 58 + class="border-base-500/20 bg-base-200/50 text-base-600 dark:border-base-400/20 dark:bg-base-800/50 dark:text-base-400 accent:border-accent-900 flex max-h-160 w-full cursor-pointer items-center justify-center rounded-2xl border text-center text-sm" 59 + > 60 + Sensitive content, click to show. 61 + </button> 62 + {:else if data.images.length === 1} 63 + {@render imageSnippet(data.images[0])} 64 + {:else} 65 + <div class="columns-2 gap-4"> 66 + {#each data.images as image (image.thumb)} 67 + {@render imageSnippet(image, 'mb-4')} 68 + {/each} 69 + </div> 70 + {/if}
+51
packages/social/src/lib/components/embed/QuotedPost.svelte
··· 1 + <script lang="ts"> 2 + import type { EmbedRecordData } from './types'; 3 + import type { PostData } from '../post'; 4 + import Post from '../post/Post.svelte'; 5 + 6 + const { 7 + record, 8 + showEmbed = false 9 + }: { 10 + record: EmbedRecordData; 11 + showEmbed?: boolean; 12 + } = $props(); 13 + 14 + const postData: PostData = $derived({ 15 + author: record.author, 16 + href: record.href, 17 + htmlContent: record.htmlContent, 18 + createdAt: record.createdAt ?? '' 19 + }); 20 + 21 + const embeds = $derived( 22 + showEmbed && record.embed ? [record.embed] : [] 23 + ); 24 + </script> 25 + 26 + {#if record.onclick} 27 + <button 28 + class="border-base-300 dark:border-base-600/30 accent:border-accent-300/20 accent:bg-accent-100/10 bg-base-500/10 dark:bg-black/30 hover:bg-base-500/15 dark:hover:bg-black/40 w-full cursor-pointer overflow-hidden rounded-2xl border p-3 text-left text-sm transition-colors" 29 + onclick={() => record.onclick!(record, record.href)} 30 + > 31 + <Post 32 + data={postData} 33 + compact 34 + showAvatar={false} 35 + {embeds} 36 + onclickhandle={record.onclickhandle} 37 + /> 38 + </button> 39 + {:else} 40 + <div 41 + class="border-base-300 dark:border-base-600/30 accent:border-accent-300/20 accent:bg-accent-100/10 bg-base-500/10 dark:bg-black/30 overflow-hidden rounded-2xl border p-3 text-sm" 42 + > 43 + <Post 44 + data={postData} 45 + compact 46 + showAvatar={false} 47 + {embeds} 48 + onclickhandle={record.onclickhandle} 49 + /> 50 + </div> 51 + {/if}
+78
packages/social/src/lib/components/embed/Video.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import type { EmbedVideoData } from './types'; 4 + 5 + const { 6 + data, 7 + showSensitive = true 8 + }: { 9 + data: EmbedVideoData; 10 + showSensitive?: boolean; 11 + } = $props(); 12 + 13 + let revealed = $state(false); 14 + let element: HTMLMediaElement = $state()! 15 + 16 + onMount(async () => { 17 + if (data.sensitive && showSensitive && !revealed) return; 18 + initPlayer(); 19 + }); 20 + 21 + async function initPlayer() { 22 + const [{ default: Plyr }, { default: Hls }] = await Promise.all([ 23 + import('plyr'), 24 + import('hls.js') 25 + ]); 26 + 27 + if (Hls.isSupported()) { 28 + const hls = new Hls(); 29 + hls.loadSource(data.video.playlist); 30 + hls.attachMedia(element); 31 + } 32 + 33 + new Plyr(element, { 34 + controls: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'fullscreen'], 35 + ratio: data.video.aspectRatio 36 + ? `${data.video.aspectRatio.width}:${data.video.aspectRatio.height}` 37 + : '16:9' 38 + }); 39 + } 40 + 41 + function reveal() { 42 + revealed = true; 43 + // Wait for the video element to render, then init 44 + requestAnimationFrame(() => initPlayer()); 45 + } 46 + </script> 47 + 48 + <svelte:head> 49 + <link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" /> 50 + </svelte:head> 51 + 52 + {#if data.sensitive && showSensitive && !revealed} 53 + <button 54 + onclick={reveal} 55 + style={data.video.aspectRatio 56 + ? `aspect-ratio: ${data.video.aspectRatio.width} / ${data.video.aspectRatio.height}` 57 + : 'aspect-ratio: 16 / 9'} 58 + class="border-base-500/20 bg-base-200/50 text-base-600 dark:border-base-400/20 dark:bg-base-800/50 dark:text-base-400 accent:border-accent-900 flex w-full cursor-pointer items-center justify-center rounded-2xl border text-center text-sm" 59 + > 60 + Sensitive content, click to show. 61 + </button> 62 + {:else} 63 + <div 64 + style={data.video.aspectRatio 65 + ? `aspect-ratio: ${data.video.aspectRatio.width} / ${data.video.aspectRatio.height}` 66 + : 'aspect-ratio: 16 / 9'} 67 + class="border-base-300 dark:border-base-400/40 w-full max-w-full overflow-hidden rounded-2xl border" 68 + > 69 + <!-- svelte-ignore a11y_media_has_caption --> 70 + <video bind:this={element} class="h-full w-full" aria-label={data.video.alt}></video> 71 + </div> 72 + {/if} 73 + 74 + <style> 75 + * { 76 + --plyr-color-main: var(--color-accent-500); 77 + } 78 + </style>
+16
packages/social/src/lib/components/embed/index.ts
··· 1 + export type { 2 + ImageData, 3 + EmbedImageData, 4 + EmbedExternalData, 5 + EmbedVideoData, 6 + EmbedRecord, 7 + EmbedRecordData, 8 + UnknownEmbed, 9 + Embed 10 + } from './types'; 11 + 12 + export { default as EmbedImages } from './Images.svelte'; 13 + export { default as EmbedExternal } from './External.svelte'; 14 + export { default as EmbedVideo } from './Video.svelte'; 15 + export { default as EmbedQuotedPost } from './QuotedPost.svelte'; 16 + export { default as EmbedRouter } from './Embed.svelte';
+71
packages/social/src/lib/components/embed/types.ts
··· 1 + export type ImageData = { 2 + alt: string; 3 + thumb: string; 4 + fullsize: string; 5 + aspectRatio?: { 6 + width: number; 7 + height: number; 8 + }; 9 + }; 10 + 11 + export type EmbedImageData = { 12 + type: 'images'; 13 + images: ImageData[]; 14 + sensitive?: boolean; 15 + onclick?: (image: ImageData) => void; 16 + }; 17 + 18 + export type EmbedExternalData = { 19 + type: 'external'; 20 + external: { 21 + href: string; 22 + thumb: string; 23 + title: string; 24 + description: string; 25 + }; 26 + }; 27 + 28 + export type EmbedVideoData = { 29 + type: 'video'; 30 + video: { 31 + playlist: string; 32 + thumb: string; 33 + alt: string; 34 + aspectRatio?: { 35 + width: number; 36 + height: number; 37 + }; 38 + }; 39 + sensitive?: boolean; 40 + }; 41 + 42 + export type EmbedRecordData = { 43 + author: { 44 + displayName: string; 45 + handle: string; 46 + avatar?: string; 47 + href?: string; 48 + }; 49 + href?: string; 50 + htmlContent?: string; 51 + createdAt?: string; 52 + embed?: Embed; 53 + onclick?: (data: EmbedRecordData, href?: string) => void; 54 + onclickhandle?: (handle: string, href?: string) => void; 55 + }; 56 + 57 + export type EmbedRecord = { 58 + type: 'record'; 59 + record: EmbedRecordData; 60 + }; 61 + 62 + export type UnknownEmbed = { 63 + type: 'unknown'; 64 + } & Record<string, unknown>; 65 + 66 + export type Embed = 67 + | EmbedImageData 68 + | EmbedExternalData 69 + | EmbedVideoData 70 + | EmbedRecord 71 + | UnknownEmbed;
+6 -9
packages/social/src/lib/components/emoji-picker/EmojiPicker.svelte
··· 15 15 import('emoji-picker-element').then(({ Database }) => { 16 16 emojiDatabase.db = new Database(); 17 17 18 - // go through all groups and check if the emoji is supported (will be cached) 19 - // so that things appear faster when we select a group 20 - for (const group of allGroups) { 21 - emojiDatabase.db.getEmojiByGroup(group.id).then((emojis) => { 22 - for (const emoji of emojis) { 23 - isEmojiSupported(emoji.unicode); 24 - } 25 - }); 26 - } 18 + // only pre-cache the first group, others get cached on demand when selected 19 + emojiDatabase.db.getEmojiByGroup(allGroups[0].id).then((emojis) => { 20 + for (const emoji of emojis) { 21 + isEmojiSupported(emoji.unicode); 22 + } 23 + }); 27 24 }); 28 25 console.log('emojis loaded'); 29 26 }
+150
packages/social/src/lib/components/microblogging-post-creator/MicrobloggingPostCreator.svelte
··· 1 + <script lang="ts" module> 2 + import type { Snippet } from 'svelte'; 3 + import type { Readable } from 'svelte/store'; 4 + import type { MentionItem, JSONContent, SvelteTiptap } from '@foxui/text'; 5 + import type { LinkAddedEvent } from '@foxui/text'; 6 + 7 + export interface MicrobloggingPostContent { 8 + text: string; 9 + json: JSONContent; 10 + } 11 + </script> 12 + 13 + <script lang="ts"> 14 + import { 15 + PlainTextEditor, 16 + MentionNode, 17 + MentionMenu, 18 + HashtagDecoration, 19 + LinkExtension 20 + } from '@foxui/text'; 21 + import { cn } from '@foxui/core'; 22 + 23 + function extractText(json: JSONContent): string { 24 + let text = ''; 25 + function walk(node: JSONContent) { 26 + if (node.type === 'doc') { 27 + for (const child of node.content ?? []) walk(child); 28 + } else if (node.type === 'paragraph') { 29 + if (text.length > 0) text += '\n'; 30 + for (const child of node.content ?? []) walk(child); 31 + } else if (node.type === 'mention') { 32 + text += `@${node.attrs?.label ?? node.attrs?.id}`; 33 + } else if (node.type === 'text') { 34 + text += node.text ?? ''; 35 + } 36 + } 37 + walk(json); 38 + return text; 39 + } 40 + 41 + let { 42 + class: className, 43 + placeholder = "What's on your mind?", 44 + content = $bindable<MicrobloggingPostContent>({ text: '', json: { type: 'doc' } }), 45 + maxLength = 300, 46 + onupdate, 47 + editor = $bindable<Readable<SvelteTiptap.Editor>>(), 48 + ref = $bindable<HTMLDivElement | null>(null), 49 + searchMentions, 50 + embed = $bindable<unknown>(undefined), 51 + onlinkadded, 52 + embedPreview, 53 + mentionItem: mentionItemSnippet 54 + }: { 55 + class?: string; 56 + placeholder?: string; 57 + content?: MicrobloggingPostContent; 58 + maxLength?: number; 59 + onupdate?: (content: MicrobloggingPostContent) => void; 60 + editor?: Readable<SvelteTiptap.Editor>; 61 + ref?: HTMLDivElement | null; 62 + searchMentions?: (query: string) => Promise<MentionItem[]>; 63 + embed?: unknown; 64 + onlinkadded?: (event: LinkAddedEvent) => void; 65 + embedPreview?: Snippet<[{ embed: unknown; removeEmbed: () => void }]>; 66 + mentionItem?: Snippet< 67 + [{ item: MentionItem; isActive: boolean; select: () => void }] 68 + >; 69 + } = $props(); 70 + 71 + let charCount = $derived(content.text.length); 72 + let remaining = $derived(maxLength - charCount); 73 + 74 + let extraExtensions = $derived.by(() => { 75 + const exts: any[] = [ 76 + LinkExtension.configure({ onlinkadded: handleLinkAdded }), 77 + HashtagDecoration 78 + ]; 79 + if (searchMentions) { 80 + exts.push( 81 + MentionNode.configure({ 82 + HTMLAttributes: { 83 + class: 'text-accent-600 dark:text-accent-400 font-medium' 84 + } 85 + }) 86 + ); 87 + } 88 + return exts; 89 + }); 90 + 91 + function handleLinkAdded(event: LinkAddedEvent) { 92 + if (!embed) { 93 + embed = { type: 'link', url: event.href, text: event.text }; 94 + } 95 + onlinkadded?.(event); 96 + } 97 + 98 + function removeEmbed() { 99 + embed = undefined; 100 + } 101 + </script> 102 + 103 + <div class={cn('relative', className)}> 104 + <PlainTextEditor 105 + bind:editor 106 + bind:ref 107 + {placeholder} 108 + {extraExtensions} 109 + onupdate={(c) => { 110 + const json = c as JSONContent; 111 + content = { text: extractText(json), json }; 112 + onupdate?.(content); 113 + }} 114 + > 115 + {#if searchMentions && $editor} 116 + <MentionMenu editor={$editor} items={searchMentions} item={mentionItemSnippet} /> 117 + {/if} 118 + </PlainTextEditor> 119 + 120 + {#if embed && embedPreview} 121 + <div class="mt-2"> 122 + {@render embedPreview({ embed, removeEmbed })} 123 + </div> 124 + {/if} 125 + 126 + {#if maxLength} 127 + <div 128 + class={cn( 129 + 'mt-1 text-right text-xs tabular-nums', 130 + remaining < 0 131 + ? 'text-red-500' 132 + : remaining < 20 133 + ? 'text-amber-500' 134 + : 'text-base-400' 135 + )} 136 + > 137 + {remaining} 138 + </div> 139 + {/if} 140 + </div> 141 + 142 + <style> 143 + :global(.tiptap .hashtag) { 144 + color: var(--color-accent-600); 145 + } 146 + 147 + :global(.dark .tiptap .hashtag) { 148 + color: var(--color-accent-400); 149 + } 150 + </style>
+26
packages/social/src/lib/components/microblogging-post-creator/bluesky.ts
··· 1 + import type { MentionItem } from '@foxui/text'; 2 + import { searchActorsTypeahead } from '../atproto-handle-popup/helper'; 3 + 4 + export { editorJsonToBlueskyPost } from './facets'; 5 + export type { BlueskyPostContent, BlueskyFacet } from './facets'; 6 + 7 + /** 8 + * Creates a mention search function for Bluesky actors. 9 + * Use this with the `searchMentions` prop of MicrobloggingPostCreator. 10 + * 11 + * @example 12 + * ```svelte 13 + * <MicrobloggingPostCreator searchMentions={createBlueskyMentionSearch()} /> 14 + * ``` 15 + */ 16 + export function createBlueskyMentionSearch(host?: string) { 17 + return async (query: string): Promise<MentionItem[]> => { 18 + const result = await searchActorsTypeahead(query, 8, host); 19 + return result.actors.map((actor) => ({ 20 + id: actor.did, 21 + label: actor.handle, 22 + avatar: actor.avatar, 23 + data: { displayName: actor.displayName } 24 + })); 25 + }; 26 + }
+104
packages/social/src/lib/components/microblogging-post-creator/facets.ts
··· 1 + import type { JSONContent } from '@foxui/text'; 2 + 3 + interface MentionFeature { 4 + $type: 'app.bsky.richtext.facet#mention'; 5 + did: string; 6 + } 7 + 8 + interface LinkFeature { 9 + $type: 'app.bsky.richtext.facet#link'; 10 + uri: string; 11 + } 12 + 13 + interface TagFeature { 14 + $type: 'app.bsky.richtext.facet#tag'; 15 + tag: string; 16 + } 17 + 18 + type Feature = MentionFeature | LinkFeature | TagFeature; 19 + 20 + interface FacetIndex { 21 + byteStart: number; 22 + byteEnd: number; 23 + } 24 + 25 + export interface BlueskyFacet { 26 + index: FacetIndex; 27 + features: Feature[]; 28 + } 29 + 30 + export interface BlueskyPostContent { 31 + text: string; 32 + facets: BlueskyFacet[]; 33 + } 34 + 35 + const encoder = new TextEncoder(); 36 + 37 + function byteLength(str: string): number { 38 + return encoder.encode(str).byteLength; 39 + } 40 + 41 + export function editorJsonToBlueskyPost(json: JSONContent): BlueskyPostContent { 42 + let text = ''; 43 + const facets: BlueskyFacet[] = []; 44 + 45 + function processNode(node: JSONContent) { 46 + if (node.type === 'doc') { 47 + for (const child of node.content ?? []) { 48 + processNode(child); 49 + } 50 + } else if (node.type === 'paragraph') { 51 + if (text.length > 0) text += '\n'; 52 + for (const child of node.content ?? []) { 53 + processInline(child); 54 + } 55 + } 56 + } 57 + 58 + function processInline(node: JSONContent) { 59 + if (node.type === 'mention') { 60 + const label = `@${node.attrs?.label ?? node.attrs?.id}`; 61 + const byteStart = byteLength(text); 62 + text += label; 63 + const byteEnd = byteLength(text); 64 + facets.push({ 65 + index: { byteStart, byteEnd }, 66 + features: [{ $type: 'app.bsky.richtext.facet#mention', did: node.attrs?.id }] 67 + }); 68 + } else if (node.type === 'text') { 69 + const nodeText = node.text ?? ''; 70 + const linkMark = node.marks?.find((m) => m.type === 'link'); 71 + if (linkMark) { 72 + const byteStart = byteLength(text); 73 + text += nodeText; 74 + const byteEnd = byteLength(text); 75 + facets.push({ 76 + index: { byteStart, byteEnd }, 77 + features: [{ $type: 'app.bsky.richtext.facet#link', uri: linkMark.attrs?.href }] 78 + }); 79 + } else { 80 + text += nodeText; 81 + } 82 + } 83 + } 84 + 85 + processNode(json); 86 + 87 + // Detect hashtags in the final text 88 + const hashtagRegex = 89 + /(?<=^|\s)#([a-zA-Z\u00C0-\u024F\u1E00-\u1EFF][\w\u00C0-\u024F\u1E00-\u1EFF]*)/g; 90 + let match; 91 + while ((match = hashtagRegex.exec(text)) !== null) { 92 + const fullMatch = match[0]; 93 + const tag = match[1]; 94 + const charStart = match.index; 95 + const byteStart = byteLength(text.slice(0, charStart)); 96 + const byteEnd = byteStart + byteLength(fullMatch); 97 + facets.push({ 98 + index: { byteStart, byteEnd }, 99 + features: [{ $type: 'app.bsky.richtext.facet#tag', tag }] 100 + }); 101 + } 102 + 103 + return { text, facets }; 104 + }
+8
packages/social/src/lib/components/microblogging-post-creator/index.ts
··· 1 + export { default as MicrobloggingPostCreator } from './MicrobloggingPostCreator.svelte'; 2 + export type { MicrobloggingPostContent } from './MicrobloggingPostCreator.svelte'; 3 + export { 4 + createBlueskyMentionSearch, 5 + editorJsonToBlueskyPost, 6 + type BlueskyPostContent, 7 + type BlueskyFacet 8 + } from './bluesky';
+7 -5
packages/social/src/lib/components/nested-comments/Comment.svelte
··· 4 4 import { Avatar, Prose } from '@foxui/core'; 5 5 import { RelativeTime } from '@foxui/time'; 6 6 import type { PostData } from '../post'; 7 - import Embed from '../post/embeds/Embed.svelte'; 7 + import Embed from '../embed/Embed.svelte'; 8 8 9 9 const { comment, depth = 0 }: { comment: PostData; depth: number } = $props(); 10 10 ··· 73 73 {@render top(false)} 74 74 <Prose>{@html comment.htmlContent}</Prose> 75 75 76 - {#if comment.embed} 77 - <Embed embed={comment.embed} /> 76 + {#if comment.embeds?.length} 77 + {#each comment.embeds as embed} 78 + <Embed {embed} /> 79 + {/each} 78 80 {/if} 79 81 80 82 <div class="text-base-500 dark:text-base-400 mt-2 flex gap-8"> ··· 99 101 /> 100 102 </svg> 101 103 <span class="sr-only">Replies</span> 102 - {numberToHumanReadable(comment.replyCount)} 104 + {numberToHumanReadable(comment.replyCount ?? 0)} 103 105 </a> 104 106 105 107 <a ··· 123 125 /> 124 126 </svg> 125 127 <span class="sr-only">Likes</span> 126 - {numberToHumanReadable(comment.likeCount)} 128 + {numberToHumanReadable(comment.likeCount ?? 0)} 127 129 </a> 128 130 </div> 129 131
+37 -293
packages/social/src/lib/components/post/Post.svelte
··· 3 3 import type { WithElementRef } from 'bits-ui'; 4 4 import type { HTMLAttributes } from 'svelte/elements'; 5 5 import type { PostProps } from './types'; 6 - import PostAction from './PostAction.svelte'; 7 - import { numberToHumanReadable } from '..'; 8 - import { RelativeTime } from '@foxui/time'; 9 - import PostEmbed from './PostEmbed.svelte'; 6 + import Embed from '../embed/Embed.svelte'; 7 + import PostHeader from './PostHeader.svelte'; 8 + import { ActionButtons } from '../action-buttons'; 10 9 11 10 let { 12 11 ref = $bindable(), 13 12 data, 14 13 class: className, 15 - bookmarked = $bindable(false), 16 - liked = $bindable(false), 17 14 18 - showReply = $bindable(true), 19 - showRepost = $bindable(true), 20 - showLike = $bindable(true), 21 - showBookmark = $bindable(true), 15 + embeds, 16 + showSensitive = true, 22 17 23 - onReplyClick, 24 - onRepostClick, 25 - onLikeClick, 26 - onBookmarkClick, 18 + actions, 27 19 28 - replyHref, 29 - repostHref, 30 - likeHref, 31 - 32 - onclickimage, 33 - onclickpost, 34 20 onclickhandle, 35 21 36 - timestampHref, 37 - ontimestampclick, 38 - 39 - customActions, 22 + timestamp, 40 23 41 24 children, 42 25 ··· 44 27 45 28 showAvatar = true, 46 29 compact = false, 47 - 48 - showImages = true, 49 - showExternal = true, 50 - showVideo = true, 51 - showQuotes = true 30 + target = '_blank' 52 31 }: WithElementRef<HTMLAttributes<HTMLDivElement>> & PostProps = $props(); 53 - 54 - function handleContentClick(e: MouseEvent) { 55 - if (!onclickhandle) return; 56 - const target = (e.target as HTMLElement).closest('a'); 57 - if (!target) return; 58 - const href = target.getAttribute('href'); 59 - if (!href) return; 60 - // Match profile links like /profile/did:... or /profile/handle 61 - const match = href.match(/\/profile\/([^/]+)$/); 62 - if (match) { 63 - e.preventDefault(); 64 - onclickhandle(match[1], href); 65 - } 66 - } 67 32 </script> 68 33 69 34 <div ··· 97 62 {:else} 98 63 <a 99 64 href={data.reposted.href} 100 - target="_blank" 65 + {target} 101 66 class="hover:text-accent-600 dark:hover:text-accent-400 font-bold" 102 67 > 103 68 @{data.reposted.handle} ··· 133 98 {:else} 134 99 <a 135 100 href={data.replyTo.href} 136 - target="_blank" 101 + {target} 137 102 class="hover:text-accent-600 dark:hover:text-accent-400 font-bold" 138 103 > 139 104 @{data.replyTo.handle} ··· 142 107 </div> 143 108 </div> 144 109 {/if} 110 + 145 111 <div class="flex gap-4"> 146 112 {#if showAvatar && data.author.avatar} 147 113 {#if onclickhandle} 148 114 <button 149 - class="flex-shrink-0 cursor-pointer" 115 + class="shrink-0 cursor-pointer" 150 116 onclick={() => onclickhandle(data.author.handle, data.author.href)} 151 117 > 152 118 <img 153 119 src={data.author.avatar} 154 120 alt="" 155 - class={compact 156 - ? 'size-7 rounded-full object-cover' 157 - : 'size-10 rounded-full object-cover'} 121 + class={compact ? 'size-7 rounded-full object-cover' : 'size-10 rounded-full object-cover'} 158 122 /> 159 123 </button> 160 124 {:else} 161 - <a href={data.author.href} target="_blank" class="flex-shrink-0"> 125 + <a href={data.author.href} {target} class="flex-shrink-0"> 162 126 <img 163 127 src={data.author.avatar} 164 128 alt="" 165 - class={compact 166 - ? 'size-7 rounded-full object-cover' 167 - : 'size-10 rounded-full object-cover'} 129 + class={compact ? 'size-7 rounded-full object-cover' : 'size-10 rounded-full object-cover'} 168 130 /> 169 131 </a> 170 132 {/if} 171 133 {/if} 172 134 173 - <div class="min-w-0 w-full"> 174 - <div class="mb-1 flex items-start justify-between gap-2"> 175 - <div class="flex items-start gap-4"> 176 - {#if onclickhandle} 177 - <button 178 - class="hover:bg-accent-900/5 accent:hover:bg-accent-100/10 group/post-author -mx-2 -my-0.5 flex cursor-pointer flex-col items-baseline gap-x-2 gap-y-0.5 rounded-xl px-2 py-0.5 sm:flex-row" 179 - onclick={() => onclickhandle(data.author.handle, data.author.href)} 180 - > 181 - {#if data.author.displayName} 182 - <div 183 - class="text-base-900 group-hover/post-author:text-accent-600 dark:text-base-50 dark:group-hover/post-author:text-accent-300 accent:group-hover/post-author:text-accent-950 line-clamp-1 text-sm leading-tight font-semibold" 184 - > 185 - {data.author.displayName} 186 - </div> 187 - {/if} 188 - <div 189 - class={cn( 190 - 'group-hover/post-author:text-accent-600 dark:group-hover/post-author:text-accent-400 accent:text-accent-950 accent:group-hover/post-author:text-accent-900 text-sm', 191 - !data.author.displayName 192 - ? 'text-base-900 dark:text-base-50 font-semibold' 193 - : 'text-base-600 dark:text-base-400' 194 - )} 195 - > 196 - @{data.author.handle} 197 - </div> 198 - </button> 199 - {:else if data.author.href} 200 - <a 201 - class="hover:bg-accent-900/5 accent:hover:bg-accent-100/10 group/post-author -mx-2 -my-0.5 flex flex-col items-baseline gap-x-2 gap-y-0.5 rounded-xl px-2 py-0.5 sm:flex-row" 202 - href={data.author.href} 203 - target="_blank" 204 - > 205 - {#if data.author.displayName} 206 - <div 207 - class="text-base-900 group-hover/post-author:text-accent-600 dark:text-base-50 dark:group-hover/post-author:text-accent-300 accent:group-hover/post-author:text-accent-950 line-clamp-1 text-sm leading-tight font-semibold" 208 - > 209 - {data.author.displayName} 210 - </div> 211 - {/if} 212 - <div 213 - class={cn( 214 - 'group-hover/post-author:text-accent-600 dark:group-hover/post-author:text-accent-400 accent:text-accent-950 accent:group-hover/post-author:text-accent-900 text-sm', 215 - !data.author.displayName 216 - ? 'text-base-900 dark:text-base-50 font-semibold' 217 - : 'text-base-600 dark:text-base-400' 218 - )} 219 - > 220 - @{data.author.handle} 221 - </div> 222 - </a> 223 - {:else} 224 - <div 225 - class="-mx-2 -my-0.5 flex flex-col items-baseline gap-x-2 gap-y-0.5 rounded-xl px-2 py-0.5 sm:flex-row" 226 - > 227 - <div class="text-base-900 dark:text-base-50 text-sm leading-tight font-semibold"> 228 - {data.author.displayName} 229 - </div> 230 - <div class="text-base-600 dark:text-base-400 accent:text-accent-950 text-sm"> 231 - @{data.author.handle} 232 - </div> 233 - </div> 234 - {/if} 135 + <div class="w-full min-w-0"> 136 + <PostHeader 137 + author={data.author} 138 + createdAt={data.createdAt} 139 + {timestamp} 140 + {onclickhandle} 141 + showAvatar={false} 142 + {compact} 143 + {logo} 144 + {target} 145 + /> 235 146 236 - {#if timestampHref} 237 - <a 238 - href={timestampHref} 239 - target="_blank" 240 - class={cn( 241 - 'text-base-600 dark:text-base-400 accent:text-accent-950 hover:text-accent-600 dark:hover:text-accent-400 block no-underline', 242 - compact ? 'text-xs' : 'text-sm' 243 - )} 244 - > 245 - <RelativeTime date={new Date(data.createdAt)} locale="en" /> 246 - </a> 247 - {:else if ontimestampclick} 248 - <button 249 - onclick={ontimestampclick} 250 - class={cn( 251 - 'text-base-600 dark:text-base-400 accent:text-accent-950 hover:text-accent-600 dark:hover:text-accent-400 block cursor-pointer', 252 - compact ? 'text-xs' : 'text-sm' 253 - )} 254 - > 255 - <RelativeTime date={new Date(data.createdAt)} locale="en" /> 256 - </button> 257 - {:else} 258 - <div 259 - class={cn( 260 - 'text-base-600 dark:text-base-400 accent:text-accent-950 block no-underline', 261 - compact ? 'text-xs' : 'text-sm' 262 - )} 263 - > 264 - <RelativeTime date={new Date(data.createdAt)} locale="en" /> 265 - </div> 266 - {/if} 267 - </div> 268 - 269 - {#if logo} 270 - {@render logo?.()} 271 - {/if} 272 - </div> 273 - 274 - <!-- svelte-ignore a11y_click_events_have_key_events --> 275 - <!-- svelte-ignore a11y_no_static_element_interactions --> 276 - <div 277 - class={cn('post-content break-words', compact ? 'text-sm' : 'text-base')} 278 - onclick={onclickhandle ? handleContentClick : undefined} 279 - > 147 + <div class={cn('post-content wrap-break-word', compact ? 'text-sm' : 'text-base')}> 280 148 {#if data.htmlContent} 281 149 {@html sanitize(data.htmlContent, { ADD_ATTR: ['target'] })} 282 150 {:else} ··· 284 152 {/if} 285 153 </div> 286 154 287 - <PostEmbed 288 - {data} 289 - {showImages} 290 - {showExternal} 291 - {showVideo} 292 - {showQuotes} 293 - {onclickimage} 294 - {onclickpost} 295 - {onclickhandle} 296 - /> 155 + {#if embeds?.length} 156 + {#each embeds as embed} 157 + <Embed {embed} {showSensitive} /> 158 + {/each} 159 + {/if} 297 160 298 - {#if !compact && (showReply || showRepost || showLike || showBookmark || customActions)} 299 - <div 300 - class="text-base-500 dark:text-base-400 accent:text-base-900 mt-4 flex justify-between gap-2" 301 - > 302 - {#if showReply} 303 - <PostAction onclick={onReplyClick} href={replyHref}> 304 - <svg 305 - xmlns="http://www.w3.org/2000/svg" 306 - fill="none" 307 - viewBox="0 0 24 24" 308 - stroke-width="1.5" 309 - stroke="currentColor" 310 - class="group-hover/post-action:bg-accent-500/10 group-hover/post-action:text-accent-700 dark:group-hover/post-action:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 311 - > 312 - <path 313 - stroke-linecap="round" 314 - stroke-linejoin="round" 315 - d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 0 1-.923 1.785A5.969 5.969 0 0 0 6 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337Z" 316 - /> 317 - </svg> 318 - {#if data.replyCount} 319 - {numberToHumanReadable(data.replyCount)} 320 - {/if} 321 - </PostAction> 322 - {/if} 323 - 324 - {#if showRepost} 325 - <PostAction onclick={onRepostClick} href={repostHref}> 326 - <svg 327 - xmlns="http://www.w3.org/2000/svg" 328 - fill="none" 329 - viewBox="0 0 24 24" 330 - stroke-width="1.5" 331 - stroke="currentColor" 332 - class="group-hover/post-action:bg-accent-500/10 group-hover/post-action:text-accent-700 dark:group-hover/post-action:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 333 - > 334 - <path 335 - stroke-linecap="round" 336 - stroke-linejoin="round" 337 - d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" 338 - /> 339 - </svg> 340 - {#if data.repostCount} 341 - {numberToHumanReadable(data.repostCount)} 342 - {/if} 343 - </PostAction> 344 - {/if} 345 - {#if showLike} 346 - <PostAction 347 - class={liked ? 'text-accent-700 dark:text-accent-500 font-semibold' : ''} 348 - onclick={onLikeClick} 349 - href={likeHref} 350 - > 351 - {#if liked} 352 - <svg 353 - xmlns="http://www.w3.org/2000/svg" 354 - viewBox="0 0 24 24" 355 - fill="currentColor" 356 - class="group-hover/post-action:bg-accent-500/10 text-accent-700 dark:text-accent-500 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 357 - > 358 - <path 359 - d="m11.645 20.91-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z" 360 - /> 361 - </svg> 362 - {:else} 363 - <svg 364 - xmlns="http://www.w3.org/2000/svg" 365 - fill="none" 366 - viewBox="0 0 24 24" 367 - stroke-width="1.5" 368 - stroke="currentColor" 369 - class="group-hover/post-action:bg-accent-500/10 group-hover/post-action:text-accent-700 dark:group-hover/post-action:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 370 - > 371 - <path 372 - stroke-linecap="round" 373 - stroke-linejoin="round" 374 - d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" 375 - /> 376 - </svg> 377 - {/if} 378 - {#if data.likeCount} 379 - {numberToHumanReadable(data.likeCount)} 380 - {/if} 381 - </PostAction> 382 - {/if} 383 - 384 - {#if showBookmark} 385 - <PostAction onclick={onBookmarkClick}> 386 - <span class="sr-only">Bookmark</span> 387 - 388 - {#if bookmarked} 389 - <svg 390 - xmlns="http://www.w3.org/2000/svg" 391 - viewBox="0 0 24 24" 392 - fill="currentColor" 393 - class="group-hover/post-action:bg-accent-500/10 text-accent-700 dark:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 394 - > 395 - <path 396 - fill-rule="evenodd" 397 - d="M6.32 2.577a49.255 49.255 0 0 1 11.36 0c1.497.174 2.57 1.46 2.57 2.93V21a.75.75 0 0 1-1.085.67L12 18.089l-7.165 3.583A.75.75 0 0 1 3.75 21V5.507c0-1.47 1.073-2.756 2.57-2.93Z" 398 - clip-rule="evenodd" 399 - /> 400 - </svg> 401 - {:else} 402 - <svg 403 - xmlns="http://www.w3.org/2000/svg" 404 - fill="none" 405 - viewBox="0 0 24 24" 406 - stroke-width="1.5" 407 - stroke="currentColor" 408 - class="group-hover/post-action:bg-accent-500/10 group-hover/post-action:text-accent-700 dark:group-hover/post-action:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 409 - > 410 - <path 411 - stroke-linecap="round" 412 - stroke-linejoin="round" 413 - d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z" 414 - /> 415 - </svg> 416 - {/if} 417 - </PostAction> 418 - {/if} 419 - 420 - {@render customActions?.()} 421 - </div> 161 + {#if actions} 162 + <ActionButtons 163 + {...actions} 164 + class={cn('mt-4', actions.class)} 165 + /> 422 166 {/if} 423 167 </div> 424 168 </div>
-37
packages/social/src/lib/components/post/PostAction.svelte
··· 1 - <script lang="ts"> 2 - import { cn } from '@foxui/core'; 3 - import type { Snippet } from 'svelte'; 4 - 5 - let { 6 - onclick, 7 - children, 8 - href, 9 - class: className 10 - }: { 11 - onclick?: () => void; 12 - children: Snippet; 13 - class?: string; 14 - href?: string; 15 - } = $props(); 16 - </script> 17 - 18 - {#if onclick} 19 - <button 20 - class={cn('group/post-action inline-flex cursor-pointer items-center gap-2 text-sm', className)} 21 - {onclick} 22 - > 23 - {@render children?.()} 24 - </button> 25 - {:else if href} 26 - <a 27 - class={cn('group/post-action inline-flex cursor-pointer items-center gap-2 text-sm', className)} 28 - {href} 29 - target="_blank" 30 - > 31 - {@render children?.()} 32 - </a> 33 - {:else} 34 - <div class={cn('inline-flex items-center gap-2 text-sm', className)}> 35 - {@render children?.()} 36 - </div> 37 - {/if}
-48
packages/social/src/lib/components/post/PostEmbed.svelte
··· 1 - <script lang="ts"> 2 - import { hasNSFWLabel, type PostData, type PostImageData, type QuotedPostData } from '.'; 3 - import Embed from './embeds/Embed.svelte'; 4 - 5 - let { 6 - data, 7 - showImages = true, 8 - showExternal = true, 9 - showVideo = true, 10 - showQuotes = true, 11 - onclickimage, 12 - onclickpost, 13 - onclickhandle 14 - }: { 15 - data: PostData; 16 - showImages?: boolean; 17 - showExternal?: boolean; 18 - showVideo?: boolean; 19 - showQuotes?: boolean; 20 - onclickimage?: (image: PostImageData) => void; 21 - onclickpost?: (data: PostData | QuotedPostData, href?: string) => void; 22 - onclickhandle?: (handle: string, href?: string) => void; 23 - } = $props(); 24 - 25 - let showNSFW = $state(false); 26 - </script> 27 - 28 - {#if data.embed} 29 - {#if hasNSFWLabel(data) && !showNSFW} 30 - <button 31 - onclick={() => (showNSFW = true)} 32 - class="border-base-500/20 bg-base-200/50 text-base-600 dark:border-base-400/20 dark:bg-base-800/50 dark:text-base-400 accent:border-accent-900 mt-4 flex h-18 w-full cursor-pointer items-center justify-center rounded-2xl border text-center text-sm" 33 - > 34 - NSFW content, click to show. 35 - </button> 36 - {:else} 37 - <Embed 38 - embed={data.embed} 39 - {showImages} 40 - {showExternal} 41 - {showVideo} 42 - {showQuotes} 43 - {onclickimage} 44 - {onclickpost} 45 - {onclickhandle} 46 - /> 47 - {/if} 48 - {/if}
+147
packages/social/src/lib/components/post/PostHeader.svelte
··· 1 + <script lang="ts"> 2 + import { cn } from '@foxui/core'; 3 + import type { PostHeaderProps } from './types'; 4 + import { RelativeTime } from '@foxui/time'; 5 + 6 + let { 7 + author, 8 + createdAt, 9 + timestamp, 10 + onclickhandle, 11 + showAvatar = true, 12 + compact = false, 13 + logo, 14 + target = '_blank', 15 + class: className 16 + }: PostHeaderProps = $props(); 17 + </script> 18 + 19 + <div class={cn('flex gap-4', className)}> 20 + {#if showAvatar && author.avatar} 21 + {#if onclickhandle} 22 + <button 23 + class="shrink-0 cursor-pointer" 24 + onclick={() => onclickhandle(author.handle, author.href)} 25 + > 26 + <img 27 + src={author.avatar} 28 + alt="" 29 + class={compact ? 'size-7 rounded-full object-cover' : 'size-10 rounded-full object-cover'} 30 + /> 31 + </button> 32 + {:else} 33 + <a href={author.href} {target} class="flex-shrink-0"> 34 + <img 35 + src={author.avatar} 36 + alt="" 37 + class={compact ? 'size-7 rounded-full object-cover' : 'size-10 rounded-full object-cover'} 38 + /> 39 + </a> 40 + {/if} 41 + {/if} 42 + 43 + <div class="w-full min-w-0"> 44 + <div class="mb-1 flex items-start justify-between gap-2"> 45 + <div class="flex items-start gap-4"> 46 + {#if onclickhandle} 47 + <button 48 + class="hover:bg-accent-900/5 accent:hover:bg-accent-100/10 group/post-author -mx-2 -my-0.5 flex cursor-pointer flex-col items-baseline gap-x-2 gap-y-0.5 rounded-xl px-2 py-0.5 sm:flex-row" 49 + onclick={() => onclickhandle(author.handle, author.href)} 50 + > 51 + {#if author.displayName} 52 + <div 53 + class="text-base-900 group-hover/post-author:text-accent-600 dark:text-base-50 dark:group-hover/post-author:text-accent-300 accent:group-hover/post-author:text-accent-950 line-clamp-1 text-sm leading-tight font-semibold" 54 + > 55 + {author.displayName} 56 + </div> 57 + {/if} 58 + <div 59 + class={cn( 60 + 'group-hover/post-author:text-accent-600 dark:group-hover/post-author:text-accent-400 accent:text-accent-950 accent:group-hover/post-author:text-accent-900 text-sm', 61 + !author.displayName 62 + ? 'text-base-900 dark:text-base-50 font-semibold' 63 + : 'text-base-600 dark:text-base-400' 64 + )} 65 + > 66 + @{author.handle} 67 + </div> 68 + </button> 69 + {:else if author.href} 70 + <a 71 + class="hover:bg-accent-900/5 accent:hover:bg-accent-100/10 group/post-author -mx-2 -my-0.5 flex flex-col items-baseline gap-x-2 gap-y-0.5 rounded-xl px-2 py-0.5 sm:flex-row" 72 + href={author.href} 73 + {target} 74 + > 75 + {#if author.displayName} 76 + <div 77 + class="text-base-900 group-hover/post-author:text-accent-600 dark:text-base-50 dark:group-hover/post-author:text-accent-300 accent:group-hover/post-author:text-accent-950 line-clamp-1 text-sm leading-tight font-semibold" 78 + > 79 + {author.displayName} 80 + </div> 81 + {/if} 82 + <div 83 + class={cn( 84 + 'group-hover/post-author:text-accent-600 dark:group-hover/post-author:text-accent-400 accent:text-accent-950 accent:group-hover/post-author:text-accent-900 text-sm', 85 + !author.displayName 86 + ? 'text-base-900 dark:text-base-50 font-semibold' 87 + : 'text-base-600 dark:text-base-400' 88 + )} 89 + > 90 + @{author.handle} 91 + </div> 92 + </a> 93 + {:else} 94 + <div 95 + class="-mx-2 -my-0.5 flex flex-col items-baseline gap-x-2 gap-y-0.5 rounded-xl px-2 py-0.5 sm:flex-row" 96 + > 97 + <div class="text-base-900 dark:text-base-50 text-sm leading-tight font-semibold"> 98 + {author.displayName} 99 + </div> 100 + <div class="text-base-600 dark:text-base-400 accent:text-accent-950 text-sm"> 101 + @{author.handle} 102 + </div> 103 + </div> 104 + {/if} 105 + 106 + <span class="text-base-400 dark:text-base-600 text-sm">&middot;</span> 107 + 108 + {#if timestamp?.href} 109 + <a 110 + href={timestamp.href} 111 + {target} 112 + class={cn( 113 + 'text-base-600 dark:text-base-400 accent:text-accent-950 hover:text-accent-600 dark:hover:text-accent-400 block no-underline', 114 + compact ? 'text-xs' : 'text-sm' 115 + )} 116 + > 117 + <RelativeTime date={new Date(createdAt)} locale="en" /> 118 + </a> 119 + {:else if timestamp?.onclick} 120 + <button 121 + onclick={timestamp.onclick} 122 + class={cn( 123 + 'text-base-600 dark:text-base-400 accent:text-accent-950 hover:text-accent-600 dark:hover:text-accent-400 block cursor-pointer', 124 + compact ? 'text-xs' : 'text-sm' 125 + )} 126 + > 127 + <RelativeTime date={new Date(createdAt)} locale="en" /> 128 + </button> 129 + {:else} 130 + <div 131 + class={cn( 132 + 'text-base-600 dark:text-base-400 accent:text-accent-950 block no-underline', 133 + compact ? 'text-xs' : 'text-sm' 134 + )} 135 + > 136 + <RelativeTime date={new Date(createdAt)} locale="en" /> 137 + </div> 138 + {/if} 139 + </div> 140 + 141 + {#if logo} 142 + {@render logo?.()} 143 + {/if} 144 + </div> 145 + 146 + </div> 147 + </div>
+2 -19
packages/social/src/lib/components/post/index.ts
··· 1 1 export type { 2 - PostImageData, 3 - PostEmbedImage, 4 - PostEmbedExternal, 5 - PostEmbedVideo, 6 - QuotedPostData, 7 - PostEmbedRecord, 8 - PostEmbedRecordWithMedia, 9 - UnknownEmbed, 10 - PostEmbed, 11 2 PostData, 3 + PostHeaderProps, 12 4 PostProps 13 5 } from './types'; 14 6 15 - import type { PostData } from './types'; 16 - 17 - export const nsfwLabels = ['porn', 'sexual', 'graphic-media', 'nudity']; 18 - 19 - export function hasNSFWLabel(post: PostData): boolean { 20 - if (!post.labels) return false; 21 - 22 - return post.labels.some((label) => nsfwLabels.includes(label)); 23 - } 24 - 25 7 export { default as Post } from './Post.svelte'; 8 + export { default as PostHeader } from './PostHeader.svelte';
+23 -108
packages/social/src/lib/components/post/types.ts
··· 1 1 import type { Snippet } from 'svelte'; 2 - 3 - export type PostImageData = { 4 - alt: string; 5 - thumb: string; 6 - fullsize: string; 7 - aspectRatio?: { 8 - width: number; 9 - height: number; 10 - }; 11 - }; 12 - 13 - export type PostEmbedImage = { 14 - type: 'images'; 15 - images: PostImageData[]; 16 - }; 17 - 18 - export type PostEmbedExternal = { 19 - type: 'external'; 20 - 21 - external: { 22 - href: string; 23 - thumb: string; 24 - title: string; 25 - description: string; 26 - }; 27 - }; 28 - 29 - export type PostEmbedVideo = { 30 - type: 'video'; 31 - 32 - video: { 33 - playlist: string; 34 - 35 - thumb: string; 36 - alt: string; 37 - 38 - aspectRatio?: { 39 - width: number; 40 - height: number; 41 - }; 42 - }; 43 - }; 44 - 45 - export type QuotedPostData = { 46 - author: { 47 - displayName: string; 48 - handle: string; 49 - avatar?: string; 50 - href?: string; 51 - }; 52 - href?: string; 53 - htmlContent?: string; 54 - createdAt?: string; 55 - embed?: PostEmbed; 56 - }; 57 - 58 - export type PostEmbedRecord = { 59 - type: 'record'; 60 - record: QuotedPostData; 61 - }; 62 - 63 - export type PostEmbedRecordWithMedia = { 64 - type: 'recordWithMedia'; 65 - record: QuotedPostData; 66 - media: PostEmbed; 67 - }; 68 - 69 - export type UnknownEmbed = { 70 - type: 'unknown'; 71 - } & Record<string, unknown>; 72 - 73 - export type PostEmbed = 74 - | PostEmbedImage 75 - | PostEmbedExternal 76 - | PostEmbedVideo 77 - | PostEmbedRecord 78 - | PostEmbedRecordWithMedia 79 - | UnknownEmbed; 2 + import type { Embed } from '../embed'; 3 + import type { ActionButtonsProps } from '../action-buttons'; 80 4 81 5 export type PostData = { 82 6 href?: string; ··· 92 16 href?: string; 93 17 }; 94 18 95 - replyCount: number; 96 - repostCount: number; 97 - likeCount: number; 19 + replyCount?: number; 20 + repostCount?: number; 21 + likeCount?: number; 98 22 99 23 createdAt: string; 100 24 101 - embed?: PostEmbed; 25 + embeds?: Embed[]; 102 26 103 27 htmlContent?: string; 104 28 ··· 107 31 labels?: string[]; 108 32 }; 109 33 34 + export type PostHeaderProps = { 35 + author: PostData['author']; 36 + createdAt: string; 37 + timestamp?: { href?: string; onclick?: () => void }; 38 + onclickhandle?: (handle: string, href?: string) => void; 39 + showAvatar?: boolean; 40 + compact?: boolean; 41 + logo?: Snippet; 42 + target?: string; 43 + class?: string; 44 + }; 45 + 110 46 export type PostProps = { 111 47 data: PostData; 112 48 class?: string; 113 49 children?: Snippet; 114 50 115 - bookmarked?: boolean; 116 - liked?: boolean; 51 + embeds?: Embed[]; 52 + showSensitive?: boolean; 117 53 118 - showReply?: boolean; 119 - showRepost?: boolean; 120 - showLike?: boolean; 121 - showBookmark?: boolean; 54 + actions?: ActionButtonsProps; 122 55 123 - onReplyClick?: () => void; 124 - onRepostClick?: () => void; 125 - onLikeClick?: () => void; 126 - onBookmarkClick?: () => void; 127 - 128 - replyHref?: string; 129 - repostHref?: string; 130 - likeHref?: string; 131 - 132 - onclickimage?: (image: PostImageData) => void; 133 - onclickpost?: (data: PostData | QuotedPostData, href?: string) => void; 134 56 onclickhandle?: (handle: string, href?: string) => void; 135 57 136 - timestampHref?: string; 137 - ontimestampclick?: () => void; 138 - 139 - customActions?: Snippet; 58 + timestamp?: { href?: string; onclick?: () => void }; 140 59 141 60 logo?: Snippet; 142 61 143 62 showAvatar?: boolean; 144 63 compact?: boolean; 145 - 146 - showImages?: boolean; 147 - showExternal?: boolean; 148 - showVideo?: boolean; 149 - showQuotes?: boolean; 64 + target?: string; 150 65 };
+2 -2
apps/docs/src/lib/docs/core/toggle-group/api.ts
··· 25 25 default: "'default'" 26 26 }, 27 27 size: { 28 - type: { type: 'enum', definition: "'default' | 'sm' | 'lg'" }, 28 + type: { type: 'enum', definition: "'xs' | 'default' | 'sm' | 'lg'" }, 29 29 description: 'The size of the toggle group items.', 30 30 default: "'default'" 31 31 }, ··· 75 75 description: 'Override the visual variant for this specific item. Falls back to the parent ToggleGroup variant.' 76 76 }, 77 77 size: { 78 - type: { type: 'enum', definition: "'default' | 'sm' | 'lg'" }, 78 + type: { type: 'enum', definition: "'xs' | 'default' | 'sm' | 'lg'" }, 79 79 description: 'Override the size for this specific item. Falls back to the parent ToggleGroup size.' 80 80 }, 81 81 disabled: {
+13
apps/docs/src/lib/docs/core/toggle/Example.svelte
··· 18 18 /> 19 19 </svg> 20 20 </Toggle> 21 + <Toggle size="xs" 22 + ><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> 23 + <path 24 + d="M11.7 2.805a.75.75 0 0 1 .6 0A60.65 60.65 0 0 1 22.83 8.72a.75.75 0 0 1-.231 1.337 49.948 49.948 0 0 0-9.902 3.912l-.003.002c-.114.06-.227.119-.34.18a.75.75 0 0 1-.707 0A50.88 50.88 0 0 0 7.5 12.173v-.224c0-.131.067-.248.172-.311a54.615 54.615 0 0 1 4.653-2.52.75.75 0 0 0-.65-1.352 56.123 56.123 0 0 0-4.78 2.589 1.858 1.858 0 0 0-.859 1.228 49.803 49.803 0 0 0-4.634-1.527.75.75 0 0 1-.231-1.337A60.653 60.653 0 0 1 11.7 2.805Z" 25 + /> 26 + <path 27 + d="M13.06 15.473a48.45 48.45 0 0 1 7.666-3.282c.134 1.414.22 2.843.255 4.284a.75.75 0 0 1-.46.711 47.87 47.87 0 0 0-8.105 4.342.75.75 0 0 1-.832 0 47.87 47.87 0 0 0-8.104-4.342.75.75 0 0 1-.461-.71c.035-1.442.121-2.87.255-4.286.921.304 1.83.634 2.726.99v1.27a1.5 1.5 0 0 0-.14 2.508c-.09.38-.222.753-.397 1.11.452.213.901.434 1.346.66a6.727 6.727 0 0 0 .551-1.607 1.5 1.5 0 0 0 .14-2.67v-.645a48.549 48.549 0 0 1 3.44 1.667 2.25 2.25 0 0 0 2.12 0Z" 28 + /> 29 + <path 30 + d="M4.462 19.462c.42-.419.753-.89 1-1.395.453.214.902.435 1.347.662a6.742 6.742 0 0 1-1.286 1.794.75.75 0 0 1-1.06-1.06Z" 31 + /> 32 + </svg> 33 + </Toggle> 21 34 <Toggle size="sm" 22 35 ><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> 23 36 <path
+1 -1
apps/docs/src/lib/docs/core/toggle/api.ts
··· 21 21 default: "'default'" 22 22 }, 23 23 size: { 24 - type: { type: 'enum', definition: "'default' | 'sm' | 'lg'" }, 24 + type: { type: 'enum', definition: "'xs' | 'default' | 'sm' | 'lg'" }, 25 25 description: 'The size of the toggle.', 26 26 default: "'default'" 27 27 },
+12
apps/docs/src/lib/docs/social/action-buttons/Card.svelte
··· 1 + <script> 2 + import { ActionButtons } from '@foxui/all'; 3 + </script> 4 + 5 + <div class="flex h-full w-full items-center justify-center p-4"> 6 + <ActionButtons 7 + reply={{ count: 5 }} 8 + repost={{ count: 12 }} 9 + like={{ count: 99 }} 10 + bookmark={{}} 11 + /> 12 + </div>
+44
apps/docs/src/lib/docs/social/action-buttons/Documentation.md
··· 1 + ## Usage 2 + 3 + ### ActionButtons Container 4 + 5 + ```svelte 6 + <script lang="ts"> 7 + let liked = $state(false); 8 + let bookmarked = $state(false); 9 + </script> 10 + 11 + <ActionButtons 12 + reply={{ count: 5, onclick: () => console.log('reply') }} 13 + repost={{ count: 12, onclick: () => console.log('repost') }} 14 + like={{ count: 99, active: liked, onclick: () => (liked = !liked) }} 15 + bookmark={{ active: bookmarked, onclick: () => (bookmarked = !bookmarked) }} 16 + /> 17 + ``` 18 + 19 + ### Individual Buttons 20 + 21 + Each button can be used standalone: 22 + 23 + ```svelte 24 + <script lang="ts"> 25 + let liked = $state(false); 26 + </script> 27 + 28 + <LikeButton count={42} active={liked} onclick={() => (liked = !liked)} /> 29 + ``` 30 + 31 + ### State Management 32 + 33 + The parent controls the state via `active` prop. The `onclick` callback handles toggling: 34 + 35 + ```svelte 36 + <LikeButton 37 + count={42} 38 + active={liked} 39 + onclick={async () => { 40 + await api.like(postId); 41 + liked = !liked; 42 + }} 43 + /> 44 + ```
+51
apps/docs/src/lib/docs/social/action-buttons/Example.svelte
··· 1 + <script lang="ts"> 2 + import { Box, toast } from '@foxui/all'; 3 + import { ActionButtons, ReplyButton, LikeButton, RepostButton, BookmarkButton } from '@foxui/all'; 4 + 5 + let liked = $state(false); 6 + let bookmarked = $state(false); 7 + let likeCount = $state(420); 8 + 9 + let liked2 = $state(false); 10 + let likeCount2 = $state(99); 11 + let bookmarked2 = $state(false); 12 + </script> 13 + 14 + <Box class="not-prose relative flex min-h-24 w-full flex-col gap-8"> 15 + <div> 16 + <h3 class="text-base-600 dark:text-base-400 mb-2 text-sm font-medium">ActionButtons</h3> 17 + <ActionButtons 18 + reply={{ count: 69, onclick: () => toast('Reply clicked') }} 19 + repost={{ count: 42, onclick: () => toast('Repost clicked') }} 20 + like={{ 21 + count: likeCount, 22 + active: liked, 23 + onclick: () => { 24 + likeCount += liked ? -1 : 1; 25 + liked = !liked; 26 + } 27 + }} 28 + bookmark={{ 29 + active: bookmarked, 30 + onclick: () => { 31 + bookmarked = !bookmarked; 32 + } 33 + }} 34 + /> 35 + </div> 36 + 37 + <div> 38 + <h3 class="text-base-600 dark:text-base-400 mb-2 text-sm font-medium">Individual Buttons</h3> 39 + <div class="text-base-500 dark:text-base-400 flex gap-8"> 40 + <ReplyButton count={5} onclick={() => toast('Reply')} /> 41 + <RepostButton count={12} onclick={() => toast('Repost')} /> 42 + <LikeButton count={likeCount2} active={liked2} onclick={() => { 43 + likeCount2 += liked2 ? -1 : 1; 44 + liked2 = !liked2; 45 + }} /> 46 + <BookmarkButton active={bookmarked2} onclick={() => { 47 + bookmarked2 = !bookmarked2; 48 + }} /> 49 + </div> 50 + </div> 51 + </Box>
+116
apps/docs/src/lib/docs/social/action-buttons/api.ts
··· 1 + import type { APISchema } from '$lib/types/schema'; 2 + 3 + export default [ 4 + { 5 + title: 'ActionButtons', 6 + description: 'Container for social action buttons (reply, repost, like, bookmark).', 7 + props: { 8 + reply: { 9 + type: { type: 'object', definition: 'ReplyButtonProps' }, 10 + description: 'Props for the reply button. Omit to hide.' 11 + }, 12 + repost: { 13 + type: { type: 'object', definition: 'RepostButtonProps' }, 14 + description: 'Props for the repost button. Omit to hide.' 15 + }, 16 + like: { 17 + type: { type: 'object', definition: 'LikeButtonProps' }, 18 + description: 'Props for the like button. Omit to hide.' 19 + }, 20 + bookmark: { 21 + type: { type: 'object', definition: 'BookmarkButtonProps' }, 22 + description: 'Props for the bookmark button. Omit to hide.' 23 + }, 24 + customActions: { 25 + type: 'Snippet', 26 + description: 'Additional custom action buttons.' 27 + }, 28 + class: { 29 + type: 'string', 30 + description: 'Additional CSS classes to apply.' 31 + } 32 + } 33 + }, 34 + { 35 + title: 'ReplyButton', 36 + description: 'A reply action button with count.', 37 + props: { 38 + count: { 39 + type: 'number', 40 + description: 'Number of replies to display.' 41 + }, 42 + onclick: { 43 + type: { type: 'function', definition: '() => void' }, 44 + description: 'Callback when the reply button is clicked.' 45 + }, 46 + href: { 47 + type: 'string', 48 + description: 'URL for the reply action link. Used when onclick is not provided.' 49 + } 50 + } 51 + }, 52 + { 53 + title: 'RepostButton', 54 + description: 'A repost action button with count.', 55 + props: { 56 + count: { 57 + type: 'number', 58 + description: 'Number of reposts to display.' 59 + }, 60 + active: { 61 + type: 'boolean', 62 + description: 'Whether the post is reposted.', 63 + default: 'false', 64 + bindable: true 65 + }, 66 + onclick: { 67 + type: { type: 'function', definition: '() => void' }, 68 + description: 'Callback when the repost button is clicked.' 69 + }, 70 + href: { 71 + type: 'string', 72 + description: 'URL for the repost action link. Used when onclick is not provided.' 73 + } 74 + } 75 + }, 76 + { 77 + title: 'LikeButton', 78 + description: 'A like toggle button with count.', 79 + props: { 80 + count: { 81 + type: 'number', 82 + description: 'Number of likes to display.' 83 + }, 84 + active: { 85 + type: 'boolean', 86 + description: 'Whether the post is liked.', 87 + default: 'false', 88 + bindable: true 89 + }, 90 + onclick: { 91 + type: { type: 'function', definition: '() => void' }, 92 + description: 'Callback when the like button is clicked.' 93 + }, 94 + href: { 95 + type: 'string', 96 + description: 'URL for the like action link. Used when onclick is not provided.' 97 + } 98 + } 99 + }, 100 + { 101 + title: 'BookmarkButton', 102 + description: 'A bookmark toggle button.', 103 + props: { 104 + active: { 105 + type: 'boolean', 106 + description: 'Whether the post is bookmarked.', 107 + default: 'false', 108 + bindable: true 109 + }, 110 + onclick: { 111 + type: { type: 'function', definition: '() => void' }, 112 + description: 'Callback when the bookmark button is clicked.' 113 + } 114 + } 115 + } 116 + ] satisfies APISchema[];
+13
apps/docs/src/lib/docs/social/action-buttons/index.ts
··· 1 + import Docs from './Documentation.md'; 2 + import Example from './Example.svelte'; 3 + import Card from './Card.svelte'; 4 + import api from './api'; 5 + 6 + export default { 7 + slug: 'action-buttons', 8 + title: 'Action Buttons', 9 + docs: Docs, 10 + example: Example, 11 + card: Card, 12 + api, 13 + };
+23
apps/docs/src/lib/docs/social/atproto-handle-popup/Card.svelte
··· 1 + <script lang="ts"> 2 + import { Input } from '@foxui/all'; 3 + </script> 4 + 5 + <div class="flex w-full flex-col items-start"> 6 + <div class="relative w-full"> 7 + <Input sizeVariant="sm" type="text" placeholder="flo" class="w-full" /> 8 + <div 9 + class="border-base-300 bg-base-50 dark:bg-base-800 dark:border-base-700 mt-1.5 w-full rounded-xl border p-0.5 shadow-lg" 10 + > 11 + <div 12 + class="bg-accent-100 dark:bg-accent-600/30 flex w-full items-center gap-2 rounded-lg p-1.5 px-2" 13 + > 14 + <div class="bg-accent-500 size-5 shrink-0 rounded-full"></div> 15 + <span class="text-sm">florian.bsky.social</span> 16 + </div> 17 + <div class="flex w-full items-center gap-2 rounded-lg p-1.5 px-2"> 18 + <div class="bg-emerald-500 size-5 shrink-0 rounded-full"></div> 19 + <span class="text-sm">flora.bsky.social</span> 20 + </div> 21 + </div> 22 + </div> 23 + </div>
+7 -18
apps/docs/src/lib/docs/social/atproto-login/Card.svelte
··· 3 3 </script> 4 4 5 5 <div class="flex flex-col items-start"> 6 - <Subheading class="mb-1 inline-flex items-center gap-2 text-xl font-bold"> 7 - <svg 8 - fill="currentColor" 9 - xmlns="http://www.w3.org/2000/svg" 10 - viewBox="-40 -40 680 620" 11 - version="1.1" 12 - class={['text-accent-600 dark:text-accent-400 size-6']} 13 - aria-hidden="true" 14 - > 15 - <path 16 - d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z" 17 - /> 18 - </svg> 19 - Login with Bluesky</Subheading 20 - > 6 + <Subheading class="mb-1 text-xl font-bold">Login with your internet handle</Subheading> 7 + <div class="text-xs font-light text-neutral-800 dark:text-neutral-200"> 8 + like your bluesky account 9 + </div> 21 10 22 11 <div class="mt-2 w-full"> 23 - <Label for="bluesky-handle" class="text-sm">Your handle</Label> 12 + <Label for="atproto-handle" class="text-sm">Your handle</Label> 24 13 <div class="mt-2"> 25 14 <Input 26 15 sizeVariant="sm" 27 16 type="text" 28 - name="bluesky-handle" 29 - id="bluesky-handle" 17 + name="atproto-handle" 18 + id="atproto-handle" 30 19 placeholder="yourname.bsky.social" 31 20 class="w-full" 32 21 />
-7
apps/docs/src/lib/docs/social/bluesky-post-creator/Card.svelte
··· 1 - <div class="flex h-full w-full items-center justify-center p-4"> 2 - <div class="border-base-300 dark:border-base-700 w-full rounded-xl border p-3"> 3 - <div class="bg-base-200 dark:bg-base-700 h-3 w-3/4 rounded"></div> 4 - <div class="bg-base-200 dark:bg-base-700 mt-1.5 h-3 w-1/2 rounded"></div> 5 - <div class="text-base-400 mt-2 text-right text-[10px]">280</div> 6 - </div> 7 - </div>
-35
apps/docs/src/lib/docs/social/bluesky-post-creator/Documentation.md
··· 1 - ## Usage 2 - 3 - ```svelte 4 - <script lang="ts"> 5 - import { BlueskyPostCreator, type BlueskyPostContent } from '@foxui/social'; 6 - 7 - let content: BlueskyPostContent = $state({ text: '', facets: [] }); 8 - </script> 9 - 10 - <BlueskyPostCreator 11 - bind:content 12 - placeholder="What's on your mind?" 13 - maxLength={300} 14 - /> 15 - ``` 16 - 17 - ## Features 18 - 19 - - **@mentions** - Type `@` followed by at least 2 characters to search for Bluesky handles. Select from the popup to insert a mention with the user's DID. 20 - - **Links** - URLs are automatically detected and linked. 21 - - **#hashtags** - Type `#tag` and hashtags are automatically detected in the output facets. 22 - - **Character count** - Shows remaining characters with color indicators (amber < 20, red when over limit). 23 - 24 - ## Output 25 - 26 - The `content` bindable returns a `BlueskyPostContent` object: 27 - 28 - ```typescript 29 - { 30 - text: string; // Plain text of the post 31 - facets: BlueskyFacet[]; // Byte-indexed facets for mentions, links, and tags 32 - } 33 - ``` 34 - 35 - Each facet has the Bluesky AT Protocol format with `index.byteStart`, `index.byteEnd`, and `features` array containing the facet type (`#mention`, `#link`, or `#tag`).
-19
apps/docs/src/lib/docs/social/bluesky-post-creator/Example.svelte
··· 1 - <script lang="ts"> 2 - import { BlueskyPostCreator, type BlueskyPostContent } from '@foxui/all'; 3 - 4 - let content: BlueskyPostContent = $state({ text: '', facets: [] }); 5 - </script> 6 - 7 - <div class="not-prose flex w-full flex-col gap-4"> 8 - <BlueskyPostCreator 9 - bind:content 10 - class="border-base-300 dark:border-base-700 rounded-2xl border p-4" 11 - /> 12 - 13 - {#if content.text} 14 - <details class="text-base-500 text-xs"> 15 - <summary class="cursor-pointer">Output</summary> 16 - <pre class="bg-base-100 dark:bg-base-800 mt-2 overflow-x-auto rounded-xl p-3">{JSON.stringify(content, null, 2)}</pre> 17 - </details> 18 - {/if} 19 - </div>
-61
apps/docs/src/lib/docs/social/bluesky-post-creator/api.ts
··· 1 - import type { APISchema } from '$lib/types/schema'; 2 - 3 - export default [ 4 - { 5 - title: 'BlueskyPostCreator', 6 - description: 7 - 'A Bluesky post composition editor with support for @mentions (with handle search), auto-linked URLs, and #hashtags. Outputs Bluesky-compatible text + facets.', 8 - props: { 9 - content: { 10 - type: { 11 - type: 'object', 12 - definition: '{ text: string; facets: BlueskyFacet[] }' 13 - }, 14 - description: 15 - 'The post content with plain text and byte-indexed facets for mentions, links, and tags.', 16 - bindable: true 17 - }, 18 - placeholder: { 19 - type: 'string', 20 - description: 'Placeholder text shown when the editor is empty.', 21 - default: "\"What's on your mind?\"" 22 - }, 23 - maxLength: { 24 - type: 'number', 25 - description: 26 - 'Maximum character count. A counter is displayed that changes color as the limit approaches.', 27 - default: '300' 28 - }, 29 - onupdate: { 30 - type: { 31 - type: 'function', 32 - definition: '(content: BlueskyPostContent) => void' 33 - }, 34 - description: 'Callback invoked whenever the post content changes.' 35 - }, 36 - host: { 37 - type: 'string', 38 - description: 39 - 'Custom AT Protocol API host for handle search. Defaults to the public Bluesky API.', 40 - default: "'https://public.api.bsky.app'" 41 - }, 42 - editor: { 43 - type: { 44 - type: 'object', 45 - definition: 'Readable<Editor>' 46 - }, 47 - description: 'The underlying tiptap editor instance (svelte-tiptap store).', 48 - bindable: true 49 - }, 50 - ref: { 51 - type: 'HTMLDivElement', 52 - description: 'Reference to the container element.', 53 - bindable: true 54 - }, 55 - class: { 56 - type: 'string', 57 - description: 'Additional CSS classes for the container.' 58 - } 59 - } 60 - } 61 - ] satisfies APISchema[];
-13
apps/docs/src/lib/docs/social/bluesky-post-creator/index.ts
··· 1 - import Docs from './Documentation.md'; 2 - import Example from './Example.svelte'; 3 - import Card from './Card.svelte'; 4 - import api from './api'; 5 - 6 - export default { 7 - slug: 'bluesky-post-creator', 8 - title: 'Bluesky Post Creator', 9 - docs: Docs, 10 - example: Example, 11 - card: Card, 12 - api 13 - };
+17
apps/docs/src/lib/docs/social/bluesky-post/Card.svelte
··· 1 + <script lang="ts"> 2 + import { Post } from '@foxui/all'; 3 + </script> 4 + 5 + <div class="flex h-full w-full items-center justify-center"> 6 + <Post 7 + data={{ 8 + htmlContent: 'Hello from Bluesky!', 9 + author: { 10 + displayName: 'flo-bit', 11 + handle: 'flo-bit.dev', 12 + avatar: 'https://github.com/flo-bit.png' 13 + }, 14 + createdAt: new Date(Date.now() - 60000).toISOString() 15 + }} 16 + /> 17 + </div>
+5
apps/docs/src/lib/docs/social/bluesky-post/Documentation.md
··· 1 + The `BlueskyPost` component renders a Bluesky post directly from the raw API response (`PostView`). It handles converting the data, rich text facets, embeds, and NSFW labels automatically. 2 + 3 + Fetch posts using the [Bluesky API](https://docs.bsky.app/docs/api/app-bsky-feed-get-author-feed) and pass each post's `post` and `reason` fields directly to the component. 4 + 5 + Use `blueskyPostToPostData` if you need to convert the data yourself for use with the generic `Post` component.
+29
apps/docs/src/lib/docs/social/bluesky-post/Example.svelte
··· 1 + <script lang="ts"> 2 + import { Box } from '@foxui/all'; 3 + import { BlueskyPost } from '@foxui/all'; 4 + import { onMount } from 'svelte'; 5 + 6 + let feed: any[] = $state([]); 7 + 8 + onMount(async () => { 9 + const res = await fetch( 10 + 'https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=did:plc:257wekqxg4hyapkq6k47igmp&limit=20' 11 + ); 12 + const data = await res.json(); 13 + feed = data.feed ?? []; 14 + }); 15 + </script> 16 + 17 + <Box 18 + class="not-prose divide-base-200/50 dark:divide-base-800/70 relative flex h-96 w-full flex-col divide-y overflow-y-scroll" 19 + > 20 + {#each feed as item (item.post.uri)} 21 + <div class="py-4 first:pt-0 last:pb-0"> 22 + <BlueskyPost data={item.post} reason={item.reason} reply={item.reply} showLogo /> 23 + </div> 24 + {/each} 25 + 26 + {#if feed.length === 0} 27 + <div class="text-base-500 text-sm">Loading posts...</div> 28 + {/if} 29 + </Box>
+73
apps/docs/src/lib/docs/social/bluesky-post/api.ts
··· 1 + import type { APISchema } from '$lib/types/schema'; 2 + 3 + export default [ 4 + { 5 + title: 'BlueskyPost', 6 + description: 'Renders a Bluesky post from raw API data (PostView).', 7 + props: { 8 + data: { 9 + type: { type: 'object', definition: 'PostView' }, 10 + description: 'The raw Bluesky PostView data from the API.', 11 + required: true 12 + }, 13 + reason: { 14 + type: 'any', 15 + description: 'The feed reason (e.g. repost info) from the API response.' 16 + }, 17 + reply: { 18 + type: 'any', 19 + description: 20 + 'The feed reply context (parent/root) from the API response. Used to show "replying to" banner.' 21 + }, 22 + baseUrl: { 23 + type: 'string', 24 + description: 25 + 'Base URL for all generated links. Change this to point to a custom client.', 26 + default: "'https://bsky.app'" 27 + }, 28 + hrefs: { 29 + type: { 30 + type: 'object', 31 + definition: 32 + '{ profile?: (handle, did?) => string; post?: (handle, postId) => string; hashtag?: (tag) => string }' 33 + }, 34 + description: 35 + 'Override individual URL builders. Each function overrides the default baseUrl-based pattern.' 36 + }, 37 + target: { 38 + type: 'string', 39 + description: 'The target attribute for all links.', 40 + default: "'_blank'" 41 + }, 42 + showLogo: { 43 + type: 'boolean', 44 + description: 'Whether to show the Bluesky butterfly logo in the header.', 45 + default: 'false' 46 + }, 47 + showAvatar: { 48 + type: 'boolean', 49 + description: 'Whether to show the author avatar.', 50 + default: 'true' 51 + }, 52 + compact: { 53 + type: 'boolean', 54 + description: 'Whether to use a compact layout.', 55 + default: 'false' 56 + }, 57 + actions: { 58 + type: { type: 'object', definition: 'ActionButtonsProps' }, 59 + description: 60 + 'Override the default action buttons. By default, shows reply/repost/like counts linking to the post.' 61 + }, 62 + embeds: { 63 + type: { type: 'object', definition: 'Embed[]' }, 64 + description: 'Override the automatically extracted embeds.' 65 + }, 66 + showSensitive: { 67 + type: 'boolean', 68 + description: 'Whether to show the sensitive content overlay on flagged embeds.', 69 + default: 'true' 70 + } 71 + } 72 + } 73 + ] satisfies APISchema[];
+13
apps/docs/src/lib/docs/social/bluesky-post/index.ts
··· 1 + import Docs from './Documentation.md'; 2 + import Example from './Example.svelte'; 3 + import Card from './Card.svelte'; 4 + import api from './api'; 5 + 6 + export default { 7 + slug: 'bluesky-post', 8 + title: 'Bluesky Post', 9 + docs: Docs, 10 + example: Example, 11 + card: Card, 12 + api, 13 + };
+19
apps/docs/src/lib/docs/social/embed/Card.svelte
··· 1 + <script> 2 + import { EmbedExternal } from '@foxui/all'; 3 + </script> 4 + 5 + <div class="flex h-full w-full items-center justify-center"> 6 + <div class="scale-85 w-full"> 7 + <EmbedExternal 8 + data={{ 9 + type: 'external', 10 + external: { 11 + href: 'https://flo-bit.dev', 12 + thumb: 'https://flo-bit.dev/og.jpg', 13 + title: "hi, i'm florian", 14 + description: 'full-stack software engineer based in berlin' 15 + } 16 + }} 17 + /> 18 + </div> 19 + </div>
+61
apps/docs/src/lib/docs/social/embed/Documentation.md
··· 1 + ## Usage 2 + 3 + ### Images 4 + 5 + ```svelte 6 + <EmbedImages 7 + data={{ 8 + type: 'images', 9 + images: [ 10 + { 11 + alt: 'A photo', 12 + thumb: 'https://example.com/thumb.jpg', 13 + fullsize: 'https://example.com/full.jpg', 14 + aspectRatio: { width: 4, height: 3 } 15 + } 16 + ] 17 + }} 18 + onclickimage={(image) => console.log(image)} 19 + /> 20 + ``` 21 + 22 + ### External Link 23 + 24 + ```svelte 25 + <EmbedExternal 26 + data={{ 27 + type: 'external', 28 + external: { 29 + href: 'https://example.com', 30 + thumb: 'https://example.com/og.jpg', 31 + title: 'Example Site', 32 + description: 'A description of the link' 33 + } 34 + }} 35 + /> 36 + ``` 37 + 38 + ### Video 39 + 40 + ```svelte 41 + <EmbedVideo 42 + data={{ 43 + type: 'video', 44 + video: { 45 + playlist: 'https://example.com/video.m3u8', 46 + thumb: 'https://example.com/poster.jpg', 47 + alt: 'A video', 48 + aspectRatio: { width: 16, height: 9 } 49 + } 50 + }} 51 + /> 52 + ``` 53 + 54 + ### Sensitive Content 55 + 56 + The `EmbedImages` and `EmbedVideo` components support a `sensitive` prop that hides the content behind a click-to-reveal overlay. 57 + 58 + ```svelte 59 + <EmbedImages sensitive data={...} /> 60 + <EmbedVideo sensitive data={...} /> 61 + ```
+93
apps/docs/src/lib/docs/social/embed/Example.svelte
··· 1 + <script lang="ts"> 2 + import { Box } from '@foxui/all'; 3 + import { EmbedImages, EmbedExternal, EmbedVideo, EmbedQuotedPost } from '@foxui/all'; 4 + </script> 5 + 6 + <Box class="not-prose relative flex min-h-24 w-full flex-col gap-8"> 7 + <div> 8 + <h3 class="text-base-600 dark:text-base-400 mb-2 text-sm font-medium">Images</h3> 9 + <EmbedImages 10 + data={{ 11 + type: 'images', 12 + images: [ 13 + { 14 + alt: 'A beautiful sunset', 15 + thumb: 'https://picsum.photos/seed/embed1/400/300', 16 + fullsize: 'https://picsum.photos/seed/embed1/1200/900', 17 + aspectRatio: { width: 4, height: 3 } 18 + }, 19 + { 20 + alt: 'Mountain landscape', 21 + thumb: 'https://picsum.photos/seed/embed2/400/300', 22 + fullsize: 'https://picsum.photos/seed/embed2/1200/900', 23 + aspectRatio: { width: 4, height: 3 } 24 + } 25 + ] 26 + }} 27 + /> 28 + </div> 29 + 30 + <div> 31 + <h3 class="text-base-600 dark:text-base-400 mb-2 text-sm font-medium">Images (sensitive)</h3> 32 + <EmbedImages 33 + data={{ 34 + type: 'images', 35 + sensitive: true, 36 + images: [ 37 + { 38 + alt: 'Hidden image', 39 + thumb: 'https://picsum.photos/seed/embed3/400/300', 40 + fullsize: 'https://picsum.photos/seed/embed3/1200/900', 41 + aspectRatio: { width: 4, height: 3 } 42 + } 43 + ] 44 + }} 45 + /> 46 + </div> 47 + 48 + <div> 49 + <h3 class="text-base-600 dark:text-base-400 mb-2 text-sm font-medium">External Link</h3> 50 + <EmbedExternal 51 + data={{ 52 + type: 'external', 53 + external: { 54 + href: 'https://flo-bit.dev', 55 + thumb: 'https://flo-bit.dev/og.jpg', 56 + title: "hi, i'm florian", 57 + description: 'full-stack software engineer based in berlin' 58 + } 59 + }} 60 + /> 61 + </div> 62 + 63 + <div> 64 + <h3 class="text-base-600 dark:text-base-400 mb-2 text-sm font-medium">Video (sensitive)</h3> 65 + <EmbedVideo 66 + data={{ 67 + type: 'video', 68 + sensitive: true, 69 + video: { 70 + playlist: '', 71 + thumb: '', 72 + alt: 'A video', 73 + aspectRatio: { width: 16, height: 9 } 74 + } 75 + }} 76 + /> 77 + </div> 78 + 79 + <div> 80 + <h3 class="text-base-600 dark:text-base-400 mb-2 text-sm font-medium">Quoted Post</h3> 81 + <EmbedQuotedPost 82 + record={{ 83 + author: { 84 + displayName: 'flo-bit', 85 + handle: 'flo-bit.dev', 86 + avatar: 'https://github.com/flo-bit.png' 87 + }, 88 + htmlContent: 'This is a quoted post!', 89 + createdAt: new Date(Date.now() - 60000).toISOString() 90 + }} 91 + /> 92 + </div> 93 + </Box>
+84
apps/docs/src/lib/docs/social/embed/api.ts
··· 1 + import type { APISchema } from '$lib/types/schema'; 2 + 3 + export default [ 4 + { 5 + title: 'EmbedImages', 6 + description: 'Displays a grid of images with optional click handling and sensitive content overlay.', 7 + props: { 8 + data: { 9 + type: { type: 'object', definition: 'EmbedImageData' }, 10 + description: 11 + 'The image embed data containing an array of images with alt text, thumbnails, and aspect ratios. Includes optional sensitive flag and onclick handler.', 12 + required: true 13 + }, 14 + showSensitive: { 15 + type: 'boolean', 16 + description: 'Whether to show the sensitive content overlay when data.sensitive is true.', 17 + default: 'true' 18 + } 19 + } 20 + }, 21 + { 22 + title: 'EmbedExternal', 23 + description: 'Displays an external link card with thumbnail, title, and domain.', 24 + props: { 25 + data: { 26 + type: { type: 'object', definition: 'EmbedExternalData' }, 27 + description: 28 + 'The external link embed data containing href, thumbnail, title, and description.', 29 + required: true 30 + } 31 + } 32 + }, 33 + { 34 + title: 'EmbedVideo', 35 + description: 'Displays an HLS video player with optional sensitive content overlay.', 36 + props: { 37 + data: { 38 + type: { type: 'object', definition: 'EmbedVideoData' }, 39 + description: 40 + 'The video embed data containing playlist URL, thumbnail, alt text, aspect ratio, and optional sensitive flag.', 41 + required: true 42 + }, 43 + showSensitive: { 44 + type: 'boolean', 45 + description: 'Whether to show the sensitive content overlay when data.sensitive is true.', 46 + default: 'true' 47 + } 48 + } 49 + }, 50 + { 51 + title: 'EmbedQuotedPost', 52 + description: 'Displays a quoted/embedded post record.', 53 + props: { 54 + record: { 55 + type: { type: 'object', definition: 'EmbedRecordData' }, 56 + description: 57 + 'The quoted post data including author, content, optional nested embed, and optional onclick/onclickhandle callbacks.', 58 + required: true 59 + }, 60 + showEmbed: { 61 + type: 'boolean', 62 + description: 'Whether to show embeds within the quoted post.', 63 + default: 'false' 64 + } 65 + } 66 + }, 67 + { 68 + title: 'EmbedRouter', 69 + description: 70 + 'Routes an embed to the correct component based on its type. Handles images, external links, video, and quoted posts.', 71 + props: { 72 + embed: { 73 + type: { type: 'object', definition: 'Embed' }, 74 + description: 'The embed data to render. Each embed carries its own sensitive flag and callbacks.', 75 + required: true 76 + }, 77 + showSensitive: { 78 + type: 'boolean', 79 + description: 'Whether to show the sensitive content overlay for flagged embeds.', 80 + default: 'true' 81 + } 82 + } 83 + } 84 + ] satisfies APISchema[];
+13
apps/docs/src/lib/docs/social/embed/index.ts
··· 1 + import Docs from './Documentation.md'; 2 + import Example from './Example.svelte'; 3 + import Card from './Card.svelte'; 4 + import api from './api'; 5 + 6 + export default { 7 + slug: 'embed', 8 + title: 'Embeds', 9 + docs: Docs, 10 + example: Example, 11 + card: Card, 12 + api, 13 + };
+1 -1
apps/docs/src/lib/docs/social/github-corner/Example.svelte
··· 3 3 import { GithubCorner } from '@foxui/all'; 4 4 </script> 5 5 6 - <Box class="not-prose relative min-h-24 w-full"> 6 + <Box class="not-prose relative min-h-24 w-full overflow-hidden"> 7 7 <GithubCorner class="absolute right-0 top-0" href="https://github.com/flo-bit/ui-kit" /> 8 8 <div class="h-24"></div> 9 9 </Box>
+8
apps/docs/src/lib/docs/social/microblogging-post-creator/Card.svelte
··· 1 + <div class="flex h-full w-full items-center justify-center p-4"> 2 + <div class="border-base-300 dark:border-base-700 w-full rounded-xl border p-3"> 3 + <div class="bg-base-200 dark:bg-base-700 h-3 w-3/4 rounded"></div> 4 + <div class="bg-base-200 dark:bg-base-700 mt-1.5 h-3 w-1/2 rounded"></div> 5 + <div class="bg-base-200 dark:bg-base-700 mt-3 h-16 w-full rounded-lg"></div> 6 + <div class="text-base-400 mt-2 text-right text-[10px]">280</div> 7 + </div> 8 + </div>
+76
apps/docs/src/lib/docs/social/microblogging-post-creator/Documentation.md
··· 1 + ## Usage 2 + 3 + ```svelte 4 + <script lang="ts"> 5 + import { 6 + MicrobloggingPostCreator, 7 + createBlueskyMentionSearch, 8 + type MicrobloggingPostContent 9 + } from '@foxui/social'; 10 + 11 + let content: MicrobloggingPostContent = $state({ text: '', json: { type: 'doc' } }); 12 + </script> 13 + 14 + <MicrobloggingPostCreator 15 + bind:content 16 + searchMentions={createBlueskyMentionSearch()} 17 + maxLength={300} 18 + /> 19 + ``` 20 + 21 + ## Features 22 + 23 + - **@mentions** - Provide a `searchMentions` function to enable mention support. Type `@` followed by at least 2 characters to trigger the search popup. 24 + - **Links** - URLs are automatically detected and linked. 25 + - **#hashtags** - Hashtags are visually highlighted in the editor. 26 + - **Embeds** - When a link is added and no embed exists, the `embed` prop is automatically set to `{ type: 'link', url, text }`. Provide an `embedPreview` snippet to render it. 27 + - **Character count** - Shows remaining characters with color indicators (amber < 20, red when over limit). 28 + 29 + ## Bluesky Integration 30 + 31 + Use the provided helpers for Bluesky: 32 + 33 + ```svelte 34 + <script lang="ts"> 35 + import { 36 + MicrobloggingPostCreator, 37 + createBlueskyMentionSearch, 38 + editorJsonToBlueskyPost, 39 + type MicrobloggingPostContent 40 + } from '@foxui/social'; 41 + 42 + let content: MicrobloggingPostContent = $state({ text: '', json: { type: 'doc' } }); 43 + 44 + // Convert to Bluesky format when posting 45 + function post() { 46 + const blueskyContent = editorJsonToBlueskyPost(content.json); 47 + // blueskyContent.text + blueskyContent.facets 48 + } 49 + </script> 50 + 51 + <MicrobloggingPostCreator 52 + bind:content 53 + searchMentions={createBlueskyMentionSearch()} 54 + /> 55 + ``` 56 + 57 + ## Embeds 58 + 59 + Bind the `embed` prop and provide an `embedPreview` snippet to show embed previews: 60 + 61 + ```svelte 62 + <script lang="ts"> 63 + import { MicrobloggingPostCreator } from '@foxui/social'; 64 + 65 + let embed = $state(); 66 + </script> 67 + 68 + <MicrobloggingPostCreator bind:embed> 69 + {#snippet embedPreview({ embed, removeEmbed })} 70 + <div class="flex items-center gap-2 rounded-xl border p-3"> 71 + <span class="flex-1 truncate text-sm">{embed.url}</span> 72 + <button onclick={removeEmbed}>Remove</button> 73 + </div> 74 + {/snippet} 75 + </MicrobloggingPostCreator> 76 + ```
+41
apps/docs/src/lib/docs/social/microblogging-post-creator/Example.svelte
··· 1 + <script lang="ts"> 2 + import { 3 + MicrobloggingPostCreator, 4 + createBlueskyMentionSearch, 5 + type MicrobloggingPostContent 6 + } from '@foxui/all'; 7 + 8 + let content: MicrobloggingPostContent = $state({ text: '', json: { type: 'doc' } }); 9 + let embed: { type: string; url: string; text: string } | undefined = $state(undefined); 10 + 11 + const searchMentions = createBlueskyMentionSearch(); 12 + </script> 13 + 14 + <div class="not-prose flex w-full flex-col gap-4"> 15 + <MicrobloggingPostCreator 16 + bind:content 17 + {searchMentions} 18 + bind:embed 19 + class="border-base-300 dark:border-base-700 rounded-2xl border p-4" 20 + > 21 + {#snippet embedPreview({ embed: e, removeEmbed })} 22 + <div 23 + class="border-base-300 dark:border-base-700 flex items-center gap-2 rounded-xl border p-3" 24 + > 25 + <span class="text-base-500 min-w-0 flex-1 truncate text-sm">{e.url}</span> 26 + <button 27 + onclick={removeEmbed} 28 + class="text-base-400 hover:text-base-600 dark:hover:text-base-300 text-xs" 29 + >Remove</button 30 + > 31 + </div> 32 + {/snippet} 33 + </MicrobloggingPostCreator> 34 + 35 + {#if content.text} 36 + <details class="text-sm"> 37 + <summary class="cursor-pointer">Output</summary> 38 + <pre class="mt-2 overflow-auto rounded-lg bg-black/5 p-3 dark:bg-white/5">{JSON.stringify(content, null, 2)}</pre> 39 + </details> 40 + {/if} 41 + </div>
+91
apps/docs/src/lib/docs/social/microblogging-post-creator/api.ts
··· 1 + import type { APISchema } from '$lib/types/schema'; 2 + 3 + export default [ 4 + { 5 + title: 'MicrobloggingPostCreator', 6 + description: 7 + 'A platform-agnostic microblogging post editor with support for @mentions, auto-linked URLs, #hashtags, and embeds. Includes Bluesky helpers for easy integration.', 8 + props: { 9 + content: { 10 + type: { 11 + type: 'object', 12 + definition: '{ text: string; json: JSONContent }' 13 + }, 14 + description: 'The post content with plain text and editor JSON.', 15 + bindable: true 16 + }, 17 + placeholder: { 18 + type: 'string', 19 + description: 'Placeholder text shown when the editor is empty.', 20 + default: "\"What's on your mind?\"" 21 + }, 22 + maxLength: { 23 + type: 'number', 24 + description: 25 + 'Maximum character count. A counter is displayed that changes color as the limit approaches.', 26 + default: '300' 27 + }, 28 + searchMentions: { 29 + type: { 30 + type: 'function', 31 + definition: '(query: string) => Promise<MentionItem[]>' 32 + }, 33 + description: 34 + 'Async function to search for mentionable users. When provided, enables @mention support with a popup menu. Use `createBlueskyMentionSearch()` for Bluesky.' 35 + }, 36 + embed: { 37 + type: 'unknown', 38 + description: 39 + 'The current embed data. Automatically populated with `{ type: "link", url, text }` when a link is added and no embed exists yet.', 40 + bindable: true 41 + }, 42 + onlinkadded: { 43 + type: { 44 + type: 'function', 45 + definition: '(event: LinkAddedEvent) => void' 46 + }, 47 + description: 48 + 'Callback invoked when a link is added to the editor. Receives `{ href, text, editor }`.' 49 + }, 50 + embedPreview: { 51 + type: { 52 + type: 'snippet', 53 + definition: 'Snippet<[{ embed: unknown; removeEmbed: () => void }]>' 54 + }, 55 + description: 'Snippet to render the embed preview below the editor.' 56 + }, 57 + mentionItem: { 58 + type: { 59 + type: 'snippet', 60 + definition: 'Snippet<[{ item: MentionItem; isActive: boolean; select: () => void }]>' 61 + }, 62 + description: 63 + 'Custom snippet for rendering mention menu items. Falls back to a default with avatar and display name.' 64 + }, 65 + onupdate: { 66 + type: { 67 + type: 'function', 68 + definition: '(content: MicrobloggingPostContent) => void' 69 + }, 70 + description: 'Callback invoked whenever the post content changes.' 71 + }, 72 + editor: { 73 + type: { 74 + type: 'object', 75 + definition: 'Readable<Editor>' 76 + }, 77 + description: 'The underlying tiptap editor instance (svelte-tiptap store).', 78 + bindable: true 79 + }, 80 + ref: { 81 + type: 'HTMLDivElement', 82 + description: 'Reference to the container element.', 83 + bindable: true 84 + }, 85 + class: { 86 + type: 'string', 87 + description: 'Additional CSS classes for the container.' 88 + } 89 + } 90 + } 91 + ] satisfies APISchema[];
+13
apps/docs/src/lib/docs/social/microblogging-post-creator/index.ts
··· 1 + import Docs from './Documentation.md'; 2 + import Example from './Example.svelte'; 3 + import Card from './Card.svelte'; 4 + import api from './api'; 5 + 6 + export default { 7 + slug: 'microblogging-post-creator', 8 + title: 'Microblogging Post Creator', 9 + docs: Docs, 10 + example: Example, 11 + card: Card, 12 + api 13 + };
+1 -4
apps/docs/src/lib/docs/social/post/Card.svelte
··· 11 11 handle: 'flo-bit.dev', 12 12 avatar: 'https://github.com/flo-bit.png' 13 13 }, 14 - createdAt: new Date(Date.now() - 10 * 1000).toISOString(), 15 - replyCount: 0, 16 - repostCount: 0, 17 - likeCount: 0 14 + createdAt: new Date(Date.now() - 10 * 1000).toISOString() 18 15 }} 19 16 /> 20 17 </div>
+5 -5
apps/docs/src/lib/docs/social/post/Documentation.md
··· 9 9 avatar: 'https://github.com/flo-bit.png', 10 10 href: 'https://github.com/flo-bit' 11 11 }, 12 - createdAt: new Date().toISOString(), 13 - replyCount: 69, 14 - repostCount: 42, 15 - likeCount: 420 12 + createdAt: new Date().toISOString() 13 + }} 14 + actions={{ 15 + reply: { count: 69, onclick: () => console.log('reply') }, 16 + like: { count: 420, onclick: () => console.log('like') } 16 17 }} 17 18 > 18 19 Hello, world! 19 20 </Post> 20 21 ``` 21 -
+19 -17
apps/docs/src/lib/docs/social/post/Example.svelte
··· 4 4 5 5 let liked = $state(false); 6 6 let bookmarked = $state(false); 7 + let likeCount = $state(99); 7 8 </script> 8 9 9 10 <Box class="not-prose relative flex min-h-24 w-full flex-col gap-8"> ··· 15 16 avatar: 'https://github.com/flo-bit.png', 16 17 href: 'https://bsky.app/profile/flo-bit.dev' 17 18 }, 18 - createdAt: new Date(Date.now() - 10000).toISOString(), 19 - replyCount: 69, 20 - repostCount: 42, 21 - likeCount: 420, 19 + createdAt: new Date(Date.now() - 10000).toISOString() 22 20 }} 23 - onReplyClick={() => { 24 - toast('You clicked on reply'); 21 + actions={{ 22 + reply: { count: 69, onclick: () => toast('You clicked on reply') }, 23 + repost: { count: 42 }, 24 + like: { 25 + count: likeCount, 26 + active: liked, 27 + onclick: () => { 28 + likeCount += liked ? -1 : 1; 29 + liked = !liked; 30 + } 31 + }, 32 + bookmark: { 33 + active: bookmarked, 34 + onclick: () => { 35 + bookmarked = !bookmarked; 36 + } 37 + } 25 38 }} 26 - onRepostClick={() => { 27 - toast('You clicked on repost'); 28 - }} 29 - onLikeClick={() => { 30 - liked = !liked; 31 - }} 32 - liked={liked} 33 - onBookmarkClick={() => { 34 - bookmarked = !bookmarked; 35 - }} 36 - bookmarked={bookmarked} 37 39 > 38 40 Hello, world! 39 41 </Post>
+32 -113
apps/docs/src/lib/docs/social/post/api.ts
··· 3 3 export default [ 4 4 { 5 5 title: 'Post', 6 - description: 'A social media post component with avatar, text, images, video, quotes, and action buttons.', 6 + description: 'A social media post component with avatar, text, embeds, and action buttons.', 7 7 props: { 8 8 data: { 9 9 type: { type: 'object', definition: 'PostData' }, 10 - description: 'The post data including author info, text, images, video, timestamp, and engagement counts. See PostData type for full structure.', 10 + description: 'The post data including author info, text, and timestamp.', 11 11 required: true 12 + }, 13 + embeds: { 14 + type: { type: 'object', definition: 'Embed[]' }, 15 + description: 'Array of embeds to render (images, video, external links, quoted posts).' 16 + }, 17 + showSensitive: { 18 + type: 'boolean', 19 + description: 'Whether to show the sensitive content overlay on flagged embeds.', 20 + default: 'true' 21 + }, 22 + actions: { 23 + type: { type: 'object', definition: 'ActionButtonsProps' }, 24 + description: 25 + 'Action buttons config (reply, repost, like, bookmark, customActions). Omit to hide actions.' 26 + }, 27 + onclickhandle: { 28 + type: { type: 'function', definition: '(handle: string, href?: string) => void' }, 29 + description: 'Callback when an author handle is clicked.' 30 + }, 31 + timestamp: { 32 + type: { type: 'object', definition: '{ href?: string; onclick?: () => void }' }, 33 + description: 'Timestamp configuration. Provide href for a link or onclick for a button.' 34 + }, 35 + logo: { 36 + type: 'Snippet', 37 + description: 'Custom logo snippet displayed in the post header.' 12 38 }, 13 39 showAvatar: { 14 40 type: 'boolean', ··· 20 46 description: 'Whether to use a compact layout.', 21 47 default: 'false' 22 48 }, 23 - showImages: { 24 - type: 'boolean', 25 - description: 'Whether to show attached images.', 26 - default: 'true' 27 - }, 28 - showVideo: { 29 - type: 'boolean', 30 - description: 'Whether to show attached video.', 31 - default: 'true' 32 - }, 33 - showExternal: { 34 - type: 'boolean', 35 - description: 'Whether to show external link cards.', 36 - default: 'true' 37 - }, 38 - showQuotes: { 39 - type: 'boolean', 40 - description: 'Whether to show quoted posts.', 41 - default: 'true' 42 - }, 43 - showReply: { 44 - type: 'boolean', 45 - description: 'Whether to show the reply action button.', 46 - default: 'true', 47 - bindable: true 48 - }, 49 - showRepost: { 50 - type: 'boolean', 51 - description: 'Whether to show the repost action button.', 52 - default: 'true', 53 - bindable: true 54 - }, 55 - showLike: { 56 - type: 'boolean', 57 - description: 'Whether to show the like action button.', 58 - default: 'true', 59 - bindable: true 60 - }, 61 - showBookmark: { 62 - type: 'boolean', 63 - description: 'Whether to show the bookmark action button.', 64 - default: 'true', 65 - bindable: true 66 - }, 67 - liked: { 68 - type: 'boolean', 69 - description: 'Whether the post is liked.', 70 - default: 'false', 71 - bindable: true 72 - }, 73 - bookmarked: { 74 - type: 'boolean', 75 - description: 'Whether the post is bookmarked.', 76 - default: 'false', 77 - bindable: true 78 - }, 79 - onReplyClick: { 80 - type: { type: 'function', definition: '() => void' }, 81 - description: 'Callback when the reply button is clicked.' 82 - }, 83 - onRepostClick: { 84 - type: { type: 'function', definition: '() => void' }, 85 - description: 'Callback when the repost button is clicked.' 86 - }, 87 - onLikeClick: { 88 - type: { type: 'function', definition: '() => void' }, 89 - description: 'Callback when the like button is clicked.' 90 - }, 91 - onBookmarkClick: { 92 - type: { type: 'function', definition: '() => void' }, 93 - description: 'Callback when the bookmark button is clicked.' 94 - }, 95 - onclickimage: { 96 - type: { type: 'function', definition: '(image: PostImageData) => void' }, 97 - description: 'Callback when a post image is clicked.' 98 - }, 99 - onclickpost: { 100 - type: { type: 'function', definition: '(data: PostData | QuotedPostData, href?: string) => void' }, 101 - description: 'Callback when the post body is clicked.' 102 - }, 103 - onclickhandle: { 104 - type: { type: 'function', definition: '(handle: string, href?: string) => void' }, 105 - description: 'Callback when the author handle is clicked.' 106 - }, 107 - replyHref: { 49 + target: { 108 50 type: 'string', 109 - description: 'URL for the reply action link.' 110 - }, 111 - repostHref: { 112 - type: 'string', 113 - description: 'URL for the repost action link.' 114 - }, 115 - likeHref: { 116 - type: 'string', 117 - description: 'URL for the like action link.' 118 - }, 119 - timestampHref: { 120 - type: 'string', 121 - description: 'URL for the timestamp link.' 122 - }, 123 - ontimestampclick: { 124 - type: { type: 'function', definition: '() => void' }, 125 - description: 'Callback when the timestamp is clicked.' 126 - }, 127 - customActions: { 128 - type: 'Snippet', 129 - description: 'Custom action buttons to display alongside the default actions.' 130 - }, 131 - logo: { 132 - type: 'Snippet', 133 - description: 'Custom logo snippet displayed in the post header.' 51 + description: 'The target attribute for all links in the post.', 52 + default: "'_blank'" 134 53 }, 135 54 children: { 136 55 type: 'Snippet', 137 - description: 'Additional content rendered below the post.' 56 + description: 'Post content when not using htmlContent.' 138 57 }, 139 58 class: { 140 59 type: 'string',
+3 -29
apps/docs/src/routes/(main)/docs/quick-start/QuickStart.md
··· 20 20 npm install @foxui/core 21 21 ``` 22 22 23 - ## 3. Set theme variables in your `app.css` 23 + ## 3. Set up your `app.css` 24 24 25 - You can change the colors to your liking. 25 + Add the following to your `app.css` to import the theme (which includes the base/accent color system and all color classes): 26 26 27 27 ```css 28 - @source "../node_modules/@foxui"; 29 - 30 28 @custom-variant dark (&:is(.dark *)); 31 29 32 - @theme { 33 - --color-base-50: var(--color-zinc-50); 34 - --color-base-100: var(--color-zinc-100); 35 - --color-base-200: var(--color-zinc-200); 36 - --color-base-300: var(--color-zinc-300); 37 - --color-base-400: var(--color-zinc-400); 38 - --color-base-500: var(--color-zinc-500); 39 - --color-base-600: var(--color-zinc-600); 40 - --color-base-700: var(--color-zinc-700); 41 - --color-base-800: var(--color-zinc-800); 42 - --color-base-900: var(--color-zinc-900); 43 - --color-base-950: var(--color-zinc-950); 44 - 45 - --color-accent-50: var(--color-emerald-50); 46 - --color-accent-100: var(--color-emerald-100); 47 - --color-accent-200: var(--color-emerald-200); 48 - --color-accent-300: var(--color-emerald-300); 49 - --color-accent-400: var(--color-emerald-400); 50 - --color-accent-500: var(--color-emerald-500); 51 - --color-accent-600: var(--color-emerald-600); 52 - --color-accent-700: var(--color-emerald-700); 53 - --color-accent-800: var(--color-emerald-800); 54 - --color-accent-900: var(--color-emerald-900); 55 - --color-accent-950: var(--color-emerald-950); 56 - } 30 + @import '@foxui/core/theme.css'; 57 31 ``` 58 32 59 33 ## 4. Use a component
-58
packages/social/src/lib/components/post/embeds/Embed.svelte
··· 1 - <script lang="ts"> 2 - import type { PostEmbed, PostImageData, PostData, QuotedPostData } from '../'; 3 - import External from './External.svelte'; 4 - import Images from './Images.svelte'; 5 - import Video from './Video.svelte'; 6 - import QuotedPost from './QuotedPost.svelte'; 7 - 8 - const { 9 - embed, 10 - showImages = true, 11 - showExternal = true, 12 - showVideo = true, 13 - showQuotes = true, 14 - onclickimage, 15 - onclickpost, 16 - onclickhandle 17 - }: { 18 - embed: PostEmbed; 19 - showImages?: boolean; 20 - showExternal?: boolean; 21 - showVideo?: boolean; 22 - showQuotes?: boolean; 23 - onclickimage?: (image: PostImageData) => void; 24 - onclickpost?: (data: PostData | QuotedPostData, href?: string) => void; 25 - onclickhandle?: (handle: string, href?: string) => void; 26 - } = $props(); 27 - </script> 28 - 29 - <div class="flex flex-col gap-2 pt-3 text-sm"> 30 - {#if embed.type === 'images' && showImages} 31 - <Images data={embed} {onclickimage} /> 32 - {:else if embed.type === 'external' && embed.external && showExternal} 33 - <External data={embed} /> 34 - {:else if embed.type === 'video' && embed.video && showVideo} 35 - <Video data={embed} /> 36 - {:else if embed.type === 'record' && embed.record && showQuotes} 37 - <QuotedPost record={embed.record} {onclickpost} {onclickhandle} {onclickimage} /> 38 - {:else if embed.type === 'recordWithMedia' && embed.record} 39 - {#if embed.media} 40 - {#if embed.media.type === 'images' && showImages} 41 - <Images data={embed.media} {onclickimage} /> 42 - {:else if embed.media.type === 'external' && embed.media.external && showExternal} 43 - <External data={embed.media} /> 44 - {:else if embed.media.type === 'video' && embed.media.video && showVideo} 45 - <Video data={embed.media} /> 46 - {/if} 47 - {/if} 48 - {#if showQuotes} 49 - <QuotedPost record={embed.record} {onclickpost} {onclickhandle} {onclickimage} /> 50 - {/if} 51 - {:else if embed.type === 'unknown'} 52 - <div 53 - class="text-base-700 dark:text-base-300 bg-base-200/50 dark:bg-base-900/50 border-base-300 dark:border-base-600/30 rounded-2xl border p-4 text-sm" 54 - > 55 - Unsupported embed type 56 - </div> 57 - {/if} 58 - </div>
-40
packages/social/src/lib/components/post/embeds/External.svelte
··· 1 - <script lang="ts"> 2 - import type { PostEmbedExternal } from '..'; 3 - 4 - const { data }: { data: PostEmbedExternal } = $props(); 5 - 6 - // svelte-ignore state_referenced_locally 7 - const domain = new URL(data.external.href).hostname.replace('www.', ''); 8 - </script> 9 - 10 - <article 11 - class={[ 12 - 'group dark:bg-base-900 bg-base-200 border-base-300 dark:border-base-600/30 relative isolate flex max-w-md flex-col justify-end overflow-hidden rounded-2xl border p-4', 13 - data.external.thumb ? 'aspect-video' : '' 14 - ]} 15 - > 16 - {#if data.external.thumb} 17 - <img 18 - src={data.external.thumb} 19 - alt={data.external.description} 20 - class="absolute inset-0 -z-10 size-full object-cover transition-transform duration-500 ease-in-out group-hover:scale-102" 21 - /> 22 - {/if} 23 - <div 24 - class="dark:from-base-950/90 dark:via-base-950/40 from-base-50/90 via-base-50/40 absolute inset-0 -z-10 bg-linear-to-t" 25 - ></div> 26 - 27 - <div 28 - class="text-base-700 dark:text-base-300 flex flex-wrap items-center gap-y-1 overflow-hidden text-sm" 29 - > 30 - <div>{domain}</div> 31 - </div> 32 - <h3 33 - class="dark:text-base-50 text-base-900 group-hover:text-accent-600 dark:group-hover:text-accent-400 mt-1 text-lg/6 font-semibold transition-colors duration-200" 34 - > 35 - <a href={data.external.href} target="_blank" rel="noopener noreferrer nofollow"> 36 - <span class="absolute inset-0"></span> 37 - {data.external.title} 38 - </a> 39 - </h3> 40 - </article>
-50
packages/social/src/lib/components/post/embeds/Images.svelte
··· 1 - <script lang="ts"> 2 - import type { PostEmbedImage, PostImageData } from '..'; 3 - 4 - const { 5 - data, 6 - onclickimage 7 - }: { data: PostEmbedImage; onclickimage?: (image: PostImageData) => void } = $props(); 8 - </script> 9 - 10 - {#snippet imageSnippet(image: PostImageData, className?: string)} 11 - {#if onclickimage} 12 - <button class="cursor-pointer" onclick={() => onclickimage(image)}> 13 - <img 14 - loading="lazy" 15 - src={image.thumb} 16 - alt={image.alt} 17 - style={image.aspectRatio 18 - ? `aspect-ratio: ${image.aspectRatio.width} / ${image.aspectRatio.height}` 19 - : 'aspect-ratio: 1 / 1'} 20 - class={[ 21 - 'border-base-500/20 dark:border-base-400/20 accent:border-accent-900 max-h-[40rem] w-fit max-w-full rounded-2xl border object-contain', 22 - className 23 - ]} 24 - /> 25 - </button> 26 - {:else} 27 - <img 28 - loading="lazy" 29 - src={image.thumb} 30 - alt={image.alt} 31 - style={image.aspectRatio 32 - ? `aspect-ratio: ${image.aspectRatio.width} / ${image.aspectRatio.height}` 33 - : 'aspect-ratio: 1 / 1'} 34 - class={[ 35 - 'border-base-500/20 dark:border-base-400/20 accent:border-accent-900 max-h-[40rem] w-fit max-w-full rounded-2xl border object-contain', 36 - className 37 - ]} 38 - /> 39 - {/if} 40 - {/snippet} 41 - 42 - {#if data.images.length === 1} 43 - {@render imageSnippet(data.images[0])} 44 - {:else} 45 - <div class="columns-2 gap-4"> 46 - {#each data.images as image (image.thumb)} 47 - {@render imageSnippet(image, 'mb-4')} 48 - {/each} 49 - </div> 50 - {/if}
-72
packages/social/src/lib/components/post/embeds/QuotedPost.svelte
··· 1 - <script lang="ts"> 2 - import type { QuotedPostData, PostData, PostImageData } from '..'; 3 - import Post from '../Post.svelte'; 4 - 5 - const { 6 - record, 7 - showEmbed = false, 8 - onclickpost, 9 - onclickhandle, 10 - onclickimage 11 - }: { 12 - record: QuotedPostData; 13 - showEmbed?: boolean; 14 - onclickpost?: (data: PostData | QuotedPostData, href?: string) => void; 15 - onclickhandle?: (handle: string, href?: string) => void; 16 - onclickimage?: (image: PostImageData) => void; 17 - } = $props(); 18 - 19 - const postData: PostData = $derived({ 20 - author: record.author, 21 - href: record.href, 22 - htmlContent: record.htmlContent, 23 - embed: record.embed, 24 - createdAt: record.createdAt ?? '', 25 - replyCount: 0, 26 - repostCount: 0, 27 - likeCount: 0 28 - }); 29 - </script> 30 - 31 - {#if onclickpost} 32 - <button 33 - class="border-base-300 dark:border-base-600/30 accent:border-accent-300/20 accent:bg-accent-100/10 bg-base-500/10 dark:bg-black/30 hover:bg-base-500/15 dark:hover:bg-black/40 w-full cursor-pointer overflow-hidden rounded-2xl border p-3 text-left text-sm transition-colors" 34 - onclick={() => onclickpost(record, record.href)} 35 - > 36 - <Post 37 - data={postData} 38 - compact 39 - showAvatar={false} 40 - showReply={false} 41 - showRepost={false} 42 - showLike={false} 43 - showBookmark={false} 44 - showImages={showEmbed} 45 - showExternal={showEmbed} 46 - showVideo={showEmbed} 47 - showQuotes={false} 48 - {onclickhandle} 49 - {onclickimage} 50 - /> 51 - </button> 52 - {:else} 53 - <div 54 - class="border-base-300 dark:border-base-600/30 accent:border-accent-300/20 accent:bg-accent-100/10 bg-base-500/10 dark:bg-black/30 overflow-hidden rounded-2xl border p-3 text-sm" 55 - > 56 - <Post 57 - data={postData} 58 - compact 59 - showAvatar={false} 60 - showReply={false} 61 - showRepost={false} 62 - showLike={false} 63 - showBookmark={false} 64 - showImages={showEmbed} 65 - showExternal={showEmbed} 66 - showVideo={showEmbed} 67 - showQuotes={false} 68 - {onclickhandle} 69 - {onclickimage} 70 - /> 71 - </div> 72 - {/if}
-48
packages/social/src/lib/components/post/embeds/Video.svelte
··· 1 - <script lang="ts"> 2 - import { onMount } from 'svelte'; 3 - import type { PostEmbedVideo } from '..'; 4 - 5 - const { data }: { data: PostEmbedVideo } = $props(); 6 - 7 - onMount(async () => { 8 - const [{ default: Plyr }, { default: Hls }] = await Promise.all([ 9 - import('plyr'), 10 - import('hls.js') 11 - ]); 12 - 13 - if (Hls.isSupported()) { 14 - const hls = new Hls(); 15 - hls.loadSource(data.video.playlist); 16 - hls.attachMedia(element); 17 - } 18 - 19 - new Plyr(element, { 20 - controls: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'fullscreen'], 21 - ratio: data.video.aspectRatio 22 - ? `${data.video.aspectRatio.width}:${data.video.aspectRatio.height}` 23 - : '16:9' 24 - }); 25 - }); 26 - 27 - let element: HTMLMediaElement; 28 - </script> 29 - 30 - <svelte:head> 31 - <link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" /> 32 - </svelte:head> 33 - 34 - <div 35 - style={data.video.aspectRatio 36 - ? `aspect-ratio: ${data.video.aspectRatio.width} / ${data.video.aspectRatio.height}` 37 - : 'aspect-ratio: 16 / 9'} 38 - class="border-base-300 dark:border-base-400/40 w-full max-w-full overflow-hidden rounded-2xl border" 39 - > 40 - <!-- svelte-ignore a11y_media_has_caption --> 41 - <video bind:this={element} class="h-full w-full" aria-label={data.video.alt}></video> 42 - </div> 43 - 44 - <style> 45 - * { 46 - --plyr-color-main: var(--color-accent-500); 47 - } 48 - </style>