@nooticr/mcp 1.26.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +521 -0
- package/dist/auth.js +170 -0
- package/dist/config.js +54 -0
- package/dist/idempotency.js +32 -0
- package/dist/index.js +417 -0
- package/dist/nooticr.js +6 -0
- package/dist/oauth.js +263 -0
- package/dist/shared/brand-watch.js +297 -0
- package/dist/shared/cimd.js +231 -0
- package/dist/shared/collab.js +447 -0
- package/dist/shared/comment-review.js +171 -0
- package/dist/shared/connections.js +80 -0
- package/dist/shared/evidence.js +410 -0
- package/dist/shared/handoff.js +526 -0
- package/dist/shared/jobs.js +1785 -0
- package/dist/shared/nooticr.js +249 -0
- package/dist/shared/oauth.js +132 -0
- package/dist/shared/output-schemas.js +1046 -0
- package/dist/shared/own-account.js +559 -0
- package/dist/shared/performance.js +156 -0
- package/dist/shared/prompts.js +383 -0
- package/dist/shared/spend.js +158 -0
- package/dist/shared/tasks.js +88 -0
- package/dist/shared/tools-def.js +387 -0
- package/dist/shared/tools.js +2027 -0
- package/dist/shared/ui-template.js +2856 -0
- package/dist/shared/video.js +152 -0
- package/dist/shared/view-meta.js +34 -0
- package/dist/shared/watchlist.js +603 -0
- package/dist/video.js +6 -0
- package/package.json +62 -0
|
@@ -0,0 +1,2856 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Apps HTML UI template for Nooticr tools.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Content renders when a tool result arrives (no loading screen)
|
|
6
|
+
* - Idle/resource-preview state shows a branded ready view
|
|
7
|
+
* - Smooth fade-in transitions when data arrives
|
|
8
|
+
* - Expandable analysis sections (click to expand/collapse)
|
|
9
|
+
* - Click-to-copy on hashtags, hooks, quotes
|
|
10
|
+
* - Hover micro-interactions on cards
|
|
11
|
+
* - Dark/light theme auto-detection
|
|
12
|
+
* - Responsive layout
|
|
13
|
+
*/
|
|
14
|
+
export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
|
|
15
|
+
<html lang="en">
|
|
16
|
+
<head>
|
|
17
|
+
<meta charset="UTF-8"/>
|
|
18
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
19
|
+
<title>Nooticr</title>
|
|
20
|
+
<style>
|
|
21
|
+
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--leading-snug:1.375;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--shadow-sm:0 1px 3px 0 #0000001a, 0 1px 2px -1px #0000001a;--shadow-md:0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a;--shadow-lg:0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a;--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html{overflow:hidden auto}[hidden]{display:none!important}a{color:inherit;text-decoration:none}body{color:var(--fg);background:0 0;max-width:100vw;padding:10px;font-family:system-ui,-apple-system,Segoe UI,sans-serif;line-height:1.5;overflow-x:hidden}}@layer components{.load-bar{background:var(--tag);border-radius:3.40282e38px;height:3px;margin-bottom:14px;position:relative;overflow:hidden}.load-bar:after{content:"";background:var(--brand);border-radius:3.40282e38px;width:42%;animation:1.35s cubic-bezier(.45,0,.55,1) infinite sweep;position:absolute;top:0;bottom:0}@keyframes sweep{0%{left:-45%}50%{left:58%}to{left:-45%}}.load-head{align-items:center;gap:calc(var(--spacing) * 2);color:var(--muted);margin-bottom:11px;font-size:12.5px;font-weight:600;display:flex}.load-head .dot{background:var(--brand);border-radius:50%;flex-shrink:0;width:7px;height:7px;animation:1.2s ease-in-out infinite pulse}@keyframes pulse{0%,to{opacity:.35}50%{opacity:1}}.sk{border-radius:var(--radius-md);background:var(--tag);animation:1.5s ease-in-out infinite skpulse;position:relative;overflow:hidden}@keyframes skpulse{0%,to{opacity:1}50%{opacity:.55}}.sk-media{aspect-ratio:9/16;width:100%;max-height:var(--stage-max,420px);border-radius:0}.sk-media.h{aspect-ratio:16/9}.sk-line{height:11px;margin-top:8px}.sk-line.w40{width:40%}.sk-line.w60{width:60%}.sk-line.w80{width:80%}.sk-pill{border-radius:999px;width:76px;height:22px;margin:0 5px 5px 0;display:inline-block}.sk-card{border-radius:var(--radius-xl);border:1px solid var(--border);background:var(--card);flex-shrink:0;width:min(340px,86vw);min-width:min(340px,86vw);overflow:hidden}.sk-body{padding:13px 14px}.sk-row{align-items:center;gap:calc(var(--spacing) * 3);border-bottom:1px solid var(--border);padding:11px 0;display:flex}.sk-avatar{border-radius:10px;flex-shrink:0;width:44px;height:44px}.sk-strip{gap:calc(var(--spacing) * 3);padding:2px 0 8px;display:flex;overflow:hidden}@media (prefers-reduced-motion:reduce){.load-bar:after,.sk,.load-head .dot{animation:none}.load-bar:after{opacity:.5;width:100%;left:0}}.fade-in{animation:.4s ease-out fadeIn}@keyframes fadeIn{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.stagger>*{opacity:0;animation:.4s ease-out forwards fadeIn}.stagger>:first-child{animation-delay:50ms}.stagger>:nth-child(2){animation-delay:.1s}.stagger>:nth-child(3){animation-delay:.15s}.stagger>:nth-child(4){animation-delay:.2s}.stagger>:nth-child(5){animation-delay:.25s}.stagger>:nth-child(6){animation-delay:.3s}.stagger>:nth-child(7){animation-delay:.35s}.stagger>:nth-child(8){animation-delay:.4s}.gallery-wrap{max-width:100%;position:relative;overflow:hidden}.gallery{gap:calc(var(--spacing) * 3);scroll-snap-type:x mandatory;scroll-behavior:smooth;-webkit-overflow-scrolling:touch;scrollbar-width:none;width:100%;max-width:100%;padding:2px 0 8px;display:flex;overflow-x:auto}.gallery::-webkit-scrollbar{display:none}.gallery-dots{justify-content:center;gap:calc(var(--spacing) * 1.5);flex-wrap:wrap;margin-top:10px;display:flex}.gallery-dots .dot{background:var(--border);cursor:pointer;width:8px;height:8px;transition:var(--transition);border:none;border-radius:50%;padding:0}.gallery-dots .dot.active{background:var(--accent);border-radius:999px;width:20px}.gallery-nav{z-index:2;cursor:pointer;background:var(--card);border:1px solid var(--border);width:32px;height:32px;color:var(--fg);transition:var(--transition);opacity:0;border-radius:50%;justify-content:center;align-items:center;font-size:14px;display:flex;position:absolute;top:50%;transform:translateY(-50%)}.gallery-wrap:hover .gallery-nav{opacity:1}.gallery-nav:hover{background:var(--accent);color:var(--card)}.gallery-nav.prev{left:2px}.gallery-nav.next{right:2px}.card{border-radius:var(--radius-xl);border:1px solid var(--border);background:var(--card);width:min(380px,100%);min-width:min(380px,100%);max-width:100%;transition:var(--transition);cursor:default;scroll-snap-align:center;flex-shrink:0;display:block}.card.card-media{--ar-w:9;--ar-h:16;width:min(100%, calc(var(--stage-max,520px) * var(--ar-w) / var(--ar-h)));background:0 0;border-color:#0000;min-width:0;margin:0 auto}.card.card-media.h{--ar-w:16;--ar-h:9}.card.card-media.sq{--ar-w:1;--ar-h:1}.card-media .card-foot{background:0 0;justify-content:flex-end;padding:8px 4px 0;display:flex}.card-media .btn-sm{margin-top:0}.card:hover{border-color:var(--muted)}.card-media:hover{box-shadow:var(--shadow-md);transform:translateY(-2px)}.card-wide{width:100%}.card img:not(.mp-slide){object-fit:cover;width:100%;height:260px;display:block}.card-wide img:not(.mp-slide){max-height:340px}.card video{object-fit:contain;background:#000;border:none;outline:none;width:100%;min-height:220px;max-height:520px;display:block}.card-wide video{max-height:520px}.card-body{padding:14px 16px}.badge{align-items:center;gap:var(--spacing);text-transform:capitalize;transition:var(--transition);border-radius:3.40282e38px;padding:3px 10px;font-size:11px;font-weight:700;display:inline-flex}.handle{color:var(--muted);margin-left:auto;font-size:12px}.tag{align-items:center;gap:var(--spacing);transition:var(--transition);cursor:default;border-radius:3.40282e38px;margin:2px;padding:4px 10px;font-size:12px;display:inline-flex}.tag.clickable{cursor:pointer;-webkit-user-select:none;user-select:none}.tag.clickable:hover{background:var(--tag)}.tag.copied{background:var(--green)!important;color:#fff!important}.title{margin:6px 0;font-size:14px;font-weight:600;line-height:1.35}.card-wide .title{font-size:16px;font-weight:700}.caption{color:var(--muted);margin:6px 0 0;font-size:13px;line-height:1.5}.stats{align-items:center;gap:calc(var(--spacing) * 1.5);flex-wrap:wrap;margin-top:8px;display:flex}.stat-pill{align-items:center;gap:var(--spacing);color:var(--fg);background:var(--tag);transition:var(--transition);border-radius:3.40282e38px;padding:5px 12px;font-size:12px;display:inline-flex}.stat-pill:hover{background:var(--border)}.btn{cursor:pointer;align-items:center;gap:calc(var(--spacing) * 1.5);border-radius:var(--radius-lg);color:#fff;background:var(--brand);transition:var(--transition);border:none;margin-top:12px;padding:8px 18px;font-size:13px;font-weight:600;text-decoration-line:none;display:inline-flex}.btn:hover{background:var(--brand-2)}.btn:active{opacity:.9}.btn-sm{margin-top:8px;padding:5px 12px;font-size:11px}.section{border-bottom:1px solid var(--border);margin-bottom:6px;overflow:hidden}.section-header{cursor:pointer;-webkit-user-select:none;user-select:none;transition:var(--transition);justify-content:space-between;align-items:center;padding:10px 2px;display:flex}.section-header:hover .section-label{color:var(--fg)}.section-label{color:var(--muted);text-transform:uppercase;letter-spacing:.06em;font-size:11px;font-weight:700}.section-chevron{color:var(--muted);font-size:12px;transition:transform .2s}.section-header.open .section-chevron{transform:rotate(180deg)}.section-content{max-height:0;padding:0 2px;transition:max-height .28s,padding .28s;overflow:hidden}.section-content.open{max-height:2400px;padding:4px 2px 14px}.section-text{color:var(--fg);font-size:13px;line-height:1.6}.ai-actions{gap:calc(var(--spacing) * 2);flex-wrap:wrap;margin-top:14px;display:flex}.ai-btn{cursor:pointer;align-items:center;gap:calc(var(--spacing) * 2);border:1px solid var(--border);background:var(--card);color:var(--fg);font:inherit;transition:var(--transition);border-radius:3.40282e38px;padding:8px 13px;font-size:13px;font-weight:600;display:inline-flex}.ai-btn:hover:not(:disabled){border-color:var(--brand);background:var(--brand-soft)}.ai-btn:disabled{opacity:.55;cursor:not-allowed}.ai-btn .spark{color:var(--brand);flex-shrink:0;display:inline-flex}.ai-btn .price{white-space:nowrap;color:var(--brand);background:var(--brand-soft);border-radius:3.40282e38px;align-items:center;padding:2px 7px;font-size:11px;font-weight:700;display:inline-flex}.ai-btn.busy .spark{animation:1.1s linear infinite spin}.ai-note{color:var(--muted);margin-top:8px;font-size:11.5px}.an-title{align-items:center;gap:calc(var(--spacing) * 2);margin-bottom:14px;font-size:16px;font-weight:700;display:flex}.verdict{gap:calc(var(--spacing) * 2.5);grid-template-columns:repeat(1,minmax(0,1fr));margin-bottom:12px;display:grid}@media (min-width:420px){.verdict{grid-template-columns:1fr 1fr}}.meter{border-radius:var(--radius-md);background:var(--tag);padding:9px 11px}.meter-top{justify-content:space-between;align-items:baseline;gap:calc(var(--spacing) * 2);letter-spacing:.05em;text-transform:uppercase;color:var(--muted);font-size:11px;font-weight:700;display:flex}.meter-top b{letter-spacing:0;font-size:14px}.meter-track{background:var(--border);border-radius:3.40282e38px;height:5px;margin-top:6px;overflow:hidden}.meter-track>i{border-radius:3.40282e38px;height:100%;display:block}.chiprow{gap:calc(var(--spacing) * 1.5);flex-wrap:wrap;display:flex}.mhead{z-index:3;background:var(--bg);border-bottom:1px solid var(--border);padding:2px 0 10px;position:sticky;top:0}.mhead-top{justify-content:space-between;align-items:flex-end;gap:calc(var(--spacing) * 2.5);flex-wrap:wrap;display:flex}.mention-term{color:var(--muted);margin-bottom:2px;font-size:13px}.mention-term b{color:var(--fg)}.mention-total{color:var(--fg);letter-spacing:-.01em;font-size:20px;font-weight:650}.mention-down{color:var(--muted);margin-top:8px;font-size:12px}.mention-window{color:var(--muted);border:1px solid var(--border);border-radius:3.40282e38px;margin-left:6px;padding:1px 8px;font-size:11px;display:inline-block}.mention-summary{color:var(--muted);max-width:60ch;margin-top:6px;font-size:13px;line-height:1.45}.msort{background:var(--tag);border-radius:3.40282e38px;flex:none;align-items:center;gap:2px;padding:3px;display:inline-flex}.msort-btn{color:var(--muted);cursor:pointer;transition:var(--transition);white-space:nowrap;background:0 0;border:0;border-radius:999px;padding:5px 14px;font-size:11.5px;font-weight:600}.msort-btn:hover{color:var(--fg)}.msort-btn.on{color:var(--card);background:var(--accent)}.mchips{gap:calc(var(--spacing) * 1.5);flex-wrap:wrap;margin-top:10px;display:flex}.mchip{cursor:pointer;align-items:center;gap:calc(var(--spacing) * 2);color:var(--muted);border:1px solid var(--border);transition:var(--transition);white-space:nowrap;background:0 0;border-radius:3.40282e38px;padding:5px 12px;font-size:12px;font-weight:600;display:inline-flex}.mchip:hover{color:var(--fg);border-color:var(--muted)}.mchip b{color:var(--fg);background:var(--tag);font-variant-numeric:tabular-nums;border-radius:3.40282e38px;padding:1px 7px;font-weight:700}.mchip.on{color:var(--card);background:var(--accent);border-color:#0000}.mchip.on b{color:inherit;background:0 0;padding:1px 0}.mchip.on .mchip-mark{color:inherit}.mchip.quiet{opacity:.5;cursor:default}.mchip.quiet:hover{color:var(--muted);border-color:var(--border)}.mchip-mark{color:var(--brand);display:inline-flex}.mchip-mark svg{fill:currentColor;width:13px;height:13px}.mgroups{gap:calc(var(--spacing) * 2.5);flex-direction:column;margin-top:12px;display:flex}.mgroup{border-radius:var(--radius-xl);border:1px solid var(--border);background:var(--card);padding:12px 14px 6px}.mgroup-head{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;margin-bottom:6px;display:flex}.mgroup-plat{align-items:center;gap:calc(var(--spacing) * 1.5);color:var(--brand);font-size:11px;font-weight:600;display:inline-flex}.mgroup-plat svg{fill:currentColor;width:12px;height:12px}.mgroup-about{text-transform:uppercase;letter-spacing:.04em;color:var(--muted);border:1px solid var(--border);border-radius:3.40282e38px;padding:1px 7px;font-size:10.5px}.mgroup-reach{color:var(--muted);margin-left:auto;font-size:11px}.mgroup-title{color:var(--fg);-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:13px;font-weight:600;line-height:1.35;display:-webkit-box;overflow:hidden}.mgroup-rest{color:var(--muted);cursor:pointer;transition:var(--transition);background:0 0;border:0;align-self:flex-start;margin-top:2px;padding:2px 0;font-size:11.5px;font-weight:600}.mgroup-rest:hover{color:var(--fg)}.mgroup-actions{align-items:center;gap:calc(var(--spacing) * 3);margin-top:5px;display:flex}.mgroup-link{color:var(--muted);font-size:11.5px;text-decoration:none}.mgroup-link:hover{color:var(--fg);text-decoration:underline}.mgroup-all{color:var(--muted);cursor:pointer;transition:var(--transition);background:0 0;border:0;padding:0;font-size:11.5px;font-weight:600}.mgroup-all:hover{color:var(--fg)}.mgroup-none{color:var(--muted);margin-top:8px;font-size:12px}.mgroup-media{margin-top:10px}.mentions{flex-direction:column;margin-top:8px;display:flex}.mention{cursor:pointer;align-items:flex-start;gap:calc(var(--spacing) * 3);transition:var(--transition);padding:11px 2px;display:flex}.mention+.mention{border-top:1px solid var(--border)}.mention:hover{background:var(--card-hover)}.mention.picked{background:var(--tag)}.mention-pick{width:15px;height:15px;accent-color:var(--accent);cursor:pointer;flex:none;margin-top:11px}.mention-av{background:var(--tag);border:1px solid var(--border);border-radius:3.40282e38px;flex:none;width:36px;height:36px;position:relative;overflow:visible}.mention-av-init{color:var(--muted);border-radius:3.40282e38px;justify-content:center;align-items:center;font-size:14px;font-weight:700;display:flex;position:absolute;inset:0}.mention-av-img{object-fit:cover;border-radius:3.40282e38px;width:100%;height:100%;position:absolute;inset:0}.mention-av-badge{background:var(--card);width:16px;height:16px;color:var(--brand);border:1px solid var(--border);border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex;position:absolute;bottom:-3px;right:-3px}.mention-av-badge svg{fill:currentColor;width:9.5px;height:9.5px}.mention-body{gap:calc(var(--spacing) * .5);flex-direction:column;flex:1;min-width:0;display:flex}.mention-head{align-items:baseline;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.mention-who{color:var(--fg);font-size:13px;font-weight:650}.mention-when{color:var(--muted);font-size:11.5px}.mention-when:before{content:"ยท";color:var(--border);margin-right:7px}.mention-hits{color:var(--fg);background:var(--tag);border:1px solid var(--border);border-radius:3.40282e38px;margin-left:auto;padding:1px 7px;font-size:10.5px;font-weight:700}.mention-text{color:var(--fg);word-break:break-word;margin-top:4px;font-size:13.5px;line-height:1.5}.mention-text mark{color:inherit;background:#f43f5e26;border-radius:4px;padding:1px 3px;font-weight:600}.mention-note{border-left:2px solid var(--brand,#f43f5e);background:var(--tag);color:var(--fg);white-space:pre-wrap;word-break:break-word;border-radius:0 6px 6px 0;margin-top:6px;padding:6px 9px;font-size:12.5px;line-height:1.5;display:block}.mention-meta{align-items:center;gap:calc(var(--spacing) * 3.5);margin-top:6px;display:flex}.mention-stat{align-items:center;gap:var(--spacing);color:var(--muted);font-size:11px;display:inline-flex}.mention-stat svg{fill:none;stroke:currentColor;stroke-width:1.8px;stroke-linecap:round;stroke-linejoin:round;width:12px;height:12px}.chip{text-transform:capitalize;letter-spacing:.01em;border:1px solid var(--border);color:var(--muted);border-radius:3.40282e38px;padding:1px 7px;font-size:10px;font-weight:600}.chip-positive{color:#15803d;background:#15803d14;border-color:#15803d55}.chip-negative{color:#b91c1c;background:#b91c1c14;border-color:#b91c1c55}.chip-mixed{color:#a16207;background:#a1620714;border-color:#a1620755}.chip-neutral{color:var(--muted)}.chip-cat{background:var(--tag)}.mention-more{cursor:pointer;border-radius:var(--radius-xl);width:100%;color:var(--fg);background:var(--tag);border:1px solid var(--border);transition:var(--transition);margin-top:12px;padding:10px;font-size:13px;font-weight:600;display:block}.mention-more:hover:not(:disabled){border-color:var(--muted)}.mention-more:disabled{opacity:.6;cursor:default}.sm-thumb{border-radius:var(--radius-lg);object-fit:cover;background:var(--tag);border:1px solid var(--border);flex:none;width:56px;height:56px}.tag.copyable{cursor:pointer;border:1px solid var(--border);font:inherit;align-items:center;gap:5px;font-size:12px;display:inline-flex}.tag.copyable:hover{border-color:var(--accent)}.tag.warn{background:var(--red)}@supports (color:color-mix(in lab, red, red)){.tag.warn{background:color-mix(in srgb, var(--red) 12%, transparent)}}.tag.warn{color:var(--red);border:1px solid var(--red)}@supports (color:color-mix(in lab, red, red)){.tag.warn{border:1px solid color-mix(in srgb, var(--red) 35%, transparent)}}.lede-box{background:var(--tag);border-radius:var(--radius-sm);margin-bottom:12px;padding:11px 13px;font-size:13.5px;line-height:1.6}.steps-list{gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}.step-row{align-items:flex-start;gap:calc(var(--spacing) * 2.5);grid-template-columns:78px 1fr;display:grid}.step-k{letter-spacing:.05em;text-transform:uppercase;color:var(--muted);padding-top:2px;font-size:10.5px;font-weight:700}.step-v{font-size:13px;line-height:1.5}.an-list{margin:0;padding-left:18px;font-size:13px;line-height:1.55}.an-list li{margin:4px 0}.clamp6{-webkit-line-clamp:6;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.hook-bar-track{align-items:center;gap:calc(var(--spacing) * 2.5);margin-top:6px;display:flex}.hook-bar{background:var(--tag);border-radius:3.40282e38px;flex:1;height:10px;overflow:hidden}.hook-bar-fill{border-radius:3.40282e38px;height:100%;transition:width .8s cubic-bezier(.4,0,.2,1)}.hook-score{text-align:right;min-width:40px;font-size:15px;font-weight:800}.quote-box{border-radius:var(--radius-md);background:var(--tag);border-left:3px solid var(--blue);margin:6px 0;padding:10px 14px;font-size:13px;font-weight:600;line-height:1.45;position:relative}.quote-box .copy-btn{cursor:pointer;border-radius:var(--radius-md);background:var(--card);border:1px solid var(--border);color:var(--muted);transition:var(--transition);opacity:0;padding:3px 8px;font-size:10px;position:absolute;top:8px;right:8px}.quote-box:hover .copy-btn{opacity:1}.copy-btn:hover{background:var(--accent);color:var(--card)}.theme-chip{align-items:center;gap:calc(var(--spacing) * 1.5);background:var(--tag);border:1px solid var(--border);color:var(--fg);border-radius:3.40282e38px;margin:0 4px 5px 0;padding:4px 10px;font-size:12px;display:inline-flex}.theme-chip b{color:var(--brand,var(--blue));font-variant-numeric:tabular-nums}.sec-label{letter-spacing:.05em;text-transform:uppercase;color:var(--muted);margin-bottom:7px;font-size:11px;font-weight:700}.cbadge{white-space:nowrap;border-radius:4px;padding:1px 5px;font-size:10px;font-weight:700}.cbadge.pin{color:#b45309;background:#fef3c7}.cbadge.liked{color:#be185d;background:#fce7f3}.cmeta{align-items:center;gap:calc(var(--spacing) * 2);color:var(--muted);flex-wrap:wrap;margin-top:5px;font-size:11px;display:flex}.ctext{color:var(--fg);font-size:13px;line-height:1.45}.ctext.clamp{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.cmore{color:var(--blue);cursor:pointer;background:0 0;border:none;padding:3px 0 0;font-family:inherit;font-size:11.5px;font-weight:600}.comment-row{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-md);border-bottom:1px solid var(--border);transition:var(--transition);padding:10px 0;display:flex}.comment-row:last-child{border-bottom:none}.comment-row:hover{background:var(--tag)}.creator-card{border-radius:var(--radius-xl);border:1px solid var(--border);background:var(--card);width:min(240px,80vw);min-width:min(240px,80vw);transition:var(--transition);scroll-snap-align:start;flex-shrink:0;padding:16px;display:block}.creator-card:hover{border-color:var(--muted)}.avatar{object-fit:cover;border:2px solid var(--border);border-radius:3.40282e38px;width:52px;height:52px}.avatar-placeholder{border-radius:3.40282e38px;justify-content:center;align-items:center;width:52px;height:52px;font-size:22px;display:flex}.verified{color:#fff;border-radius:3.40282e38px;justify-content:center;align-items:center;width:18px;height:18px;margin-left:4px;font-size:10px;display:inline-flex}.sound-card{border-radius:var(--radius-xl);border:1px solid var(--border);background:var(--card);width:min(300px,88vw);min-width:min(300px,88vw);transition:var(--transition);scroll-snap-align:start;flex-shrink:0;padding:14px;display:block}.sound-card:hover{border-color:var(--muted)}.sound-cover{border-radius:var(--radius-md);object-fit:cover;width:68px;height:68px}.mp{--ar-w:9;--ar-h:16;width:min(100%, calc(var(--stage-max,520px) * var(--ar-w) / var(--ar-h)));background:#000;border:2px solid #0000;border-radius:18px;margin-inline:auto;position:relative;overflow:hidden}.mp.playing{border-color:var(--brand)}.card-media>.mp{width:100%}.mp.h{--ar-w:16;--ar-h:9}.mp.sq{--ar-w:1;--ar-h:1}.mp-stage{width:100%;aspect-ratio:var(--ar-w) / var(--ar-h);max-height:var(--stage-max,520px);background:#000;position:relative;overflow:hidden}.mp .mp-stage>video{object-fit:contain;background:#000;width:100%;height:100%;max-height:none;display:block}.mp .mp-slide{object-fit:contain;opacity:0;background:#000;width:100%;height:100%;max-height:none;transition:opacity .45s;position:absolute;inset:0;transform:none}.mp-slide.active{opacity:1}.mp-ov{pointer-events:none;z-index:2;position:absolute;inset:0}.mp-ov:after{content:"";pointer-events:none;background:linear-gradient(#0000,#00000047 38%,#000000b8);height:46%;position:absolute;bottom:0;left:0;right:0}.mp-ov:before{content:"";pointer-events:none;background:linear-gradient(#00000073,#0000);height:16%;position:absolute;top:0;left:0;right:0}.mp-badge,.mp-chip,.mp-rail,.mp-meta,.mp-tap,.mp-nav{z-index:1}.mp-ov>*{pointer-events:auto}.mp-badge{text-transform:capitalize;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);color:#fff;background:#0000008c;border-radius:3.40282e38px;align-items:center;gap:5px;padding:4px 9px;font-size:11px;font-weight:700;display:inline-flex;position:absolute;top:10px;left:10px}.mp-pick{z-index:3;cursor:pointer;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);color:#0000;width:26px;height:26px;transition:var(--transition);background:#0000006b;border:2px solid #ffffffd9;border-radius:3.40282e38px;justify-content:center;align-items:center;padding:0;display:flex;position:absolute;top:9px;right:9px}.mp-pick:hover{background:#000000a8;transform:scale(1.08)}.mp-open{z-index:3;cursor:pointer;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);color:#fff;width:26px;height:26px;transition:var(--transition);background:#0000006b;border:2px solid #ffffffd9;border-radius:3.40282e38px;justify-content:center;align-items:center;padding:0;text-decoration-line:none;display:flex;position:absolute;top:9px;right:9px}.mp-open:hover{background:#000000a8;transform:scale(1.08)}.mp-open.with-pick{right:43px}.mp-pick[aria-pressed=true]{background:var(--brand,#ff4d23);color:#fff;border-color:#fff}.mp.picked{outline:2px solid var(--brand,#ff4d23);outline-offset:-2px}.mp-pick~.mp-chip,.mp[data-mp=slides] .mp-chip{top:42px}.pickbar{z-index:20;align-items:center;gap:calc(var(--spacing) * 2.5);background:var(--card);border:1px solid var(--border);box-shadow:var(--shadow-lg);border-radius:3.40282e38px;margin:12px 0 0;padding:10px 12px;animation:.2s ease-out fadeIn;display:flex;position:sticky;bottom:0}.pickbar .n{white-space:nowrap;font-size:13px;font-weight:700}.pickbar .sp{flex:1}.pickbar button{cursor:pointer;white-space:nowrap;border:1px solid var(--border);background:var(--card);color:var(--fg);font:inherit;transition:var(--transition);border-radius:3.40282e38px;padding:6px 13px;font-size:12.5px;font-weight:600}.pickbar button.primary{background:var(--brand,#ff4d23);color:#fff;border-color:#0000}.pickbar button:hover{filter:brightness(1.08)}.pickbar button:disabled{opacity:.5;cursor:not-allowed}.pickbar .hint{color:var(--muted);font-size:11.5px}.mp-chip{-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);color:#fff;font-variant-numeric:tabular-nums;background:#0000008c;border-radius:3.40282e38px;padding:3px 9px;font-size:11px;font-weight:700;position:absolute;top:10px;right:10px}.mp-rail{align-items:center;gap:calc(var(--spacing) * 3.5);flex-direction:column;display:flex;position:absolute;bottom:76px;right:8px}.mp-act{cursor:default;background:0 0;border:none;flex-direction:column;align-items:center;gap:3px;padding:0;display:flex}.mp-act .ic{-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);color:#fff;width:38px;height:38px;transition:var(--transition);background:#0006;border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.mp-act:hover .ic{background:#0000009e;transform:scale(1.06)}.mp-act .n{color:#fff;text-shadow:0 1px 3px #000c;font-variant-numeric:tabular-nums;font-size:11px;font-weight:700}.mp-meta{color:#fff;text-shadow:0 1px 3px #000000d9;position:absolute;bottom:60px;left:12px;right:64px}.mp-handle{margin-bottom:3px;font-size:13px;font-weight:800}.mp-cap{-webkit-line-clamp:2;opacity:.95;-webkit-box-orient:vertical;font-size:12px;line-height:1.4;display:-webkit-box;overflow:hidden}.mp-song{white-space:nowrap;text-overflow:ellipsis;align-items:center;gap:5px;margin-top:6px;font-size:11px;font-weight:600;display:flex;overflow:hidden}.mp-song .note{flex:none;display:inline-flex}.mp.playing .mp-song .note{animation:3s linear infinite spin}.mp-song-t{white-space:nowrap;text-overflow:ellipsis;overflow:hidden}@keyframes spin{to{transform:rotate(360deg)}}.mp-tap{cursor:pointer;background:0 0;border:none;justify-content:center;align-items:center;display:flex;position:absolute;inset:0}.mp-tap span{-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);color:#fff;width:64px;height:64px;transition:var(--transition);opacity:1;background:#00000073;border-radius:3.40282e38px;justify-content:center;align-items:center;font-size:26px;display:flex}.mp.playing .mp-tap span{opacity:0}.mp.playing:hover .mp-tap span{opacity:.65}.mp-nav{cursor:pointer;color:#fff;opacity:0;width:34px;height:34px;transition:var(--transition);background:#0000008c;border:none;border-radius:3.40282e38px;justify-content:center;align-items:center;font-size:18px;display:flex;position:absolute;top:50%;transform:translateY(-50%)}.mp:hover .mp-nav,.mp:focus-within .mp-nav{opacity:1}.mp-nav.prev{left:8px}.mp-nav.next{right:8px}.mp-nav:hover{background:#000c}.mp-ctl{z-index:3;background:linear-gradient(#0000,#000000d9);align-items:center;gap:9px;padding:9px 11px;display:flex;position:absolute;bottom:0;left:0;right:0}.mp-btn{cursor:pointer;color:#111;width:30px;height:30px;transition:var(--transition);background:#fffffff2;border:none;border-radius:3.40282e38px;flex:none;justify-content:center;align-items:center;font-size:12px;display:flex}.mp-btn:hover{transform:scale(1.09)}.mp-time{color:#fff;font-variant-numeric:tabular-nums;text-shadow:0 1px 2px #000c;flex-shrink:0;font-size:10px;font-weight:700}.mp-seek{cursor:pointer;flex:1;align-items:center;min-width:0;height:14px;display:flex;position:relative}.mp-segs{gap:calc(var(--spacing) * .5);border-radius:3.40282e38px;width:100%;height:3px;transition:height .15s;display:flex;overflow:hidden}.mp-seek:hover .mp-segs{height:5px}.mp-seg{background:#ffffff52;border-radius:3.40282e38px;flex:1;height:100%;overflow:hidden}.mp-seg>i{background:#fff;border-radius:3.40282e38px;width:0;height:100%;display:block}.mp-seg.done>i{width:100%}.mp-icon{color:#fff;cursor:pointer;opacity:.9;background:0 0;border:none;flex-shrink:0;padding:0;font-size:14px;line-height:1}.mp-icon:hover{opacity:1}.mp-spin{pointer-events:none;z-index:2;justify-content:center;align-items:center;display:flex;position:absolute;inset:0}.mp-spin>i{border:3px solid #ffffff40;border-top-color:#fff;border-radius:3.40282e38px;width:34px;height:34px;animation:.8s linear infinite spin}.mp-err{pointer-events:none;z-index:4;justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);text-align:center;color:#fff;background:#000000d1;flex-direction:column;padding:16px;font-size:12px;font-weight:600;display:flex;position:absolute;inset:0}.mp:focus-visible{outline-offset:-2px;outline:2px solid #fff}.mp-btn:focus-visible,.mp-icon:focus-visible,.mp-nav:focus-visible,.mp-tap:focus-visible,.au-play:focus-visible,.gallery-nav:focus-visible,.dot:focus-visible{outline-offset:2px;outline:2px solid #fff}@media (prefers-reduced-motion:reduce){.mp-song .note{animation:none}.mp-slide{transition:none}.mp-spin>i{animation-duration:2s}*{scroll-behavior:auto!important}}.au{align-items:center;gap:calc(var(--spacing) * 2.5);margin-top:10px;display:flex}.au-play{cursor:pointer;color:#fff;background:var(--brand);width:34px;height:34px;transition:var(--transition);border:none;border-radius:3.40282e38px;flex:none;justify-content:center;align-items:center;display:flex}.au-body{flex:1;min-width:0}.au-seek{cursor:pointer;background:var(--fg);border-radius:3.40282e38px;width:100%;height:5px;position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.au-seek{background:color-mix(in srgb, var(--fg) 22%, transparent)}}.au-seek>i{background:var(--accent);border-radius:3.40282e38px;width:0;height:100%;display:block}.au-time{color:var(--muted);font-variant-numeric:tabular-nums;justify-content:space-between;margin-top:3px;font-size:10px;display:flex}.au-missing{align-items:center;gap:calc(var(--spacing) * 1.5);color:var(--muted);margin-top:8px;font-size:11px;display:flex}.credits-card{border-radius:var(--radius-2xl);border:1px solid var(--border);background:var(--card);max-width:380px;margin:8px 0;padding:24px}.pack{cursor:pointer;border-radius:var(--radius-xl);text-align:center;border:1px solid var(--border);transition:var(--transition);padding:16px}.pack:hover{border-color:var(--muted)}.pack-featured{border-radius:var(--radius-xl);text-align:center;border:2px solid var(--accent);padding:16px}.tier-badge{color:#1e40af;background:#dbeafe;border-radius:3.40282e38px;align-items:center;padding:4px 12px;font-size:12px;font-weight:700;display:inline-flex}.free-tag{align-items:center;gap:var(--spacing);color:#065f46;transition:var(--transition);background:#d1fae5;border-radius:3.40282e38px;margin:2px;padding:4px 10px;font-size:11px;font-weight:600;display:inline-flex}.json-block{border-radius:var(--radius-md);background:var(--tag);border:1px solid var(--border);white-space:pre-wrap;word-break:break-all;max-height:320px;margin-top:10px;padding:14px;font-family:SF Mono,Monaco,monospace;font-size:12px;overflow-y:auto}.empty-state{text-align:center;color:var(--muted);padding:48px 24px}.empty-state .icon{margin-bottom:12px;font-size:48px}.empty-state .text{font-size:14px}}@layer utilities{.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.bottom-3{bottom:calc(var(--spacing) * 3)}.left-3{left:calc(var(--spacing) * 3)}.isolate{isolation:isolate}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.m-0{margin:0}.mx-auto{margin-inline:auto}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.h-2{height:calc(var(--spacing) * 2)}.h-full{height:100%}.w-full{width:100%}.max-w-sm{max-width:var(--container-sm)}.shrink{flex-shrink:1}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.resize{resize:both}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-center{justify-content:center}.gap-1{gap:var(--spacing)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-line{border-color:var(--border)}.bg-black{background-color:var(--color-black)}.bg-soft{background-color:var(--tag)}.bg-surface{background-color:var(--card)}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.p-3{padding:calc(var(--spacing) * 3)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-1{padding-block:var(--spacing)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-10{padding-block:calc(var(--spacing) * 10)}.pl-5{padding-left:calc(var(--spacing) * 5)}.text-center{text-align:center}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.text-wrap{text-wrap:wrap}.text-ink{color:var(--fg)}.text-muted{color:var(--muted)}.text-white{color:var(--color-white)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.select-all{-webkit-user-select:all;user-select:all}}:root{color-scheme:light dark;--bg:#fafbfc;--fg:#111827;--muted:#5f6773;--border:#e5e7eb;--card:#fff;--card-hover:#f3f4f6;--tag:#f3f4f6;--accent:#14151a;--brand:#ff4d23;--brand-2:#ff8a3d;--brand-soft:#ff4d2321;--radius:10px;--radius-sm:6px;--transition:all .25s cubic-bezier(.4, 0, .2, 1);--shadow-md:0 4px 12px #00000014;--shadow-lg:0 8px 24px #0000001f;--green:#047857;--amber:#b45309;--red:#dc2626;--blue:#2563eb}@media (prefers-color-scheme:dark){:root{--bg:#0f172a;--fg:#f1f5f9;--muted:#a8b8cc;--border:#334155;--green:#34d399;--amber:#fbbf24;--red:#f87171;--blue:#60a5fa;--card:#1e293b;--card-hover:#273548;--tag:#1e293b;--accent:#e2e8f0;--brand:#ff5c33;--brand-2:#ff9351;--brand-soft:#ff5c3329;--shadow-md:0 4px 12px #0000004d;--shadow-lg:0 8px 24px #0006}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}
|
|
22
|
+
</style>
|
|
23
|
+
</head>
|
|
24
|
+
<body>
|
|
25
|
+
<div id="app"></div>
|
|
26
|
+
<script>
|
|
27
|
+
(function(){
|
|
28
|
+
var nextId=1;var pending=new Map();
|
|
29
|
+
var currentTool="";
|
|
30
|
+
|
|
31
|
+
// โโโ Tool-specific idle view โโโ
|
|
32
|
+
// Read ?tool=<name> from the resource URI to show a tailored idle screen.
|
|
33
|
+
var TOOL_NAMES={
|
|
34
|
+
analyze_post:"Analyze Post",
|
|
35
|
+
discover_social_posts:"Discover Social Posts",
|
|
36
|
+
get_user_posts:"Get User Posts",
|
|
37
|
+
get_social_media:"Get Social Media",
|
|
38
|
+
analyze_creator_profile:"Analyze Creator Profile",
|
|
39
|
+
get_post_comments:"Get Post Comments",
|
|
40
|
+
search_creators:"Search Creators",
|
|
41
|
+
get_similar_creators:"Similar Creators",
|
|
42
|
+
discover_sounds:"Discover Sounds",
|
|
43
|
+
understand_social_post:"Understand Social Post",
|
|
44
|
+
check_nooticr_credits:"Check Credits",
|
|
45
|
+
buy_nooticr_credits:"Buy Credits",
|
|
46
|
+
compose_sequence:"Compose Sequence",
|
|
47
|
+
overlay_bake:"Overlay Bake",
|
|
48
|
+
spawn_variants:"Spawn Variants",
|
|
49
|
+
bake_job_status:"Bake Job Status",
|
|
50
|
+
enqueue_publish_job:"Enqueue Publish",
|
|
51
|
+
schedule_post:"Schedule Post"
|
|
52
|
+
};
|
|
53
|
+
var TOOL_DESCS={
|
|
54
|
+
analyze_post:"Frames from the post plus its transcript, for you to read yourself.",
|
|
55
|
+
discover_social_posts:"Trending posts from any platform for a given niche.",
|
|
56
|
+
get_user_posts:"Recent posts from a specific creator handle.",
|
|
57
|
+
get_social_media:"Get post data from any social media URL.",
|
|
58
|
+
analyze_creator_profile:"Full creator profile analysis โ engagement, audience, content style.",
|
|
59
|
+
get_post_comments:"Top comments on a post โ sentiment, themes, viral threads.",
|
|
60
|
+
search_creators:"Find creators in a niche by engagement, followers, and content.",
|
|
61
|
+
get_similar_creators:"Find creators similar to a given handle.",
|
|
62
|
+
discover_sounds:"Trending sounds and music on TikTok/Instagram.",
|
|
63
|
+
understand_social_post:"The same frames and transcript, for a description of what happens on screen.",
|
|
64
|
+
check_nooticr_credits:"View your Nooticr credit balance and usage.",
|
|
65
|
+
buy_nooticr_credits:"Purchase additional Nooticr credits.",
|
|
66
|
+
compose_sequence:"AI-powered content composition for social posts.",
|
|
67
|
+
overlay_bake:"Bake text/image overlays onto video or image.",
|
|
68
|
+
spawn_variants:"Generate multiple content variants from a single seed.",
|
|
69
|
+
bake_job_status:"Check the status of a rendering/baking job.",
|
|
70
|
+
enqueue_publish_job:"Queue a post for publishing to a connected platform.",
|
|
71
|
+
schedule_post:"Schedule a post for future publishing."
|
|
72
|
+
};
|
|
73
|
+
/* How many results a tool comes back with, and in what shape โ so the
|
|
74
|
+
skeleton matches the answer instead of being a generic grey box. */
|
|
75
|
+
var LOADING_SHAPE={
|
|
76
|
+
get_social_media: {kind:"post", n:1, label:"Fetching the post"},
|
|
77
|
+
get_post_transcript: {kind:"text", n:1, label:"Reading the caption track"},
|
|
78
|
+
analyze_post: {kind:"post", n:1, label:"Analysing the post"},
|
|
79
|
+
understand_social_post: {kind:"post", n:1, label:"Watching the video"},
|
|
80
|
+
analyze_creator_profile: {kind:"list", n:4, label:"Reading the profile"},
|
|
81
|
+
compare_posts: {kind:"strip", n:2, label:"Comparing posts"},
|
|
82
|
+
discover_social_posts: {kind:"strip", n:3, label:"Searching posts"},
|
|
83
|
+
get_user_posts: {kind:"strip", n:3, label:"Loading their posts"},
|
|
84
|
+
search_creators: {kind:"list", n:5, label:"Finding creators"},
|
|
85
|
+
get_similar_creators: {kind:"list", n:5, label:"Finding similar creators"},
|
|
86
|
+
discover_sounds: {kind:"list", n:4, label:"Finding trending sounds"},
|
|
87
|
+
discover_hashtags: {kind:"list", n:6, label:"Reading the trend board"},
|
|
88
|
+
get_post_comments: {kind:"list", n:6, label:"Loading comments"},
|
|
89
|
+
analyze_comments: {kind:"text", n:1, label:"Reading the comment section"},
|
|
90
|
+
check_nooticr_credits: {kind:"text", n:1, label:"Checking your balance"},
|
|
91
|
+
buy_nooticr_credits: {kind:"text", n:1, label:"Opening checkout"}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function skPostCard(){
|
|
95
|
+
return '<div class="sk-card"><div class="sk sk-media"></div><div class="sk-body">'
|
|
96
|
+
+'<span class="sk sk-pill" style="width:64px"></span>'
|
|
97
|
+
+'<div class="sk sk-line w80"></div><div class="sk sk-line w60"></div></div></div>';
|
|
98
|
+
}
|
|
99
|
+
function skRow(){
|
|
100
|
+
return '<div class="sk-row"><div class="sk sk-avatar"></div>'
|
|
101
|
+
+'<div style="flex:1;min-width:0"><div class="sk sk-line w40"></div>'
|
|
102
|
+
+'<div class="sk sk-line w80"></div></div></div>';
|
|
103
|
+
}
|
|
104
|
+
function skText(){
|
|
105
|
+
return '<div class="sk sk-line w60"></div><div class="sk sk-line"></div>'
|
|
106
|
+
+'<div class="sk sk-line"></div><div class="sk sk-line w80"></div>'
|
|
107
|
+
+'<div style="margin-top:14px"><span class="sk sk-pill"></span>'
|
|
108
|
+
+'<span class="sk sk-pill" style="width:58px"></span>'
|
|
109
|
+
+'<span class="sk sk-pill" style="width:92px"></span></div>';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Skeleton that mirrors the shape of the result the tool will return. */
|
|
113
|
+
// The tool-input notifications carry only the arguments, never the tool
|
|
114
|
+
// name, so when the host does not add one the arguments decide the shape.
|
|
115
|
+
// Guessing the skeleton wrong costs nothing - it is a placeholder - while
|
|
116
|
+
// always falling back to a bare text block made every load look the same.
|
|
117
|
+
function shapeFromArgs(a){
|
|
118
|
+
if(!a||typeof a!=="object")return null;
|
|
119
|
+
if(Array.isArray(a.urls))
|
|
120
|
+
return {kind:"strip",n:Math.min(5,Math.max(2,a.urls.length)),label:"Comparing posts"};
|
|
121
|
+
if(a.draft)return {kind:"text",n:1,label:"Scoring the draft"};
|
|
122
|
+
if(a.username)return {kind:"strip",n:3,label:"Loading their posts"};
|
|
123
|
+
if(a.country||a.days)return {kind:"list",n:6,label:"Reading the trend board"};
|
|
124
|
+
if(a.niche||a.keyword)return {kind:"strip",n:3,label:"Searching"};
|
|
125
|
+
if(a.url)return {kind:"post",n:1,label:"Working on the post"};
|
|
126
|
+
if(a.topic)return {kind:"text",n:1,label:"Writing"};
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function renderCancelled(reason){
|
|
131
|
+
var app=document.getElementById("app");
|
|
132
|
+
if(!app||toolResultReceived)return;
|
|
133
|
+
app.innerHTML='<div class="empty-state fade-in"><div class="icon">x</div><div class="text">'
|
|
134
|
+
+esc(reason?("Cancelled: "+reason):"The tool call was cancelled")+"</div></div>";
|
|
135
|
+
setTimeout(reportSize,50);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// A ChatGPT-shaped host sends no tool-input notification, so the poll below
|
|
139
|
+
// is what drives the skeleton there - which means this runs over and over
|
|
140
|
+
// within one call: every 250ms while polling, plus once per set_globals,
|
|
141
|
+
// and that fires for theme, display mode and height changes as well as for
|
|
142
|
+
// results. Rewriting innerHTML on each of them restarted .fade-in (opacity
|
|
143
|
+
// 0, shifted 8px down), the load-bar sweep and every skeleton shine four
|
|
144
|
+
// times a second, and posted a size-changed at the same rate for the host
|
|
145
|
+
// to re-lay-out on. That is the blinking. The skeleton is a pure function
|
|
146
|
+
// of its shape, so paint it once and let the CSS animations run: repaint
|
|
147
|
+
// only when the shape changes, or when it is no longer on screen because a
|
|
148
|
+
// result replaced it - which is the second call in a reused view.
|
|
149
|
+
var loadingSig=null;
|
|
150
|
+
function renderLoading(tool,args){
|
|
151
|
+
var app=document.getElementById("app");
|
|
152
|
+
if(!app||toolResultReceived)return;
|
|
153
|
+
var shape=LOADING_SHAPE[tool]||shapeFromArgs(args)||{kind:"text",n:1,label:"Working"};
|
|
154
|
+
var sig=shape.kind+"|"+shape.n+"|"+shape.label;
|
|
155
|
+
if(sig===loadingSig&&app.querySelector(".load-bar"))return;
|
|
156
|
+
loadingSig=sig;
|
|
157
|
+
var inner="";
|
|
158
|
+
if(shape.kind==="post"){
|
|
159
|
+
inner='<div style="display:flex;justify-content:center">'+skPostCard()+"</div>";
|
|
160
|
+
}else if(shape.kind==="strip"){
|
|
161
|
+
var cards="";
|
|
162
|
+
for(var i=0;i<shape.n;i++)cards+=skPostCard();
|
|
163
|
+
inner='<div class="sk-strip">'+cards+"</div>";
|
|
164
|
+
}else if(shape.kind==="list"){
|
|
165
|
+
var rows="";
|
|
166
|
+
for(var j=0;j<shape.n;j++)rows+=skRow();
|
|
167
|
+
inner='<div class="card card-wide"><div class="card-body">'+rows+"</div></div>";
|
|
168
|
+
}else{
|
|
169
|
+
inner='<div class="card card-wide"><div class="card-body">'+skText()+"</div></div>";
|
|
170
|
+
}
|
|
171
|
+
app.innerHTML='<div class="fade-in"><div class="load-bar"></div>'
|
|
172
|
+
+'<div class="load-head"><span class="dot"></span><span>'
|
|
173
|
+
+esc(shape.label)+"โฆ</span></div>"+inner+"</div>";
|
|
174
|
+
setTimeout(reportSize,50);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The host serves this HTML from its own URL, so on ChatGPT there is no
|
|
178
|
+
// ui:// path to read a tool name out of - which is why its view said
|
|
179
|
+
// "Interactive View" and the generic placeholder while Claude, whose URL
|
|
180
|
+
// does carry it, named the tool. The server knows which view it is serving,
|
|
181
|
+
// so it substitutes it here.
|
|
182
|
+
var BAKED_TOOL="__NOOTICR_TOOL__";
|
|
183
|
+
function bakedTool(){
|
|
184
|
+
return BAKED_TOOL.indexOf("NOOTICR_TOOL")>=0?"":BAKED_TOOL;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function renderIdle(){
|
|
188
|
+
// Extract tool name from URI path segment (e.g. /ui://nooticr/analyze_post โ analyze_post)
|
|
189
|
+
// or from ?tool= param if the host passes it.
|
|
190
|
+
var tool=bakedTool();
|
|
191
|
+
var params=new URLSearchParams(window.location.search);
|
|
192
|
+
if(!tool)tool=params.get("tool")||"";
|
|
193
|
+
if(!tool){
|
|
194
|
+
var path=window.location.pathname;
|
|
195
|
+
var idx=path.indexOf("ui://nooticr/");
|
|
196
|
+
if(idx>=0){var rest=path.substring(idx+12);var end=rest.length;for(var ci=0;ci<rest.length;ci++){var ch=rest.charCodeAt(ci);if(ch===63||ch===35||ch===47){end=ci;break;}}tool=rest.substring(0,end);}
|
|
197
|
+
|
|
198
|
+
}
|
|
199
|
+
if(!tool){
|
|
200
|
+
var hash=window.location.hash;
|
|
201
|
+
var idx2=hash.indexOf("ui://nooticr/");
|
|
202
|
+
if(idx2>=0){var rest2=hash.substring(idx2+12);var end2=rest2.length;for(var ci2=0;ci2<rest2.length;ci2++){var ch2=rest2.charCodeAt(ci2);if(ch2===63||ch2===35||ch2===47){end2=ci2;break;}}tool=rest2.substring(0,end2);}
|
|
203
|
+
|
|
204
|
+
}
|
|
205
|
+
var label=TOOL_NAMES[tool]||"Interactive View";
|
|
206
|
+
var desc=TOOL_DESCS[tool]||"Results will appear here as soon as a tool returns.";
|
|
207
|
+
var app=document.getElementById("app");
|
|
208
|
+
if(!app)return;
|
|
209
|
+
app.innerHTML='<div class="flex flex-col items-center justify-center text-center px-6 py-10" style="min-height:220px">'
|
|
210
|
+
+'<svg width="73" height="44" viewBox="0 0 334 200" fill="currentColor" class="text-ink"><path d="M167,25.764A100,100 0 1,1 167,174.236A100,100 0 0,0 186.29,150.537A69.5,69.5 0 1,0 186.29,49.463A100,100 0 0,0 167,25.764ZM298.427,73.934A69.5,69.5 0 0,0 215.393,33.037A48.5,48.5 0 1,0 298.427,73.934ZM232.5,66.5a18,18 0 1,1 36,0a18,18 0 1,1 -36,0ZM0,100a100,100 0 1,1 200,0a100,100 0 1,1 -200,0ZM30.5,100a69.5,69.5 0 1,0 139,0a69.5,69.5 0 1,0 -139,0ZM164.427,73.934A69.5,69.5 0 0,0 81.393,33.037A48.5,48.5 0 1,0 164.427,73.934ZM98.5,66.5a18,18 0 1,1 36,0a18,18 0 1,1 -36,0Z"/></svg>'
|
|
211
|
+
+'<div class="mt-4 text-lg font-bold text-ink">'+label+'</div>'
|
|
212
|
+
+'<div class="mt-2 text-sm leading-relaxed text-muted max-w-sm">'+desc+'</div>'
|
|
213
|
+
+'</div>';
|
|
214
|
+
}
|
|
215
|
+
renderIdle();
|
|
216
|
+
|
|
217
|
+
function send(method,params){
|
|
218
|
+
var id=nextId++;
|
|
219
|
+
return new Promise(function(resolve,reject){
|
|
220
|
+
pending.set(id,{resolve:resolve,reject:reject});
|
|
221
|
+
window.parent.postMessage({jsonrpc:"2.0",id:id,method:method,params:params},"*");
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
var toolResultReceived=false;
|
|
226
|
+
|
|
227
|
+
// ChatGPT hands the result over as a global rather than a message. Its docs
|
|
228
|
+
// call the postMessage bridge above the portable path and this the
|
|
229
|
+
// compatibility alias, but a host that only sets the global would leave the
|
|
230
|
+
// view sitting on its placeholder forever - so read it too. Claude never
|
|
231
|
+
// defines window.openai, so nothing here runs there.
|
|
232
|
+
var lastOutRef=null,lastOutSig="";
|
|
233
|
+
function readHostGlobals(){
|
|
234
|
+
var api=window.openai;
|
|
235
|
+
if(!api)return;
|
|
236
|
+
var out=api.toolOutput;
|
|
237
|
+
if(out){
|
|
238
|
+
// set_globals fires for theme, display mode and height changes as well
|
|
239
|
+
// as for results. Rendering on every one of them rebuilt every <video>
|
|
240
|
+
// and <audio> in the view each time, which is the flashing - and Chrome
|
|
241
|
+
// logged 982 "too many WebMediaPlayers already in existence"
|
|
242
|
+
// interventions as the discarded players piled up. Only a genuinely new
|
|
243
|
+
// result is worth a render.
|
|
244
|
+
if(out===lastOutRef)return;
|
|
245
|
+
var sig="";
|
|
246
|
+
try{sig=JSON.stringify(out);}catch(e){sig="";}
|
|
247
|
+
if(sig&&sig===lastOutSig){lastOutRef=out;return;}
|
|
248
|
+
lastOutRef=out;lastOutSig=sig;
|
|
249
|
+
toolResultReceived=true;
|
|
250
|
+
// The bridge passes {structuredContent}; the alias passes the structured
|
|
251
|
+
// content itself. Accept either rather than guessing.
|
|
252
|
+
render(out.structuredContent?out:{structuredContent:out});
|
|
253
|
+
setTimeout(reportSize,50);
|
|
254
|
+
}else if(!toolResultReceived&&!lastOutRef){
|
|
255
|
+
// A ChatGPT-shaped host mounts this view as part of a tool call, so
|
|
256
|
+
// "window.openai exists and has no output yet" *is* the loading state.
|
|
257
|
+
// Requiring toolInput first meant the host that sets it late - or never
|
|
258
|
+
// - kept the idle placeholder up for the whole call, which is the
|
|
259
|
+
// "Results will appear here" that showed while Claude and Cursor
|
|
260
|
+
// shimmered correctly. The give-up path below restores the placeholder
|
|
261
|
+
// if nothing ever arrives.
|
|
262
|
+
renderLoading(currentTool||bakedTool(),api.toolInput||{});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// window.openai is injected asynchronously โ OpenAI's own guidance is to
|
|
266
|
+
// "wait for window.openai to be available and retry once or twice if data
|
|
267
|
+
// arrives late". A single read at parse time therefore finds nothing and
|
|
268
|
+
// never looks again, which is a view sitting on its placeholder while the
|
|
269
|
+
// host has held the result the whole time.
|
|
270
|
+
//
|
|
271
|
+
// So poll, stop the moment anything lands, and give up rather than spin
|
|
272
|
+
// forever. Claude never defines the global and delivers over postMessage
|
|
273
|
+
// instead, so it stops at the first result and costs a few no-op ticks.
|
|
274
|
+
var pollStart=Date.now();
|
|
275
|
+
function pollHostGlobals(){
|
|
276
|
+
readHostGlobals();
|
|
277
|
+
if(lastOutRef||toolResultReceived)return;
|
|
278
|
+
var waited=Date.now()-pollStart;
|
|
279
|
+
// The budget was 40 ticks - ten seconds - and every tool here that fetches
|
|
280
|
+
// a feed or watches a video runs longer than that. So the shimmer was
|
|
281
|
+
// taken down mid-call and replaced by "Results will appear here", and
|
|
282
|
+
// because this poll is the only way a host that merely sets the global is
|
|
283
|
+
// ever read, the result that landed afterwards was never picked up at all.
|
|
284
|
+
// Wait as long as the server itself waits on a job (POLL_TIMEOUT_MS).
|
|
285
|
+
if(waited>=300000){
|
|
286
|
+
// Nothing ever came. A view left shimmering forever is worse than one
|
|
287
|
+
// that says plainly it is waiting, so put the placeholder back.
|
|
288
|
+
if(window.openai)renderIdle();
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
// Quick while the global is still expected to land, slower once we are
|
|
292
|
+
// plainly just waiting on the tool.
|
|
293
|
+
setTimeout(pollHostGlobals,waited<3000?250:1000);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
window.addEventListener("openai:set_globals",function(ev){
|
|
297
|
+
var g=ev&&ev.detail?ev.detail.globals:null;
|
|
298
|
+
if(g){
|
|
299
|
+
window.openai=window.openai||{};
|
|
300
|
+
if(g.toolOutput!==undefined)window.openai.toolOutput=g.toolOutput;
|
|
301
|
+
if(g.toolInput!==undefined)window.openai.toolInput=g.toolInput;
|
|
302
|
+
}
|
|
303
|
+
readHostGlobals();
|
|
304
|
+
},{passive:true});
|
|
305
|
+
pollHostGlobals();
|
|
306
|
+
|
|
307
|
+
window.addEventListener("message",function(ev){
|
|
308
|
+
var d=ev.data;if(!d||typeof d!=="object")return;
|
|
309
|
+
if(d.id&&pending.has(d.id)){
|
|
310
|
+
var p=pending.get(d.id);pending.delete(d.id);
|
|
311
|
+
if(d.error)p.reject(new Error(d.error.message||JSON.stringify(d.error)));
|
|
312
|
+
else p.resolve(d.result);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if(d.method==="ui/notifications/tool-result"){
|
|
316
|
+
toolResultReceived=true;
|
|
317
|
+
render(d.params);
|
|
318
|
+
setTimeout(reportSize,50);
|
|
319
|
+
}
|
|
320
|
+
// tool-input is the one the spec requires and sends exactly once;
|
|
321
|
+
// tool-input-partial is optional streaming on top of it. Listening only
|
|
322
|
+
// for the partial meant a host that does not stream left the view sitting
|
|
323
|
+
// on its idle placeholder until the result landed, with no shimmer at all.
|
|
324
|
+
if(d.method==="ui/notifications/tool-input"
|
|
325
|
+
||d.method==="ui/notifications/tool-input-partial"){
|
|
326
|
+
var pp=d.params||{};
|
|
327
|
+
// The payload is just {arguments}; a host may add the name, so use it
|
|
328
|
+
// when offered and shape the skeleton from the arguments when not.
|
|
329
|
+
var n=pp.name||pp.toolName||"";
|
|
330
|
+
if(n)currentTool=n;
|
|
331
|
+
// A view is reused across calls, and this flag was set on the first
|
|
332
|
+
// result and never cleared - so every later call skipped the shimmer
|
|
333
|
+
// and left the previous tool's cards on screen. Asking for Instagram
|
|
334
|
+
// posts and then TikTok showed the Instagram ones again. A new input
|
|
335
|
+
// notification means a new call, so the old result no longer stands.
|
|
336
|
+
toolResultReceived=false;
|
|
337
|
+
renderLoading(currentTool,pp.arguments);
|
|
338
|
+
}
|
|
339
|
+
if(d.method==="ui/notifications/tool-cancelled"){
|
|
340
|
+
renderCancelled(d.params&&d.params.reason);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// MCP Apps handshake.
|
|
345
|
+
// Claude's McpUiInitializeRequestSchema requires appInfo, appCapabilities
|
|
346
|
+
// and protocolVersion (legacy hosts read capabilities/clientInfo), so send
|
|
347
|
+
// both shapes. Critically, ui/notifications/initialized must be sent
|
|
348
|
+
// UNCONDITIONALLY โ Claude keeps the widget iframe reserved-but-hidden
|
|
349
|
+
// until it receives that notification, so a reply we don't recognize must
|
|
350
|
+
// never deadlock the handshake.
|
|
351
|
+
var initializedSent=false;
|
|
352
|
+
function sendInitialized(){
|
|
353
|
+
if(initializedSent)return;
|
|
354
|
+
initializedSent=true;
|
|
355
|
+
window.parent.postMessage({jsonrpc:"2.0",method:"ui/notifications/initialized",params:{}},"*");
|
|
356
|
+
}
|
|
357
|
+
send("ui/initialize",{
|
|
358
|
+
protocolVersion:"2026-01-26",
|
|
359
|
+
appInfo:{name:"nooticr-view",version:"2.0.0"},
|
|
360
|
+
appCapabilities:{availableDisplayModes:["inline"]},
|
|
361
|
+
capabilities:{},
|
|
362
|
+
clientInfo:{name:"nooticr-view",version:"2.0.0"}
|
|
363
|
+
}).then(function(){ bridgeAlive=true; sendInitialized(); }).catch(sendInitialized);
|
|
364
|
+
// Unconditional fallback: never wait on a reply shape we don't recognize.
|
|
365
|
+
setTimeout(sendInitialized, 500);
|
|
366
|
+
|
|
367
|
+
// Report content size so flexible hosts (Claude) size the iframe correctly.
|
|
368
|
+
// Params MUST be real numbers โ claude.ai throws on null/missing width.
|
|
369
|
+
function reportSize(){
|
|
370
|
+
try{
|
|
371
|
+
var h=document.documentElement.scrollHeight||document.body.scrollHeight||400;
|
|
372
|
+
var w=document.documentElement.scrollWidth||document.body.scrollWidth||400;
|
|
373
|
+
window.parent.postMessage({jsonrpc:"2.0",method:"ui/notifications/size-changed",params:{height:h,width:w}},"*");
|
|
374
|
+
}catch(e){}
|
|
375
|
+
}
|
|
376
|
+
window.addEventListener("resize",reportSize);
|
|
377
|
+
setTimeout(reportSize,600);
|
|
378
|
+
|
|
379
|
+
// โโโ Helpers โโโ
|
|
380
|
+
function esc(s){return String(s||"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");}
|
|
381
|
+
/** "3d" / "2h" for an ISO timestamp โ compact enough for a chat panel. */
|
|
382
|
+
function relTime(iso){
|
|
383
|
+
var t=Date.parse(iso);
|
|
384
|
+
if(isNaN(t))return "";
|
|
385
|
+
var s=Math.floor((Date.now()-t)/1000);
|
|
386
|
+
if(s<0)return "";
|
|
387
|
+
if(s<3600)return Math.max(1,Math.floor(s/60))+"m";
|
|
388
|
+
if(s<86400)return Math.floor(s/3600)+"h";
|
|
389
|
+
if(s<2592000)return Math.floor(s/86400)+"d";
|
|
390
|
+
if(s<31536000)return Math.floor(s/2592000)+"mo";
|
|
391
|
+
return Math.floor(s/31536000)+"y";
|
|
392
|
+
}
|
|
393
|
+
function fmtTime(sec){sec=Math.max(0,Math.floor(Number(sec)||0));return Math.floor(sec/60)+":"+("0"+(sec%60)).slice(-2);}
|
|
394
|
+
|
|
395
|
+
// โโโ Audio engine โโโ
|
|
396
|
+
// Every <audio> in the view registers here so only one track is ever
|
|
397
|
+
// audible: starting a sound preview pauses a playing slideshow and vice
|
|
398
|
+
// versa. Elements are re-collected after each render.
|
|
399
|
+
var audioReg=[];
|
|
400
|
+
function registerAudio(el){
|
|
401
|
+
if(!el||audioReg.indexOf(el)>-1)return;
|
|
402
|
+
audioReg.push(el);
|
|
403
|
+
el.addEventListener("play",function(){
|
|
404
|
+
for(var i=0;i<audioReg.length;i++){
|
|
405
|
+
var a=audioReg[i];
|
|
406
|
+
if(a!==el&&!a.paused){try{a.pause();}catch(e){}}
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
function resetAudio(){
|
|
411
|
+
for(var i=0;i<audioReg.length;i++){try{audioReg[i].pause();}catch(e){}}
|
|
412
|
+
audioReg=[];
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// โโโ Media player controller โโโ
|
|
416
|
+
// A transport hides the difference between the two media kinds behind one
|
|
417
|
+
// interface (duration/time/seek/play/pause). The control bar is wired to
|
|
418
|
+
// the transport, never to a <video> or an <audio> directly, so the same
|
|
419
|
+
// handlers drive every player and the controls behave identically.
|
|
420
|
+
var SLIDE_MS=3000;
|
|
421
|
+
|
|
422
|
+
function videoTransport(box){
|
|
423
|
+
var v=box.querySelector("video");
|
|
424
|
+
return {
|
|
425
|
+
kind:"video",
|
|
426
|
+
el:v,
|
|
427
|
+
// Until metadata loads the element reports NaN for its duration. With
|
|
428
|
+
// preload="none" that is the whole time before the first press, and a
|
|
429
|
+
// scrubber reading 0:00 of 0:00 looks like a card that has failed
|
|
430
|
+
// rather than one waiting. The listing already told us how long the
|
|
431
|
+
// post is, so fall back to that.
|
|
432
|
+
duration:function(){
|
|
433
|
+
if(isFinite(v.duration)&&v.duration>0)return v.duration;
|
|
434
|
+
var d=Number(box.getAttribute("data-mp-dur"))||0;
|
|
435
|
+
return d>0?d:0;
|
|
436
|
+
},
|
|
437
|
+
time:function(){return v.currentTime||0;},
|
|
438
|
+
paused:function(){return v.paused;},
|
|
439
|
+
play:function(){var r=v.play();if(r&&r.catch)r.catch(function(){});},
|
|
440
|
+
pause:function(){try{v.pause();}catch(e){}},
|
|
441
|
+
seek:function(t){try{v.currentTime=t;}catch(e){}},
|
|
442
|
+
muted:function(m){if(m===undefined)return v.muted;v.muted=m;return m;},
|
|
443
|
+
step:function(dir){this.seek(Math.max(0,this.time()+dir*5));},
|
|
444
|
+
// Video paints one continuous segment.
|
|
445
|
+
segment:function(){return 0;},
|
|
446
|
+
audio:null,
|
|
447
|
+
on:function(ev,cb){v.addEventListener(ev,cb);}
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function slidesTransport(box){
|
|
452
|
+
var slides=box.querySelectorAll(".mp-slide");
|
|
453
|
+
var audio=box.querySelector("audio");
|
|
454
|
+
var total=slides.length||1;
|
|
455
|
+
var idx=0,base=0,startedAt=0,running=false,seekBroken=false;
|
|
456
|
+
// With a track, the track is the clock and each slide owns an equal share
|
|
457
|
+
// of it. Without one, slides run on a fixed beat.
|
|
458
|
+
// A track we cannot seek (a host that serves the audio without range
|
|
459
|
+
// support) would strand the clock at zero while the slides moved on, so
|
|
460
|
+
// we fall back to the wall clock the first time a seek refuses to take.
|
|
461
|
+
function driven(){
|
|
462
|
+
return !seekBroken&&!!audio&&isFinite(audio.duration)&&audio.duration>0;
|
|
463
|
+
}
|
|
464
|
+
function per(){return driven()?audio.duration/total:SLIDE_MS/1000;}
|
|
465
|
+
function paint(){
|
|
466
|
+
for(var k=0;k<total;k++)slides[k].classList.toggle("active",k===idx);
|
|
467
|
+
var chip=box.querySelector(".mp-idx");
|
|
468
|
+
if(chip)chip.textContent=idx+1;
|
|
469
|
+
}
|
|
470
|
+
var t={
|
|
471
|
+
kind:"slides",
|
|
472
|
+
el:box,
|
|
473
|
+
total:total,
|
|
474
|
+
audio:audio,
|
|
475
|
+
duration:function(){return driven()?audio.duration:total*(SLIDE_MS/1000);},
|
|
476
|
+
time:function(){
|
|
477
|
+
if(driven())return audio.currentTime||0;
|
|
478
|
+
return base+(running?(Date.now()-startedAt)/1000:0);
|
|
479
|
+
},
|
|
480
|
+
paused:function(){return !running;},
|
|
481
|
+
play:function(){
|
|
482
|
+
running=true;startedAt=Date.now();
|
|
483
|
+
if(audio){var r=audio.play();if(r&&r.catch)r.catch(function(){});}
|
|
484
|
+
},
|
|
485
|
+
pause:function(){
|
|
486
|
+
if(running)base=t.time();
|
|
487
|
+
running=false;
|
|
488
|
+
if(audio){try{audio.pause();}catch(e){}}
|
|
489
|
+
},
|
|
490
|
+
seek:function(sec){
|
|
491
|
+
sec=Math.max(0,Math.min(sec,t.duration()));
|
|
492
|
+
base=sec;startedAt=Date.now();
|
|
493
|
+
if(audio){
|
|
494
|
+
try{
|
|
495
|
+
audio.currentTime=sec;
|
|
496
|
+
if(sec>0.5&&Math.abs(audio.currentTime-sec)>1)seekBroken=true;
|
|
497
|
+
}catch(e){seekBroken=true;}
|
|
498
|
+
}
|
|
499
|
+
idx=Math.max(0,Math.min(total-1,Math.floor(sec/per())));
|
|
500
|
+
paint();
|
|
501
|
+
},
|
|
502
|
+
muted:function(m){
|
|
503
|
+
if(!audio)return true;
|
|
504
|
+
if(m===undefined)return audio.muted;
|
|
505
|
+
audio.muted=m;return m;
|
|
506
|
+
},
|
|
507
|
+
// Slideshows step a whole slide at a time, not five seconds.
|
|
508
|
+
step:function(dir){t.seek((idx+dir)*per());},
|
|
509
|
+
segment:function(){return idx;},
|
|
510
|
+
sync:function(){
|
|
511
|
+
var want=Math.max(0,Math.min(total-1,Math.floor(t.time()/per())));
|
|
512
|
+
if(want!==idx){idx=want;paint();}
|
|
513
|
+
return t.time()>=t.duration();
|
|
514
|
+
},
|
|
515
|
+
on:function(ev,cb){if(audio)audio.addEventListener(ev,cb);}
|
|
516
|
+
};
|
|
517
|
+
paint();
|
|
518
|
+
return t;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// โโโ Follow-up actions โโโ
|
|
522
|
+
// A button that spends credits must show that it will: sparkle to mark it as
|
|
523
|
+
// a follow-up the model will reason over, and the price on the face. The
|
|
524
|
+
// click asks the host to run the tool, and falls back to copying a ready
|
|
525
|
+
// prompt where the host cannot run one from a view.
|
|
526
|
+
//
|
|
527
|
+
// These are the fetch prices, which is all any of these tools cost now. The
|
|
528
|
+
// two that fan out to a second call are the two that are dearer than their
|
|
529
|
+
// headline fetch: analyze_post and understand_social_post pay 2 for the
|
|
530
|
+
// frames and 1 more for the transcript. score_draft fetches nothing, so it
|
|
531
|
+
// has no badge โ the || 0 below already hides it.
|
|
532
|
+
var AI_PRICE={analyze_post_fast:2,write_hooks:2,create_variants:2,
|
|
533
|
+
repurpose_post:2,niche_report:2,find_hook_pattern:2,analyze_post:3,
|
|
534
|
+
analyze_comments:2,compare_posts:1,understand_social_post:3,analyze_creator_profile:2};
|
|
535
|
+
|
|
536
|
+
// ChatGPT's Apps SDK does not answer the MCP Apps postMessage bridge. A
|
|
537
|
+
// widget there reaches its server through window.openai.callTool, and opens
|
|
538
|
+
// a link through window.openai.openExternal. This view only ever posted
|
|
539
|
+
// tools/call and ui/open-link, so on ChatGPT every in-view button posted
|
|
540
|
+
// into the void, waited out the 1500ms timeout and fell through to the
|
|
541
|
+
// clipboard - and a sandboxed widget iframe carries no clipboard-write
|
|
542
|
+
// grant either, so writeText rejected and the user was told "Copy failed"
|
|
543
|
+
// while nothing had happened. Prefer the host's own API where it exists and
|
|
544
|
+
// keep the bridge for the MCP Apps hosts that do answer it.
|
|
545
|
+
// A tool call is not a UI event. Fetching a feed or watching a video takes
|
|
546
|
+
// ten seconds and more - the discover call this view is built around
|
|
547
|
+
// measures 10.3s - so the 1500ms this used to wait declared a host that was
|
|
548
|
+
// running the tool correctly a failure, and showed "Copy failed" over a call
|
|
549
|
+
// that then succeeded. Wait as long as the result poll does, and keep the
|
|
550
|
+
// short deadline only for a host that answered neither the handshake nor
|
|
551
|
+
// offers an API of its own, which is the one case where nothing is coming.
|
|
552
|
+
var TOOL_CALL_BUDGET_MS=300000;
|
|
553
|
+
var bridgeAlive=false;
|
|
554
|
+
|
|
555
|
+
/** Ask the host to run a tool: onDone(result) when it does, onFail() only
|
|
556
|
+
* when the host cannot run it at all. */
|
|
557
|
+
function invokeTool(tool,args,onDone,onFail){
|
|
558
|
+
var settled=false;
|
|
559
|
+
var fail=function(){ if(settled)return; settled=true; onFail(); };
|
|
560
|
+
var api=window.openai;
|
|
561
|
+
var native=!!(api&&typeof api.callTool==="function");
|
|
562
|
+
var p=null;
|
|
563
|
+
if(native){
|
|
564
|
+
try{ p=api.callTool(tool,args||{}); if(p&&!p.then)p=Promise.resolve(p); }catch(e){ p=null; }
|
|
565
|
+
}
|
|
566
|
+
if(!p){ try{ p=send("tools/call",{name:tool,arguments:args||{}}); }catch(e2){ p=null; } }
|
|
567
|
+
if(!p||!p.then){ fail(); return; }
|
|
568
|
+
p.then(function(r){ if(settled)return; settled=true; onDone(r); },fail);
|
|
569
|
+
setTimeout(fail,(native||bridgeAlive)?TOOL_CALL_BUDGET_MS:1500);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Show what the call came back with. A host that answers in-band hands the
|
|
573
|
+
* result straight to us; one that also pushes a tool-result notification
|
|
574
|
+
* would render the same payload again, so keep the globals bookkeeping in
|
|
575
|
+
* step and let that second pass be skipped. */
|
|
576
|
+
function renderToolResult(r){
|
|
577
|
+
if(!r||typeof r!=="object")return false;
|
|
578
|
+
var sc=r.structuredContent;
|
|
579
|
+
if(!sc||typeof sc!=="object")return false;
|
|
580
|
+
lastOutRef=sc;
|
|
581
|
+
try{ lastOutSig=JSON.stringify(sc); }catch(e){ lastOutSig=""; }
|
|
582
|
+
toolResultReceived=true;
|
|
583
|
+
render({structuredContent:sc});
|
|
584
|
+
setTimeout(reportSize,50);
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** True once the host has been asked to open the link. */
|
|
589
|
+
function openHostLink(href){
|
|
590
|
+
var api=window.openai;
|
|
591
|
+
if(api&&typeof api.openExternal==="function"){
|
|
592
|
+
try{ api.openExternal({href:href}); return true; }catch(e){}
|
|
593
|
+
}
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function aiBtn(tool,label,argsJson){
|
|
598
|
+
var cost=AI_PRICE[tool]||0;
|
|
599
|
+
return '<button class="ai-btn" type="button" data-ai="'+esc(tool)+'" data-args="'+esc(argsJson)+'">'
|
|
600
|
+
+'<span class="spark">'+mpIcon("sparkle",13)+"</span><span>"+esc(label)+"</span>"
|
|
601
|
+
+(cost?'<span class="price">'+cost+" cr</span>":"")+"</button>";
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Buttons offered under a single post. */
|
|
605
|
+
function postAiActions(p){
|
|
606
|
+
var url=p&&(p.externalUrl||p.url);
|
|
607
|
+
if(!url)return "";
|
|
608
|
+
var a=JSON.stringify({url:url});
|
|
609
|
+
return '<div class="ai-actions">'
|
|
610
|
+
+aiBtn("create_variants","Create variants",a)
|
|
611
|
+
+aiBtn("write_hooks","Write hooks",a)
|
|
612
|
+
+aiBtn("repurpose_post","Repurpose",a)
|
|
613
|
+
+aiBtn("analyze_comments","Read comments",a)
|
|
614
|
+
+"</div>";
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function runAiTool(btn){
|
|
618
|
+
var tool=btn.getAttribute("data-ai");
|
|
619
|
+
var args={};
|
|
620
|
+
try{args=JSON.parse(btn.getAttribute("data-args")||"{}");}catch(e){}
|
|
621
|
+
var label=btn.querySelector("span:nth-child(2)");
|
|
622
|
+
var original=label?label.textContent:"";
|
|
623
|
+
btn.disabled=true;btn.classList.add("busy");
|
|
624
|
+
if(label)label.textContent="Workingโฆ";
|
|
625
|
+
var restore=function(text){
|
|
626
|
+
btn.classList.remove("busy");
|
|
627
|
+
if(label)label.textContent=text||original;
|
|
628
|
+
setTimeout(function(){btn.disabled=false;if(label)label.textContent=original;},1600);
|
|
629
|
+
};
|
|
630
|
+
var settled=false;
|
|
631
|
+
var fallback=function(){
|
|
632
|
+
if(settled)return;settled=true;
|
|
633
|
+
var ask="Run "+tool+" on "+(args.url||JSON.stringify(args));
|
|
634
|
+
if(navigator.clipboard&&navigator.clipboard.writeText){
|
|
635
|
+
navigator.clipboard.writeText(ask).then(function(){restore("Copied โ");})
|
|
636
|
+
.catch(function(){restore("Try in chat");});
|
|
637
|
+
}else restore("Try in chat");
|
|
638
|
+
};
|
|
639
|
+
invokeTool(tool,args,function(r){
|
|
640
|
+
settled=true;
|
|
641
|
+
restore("Sent โ");
|
|
642
|
+
renderToolResult(r);
|
|
643
|
+
},fallback);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
document.addEventListener("click",function(e){
|
|
647
|
+
var b=e.target.closest&&e.target.closest(".ai-btn[data-ai]");
|
|
648
|
+
if(b&&!b.disabled)runAiTool(b);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
// A sandboxed view cannot navigate the top-level window, so a plain anchor
|
|
652
|
+
// does nothing in most hosts - the "Open on ..." buttons and the trending
|
|
653
|
+
// hashtag rows were all inert. MCP Apps exposes ui/open-link for this.
|
|
654
|
+
// window.open stays as the fallback for hosts that do allow it.
|
|
655
|
+
document.addEventListener("click",function(e){
|
|
656
|
+
var a=e.target.closest&&e.target.closest("a[href]");
|
|
657
|
+
if(!a)return;
|
|
658
|
+
var href=a.getAttribute("href")||"";
|
|
659
|
+
if(href.indexOf("http")!==0)return;
|
|
660
|
+
e.preventDefault();
|
|
661
|
+
var popOut=function(){try{window.open(href,"_blank","noopener");}catch(err){}};
|
|
662
|
+
if(openHostLink(href))return;
|
|
663
|
+
var pr=null;
|
|
664
|
+
try{pr=send("ui/open-link",{url:href});}catch(err2){}
|
|
665
|
+
if(pr&&pr.then)pr.catch(popOut); else popOut();
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
// โโโ Post selection & comparison โโโ
|
|
669
|
+
// Comparing posts is the one action that needs more than one card, so the
|
|
670
|
+
// cards themselves carry the control: a checkbox on the media, and a menu
|
|
671
|
+
// that only exists once something is picked.
|
|
672
|
+
var picked = [];
|
|
673
|
+
|
|
674
|
+
function pickBarHtml(){
|
|
675
|
+
return '<div class="pickbar" id="pickbar" hidden>'
|
|
676
|
+
+'<span class="n"><b id="pickn">0</b> selected</span>'
|
|
677
|
+
+'<span class="hint" id="pickhint">Pick 2 or more to compare</span>'
|
|
678
|
+
+'<span class="sp"></span>'
|
|
679
|
+
+'<button type="button" id="pickclear">Clear</button>'
|
|
680
|
+
+'<button type="button" id="pickgo" class="primary" disabled>Compare</button>'
|
|
681
|
+
+"</div>";
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function syncPickBar(){
|
|
685
|
+
var bar=document.getElementById("pickbar");
|
|
686
|
+
if(!bar)return;
|
|
687
|
+
bar.hidden = picked.length===0;
|
|
688
|
+
var n=document.getElementById("pickn");
|
|
689
|
+
if(n)n.textContent=picked.length;
|
|
690
|
+
var urls=pickedUrls();
|
|
691
|
+
var go=document.getElementById("pickgo");
|
|
692
|
+
if(go)go.disabled = urls.length<2 || urls.length>5;
|
|
693
|
+
var hint=document.getElementById("pickhint");
|
|
694
|
+
if(hint){
|
|
695
|
+
hint.textContent = picked.length>5 ? "Compare takes at most 5"
|
|
696
|
+
: urls.length<2 ? (picked.length>=2 ? "Those are the same post" : "Pick 2 or more to compare")
|
|
697
|
+
: "Ready to compare";
|
|
698
|
+
}
|
|
699
|
+
setTimeout(reportSize,60);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function initPicks(){
|
|
703
|
+
picked=[];
|
|
704
|
+
document.addEventListener("click",onPickClick);
|
|
705
|
+
var clear=document.getElementById("pickclear");
|
|
706
|
+
if(clear)clear.addEventListener("click",function(){
|
|
707
|
+
picked=[];
|
|
708
|
+
document.querySelectorAll('.mp-pick[aria-pressed="true"]').forEach(function(b){
|
|
709
|
+
b.setAttribute("aria-pressed","false");
|
|
710
|
+
var mp=b.closest(".mp"); if(mp)mp.classList.remove("picked");
|
|
711
|
+
});
|
|
712
|
+
syncPickBar();
|
|
713
|
+
});
|
|
714
|
+
var go=document.getElementById("pickgo");
|
|
715
|
+
if(go)go.addEventListener("click",runCompare);
|
|
716
|
+
syncPickBar();
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function onPickClick(e){
|
|
720
|
+
var b=e.target.closest&&e.target.closest(".mp-pick[data-pick]");
|
|
721
|
+
if(!b)return;
|
|
722
|
+
e.stopPropagation(); // never let the tap reach the player underneath
|
|
723
|
+
var id=b.getAttribute("data-pick-id");
|
|
724
|
+
var i=picked.findIndex(function(x){return x.id===id;});
|
|
725
|
+
var on=i<0;
|
|
726
|
+
if(on)picked.push({id:id,url:b.getAttribute("data-pick")});
|
|
727
|
+
else picked.splice(i,1);
|
|
728
|
+
b.setAttribute("aria-pressed",on?"true":"false");
|
|
729
|
+
var mp=b.closest(".mp");
|
|
730
|
+
if(mp)mp.classList.toggle("picked",on);
|
|
731
|
+
syncPickBar();
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** Distinct URLs from the current selection โ the same post picked twice
|
|
735
|
+
* is still one post to compare. */
|
|
736
|
+
function pickedUrls(){
|
|
737
|
+
var seen=[],out=[];
|
|
738
|
+
picked.forEach(function(x){
|
|
739
|
+
if(x.url&&seen.indexOf(x.url)<0){seen.push(x.url);out.push(x.url);}
|
|
740
|
+
});
|
|
741
|
+
return out;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Ask the host to run compare_posts; fall back to copying the URLs.
|
|
745
|
+
* The method is "tools/call" - MCP Apps reuses the core MCP method for
|
|
746
|
+
* tool invocation rather than defining a prefixed one of its own. This
|
|
747
|
+
* used to send a ui-prefixed name that no host answers, so every in-view
|
|
748
|
+
* action button silently timed out into the clipboard fallback. */
|
|
749
|
+
function runCompare(){
|
|
750
|
+
var go=document.getElementById("pickgo");
|
|
751
|
+
var urls=pickedUrls().slice(0,5);
|
|
752
|
+
if(!go||urls.length<2)return;
|
|
753
|
+
go.disabled=true; go.textContent="Comparingโฆ";
|
|
754
|
+
var done=function(label){
|
|
755
|
+
go.textContent=label;
|
|
756
|
+
setTimeout(function(){ go.textContent="Compare"; syncPickBar(); },1800);
|
|
757
|
+
};
|
|
758
|
+
// Preferred path: the host runs the tool for us.
|
|
759
|
+
var settled=false;
|
|
760
|
+
var fallback=function(){
|
|
761
|
+
if(settled)return; settled=true;
|
|
762
|
+
// Hosts that do not support tool-calls from a view still let the user
|
|
763
|
+
// paste โ so hand them something ready to send.
|
|
764
|
+
// Build the newline from a char code rather than an escape sequence.
|
|
765
|
+
// This template is embedded both in a Rust raw string (escapes stay
|
|
766
|
+
// literal) and a TS template literal (escapes are resolved), so an
|
|
767
|
+
// escaped newline here becomes a real one in the TS build and breaks
|
|
768
|
+
// the surrounding string literal. Same reason this comment avoids one.
|
|
769
|
+
var NL=String.fromCharCode(10);
|
|
770
|
+
var text="Compare these posts:"+NL+urls.join(NL);
|
|
771
|
+
if(navigator.clipboard&&navigator.clipboard.writeText){
|
|
772
|
+
navigator.clipboard.writeText(text).then(function(){done("Copied โ");})
|
|
773
|
+
.catch(function(){done("Copy failed");});
|
|
774
|
+
} else done("Copy failed");
|
|
775
|
+
};
|
|
776
|
+
invokeTool("compare_posts",{urls:urls},function(r){
|
|
777
|
+
settled=true;
|
|
778
|
+
done("Sent โ");
|
|
779
|
+
renderToolResult(r);
|
|
780
|
+
},fallback);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Images cannot bubble an error, so this listens in the capture phase: any
|
|
784
|
+
// <img data-fallback> that fails swaps to its fallback once. Thumbnails and
|
|
785
|
+
// sound covers expire the same way videos do - a TikTok poster lasts about
|
|
786
|
+
// five hours - and a dead cover is the most visible kind of broken card.
|
|
787
|
+
document.addEventListener("error",function(e){
|
|
788
|
+
var el=e.target;
|
|
789
|
+
if(!el||el.tagName!=="IMG")return;
|
|
790
|
+
var alt=el.getAttribute("data-fallback")||"";
|
|
791
|
+
if(!alt||el.getAttribute("src")===alt)return;
|
|
792
|
+
el.removeAttribute("data-fallback");
|
|
793
|
+
el.setAttribute("src",alt);
|
|
794
|
+
},true);
|
|
795
|
+
|
|
796
|
+
// An avatar with nothing left to try hides, uncovering the initial drawn
|
|
797
|
+
// underneath it. A broken-image glyph in a column of faces reads as a fault;
|
|
798
|
+
// an initial reads as a person whose picture did not load.
|
|
799
|
+
document.addEventListener("error",function(e){
|
|
800
|
+
var el=e.target;
|
|
801
|
+
if(!el||el.tagName!=="IMG"||!el.hasAttribute("data-avatar"))return;
|
|
802
|
+
if(el.getAttribute("data-fallback"))return;
|
|
803
|
+
el.style.display="none";
|
|
804
|
+
},true);
|
|
805
|
+
|
|
806
|
+
// A <video> poster cannot report failure: there is no error event for it, so
|
|
807
|
+
// a dead cover leaves a black stage and nothing notices. TikTok cover
|
|
808
|
+
// signatures expire in hours, so this is the ordinary case, not the edge -
|
|
809
|
+
// measured on a two-hour-old payload whose cover already answered 403 while
|
|
810
|
+
// its video still played. Probing the URL with an Image gives us the error
|
|
811
|
+
// event the video will not, and the resolver hands back a fresh cover.
|
|
812
|
+
function healPosters(root){
|
|
813
|
+
var vids=(root||document).querySelectorAll("video[data-poster-fallback]");
|
|
814
|
+
for(var i=0;i<vids.length;i++)(function(v){
|
|
815
|
+
var poster=v.getAttribute("poster")||"",alt=v.getAttribute("data-poster-fallback")||"";
|
|
816
|
+
v.removeAttribute("data-poster-fallback");
|
|
817
|
+
if(!poster||!alt||poster===alt)return;
|
|
818
|
+
var probe=new Image();
|
|
819
|
+
probe.onerror=function(){v.setAttribute("poster",alt);};
|
|
820
|
+
probe.src=poster;
|
|
821
|
+
})(vids[i]);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// โโโ Player registry โโโ
|
|
825
|
+
// Exclusivity cannot ride on the audio elements alone: a silent slideshow
|
|
826
|
+
// owns no <audio>, so it would happily keep advancing underneath a video
|
|
827
|
+
// that just started. Every player registers its own stop() instead.
|
|
828
|
+
var players=[];
|
|
829
|
+
function registerPlayer(rec){players.push(rec);}
|
|
830
|
+
function stopOtherPlayers(except){
|
|
831
|
+
for(var i=0;i<players.length;i++){
|
|
832
|
+
if(players[i]!==except&&players[i].isPlaying())players[i].stop(false);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
function stopAllPlayers(){stopOtherPlayers(null);}
|
|
836
|
+
function resetPlayers(){stopAllPlayers();players=[];}
|
|
837
|
+
|
|
838
|
+
// Mute is a session preference, not a per-card one โ having to silence
|
|
839
|
+
// every card in a feed one at a time is the kind of thing that makes people
|
|
840
|
+
// close the tab.
|
|
841
|
+
var mutePref=false;
|
|
842
|
+
|
|
843
|
+
function initMediaPlayers(root){
|
|
844
|
+
var nodes=(root||document).querySelectorAll(".mp[data-mp]");
|
|
845
|
+
for(var n=0;n<nodes.length;n++)initMediaPlayer(nodes[n]);
|
|
846
|
+
observeVisibility();
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// โโโ Pause on scroll-away โโโ
|
|
850
|
+
// Paging to the next card in a gallery must stop the one you left, or two
|
|
851
|
+
// soundtracks end up playing over each other.
|
|
852
|
+
var mpObserver=null;
|
|
853
|
+
function observeVisibility(){
|
|
854
|
+
if(!("IntersectionObserver" in window))return;
|
|
855
|
+
if(mpObserver)mpObserver.disconnect();
|
|
856
|
+
mpObserver=new IntersectionObserver(function(entries){
|
|
857
|
+
for(var i=0;i<entries.length;i++){
|
|
858
|
+
var e=entries[i],rec=e.target.__mpRec;
|
|
859
|
+
if(!rec)continue;
|
|
860
|
+
if(e.intersectionRatio>=0.6){rec.wasVisible=true;warmMedia(e.target);continue;}
|
|
861
|
+
// Only a card that was genuinely on screen and has scrolled away
|
|
862
|
+
// stops. Without the transition check, a smooth-scroll that is still
|
|
863
|
+
// settling reads as "off screen" and kills playback the instant the
|
|
864
|
+
// user presses play on a partly-visible card.
|
|
865
|
+
if(e.intersectionRatio<0.25&&rec.wasVisible){
|
|
866
|
+
rec.wasVisible=false;
|
|
867
|
+
if(rec.isPlaying())rec.stop(false);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
},{threshold:[0,0.25,0.6,1]});
|
|
871
|
+
var nodes=document.querySelectorAll(".mp[data-mp]");
|
|
872
|
+
for(var n=0;n<nodes.length;n++)mpObserver.observe(nodes[n]);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Leaving the tab (or the host collapsing the view) must not leave audio
|
|
876
|
+
// playing out of sight.
|
|
877
|
+
document.addEventListener("visibilitychange",function(){
|
|
878
|
+
if(document.hidden)stopAllPlayers();
|
|
879
|
+
});
|
|
880
|
+
window.addEventListener("pagehide",stopAllPlayers);
|
|
881
|
+
|
|
882
|
+
// Resolver-backed video costs eight to twenty-five seconds before a URL
|
|
883
|
+
// comes back. That is the provider's own latency, measured across repeated
|
|
884
|
+
// calls for the same video, and they do not cache it - so nothing we do on
|
|
885
|
+
// our side makes the *first* one quick. Left until the play button is
|
|
886
|
+
// pressed, all of it is spent staring at a spinner, which is what "loads
|
|
887
|
+
// indefinitely" was.
|
|
888
|
+
//
|
|
889
|
+
// A card that is genuinely on screen therefore warms itself, once. Metadata
|
|
890
|
+
// only - a small range request, not the video - and it primes the resolve
|
|
891
|
+
// cache so the press that follows is instant. Cards nobody scrolls to cost
|
|
892
|
+
// nothing, which is the whole reason preload is "none" to begin with.
|
|
893
|
+
function warmMedia(box){
|
|
894
|
+
if(box.__mpWarm)return;
|
|
895
|
+
var v=box.querySelector("video");
|
|
896
|
+
if(!v)return;
|
|
897
|
+
var src=v.getAttribute("src")||"";
|
|
898
|
+
if(src.indexOf("/media/resolve")<0||v.preload!=="none")return;
|
|
899
|
+
box.__mpWarm=true;
|
|
900
|
+
v.preload="metadata";
|
|
901
|
+
try{v.load();}catch(e){}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function initMediaPlayer(box){
|
|
905
|
+
if(box.__mpInit)return;
|
|
906
|
+
box.__mpInit=true;
|
|
907
|
+
var t=box.getAttribute("data-mp")==="video"?videoTransport(box):slidesTransport(box);
|
|
908
|
+
if(!t.el)return;
|
|
909
|
+
if(t.audio)registerAudio(t.audio);
|
|
910
|
+
if(t.kind==="video")registerAudio(t.el);
|
|
911
|
+
|
|
912
|
+
var playBtn=box.querySelector(".mp-play");
|
|
913
|
+
var tapBtn=box.querySelector(".mp-tap");
|
|
914
|
+
var muteBtn=box.querySelector(".mp-mute");
|
|
915
|
+
var seekBar=box.querySelector(".mp-seek");
|
|
916
|
+
var segs=box.querySelectorAll(".mp-seg");
|
|
917
|
+
var curEl=box.querySelector(".mp-cur");
|
|
918
|
+
var durEl=box.querySelector(".mp-dur");
|
|
919
|
+
// Driven by an interval, not requestAnimationFrame: hosts render these
|
|
920
|
+
// cards in iframes that often are not compositing, where rAF never fires.
|
|
921
|
+
var ticker=null;
|
|
922
|
+
|
|
923
|
+
function paintSeek(){
|
|
924
|
+
var d=t.duration(),now=t.time();
|
|
925
|
+
if(segs.length<=1){
|
|
926
|
+
if(segs[0])segs[0].firstChild.style.width=(d>0?Math.min(100,(now/d)*100):0)+"%";
|
|
927
|
+
}else{
|
|
928
|
+
var per=d/segs.length,active=t.segment();
|
|
929
|
+
for(var k=0;k<segs.length;k++){
|
|
930
|
+
segs[k].classList.toggle("done",k<active);
|
|
931
|
+
segs[k].firstChild.style.width=
|
|
932
|
+
k<active?"100%":k>active?"0":Math.max(0,Math.min(100,((now-k*per)/per)*100))+"%";
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
if(curEl)curEl.textContent=fmtTime(now);
|
|
936
|
+
if(durEl)durEl.textContent=fmtTime(d);
|
|
937
|
+
}
|
|
938
|
+
function setPlaying(on){
|
|
939
|
+
box.classList.toggle("playing",on);
|
|
940
|
+
if(playBtn){
|
|
941
|
+
playBtn.innerHTML=mpIcon(on?"pause":"play",16);
|
|
942
|
+
playBtn.setAttribute("aria-label",on?"Pause":"Play");
|
|
943
|
+
}
|
|
944
|
+
if(tapBtn){
|
|
945
|
+
tapBtn.innerHTML="<span>"+mpIcon(on?"pause":"play",28)+"</span>";
|
|
946
|
+
tapBtn.setAttribute("aria-label",on?"Pause":"Play");
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
function applyMute(){
|
|
950
|
+
t.muted(mutePref);
|
|
951
|
+
if(muteBtn){
|
|
952
|
+
muteBtn.innerHTML=mpIcon(mutePref?"muted":"volume",17);
|
|
953
|
+
muteBtn.setAttribute("aria-label",mutePref?"Unmute":"Mute");
|
|
954
|
+
muteBtn.setAttribute("aria-pressed",mutePref?"true":"false");
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
function tick(){
|
|
958
|
+
paintSeek();
|
|
959
|
+
if(t.kind==="slides"&&t.sync&&!t.paused()&&t.sync())stop(true);
|
|
960
|
+
}
|
|
961
|
+
function start(){
|
|
962
|
+
// Only one thing plays at a time, across every card in the view.
|
|
963
|
+
stopOtherPlayers(rec);
|
|
964
|
+
rec.wasVisible=true;
|
|
965
|
+
t.play();setPlaying(true);
|
|
966
|
+
clearInterval(ticker);ticker=setInterval(tick,100);
|
|
967
|
+
tick();
|
|
968
|
+
}
|
|
969
|
+
function stop(reset){
|
|
970
|
+
t.pause();setPlaying(false);
|
|
971
|
+
clearInterval(ticker);ticker=null;
|
|
972
|
+
if(reset)t.seek(0);
|
|
973
|
+
paintSeek();
|
|
974
|
+
}
|
|
975
|
+
function toggle(){t.paused()?start():stop(false);}
|
|
976
|
+
|
|
977
|
+
// Registered before any wiring so start() can reach it.
|
|
978
|
+
var rec={wasVisible:false,
|
|
979
|
+
isPlaying:function(){return box.classList.contains("playing");},
|
|
980
|
+
stop:function(reset){stop(reset);}};
|
|
981
|
+
box.__mpRec=rec;
|
|
982
|
+
registerPlayer(rec);
|
|
983
|
+
|
|
984
|
+
if(playBtn)playBtn.addEventListener("click",toggle);
|
|
985
|
+
if(tapBtn)tapBtn.addEventListener("click",toggle);
|
|
986
|
+
if(muteBtn)muteBtn.addEventListener("click",function(){
|
|
987
|
+
mutePref=!mutePref;
|
|
988
|
+
// Apply to every player, so the choice sticks across the whole feed.
|
|
989
|
+
var all=document.querySelectorAll(".mp[data-mp]");
|
|
990
|
+
for(var i=0;i<all.length;i++)if(all[i].__mpApplyMute)all[i].__mpApplyMute();
|
|
991
|
+
});
|
|
992
|
+
box.__mpApplyMute=applyMute;
|
|
993
|
+
|
|
994
|
+
// Keyboard: the same keys people already expect from a video player.
|
|
995
|
+
box.addEventListener("keydown",function(e){
|
|
996
|
+
var k=e.key;
|
|
997
|
+
if(k===" "||k==="Spacebar"||k==="k"){e.preventDefault();toggle();}
|
|
998
|
+
else if(k==="ArrowRight"){e.preventDefault();t.step(1);paintSeek();}
|
|
999
|
+
else if(k==="ArrowLeft"){e.preventDefault();t.step(-1);paintSeek();}
|
|
1000
|
+
else if(k==="m"||k==="M"){e.preventDefault();if(muteBtn)muteBtn.click();}
|
|
1001
|
+
else if(k==="Home"){e.preventDefault();t.seek(0);paintSeek();}
|
|
1002
|
+
});
|
|
1003
|
+
if(seekBar)seekBar.addEventListener("click",function(e){
|
|
1004
|
+
var d=t.duration();
|
|
1005
|
+
if(d<=0)return;
|
|
1006
|
+
var r=seekBar.getBoundingClientRect();
|
|
1007
|
+
t.seek(((e.clientX-r.left)/r.width)*d);
|
|
1008
|
+
paintSeek();
|
|
1009
|
+
});
|
|
1010
|
+
box.addEventListener("click",function(e){
|
|
1011
|
+
var nav=e.target.closest?e.target.closest(".mp-nav"):null;
|
|
1012
|
+
if(!nav)return;
|
|
1013
|
+
t.step(Number(nav.getAttribute("data-dir"))||1);
|
|
1014
|
+
paintSeek();
|
|
1015
|
+
});
|
|
1016
|
+
// Media the user paused from elsewhere (another player claimed the
|
|
1017
|
+
// audio, or the native video UI) must not leave the button lying.
|
|
1018
|
+
t.on("ended",function(){stop(true);});
|
|
1019
|
+
t.on("pause",function(){if(!box.classList.contains("playing"))return;setPlaying(false);clearInterval(ticker);});
|
|
1020
|
+
t.on("play",function(){if(!box.classList.contains("playing"))start();});
|
|
1021
|
+
t.on("loadedmetadata",paintSeek);
|
|
1022
|
+
if(t.kind==="slides")t.on("timeupdate",tick);
|
|
1023
|
+
|
|
1024
|
+
// Buffering and failure are states the user can see, not a frozen frame.
|
|
1025
|
+
var spin=box.querySelector(".mp-spin"),errBox=box.querySelector(".mp-err");
|
|
1026
|
+
function busy(on){if(spin)spin.hidden=!on;}
|
|
1027
|
+
if(t.kind==="video"){
|
|
1028
|
+
t.on("waiting",function(){busy(true);});
|
|
1029
|
+
t.on("stalled",function(){busy(true);});
|
|
1030
|
+
t.on("playing",function(){busy(false);});
|
|
1031
|
+
t.on("canplay",function(){busy(false);});
|
|
1032
|
+
}
|
|
1033
|
+
var media=t.kind==="video"?t.el:t.audio;
|
|
1034
|
+
if(media)media.addEventListener("error",function(){
|
|
1035
|
+
// One retry through the proxy before admitting defeat: the direct CDN
|
|
1036
|
+
// link is tried first, but some hosts refuse a cross-origin request
|
|
1037
|
+
// from this frame and only the proxied copy will play.
|
|
1038
|
+
var alt=media.getAttribute("data-fallback")||box.getAttribute("data-mp-fallback")||"";
|
|
1039
|
+
if(alt&&media.getAttribute("src")!==alt){
|
|
1040
|
+
media.removeAttribute("data-fallback");
|
|
1041
|
+
box.removeAttribute("data-mp-fallback");
|
|
1042
|
+
media.setAttribute("src",alt);
|
|
1043
|
+
try{media.load();}catch(e){}
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
busy(false);
|
|
1047
|
+
if(errBox)errBox.hidden=false;
|
|
1048
|
+
stop(false);
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
setPlaying(false);
|
|
1052
|
+
applyMute();
|
|
1053
|
+
paintSeek();
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// โโโ Sound card players โโโ
|
|
1057
|
+
function initSoundPlayers(root){
|
|
1058
|
+
var nodes=(root||document).querySelectorAll(".au[data-au]");
|
|
1059
|
+
for(var n=0;n<nodes.length;n++){
|
|
1060
|
+
(function(wrap){
|
|
1061
|
+
if(wrap.__auInit)return;
|
|
1062
|
+
wrap.__auInit=true;
|
|
1063
|
+
var audio=wrap.querySelector("audio");
|
|
1064
|
+
var btn=wrap.querySelector(".au-play");
|
|
1065
|
+
var seek=wrap.querySelector(".au-seek");
|
|
1066
|
+
var fill=seek?seek.firstChild:null;
|
|
1067
|
+
var cur=wrap.querySelector(".au-cur");
|
|
1068
|
+
var dur=wrap.querySelector(".au-dur");
|
|
1069
|
+
if(!audio||!btn)return;
|
|
1070
|
+
registerAudio(audio);
|
|
1071
|
+
btn.addEventListener("click",function(){
|
|
1072
|
+
if(audio.paused){var pr=audio.play();if(pr&&pr.catch)pr.catch(function(){
|
|
1073
|
+
wrap.setAttribute("data-error","1");
|
|
1074
|
+
if(cur)cur.textContent="unavailable";
|
|
1075
|
+
});}
|
|
1076
|
+
else audio.pause();
|
|
1077
|
+
});
|
|
1078
|
+
function mark(on){
|
|
1079
|
+
btn.innerHTML=mpIcon(on?"pause":"play",14);
|
|
1080
|
+
btn.setAttribute("aria-label",on?"Pause sound":"Play sound");
|
|
1081
|
+
}
|
|
1082
|
+
// A sound preview must also silence any playing card, not just other
|
|
1083
|
+
// previews โ they are all one audio surface to the user.
|
|
1084
|
+
audio.addEventListener("play",function(){mark(true);stopAllPlayers();});
|
|
1085
|
+
audio.addEventListener("pause",function(){mark(false);});
|
|
1086
|
+
audio.addEventListener("ended",function(){mark(false);if(fill)fill.style.width="0";});
|
|
1087
|
+
audio.addEventListener("loadedmetadata",function(){
|
|
1088
|
+
if(dur&&isFinite(audio.duration))dur.textContent=fmtTime(audio.duration);
|
|
1089
|
+
});
|
|
1090
|
+
audio.addEventListener("timeupdate",function(){
|
|
1091
|
+
if(cur)cur.textContent=fmtTime(audio.currentTime);
|
|
1092
|
+
if(fill&&isFinite(audio.duration)&&audio.duration>0)
|
|
1093
|
+
fill.style.width=((audio.currentTime/audio.duration)*100)+"%";
|
|
1094
|
+
});
|
|
1095
|
+
if(seek)seek.addEventListener("click",function(e){
|
|
1096
|
+
if(!isFinite(audio.duration)||audio.duration<=0)return;
|
|
1097
|
+
var r=seek.getBoundingClientRect();
|
|
1098
|
+
try{audio.currentTime=((e.clientX-r.left)/r.width)*audio.duration;}catch(err){}
|
|
1099
|
+
});
|
|
1100
|
+
})(nodes[n]);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// Wire every player in the freshly rendered view.
|
|
1105
|
+
function initPlayers(){
|
|
1106
|
+
resetPlayers();
|
|
1107
|
+
resetAudio();
|
|
1108
|
+
setStageMax();
|
|
1109
|
+
initMediaPlayers(document);
|
|
1110
|
+
healPosters(document);
|
|
1111
|
+
initSoundPlayers(document);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// โโโ Stage size โโโ
|
|
1115
|
+
// Ceiling on the stage height, so a portrait post cannot run the card off
|
|
1116
|
+
// the screen. It is the only thing capping the stage now.
|
|
1117
|
+
var HARD_MAX=560;
|
|
1118
|
+
function availableWidth(){
|
|
1119
|
+
var vv=window.visualViewport;
|
|
1120
|
+
return (vv&&vv.width)||document.documentElement.clientWidth||window.innerWidth||0;
|
|
1121
|
+
}
|
|
1122
|
+
// The stage takes its size from the width of the frame and the aspect ratio
|
|
1123
|
+
// of the post. It must not take it from the frame's *height*.
|
|
1124
|
+
//
|
|
1125
|
+
// That height is provisional: the host sets a placeholder, waits for us to
|
|
1126
|
+
// report how tall we are, then resizes. Deriving our width from it (the CSS
|
|
1127
|
+
// takes .mp width from --stage-max via the ratio) made the card a function
|
|
1128
|
+
// of that placeholder. Measured with the same card and the same content,
|
|
1129
|
+
// varying only the height the host started with:
|
|
1130
|
+
//
|
|
1131
|
+
// starts 300px -> 492x277 starts 560px -> 738x415
|
|
1132
|
+
// starts 420px -> 706x397 starts 700px -> 738x415
|
|
1133
|
+
//
|
|
1134
|
+
// Deterministic, but the placeholder differs every time a virtualising host
|
|
1135
|
+
// unmounts a card on scroll and mounts it again - which is the "videos get
|
|
1136
|
+
// resized randomly when I scroll or they reload" report.
|
|
1137
|
+
//
|
|
1138
|
+
// So the cap is now fixed. Width comes from the frame, height follows from
|
|
1139
|
+
// the ratio, the cap only stops a portrait post running away, and we report
|
|
1140
|
+
// the height that produces. The host accommodates us rather than the other
|
|
1141
|
+
// way round, which is the only direction that terminates.
|
|
1142
|
+
function setStageMax(){
|
|
1143
|
+
document.documentElement.style.setProperty("--stage-max",HARD_MAX+"px");
|
|
1144
|
+
}
|
|
1145
|
+
// A width change is a real layout change and worth re-reporting; a height
|
|
1146
|
+
// change is the host answering us, and must not feed back.
|
|
1147
|
+
var lastWidth=availableWidth();
|
|
1148
|
+
function onViewportChange(){
|
|
1149
|
+
var w=availableWidth();
|
|
1150
|
+
if(w===lastWidth)return;
|
|
1151
|
+
lastWidth=w;
|
|
1152
|
+
reportSize();
|
|
1153
|
+
}
|
|
1154
|
+
if(window.visualViewport)window.visualViewport.addEventListener("resize",onViewportChange);
|
|
1155
|
+
window.addEventListener("orientationchange",function(){setTimeout(function(){
|
|
1156
|
+
lastWidth=availableWidth();reportSize();
|
|
1157
|
+
},120);});
|
|
1158
|
+
|
|
1159
|
+
function fmtNum(n){n=Number(n)||0;return n>=1e6?(n/1e6).toFixed(1)+"M":n>=1e3?(n/1e3).toFixed(1)+"K":String(n);}
|
|
1160
|
+
function pColor(p){return{tiktok:"#000",douyin:"#000",instagram:"#E4405F",youtube:"#FF0000",xiaohongshu:"#FF2442",x:"#000",twitter:"#000",bilibili:"#00A1D6",linkedin:"#0A66C2",reddit:"#FF4500",weibo:"#E6162D"}[p]||"#6B7280";}
|
|
1161
|
+
/** Display name for a platform key โ "xiaohongshu" is not a label. */
|
|
1162
|
+
function pTitle(p){
|
|
1163
|
+
var m={tiktok:"TikTok",douyin:"Douyin",instagram:"Instagram",youtube:"YouTube",
|
|
1164
|
+
xiaohongshu:"Xiaohongshu",twitter:"X",x:"X",bilibili:"Bilibili",
|
|
1165
|
+
linkedin:"LinkedIn",reddit:"Reddit",weibo:"Weibo"};
|
|
1166
|
+
return m[p]||(p?String(p).charAt(0).toUpperCase()+String(p).slice(1):"");
|
|
1167
|
+
}
|
|
1168
|
+
// Brand colours are chosen against a white page. TikTok, Douyin and X are
|
|
1169
|
+
// pure black, so used as label text on the dark card they were invisible.
|
|
1170
|
+
// Keep the brand colour for fills, but lift a too-dark one toward the card
|
|
1171
|
+
// foreground when it has to be read as text in dark mode.
|
|
1172
|
+
function pColorText(p){
|
|
1173
|
+
var c=pColor(p);
|
|
1174
|
+
var dq=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)");
|
|
1175
|
+
if(!dq||!dq.matches)return c;
|
|
1176
|
+
var h=c.replace("#","");
|
|
1177
|
+
if(h.length===3)h=h.charAt(0)+h.charAt(0)+h.charAt(1)+h.charAt(1)+h.charAt(2)+h.charAt(2);
|
|
1178
|
+
var r=parseInt(h.slice(0,2),16),g=parseInt(h.slice(2,4),16),b=parseInt(h.slice(4,6),16);
|
|
1179
|
+
if(isNaN(r)||isNaN(g)||isNaN(b))return c;
|
|
1180
|
+
if((0.2126*r+0.7152*g+0.0722*b)/255>0.38)return c;
|
|
1181
|
+
var t=0.66;
|
|
1182
|
+
return "rgb("+Math.round(r+(255-r)*t)+","+Math.round(g+(255-g)*t)+","+Math.round(b+(255-b)*t)+")";
|
|
1183
|
+
}
|
|
1184
|
+
// Official brand mark (simple-icons path) as an inline SVG that inherits
|
|
1185
|
+
// the current text color via fill="currentColor" โ matches badge accents.
|
|
1186
|
+
// โโโ UI icon set โโโ
|
|
1187
|
+
// Real vector icons rather than emoji: emoji render differently on every
|
|
1188
|
+
// OS (and some platforms colour them), so the control bar and engagement
|
|
1189
|
+
// rail would not look the same from one machine to the next. These inherit
|
|
1190
|
+
// currentColor and size, so one icon serves every context.
|
|
1191
|
+
var MP_ICONS={
|
|
1192
|
+
heart:"M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z",
|
|
1193
|
+
comment:"M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 9h12v2H6V9zm8 5H6v-2h8v2zm4-6H6V6h12v2z",
|
|
1194
|
+
share:"M10 9V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z",
|
|
1195
|
+
eye:"M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z",
|
|
1196
|
+
play:"M8 5v14l11-7z",
|
|
1197
|
+
pause:"M6 19h4V5H6v14zm8-14v14h4V5h-4z",
|
|
1198
|
+
volume:"M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z",
|
|
1199
|
+
muted:"M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z",
|
|
1200
|
+
note:"M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z",
|
|
1201
|
+
prev:"M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z",
|
|
1202
|
+
next:"M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z",
|
|
1203
|
+
open:"M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z",
|
|
1204
|
+
warn:"M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z",
|
|
1205
|
+
check:"M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z",
|
|
1206
|
+
sparkle:"M12 2l1.9 5.7L19.6 9.6l-5.7 1.9L12 17.2l-1.9-5.7L4.4 9.6l5.7-1.9L12 2zm6.5 10.5l.9 2.6 2.6.9-2.6.9-.9 2.6-.9-2.6-2.6-.9 2.6-.9.9-2.6zM5 14l.7 2 2 .7-2 .7L5 19.4l-.7-2-2-.7 2-.7L5 14z"
|
|
1207
|
+
};
|
|
1208
|
+
function mpIcon(name,size){
|
|
1209
|
+
var s=size||18,d=MP_ICONS[name]||"";
|
|
1210
|
+
return '<svg viewBox="0 0 24 24" width="'+s+'" height="'+s+'" fill="currentColor" style="display:block;flex-shrink:0" aria-hidden="true" focusable="false"><path d="'+d+'"/></svg>';
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* The monitoring screen, kept in a variable so filtering and sorting redraw
|
|
1215
|
+
* without another paid call. A sandboxed view cannot re-query; anything the
|
|
1216
|
+
* user narrows has to be narrowed from what is already here.
|
|
1217
|
+
*/
|
|
1218
|
+
var monitorState=null;
|
|
1219
|
+
var monitorTerm="";
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* search_spoken_mentions's own state, kept separately from monitorState
|
|
1223
|
+
* rather than shoehorned into it: the unit here is a video with excerpts,
|
|
1224
|
+
* not a comment, and the two payloads share only the idiom, not the shape.
|
|
1225
|
+
*/
|
|
1226
|
+
var spokenState=null;
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Sentiment and category, when something has classified the comment.
|
|
1230
|
+
*
|
|
1231
|
+
* These are absent on a brand sweep and present on a show_comment_review,
|
|
1232
|
+
* because the sweep has no basis for them โ nothing in the payload says
|
|
1233
|
+
* whether a comment is angry, and colouring it from a keyword guess would be
|
|
1234
|
+
* confidently wrong about the exact thing being scanned for. When a model
|
|
1235
|
+
* has actually read the words, the label is worth something, so it is drawn.
|
|
1236
|
+
*/
|
|
1237
|
+
function labelChips(m){
|
|
1238
|
+
var out="";
|
|
1239
|
+
var s=String(m.sentiment||"");
|
|
1240
|
+
if(s)out+='<span class="chip chip-'+esc(s)+'">'+esc(s)+"</span>";
|
|
1241
|
+
var c=String(m.category||"");
|
|
1242
|
+
if(c)out+='<span class="chip chip-cat">'+esc(c.split("_").join(" "))+"</span>";
|
|
1243
|
+
return out;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/** The counts a review carries, as the same filter chips the sweep uses. */
|
|
1247
|
+
function reviewChips(st){
|
|
1248
|
+
var counts=st.byCategory||{};
|
|
1249
|
+
var keys=Object.keys(counts).sort(function(a,b){return counts[b]-counts[a];});
|
|
1250
|
+
if(!keys.length)return "";
|
|
1251
|
+
return '<div class="mchips">'
|
|
1252
|
+
+['<button type="button" class="mchip'+(st.filter?"":" on")+'" data-filter="">All <b>'
|
|
1253
|
+
+esc(String(st.total))+"</b></button>"]
|
|
1254
|
+
.concat(keys.map(function(k){
|
|
1255
|
+
return '<button type="button" class="mchip'+(st.filter===k?" on":"")
|
|
1256
|
+
+'" data-filter="'+esc(k)+'">'+esc(k.split("_").join(" "))
|
|
1257
|
+
+" <b>"+esc(String(counts[k]))+"</b></button>";
|
|
1258
|
+
})).join("")
|
|
1259
|
+
+"</div>";
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/** Identifies a group across a redraw. The permalink is the stable part. */
|
|
1263
|
+
function groupKey(t){
|
|
1264
|
+
var post=t.post||{};
|
|
1265
|
+
return String(post.externalUrl||(post.platform||"")+":"+(post.title||""));
|
|
1266
|
+
}
|
|
1267
|
+
/** The group a key names, or null if the page has moved on. */
|
|
1268
|
+
function groupByKey(k){
|
|
1269
|
+
var found=null;
|
|
1270
|
+
(monitorState?monitorState.threads:[]).forEach(function(t){
|
|
1271
|
+
if(!found&&groupKey(t)===k)found=t;
|
|
1272
|
+
});
|
|
1273
|
+
return found;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* The comments "select all" means, for one group.
|
|
1278
|
+
*
|
|
1279
|
+
* Collapsing and filtering look alike โ both leave rows off the screen โ but
|
|
1280
|
+
* they mean opposite things. A collapsed comment is hidden to save space and
|
|
1281
|
+
* the reader still meant it; a filtered-out one they deliberately excluded.
|
|
1282
|
+
* So this ignores the fold and honours the filter.
|
|
1283
|
+
*/
|
|
1284
|
+
function selectableMentions(t){
|
|
1285
|
+
var all=t.mentions||[];
|
|
1286
|
+
if(!monitorState||!monitorState.filter||!monitorState.byCategory)return all;
|
|
1287
|
+
var want=monitorState.filter;
|
|
1288
|
+
return all.filter(function(m){return String(m.category||"")===want;});
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/** How many comments a group shows before it asks. */
|
|
1292
|
+
var GROUP_PEEK=4;
|
|
1293
|
+
/** Groups the reader has opened, by post URL, kept across a filter or sort. */
|
|
1294
|
+
var expandedGroups={};
|
|
1295
|
+
|
|
1296
|
+
/** Reach of the post a comment sits under โ how many people saw it. */
|
|
1297
|
+
function postReach(post){
|
|
1298
|
+
var bits=[];
|
|
1299
|
+
if(post.views)bits.push(fmtNum(post.views)+" views");
|
|
1300
|
+
if(post.likes)bits.push(fmtNum(post.likes)+" likes");
|
|
1301
|
+
if(post.comments)bits.push(fmtNum(post.comments)+" comments");
|
|
1302
|
+
return bits.slice(0,2).join(" ยท ");
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/** Newest comment in a group, for the "recent first" ordering. */
|
|
1306
|
+
function groupNewest(t){
|
|
1307
|
+
var best=0;
|
|
1308
|
+
(t.mentions||[]).forEach(function(m){
|
|
1309
|
+
var d=new Date(String(m.postedAt||""));
|
|
1310
|
+
if(!isNaN(d.getTime())&&d.getTime()>best)best=d.getTime();
|
|
1311
|
+
});
|
|
1312
|
+
return best;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function renderMonitor(){
|
|
1316
|
+
var app=document.getElementById("app");
|
|
1317
|
+
if(!app||!monitorState)return;
|
|
1318
|
+
var st=monitorState;
|
|
1319
|
+
|
|
1320
|
+
var keep=st.threads.filter(function(t){
|
|
1321
|
+
// A post with no matching comment earns its space only when the post
|
|
1322
|
+
// itself names the term; otherwise it is a card that says nothing.
|
|
1323
|
+
return Number(t.mentionCount||0)>0||!!t.postIsAboutTerm;
|
|
1324
|
+
});
|
|
1325
|
+
// Which platforms have something on screen โ not the same as which have a
|
|
1326
|
+
// comment. A post that names the term with no matching replies still shows.
|
|
1327
|
+
var onScreen={};
|
|
1328
|
+
keep.forEach(function(t){onScreen[String((t.post||{}).platform||"")]=true;});
|
|
1329
|
+
// A brand sweep filters by network, because it spans many. A review is one
|
|
1330
|
+
// post, so the network is a constant and the useful cut is the label the
|
|
1331
|
+
// model put on each comment โ which means filtering inside a group rather
|
|
1332
|
+
// than dropping whole ones.
|
|
1333
|
+
var shown=!st.filter?keep
|
|
1334
|
+
:st.byCategory
|
|
1335
|
+
?keep.map(function(t){
|
|
1336
|
+
var kept=(t.mentions||[]).filter(function(m){return String(m.category||"")===st.filter;});
|
|
1337
|
+
return kept.length?{post:t.post,postIsAboutTerm:t.postIsAboutTerm,
|
|
1338
|
+
mentionCount:kept.length,mentions:kept}:null;
|
|
1339
|
+
}).filter(Boolean)
|
|
1340
|
+
:keep.filter(function(t){
|
|
1341
|
+
return String((t.post||{}).platform||"")===st.filter;
|
|
1342
|
+
});
|
|
1343
|
+
if(st.sort==="new"){
|
|
1344
|
+
shown=shown.slice().sort(function(a,b){return groupNewest(b)-groupNewest(a);});
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
var order=Object.keys(st.counts).sort(function(a,b){return (st.counts[b]||0)-(st.counts[a]||0);});
|
|
1348
|
+
var live=order.filter(function(k){return Number(st.counts[k]||0)>0;});
|
|
1349
|
+
// A platform that returned nothing still gets a chip โ "we looked there and
|
|
1350
|
+
// it was quiet" is an answer โ but it is not a filter, because filtering to
|
|
1351
|
+
// it is a dead end.
|
|
1352
|
+
var chips=['<button type="button" class="mchip'+(st.filter?"":" on")
|
|
1353
|
+
+'" data-filter="">All <b>'+esc(String(st.total))+"</b></button>"]
|
|
1354
|
+
.concat(order.map(function(k){
|
|
1355
|
+
var n=Number(st.counts[k]||0),has=!!onScreen[k];
|
|
1356
|
+
return '<button type="button" class="mchip'+(st.filter===k?" on":"")+(has?"":" quiet")
|
|
1357
|
+
+'" data-filter="'+esc(k)+'"'+(has?"":" disabled")+' style="--brand:'+pColor(k)+'">'
|
|
1358
|
+
+'<span class="mchip-mark">'+pSvg(k,13)+"</span>"
|
|
1359
|
+
+esc(pTitle(k))+" <b>"+esc(String(n))+"</b></button>";
|
|
1360
|
+
})).join("");
|
|
1361
|
+
|
|
1362
|
+
var down=st.unavailable.length
|
|
1363
|
+
? '<div class="mention-down">Could not reach '
|
|
1364
|
+
+st.unavailable.map(function(u){return esc(pTitle(u&&u.platform||"?"));}).join(", ")
|
|
1365
|
+
+" โ those networks may have more.</div>"
|
|
1366
|
+
: "";
|
|
1367
|
+
|
|
1368
|
+
var head='<div class="mhead">'
|
|
1369
|
+
+'<div class="mhead-top">'
|
|
1370
|
+
+'<div><div class="mention-term">'+(st.byCategory?"Comment review โ ":"Mentions of ")
|
|
1371
|
+
+"<b>"+esc(st.term)+"</b> "
|
|
1372
|
+
+(st.since?'<span class="mention-window">since '+esc(st.since)
|
|
1373
|
+
+(st.sinceApplied?"":" ยท not applied")
|
|
1374
|
+
+(st.undatedMentions>0?" ยท "+esc(String(st.undatedMentions))+" undated":"")
|
|
1375
|
+
+"</span>":"")+"</div>"
|
|
1376
|
+
+'<div class="mention-total">'+esc(String(st.total))+" comment"+(st.total===1?"":"s")
|
|
1377
|
+
+(st.byCategory
|
|
1378
|
+
?(st.summary?"":"")
|
|
1379
|
+
:(live.length?" across "+esc(String(live.length))+" network"+(live.length===1?"":"s"):""))
|
|
1380
|
+
+"</div>"
|
|
1381
|
+
+(st.summary?'<div class="mention-summary">'+esc(st.summary)+"</div>":"")
|
|
1382
|
+
+"</div>"
|
|
1383
|
+
+'<div class="msort"><button type="button" class="msort-btn'+(st.sort==="loud"?" on":"")
|
|
1384
|
+
+'" data-sort="loud">Loudest</button>'
|
|
1385
|
+
+'<button type="button" class="msort-btn'+(st.sort==="new"?" on":"")
|
|
1386
|
+
+'" data-sort="new">Newest</button></div>'
|
|
1387
|
+
+"</div>"
|
|
1388
|
+
+(st.byCategory?reviewChips(st):'<div class="mchips">'+chips+"</div>")+down+"</div>";
|
|
1389
|
+
|
|
1390
|
+
if(!shown.length){
|
|
1391
|
+
app.innerHTML=head+'<div class="empty-state fade-in"><div class="icon">๐</div>'
|
|
1392
|
+
+'<div class="text">'+(st.filter?"Nothing from "+esc(pTitle(st.filter)):"Nobody has mentioned that yet")
|
|
1393
|
+
+"</div></div>";
|
|
1394
|
+
setTimeout(reportSize,50);
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
var groups=shown.map(function(t){
|
|
1399
|
+
var post=t.post||{},plat=String(post.platform||""),about=!!t.postIsAboutTerm;
|
|
1400
|
+
var reach=postReach(post),link=String(post.externalUrl||"");
|
|
1401
|
+
var n=Number(t.mentionCount||0);
|
|
1402
|
+
var all=t.mentions||[];
|
|
1403
|
+
// A coordinated burst โ seven near-identical fan posts under one Weibo
|
|
1404
|
+
// thread is a real shape in this data โ should read as one loud thing,
|
|
1405
|
+
// not push everything else off the screen. Nothing is dropped: the rest
|
|
1406
|
+
// are one click away, and the click costs nothing.
|
|
1407
|
+
var key=groupKey(t);
|
|
1408
|
+
var open=expandedGroups[key],cut=open?all.length:Math.min(all.length,GROUP_PEEK);
|
|
1409
|
+
var mentions=all.slice(0,cut).map(function(m){
|
|
1410
|
+
// No invented handle. "@someone" reads as an account you could go and
|
|
1411
|
+
// look at, and there is none โ the platform withheld it, or the
|
|
1412
|
+
// commenter is deleted. An empty handle renders as a stated absence
|
|
1413
|
+
// below instead of a fabricated name. (No backticks in here: this
|
|
1414
|
+
// whole view is one TypeScript template literal, and one would end it.)
|
|
1415
|
+
var hits=Number(m.hits||1),id=String(m.id||"");
|
|
1416
|
+
var who=m.username==null?"":String(m.username).trim();
|
|
1417
|
+
var pic=String(m.avatarProxyUrl||m.avatarUrl||"");
|
|
1418
|
+
var picAlt=String(m.avatarUrl||"");
|
|
1419
|
+
// A face and a platform mark, then the words. Reading a feed of
|
|
1420
|
+
// strangers is mostly working out who is talking; a column of handles
|
|
1421
|
+
// makes you read every one of them to find out.
|
|
1422
|
+
var avatar='<span class="mention-av" style="--brand:'+pColor(plat)+'">'
|
|
1423
|
+
+'<span class="mention-av-init">'+esc(initialOf(who))+"</span>"
|
|
1424
|
+
+(pic?'<img class="mention-av-img" data-avatar src="'+esc(pic)+'"'
|
|
1425
|
+
+(picAlt&&picAlt!==pic?' data-fallback="'+esc(picAlt)+'"':"")
|
|
1426
|
+
+' alt="" loading="lazy"/>':"")
|
|
1427
|
+
+'<span class="mention-av-badge">'+pSvg(plat,9)+"</span></span>";
|
|
1428
|
+
return '<label class="mention" data-mention-id="'+esc(id)+'">'
|
|
1429
|
+
+'<input type="checkbox" class="mention-pick" data-mid="'+esc(id)+'"/>'
|
|
1430
|
+
+avatar
|
|
1431
|
+
+'<span class="mention-body">'
|
|
1432
|
+
+'<span class="mention-head">'
|
|
1433
|
+
+(who?'<span class="mention-who">@'+esc(who)+"</span>"
|
|
1434
|
+
:'<span class="mention-who mention-who-unknown">no handle</span>')
|
|
1435
|
+
+(m.postedAt?'<span class="mention-when">'+esc(friendlyTime(m.postedAt))+"</span>":"")
|
|
1436
|
+
+(hits>1?'<span class="mention-hits" title="names it '+hits+' times">ร'+hits+"</span>":"")
|
|
1437
|
+
+"</span>"
|
|
1438
|
+
+'<span class="mention-text">'+highlightTerm(String(m.text||""),st.term)+"</span>"
|
|
1439
|
+
+(m.note?'<span class="mention-note">'+esc(String(m.note))+"</span>":"")
|
|
1440
|
+
+'<span class="mention-meta">'
|
|
1441
|
+
+'<span class="mention-stat">'+heartIcon()+esc(fmtNum(m.likes||0))+"</span>"
|
|
1442
|
+
+'<span class="mention-stat">'+replyIcon()+esc(fmtNum(m.replies||0))+"</span>"
|
|
1443
|
+
+labelChips(m)
|
|
1444
|
+
+"</span></span></label>";
|
|
1445
|
+
}).join("");
|
|
1446
|
+
return '<div class="mgroup" data-platform="'+esc(plat)+'">'
|
|
1447
|
+
+'<div class="mgroup-head">'
|
|
1448
|
+
+'<span class="mgroup-plat" style="--brand:'+pColor(plat)+'">'+pSvg(plat,12)+" "+esc(pTitle(plat))+"</span>"
|
|
1449
|
+
+(about?'<span class="mgroup-about">about '+esc(st.term)+"</span>":"")
|
|
1450
|
+
+(reach?'<span class="mgroup-reach">'+esc(reach)+"</span>":"")
|
|
1451
|
+
+"</div>"
|
|
1452
|
+
+'<div class="mgroup-title">'+esc(String(post.title||post.caption||"").slice(0,140))+"</div>"
|
|
1453
|
+
+'<div class="mgroup-actions">'
|
|
1454
|
+
+(link?'<a class="mgroup-link" href="'+esc(link)+'" target="_blank" rel="noopener">Open โ</a>':"")
|
|
1455
|
+
// A post can name the term while none of its comments do; there is
|
|
1456
|
+
// nothing to select there, so do not offer to select it.
|
|
1457
|
+
+(n?'<button type="button" class="mgroup-all" data-group-all="'+esc(key)+'">Select all '+esc(String(n))+"</button>":"")
|
|
1458
|
+
+"</div>"
|
|
1459
|
+
+(n?'<div class="mentions">'+mentions
|
|
1460
|
+
+(all.length>cut||open&&all.length>GROUP_PEEK
|
|
1461
|
+
?'<button type="button" class="mgroup-rest" data-expand="'+esc(key)+'">'
|
|
1462
|
+
+(open?"Show fewer":"Show "+(all.length-cut)+" more from this post")+"</button>"
|
|
1463
|
+
:"")
|
|
1464
|
+
+"</div>"
|
|
1465
|
+
:'<div class="mgroup-none">The post names '+esc(st.term)+", but none of its comments do.</div>")
|
|
1466
|
+
+"</div>";
|
|
1467
|
+
}).join("");
|
|
1468
|
+
|
|
1469
|
+
var more=st.hasMore&&!st.filter
|
|
1470
|
+
? '<button class="mention-more" type="button" data-next-offset="'+esc(String(st.nextOffset))
|
|
1471
|
+
+'" data-term="'+esc(st.term)+'">Load more mentions</button>'
|
|
1472
|
+
: "";
|
|
1473
|
+
app.innerHTML=head+'<div class="mgroups fade-in">'+groups+"</div>"+more+pickBarHtml();
|
|
1474
|
+
initMentionPicks();
|
|
1475
|
+
// Selections survive a filter or sort: narrowing the view is not unpicking.
|
|
1476
|
+
applyMentionPicks();
|
|
1477
|
+
setTimeout(reportSize,60);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// Filter and sort redraw from memory rather than re-querying โ the view has
|
|
1481
|
+
// no way to call the tool again for free, and the user did not ask to spend.
|
|
1482
|
+
document.addEventListener("click",function(e){
|
|
1483
|
+
var chip=e.target.closest&&e.target.closest(".mchip");
|
|
1484
|
+
if(chip&&monitorState){
|
|
1485
|
+
monitorState.filter=chip.getAttribute("data-filter")||"";
|
|
1486
|
+
renderMonitor();
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
var sort=e.target.closest&&e.target.closest(".msort-btn");
|
|
1490
|
+
if(sort&&monitorState){
|
|
1491
|
+
monitorState.sort=sort.getAttribute("data-sort")||"loud";
|
|
1492
|
+
renderMonitor();
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
var rest=e.target.closest&&e.target.closest("[data-expand]");
|
|
1496
|
+
if(rest&&monitorState){
|
|
1497
|
+
var k=rest.getAttribute("data-expand")||"";
|
|
1498
|
+
expandedGroups[k]=!expandedGroups[k];
|
|
1499
|
+
renderMonitor();
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
var all=e.target.closest&&e.target.closest("[data-group-all]");
|
|
1503
|
+
if(all&&monitorState){
|
|
1504
|
+
// Over the group's comments, not its rendered rows: a collapsed comment
|
|
1505
|
+
// is still one the reader asked for when they said "all".
|
|
1506
|
+
var t=groupByKey(all.getAttribute("data-group-all")||"");
|
|
1507
|
+
if(!t)return;
|
|
1508
|
+
var ids=selectableMentions(t).map(function(m){return String(m.id||"");}).filter(Boolean);
|
|
1509
|
+
var turnOn=ids.some(function(id){return pickedMentions.indexOf(id)<0;});
|
|
1510
|
+
ids.forEach(function(id){
|
|
1511
|
+
var i=pickedMentions.indexOf(id);
|
|
1512
|
+
if(turnOn&&i<0)pickedMentions.push(id);
|
|
1513
|
+
if(!turnOn&&i>=0)pickedMentions.splice(i,1);
|
|
1514
|
+
});
|
|
1515
|
+
applyMentionPicks();
|
|
1516
|
+
}
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
/**
|
|
1520
|
+
* search_spoken_mentions โ the mirror of the brand-monitoring screen above,
|
|
1521
|
+
* for a spoken line rather than a typed comment.
|
|
1522
|
+
*
|
|
1523
|
+
* The excerpt is the star: it is a direct quote of what was actually said,
|
|
1524
|
+
* with the term marked inside it exactly the way a comment's term match is
|
|
1525
|
+
* marked above (highlightTerm, reused verbatim โ same rose highlight, same
|
|
1526
|
+
* escaping discipline, because the text underneath is still a stranger's
|
|
1527
|
+
* words and must not be trusted with markup of its own). Each hit is one
|
|
1528
|
+
* video, identified the way a card identifies one everywhere else in this
|
|
1529
|
+
* template: platform, author, a thumbnail, its reach. And because the
|
|
1530
|
+
* expensive part of this tool is deciding how far to look rather than what
|
|
1531
|
+
* it found, the funnel โ candidates seen, transcribed, captioned, matched โ
|
|
1532
|
+
* is drawn every time, including on an empty result, so a person can tell
|
|
1533
|
+
* "nothing happened" apart from "nothing was said".
|
|
1534
|
+
*/
|
|
1535
|
+
function renderSpoken(){
|
|
1536
|
+
var app=document.getElementById("app");
|
|
1537
|
+
if(!app||!spokenState)return;
|
|
1538
|
+
var st=spokenState;
|
|
1539
|
+
|
|
1540
|
+
// Tallied from the hits actually on screen, not asked for a second
|
|
1541
|
+
// field the backend does not send โ the same principle as onScreen in
|
|
1542
|
+
// renderMonitor.
|
|
1543
|
+
var counts={};
|
|
1544
|
+
st.hits.forEach(function(h){
|
|
1545
|
+
var p=String((h.post||{}).platform||"");
|
|
1546
|
+
counts[p]=(counts[p]||0)+1;
|
|
1547
|
+
});
|
|
1548
|
+
var order=st.platforms.slice().sort(function(a,b){return (counts[b]||0)-(counts[a]||0);});
|
|
1549
|
+
var chips=['<button type="button" class="mchip'+(st.filter?"":" on")
|
|
1550
|
+
+'" data-splatform="">All <b>'+esc(String(st.hits.length))+"</b></button>"]
|
|
1551
|
+
.concat(order.map(function(k){
|
|
1552
|
+
var n=Number(counts[k]||0);
|
|
1553
|
+
return '<button type="button" class="mchip'+(st.filter===k?" on":"")+(n?"":" quiet")
|
|
1554
|
+
+'" data-splatform="'+esc(k)+'"'+(n?"":" disabled")+' style="--brand:'+pColor(k)+'">'
|
|
1555
|
+
+'<span class="mchip-mark">'+pSvg(k,13)+"</span>"
|
|
1556
|
+
+esc(pTitle(k))+" <b>"+esc(String(n))+"</b></button>";
|
|
1557
|
+
})).join("");
|
|
1558
|
+
|
|
1559
|
+
var shown=!st.filter?st.hits.slice()
|
|
1560
|
+
:st.hits.filter(function(h){return String((h.post||{}).platform||"")===st.filter;});
|
|
1561
|
+
shown=shown.slice().sort(function(a,b){
|
|
1562
|
+
return st.sort==="views"
|
|
1563
|
+
? (Number((b.post||{}).views)||0)-(Number((a.post||{}).views)||0)
|
|
1564
|
+
: (Number(b.matchCount)||0)-(Number(a.matchCount)||0)
|
|
1565
|
+
|| (Number((b.post||{}).views)||0)-(Number((a.post||{}).views)||0);
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
var funnel='<div class="stats" style="margin-top:10px">'
|
|
1569
|
+
+'<span class="stat-pill">'+mpIcon("eye",13)+" "+esc(String(st.considered))+" candidate"+(st.considered===1?"":"s")+" considered</span>"
|
|
1570
|
+
+'<span class="stat-pill">'+mpIcon("note",13)+" "+esc(String(st.transcribed))+" transcribed</span>"
|
|
1571
|
+
+'<span class="stat-pill">'+mpIcon("volume",13)+" "+esc(String(st.transcriptsAvailable))+" had captions</span>"
|
|
1572
|
+
+'<span class="stat-pill">'+mpIcon("check",13)+" "+esc(String(st.matched))+" matched</span>"
|
|
1573
|
+
+"</div>";
|
|
1574
|
+
var ceiling=st.ceilingReached
|
|
1575
|
+
? '<div class="mention-down">Only the '+esc(String(st.maxTranscripts))+' most-viewed candidates were '
|
|
1576
|
+
+'transcribed โ '+esc(String(st.considered-st.maxTranscripts))+' more were found and never checked. '
|
|
1577
|
+
+'Raise maxTranscripts to look further.</div>'
|
|
1578
|
+
: "";
|
|
1579
|
+
|
|
1580
|
+
var head='<div class="mhead">'
|
|
1581
|
+
+'<div class="mhead-top">'
|
|
1582
|
+
+'<div><div class="mention-term">Spoken mentions of <b>'+esc(st.term)+"</b>"
|
|
1583
|
+
+(st.platforms.length?'<span class="mention-window">'+esc(st.platforms.map(pTitle).join(" + "))+"</span>":"")
|
|
1584
|
+
+"</div>"
|
|
1585
|
+
+'<div class="mention-total">'+esc(String(st.matched))+" video"+(st.matched===1?"":"s")+" say it out loud</div>"
|
|
1586
|
+
+"</div>"
|
|
1587
|
+
+'<div class="msort"><button type="button" class="msort-btn'+(st.sort==="loud"?" on":"")
|
|
1588
|
+
+'" data-ssort="loud">Loudest</button>'
|
|
1589
|
+
+'<button type="button" class="msort-btn'+(st.sort==="views"?" on":"")
|
|
1590
|
+
+'" data-ssort="views">Most viewed</button></div>'
|
|
1591
|
+
+"</div>"
|
|
1592
|
+
+funnel+ceiling
|
|
1593
|
+
+(order.length>1?'<div class="mchips">'+chips+"</div>":"")
|
|
1594
|
+
+"</div>";
|
|
1595
|
+
|
|
1596
|
+
if(!shown.length){
|
|
1597
|
+
var emptyText=st.filter?"Nothing from "+esc(pTitle(st.filter))
|
|
1598
|
+
:st.transcribed===0?"No candidate posts were found to transcribe"
|
|
1599
|
+
:"Nobody said it on camera in what was checked";
|
|
1600
|
+
app.innerHTML=head+'<div class="empty-state fade-in"><div class="icon">๐</div>'
|
|
1601
|
+
+'<div class="text">'+emptyText+"</div></div>";
|
|
1602
|
+
setTimeout(reportSize,50);
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
var groups=shown.map(function(h){
|
|
1607
|
+
var post=h.post||{},plat=String(post.platform||"");
|
|
1608
|
+
var reach=postReach(post),link=String(post.externalUrl||"");
|
|
1609
|
+
var handle=String(post.creatorHandle||post.author||post.username||"").replace(/^@/,"");
|
|
1610
|
+
var title=String(post.title||post.caption||"").slice(0,160);
|
|
1611
|
+
var thumb=String(post.thumbnailUrl||post.thumbnailProxyUrl||"");
|
|
1612
|
+
var thumbFb=String((post.thumbnailUrl&&(post.thumbnailFallbackUrl||post.thumbnailProxyUrl))||"");
|
|
1613
|
+
var count=Number(h.matchCount||0);
|
|
1614
|
+
var excerpts=(h.excerpts||[]).map(function(ex){
|
|
1615
|
+
var text=String(ex.text||"");
|
|
1616
|
+
return '<div class="quote-box" style="margin:0">'
|
|
1617
|
+
+'<span class="mention-text" style="margin-top:0">'+highlightTerm(text,st.term)+"</span>"
|
|
1618
|
+
+'<button class="copy-btn" data-copy="'+esc(text)+'">Copy</button></div>';
|
|
1619
|
+
}).join("");
|
|
1620
|
+
var shownExcerpts=(h.excerpts||[]).length;
|
|
1621
|
+
var moreMatches=count-shownExcerpts;
|
|
1622
|
+
var meta=[];
|
|
1623
|
+
if(h.wordCount)meta.push('<span class="chip">'+esc(fmtNum(h.wordCount))+" words</span>");
|
|
1624
|
+
if(h.language)meta.push('<span class="chip">'+esc(String(h.language))+"</span>");
|
|
1625
|
+
if(h.autoGenerated)meta.push('<span class="chip">auto-generated</span>');
|
|
1626
|
+
return '<div class="mgroup" data-platform="'+esc(plat)+'">'
|
|
1627
|
+
+'<div class="mgroup-head">'
|
|
1628
|
+
+'<span class="mgroup-plat" style="--brand:'+pColor(plat)+'">'+pSvg(plat,12)+" "+esc(pTitle(plat))+"</span>"
|
|
1629
|
+
+(reach?'<span class="mgroup-reach">'+esc(reach)+"</span>":"")
|
|
1630
|
+
+"</div>"
|
|
1631
|
+
+'<div style="display:flex;gap:10px;align-items:flex-start">'
|
|
1632
|
+
+(thumb?'<img class="sm-thumb" src="'+esc(thumb)+'"'
|
|
1633
|
+
+(thumbFb&&thumbFb!==thumb?' data-fallback="'+esc(thumbFb)+'"':"")
|
|
1634
|
+
+' alt="" loading="lazy"/>':"")
|
|
1635
|
+
+'<div style="flex:1;min-width:0"><div class="mgroup-title">'
|
|
1636
|
+
+(handle?'<span style="color:var(--muted);font-weight:500">@'+esc(handle)+"</span> โ ":"")
|
|
1637
|
+
+esc(title)+"</div></div></div>"
|
|
1638
|
+
+'<div class="mgroup-actions">'
|
|
1639
|
+
+(link?'<a class="mgroup-link" href="'+esc(link)+'" target="_blank" rel="noopener">Open โ</a>':"")
|
|
1640
|
+
+'<span class="mention-hits" title="names it '+esc(String(count))+' times">ร'+esc(String(count))+"</span>"
|
|
1641
|
+
+meta.join("")
|
|
1642
|
+
+"</div>"
|
|
1643
|
+
+'<div style="display:flex;flex-direction:column;gap:8px;margin-top:10px">'+excerpts
|
|
1644
|
+
+(moreMatches>0?'<div class="faint" style="font-size:11px;color:var(--muted)">+'
|
|
1645
|
+
+esc(String(moreMatches))+" more mention"+(moreMatches===1?"":"s")+" later in this video, not shown</div>":"")
|
|
1646
|
+
+"</div></div>";
|
|
1647
|
+
}).join("");
|
|
1648
|
+
|
|
1649
|
+
var unavail=st.unavailableCount
|
|
1650
|
+
? '<div class="faint" style="font-size:11px;color:var(--muted);margin-top:14px;text-align:center">'
|
|
1651
|
+
+esc(String(st.unavailableCount))+" candidate"+(st.unavailableCount===1?"":"s")
|
|
1652
|
+
+" could not be reached or transcribed.</div>"
|
|
1653
|
+
: "";
|
|
1654
|
+
|
|
1655
|
+
app.innerHTML=head+'<div class="mgroups fade-in">'+groups+"</div>"+unavail;
|
|
1656
|
+
setTimeout(reportSize,60);
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
// Same reasoning as renderMonitor's own delegate: redraw from memory, never
|
|
1660
|
+
// re-fetch. Distinct data attributes (data-splatform/data-ssort rather than
|
|
1661
|
+
// data-filter/data-sort) so this cannot be confused with the monitor's own
|
|
1662
|
+
// clicks even though both reuse the same .mchip/.msort-btn look.
|
|
1663
|
+
document.addEventListener("click",function(e){
|
|
1664
|
+
var chip=e.target.closest&&e.target.closest("[data-splatform]");
|
|
1665
|
+
if(chip&&spokenState){
|
|
1666
|
+
if(chip.hasAttribute("disabled"))return;
|
|
1667
|
+
spokenState.filter=chip.getAttribute("data-splatform")||"";
|
|
1668
|
+
renderSpoken();
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
var sort=e.target.closest&&e.target.closest("[data-ssort]");
|
|
1672
|
+
if(sort&&spokenState){
|
|
1673
|
+
spokenState.sort=sort.getAttribute("data-ssort")||"loud";
|
|
1674
|
+
renderSpoken();
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
});
|
|
1678
|
+
|
|
1679
|
+
/**
|
|
1680
|
+
* Make what is on screen agree with the selection โ after a redraw, and
|
|
1681
|
+
* after a select-all that reached comments no row is showing.
|
|
1682
|
+
*/
|
|
1683
|
+
function applyMentionPicks(){
|
|
1684
|
+
document.querySelectorAll(".mention-pick").forEach(function(b){
|
|
1685
|
+
var on=pickedMentions.indexOf(b.getAttribute("data-mid")||"")>=0;
|
|
1686
|
+
b.checked=on;
|
|
1687
|
+
var row=b.closest(".mention");
|
|
1688
|
+
if(row)row.classList.toggle("picked",on);
|
|
1689
|
+
});
|
|
1690
|
+
document.querySelectorAll("[data-group-all]").forEach(function(b){
|
|
1691
|
+
var t=groupByKey(b.getAttribute("data-group-all")||"");
|
|
1692
|
+
if(!t)return;
|
|
1693
|
+
var ids=selectableMentions(t).map(function(m){return String(m.id||"");}).filter(Boolean);
|
|
1694
|
+
var allOn=ids.length&&ids.every(function(id){return pickedMentions.indexOf(id)>=0;});
|
|
1695
|
+
b.textContent=allOn?"Clear these":"Select all "+ids.length;
|
|
1696
|
+
});
|
|
1697
|
+
syncMentionBar();
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
/**
|
|
1701
|
+
* Show where the term actually appears in a comment.
|
|
1702
|
+
*
|
|
1703
|
+
* Walked by index rather than matched by regex: this template is embedded in
|
|
1704
|
+
* a Rust raw string as well as a TS literal, and a backslash means different
|
|
1705
|
+
* things in the two โ so the file carries none at all. Escaping each slice
|
|
1706
|
+
* separately also means the mark cannot be injected by the comment text,
|
|
1707
|
+
* every word of which came from a stranger.
|
|
1708
|
+
*/
|
|
1709
|
+
function highlightTerm(text,term){
|
|
1710
|
+
var src=String(text||""),t=String(term||"").trim();
|
|
1711
|
+
if(!t)return esc(src);
|
|
1712
|
+
var low=src.toLowerCase(),needle=t.toLowerCase(),out="",i=0;
|
|
1713
|
+
for(;;){
|
|
1714
|
+
var at=low.indexOf(needle,i);
|
|
1715
|
+
if(at<0){out+=esc(src.slice(i));break;}
|
|
1716
|
+
out+=esc(src.slice(i,at))+"<mark>"+esc(src.slice(at,at+needle.length))+"</mark>";
|
|
1717
|
+
i=at+needle.length;
|
|
1718
|
+
}
|
|
1719
|
+
return out;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
/**
|
|
1723
|
+
* The time the way someone would say it. A monitoring feed is read for
|
|
1724
|
+
* recency above all โ "today at 7:25 PM" answers the question the reader
|
|
1725
|
+
* actually has, which "2026-09-01T19:25:04Z" does not.
|
|
1726
|
+
*/
|
|
1727
|
+
function friendlyTime(v){
|
|
1728
|
+
var s=String(v||"");
|
|
1729
|
+
if(!s)return "";
|
|
1730
|
+
var d=new Date(s);
|
|
1731
|
+
if(isNaN(d.getTime()))return s.slice(0,10);
|
|
1732
|
+
var now=new Date(),day=86400000;
|
|
1733
|
+
var clock=d.toLocaleTimeString([],{hour:"numeric",minute:"2-digit"});
|
|
1734
|
+
if(d.toDateString()===now.toDateString())return "today at "+clock;
|
|
1735
|
+
if(d.toDateString()===new Date(now.getTime()-day).toDateString())return "yesterday at "+clock;
|
|
1736
|
+
var days=Math.floor((now.getTime()-d.getTime())/day);
|
|
1737
|
+
if(days>0&&days<7)return days+" days ago";
|
|
1738
|
+
var date=d.toLocaleDateString([],{month:"short",day:"numeric"});
|
|
1739
|
+
return d.getFullYear()===now.getFullYear()?date:date+", "+d.getFullYear();
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
function heartIcon(){
|
|
1743
|
+
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21s-7.5-4.9-9.3-9A5.2 5.2 0 0 1 12 6.2 5.2 5.2 0 0 1 21.3 12c-1.8 4.1-9.3 9-9.3 9z"/></svg>';
|
|
1744
|
+
}
|
|
1745
|
+
function replyIcon(){
|
|
1746
|
+
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 11.5a8.4 8.4 0 0 1-9 8.4 9.9 9.9 0 0 1-4.2-.9L3 20.5l1.6-4.6A8.2 8.2 0 0 1 3.6 11.5a8.4 8.4 0 0 1 9-8.4 8.4 8.4 0 0 1 8.4 8.4z"/></svg>';
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
/** First letter of a handle, for when the picture will not load. */
|
|
1750
|
+
function initialOf(name){
|
|
1751
|
+
var s=String(name||"").replace("@","").trim();
|
|
1752
|
+
return s?s.charAt(0).toUpperCase():"?";
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
/** "2026-08-18" rather than a full timestamp: the day is the useful part. */
|
|
1756
|
+
function shortDate(v){
|
|
1757
|
+
var s=String(v||"");
|
|
1758
|
+
if(!s)return "";
|
|
1759
|
+
var d=new Date(s);
|
|
1760
|
+
if(!isNaN(d.getTime()))return d.toISOString().slice(0,10);
|
|
1761
|
+
return s.slice(0,10);
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
/**
|
|
1765
|
+
* Selected comments, so an agent can be handed exactly the ones a person
|
|
1766
|
+
* picked โ reply to these, escalate those. The ids come straight from the
|
|
1767
|
+
* tool result, so what the host receives addresses the same comments.
|
|
1768
|
+
*/
|
|
1769
|
+
var pickedMentions=[];
|
|
1770
|
+
var mentionPicksWired=false;
|
|
1771
|
+
// Idempotent: the monitoring screen redraws on every filter and sort, and a
|
|
1772
|
+
// listener added per redraw would fire a selection several times over.
|
|
1773
|
+
function initMentionPicks(){
|
|
1774
|
+
if(!mentionPicksWired){
|
|
1775
|
+
mentionPicksWired=true;
|
|
1776
|
+
document.addEventListener("change",onMentionPick);
|
|
1777
|
+
document.addEventListener("click",function(e){
|
|
1778
|
+
var c=e.target.closest&&e.target.closest("#pickclear");
|
|
1779
|
+
if(!c||!pickedMentions.length)return;
|
|
1780
|
+
pickedMentions=[];
|
|
1781
|
+
applyMentionPicks();
|
|
1782
|
+
},true);
|
|
1783
|
+
}
|
|
1784
|
+
syncMentionBar();
|
|
1785
|
+
}
|
|
1786
|
+
function onMentionPick(e){
|
|
1787
|
+
var box=e.target;
|
|
1788
|
+
if(!box||!box.classList||!box.classList.contains("mention-pick"))return;
|
|
1789
|
+
var id=box.getAttribute("data-mid")||"";
|
|
1790
|
+
var i=pickedMentions.indexOf(id);
|
|
1791
|
+
if(box.checked&&i<0)pickedMentions.push(id);
|
|
1792
|
+
if(!box.checked&&i>=0)pickedMentions.splice(i,1);
|
|
1793
|
+
var row=box.closest(".mention");
|
|
1794
|
+
if(row)row.classList.toggle("picked",box.checked);
|
|
1795
|
+
syncMentionBar();
|
|
1796
|
+
}
|
|
1797
|
+
function syncMentionBar(){
|
|
1798
|
+
var bar=document.getElementById("pickbar");
|
|
1799
|
+
if(!bar)return;
|
|
1800
|
+
var n=document.getElementById("pickn");
|
|
1801
|
+
if(pickedMentions.length){
|
|
1802
|
+
bar.hidden=false;
|
|
1803
|
+
if(n)n.textContent=pickedMentions.length;
|
|
1804
|
+
var hint=document.getElementById("pickhint");
|
|
1805
|
+
if(hint)hint.textContent=pickedMentions.length===1?"1 comment selected":pickedMentions.length+" comments selected";
|
|
1806
|
+
var go=document.getElementById("pickgo");
|
|
1807
|
+
if(go){go.disabled=false;go.textContent="Analyse these";}
|
|
1808
|
+
}else{
|
|
1809
|
+
bar.hidden=true;
|
|
1810
|
+
}
|
|
1811
|
+
setTimeout(reportSize,60);
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
// Selected comments go to the host as a tool call. analyze_comments's own
|
|
1815
|
+
// inputSchema is {url, limit?}.strict() โ one post, not an arbitrary
|
|
1816
|
+
// hand-picked set of texts/ids โ so this used to post {comments, ids}
|
|
1817
|
+
// regardless of how many posts the selection spanned, and a real host
|
|
1818
|
+
// executing that call got a schema rejection every time; the "Try in
|
|
1819
|
+
// chat"/timeout fallback was the only thing anyone ever saw work. Now:
|
|
1820
|
+
// find which post(s) the picks belong to, and if they are all the same
|
|
1821
|
+
// one, ask analyze_comments to re-read that post's comments (the nearest
|
|
1822
|
+
// thing to "analyse these" the tool can actually do); otherwise say why
|
|
1823
|
+
// not, rather than sending a call the schema will refuse.
|
|
1824
|
+
document.addEventListener("click",function(e){
|
|
1825
|
+
var go=e.target.closest&&e.target.closest("#pickgo");
|
|
1826
|
+
if(!go||!pickedMentions.length)return;
|
|
1827
|
+
if(!document.querySelector(".mention-pick"))return; // the compare bar owns it
|
|
1828
|
+
e.stopPropagation();
|
|
1829
|
+
var urls={};
|
|
1830
|
+
(monitorState?monitorState.threads:[]).forEach(function(t){
|
|
1831
|
+
var u=t.post&&(t.post.externalUrl||t.post.url);
|
|
1832
|
+
if(!u)return;
|
|
1833
|
+
(t.mentions||[]).forEach(function(m){
|
|
1834
|
+
if(pickedMentions.indexOf(String(m.id||""))>=0)urls[u]=true;
|
|
1835
|
+
});
|
|
1836
|
+
});
|
|
1837
|
+
var distinct=Object.keys(urls);
|
|
1838
|
+
if(distinct.length!==1){
|
|
1839
|
+
var was=go.textContent;
|
|
1840
|
+
go.textContent=distinct.length===0?"Couldn't find that post":"Pick comments from one post only";
|
|
1841
|
+
setTimeout(function(){go.textContent=was;},2200);
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
go.disabled=true;go.textContent="Sendingโฆ";
|
|
1845
|
+
invokeTool("analyze_comments",{url:distinct[0],limit:Math.max(pickedMentions.length,20)},function(){
|
|
1846
|
+
go.textContent="Sent โ";
|
|
1847
|
+
setTimeout(function(){go.disabled=false;go.textContent="Analyse these";},1800);
|
|
1848
|
+
},function(){
|
|
1849
|
+
go.textContent="Try in chat";
|
|
1850
|
+
setTimeout(function(){go.disabled=false;go.textContent="Analyse these";},1800);
|
|
1851
|
+
});
|
|
1852
|
+
},true);
|
|
1853
|
+
|
|
1854
|
+
// Load more asks the host to re-run the tool with the next offset, which is
|
|
1855
|
+
// the only way a sandboxed view can page: it has no data of its own.
|
|
1856
|
+
document.addEventListener("click",function(e){
|
|
1857
|
+
var b=e.target.closest&&e.target.closest(".mention-more");
|
|
1858
|
+
if(!b||b.disabled)return;
|
|
1859
|
+
b.disabled=true;
|
|
1860
|
+
var was=b.textContent;
|
|
1861
|
+
b.textContent="Loadingโฆ";
|
|
1862
|
+
invokeTool("search_mentions",{
|
|
1863
|
+
term:b.getAttribute("data-term")||"",
|
|
1864
|
+
offset:Number(b.getAttribute("data-next-offset")||0),
|
|
1865
|
+
},function(){ b.textContent="Loaded โ"; },function(){
|
|
1866
|
+
b.disabled=false;b.textContent=was;
|
|
1867
|
+
});
|
|
1868
|
+
});
|
|
1869
|
+
|
|
1870
|
+
function pSvg(p,size){
|
|
1871
|
+
var s=size||14,paths={
|
|
1872
|
+
reddit:"M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286A.72.72 0 001.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0zm4.388 3.199a1.999 1.999 0 11-1.947 2.46v.002a2.37 2.37 0 00-2.032 2.341v.007c1.334.065 2.559.371 3.591.923a1.998 1.998 0 112.086 3.372c.005.061.007.121.007.183 0 2.891-3.611 5.235-8.067 5.235-4.455 0-8.066-2.344-8.066-5.235 0-.062.002-.122.007-.183a1.998 1.998 0 112.086-3.372c1.032-.552 2.257-.858 3.591-.923v-.007a3.77 3.77 0 013.412-3.751 1.999 1.999 0 011.332-1.052zM9.25 12a1.25 1.25 0 100 2.5 1.25 1.25 0 000-2.5zm5.5 0a1.25 1.25 0 100 2.5 1.25 1.25 0 000-2.5zm-5.466 3.99a.327.327 0 00-.231.094.33.33 0 000 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 00.029-.463.33.33 0 00-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 00-.232-.095z",
|
|
1873
|
+
weibo:"M10.098 20.323c-3.977.391-7.414-1.406-7.672-4.02-.259-2.609 2.759-5.047 6.74-5.441 3.979-.394 7.413 1.404 7.671 4.018.259 2.6-2.759 5.049-6.737 5.439l-.002.004zM9.05 17.219c-.384.616-1.208.884-1.829.602-.612-.279-.793-.991-.406-1.593.379-.595 1.176-.861 1.793-.601.622.263.82.972.442 1.592zm1.027-1.32c-.141.237-.449.353-.689.253-.236-.09-.312-.359-.177-.586.138-.227.436-.346.672-.24.239.09.315.36.18.601l.014-.028zm.176-2.719c-1.893-.493-4.033.45-4.857 2.118-.836 1.704-.026 3.591 1.886 4.21 1.983.64 4.318-.341 5.132-2.179.8-1.793-.201-3.642-2.161-4.149zm7.563-1.224c-.346-.105-.57-.18-.405-.615.375-.977.42-1.804 0-2.4-.781-1.112-2.915-1.053-5.364-.03 0 0-.766.331-.571-.271.376-1.217.315-2.224-.27-2.815-1.336-1.335-4.891.045-7.94 3.089C1.009 11.104 0 13.499 0 15.564c0 3.951 5.067 6.36 10.028 6.36 6.504 0 10.834-3.782 10.834-6.78 0-1.809-1.529-2.835-2.897-3.264l-.149-.03v.106zm2.482-6.135c-1.502-1.666-3.716-2.305-5.767-1.869a.914.914 0 00-.705 1.086.916.916 0 001.086.705c1.454-.309 3.03.15 4.096 1.327 1.061 1.18 1.363 2.79.9 4.2a.918.918 0 00.585 1.155.913.913 0 001.155-.585c.66-1.984.238-4.25-1.259-5.909l-.091-.11zm-2.204 1.995c-.732-.81-1.812-1.125-2.81-.915a.789.789 0 10.33 1.545c.489-.105 1.02.045 1.38.45.359.401.464.945.315 1.425a.795.795 0 00.51.996.79.79 0 00.996-.51c.301-.976.09-2.086-.646-2.895l-.075-.096z",
|
|
1874
|
+
tiktok:"M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z",
|
|
1875
|
+
instagram:"M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077",
|
|
1876
|
+
youtube:"M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z",
|
|
1877
|
+
x:"M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z",
|
|
1878
|
+
twitter:"M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z",
|
|
1879
|
+
douyin:"M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z",
|
|
1880
|
+
xiaohongshu:"M22.405 9.879c.002.016.01.02.07.019h.725a.797.797 0 0 0 .78-.972.794.794 0 0 0-.884-.618.795.795 0 0 0-.692.794c0 .101-.002.666.001.777zm-11.509 4.808c-.203.001-1.353.004-1.685.003a2.528 2.528 0 0 1-.766-.126.025.025 0 0 0-.03.014L7.7 16.127a.025.025 0 0 0 .01.032c.111.06.336.124.495.124.66.01 1.32.002 1.981 0 .01 0 .02-.006.023-.015l.712-1.545a.025.025 0 0 0-.024-.036zM.477 9.91c-.071 0-.076.002-.076.01a.834.834 0 0 0-.01.08c-.027.397-.038.495-.234 3.06-.012.24-.034.389-.135.607-.026.057-.033.042.003.112.046.092.681 1.523.787 1.74.008.015.011.02.017.02.008 0 .033-.026.047-.044.147-.187.268-.391.371-.606.306-.635.44-1.325.486-1.706.014-.11.021-.22.03-.33l.204-2.616.022-.293c.003-.029 0-.033-.03-.034zm7.203 3.757a1.427 1.427 0 0 1-.135-.607c-.004-.084-.031-.39-.235-3.06a.443.443 0 0 0-.01-.082c-.004-.011-.052-.008-.076-.008h-1.48c-.03.001-.034.005-.03.034l.021.293c.076.982.153 1.964.233 2.946.05.4.186 1.085.487 1.706.103.215.223.419.37.606.015.018.037.051.048.049.02-.003.742-1.642.804-1.765.036-.07.03-.055.003-.112zm3.861-.913h-.872a.126.126 0 0 1-.116-.178l1.178-2.625a.025.025 0 0 0-.023-.035l-1.318-.003a.148.148 0 0 1-.135-.21l.876-1.954a.025.025 0 0 0-.023-.035h-1.56c-.01 0-.02.006-.024.015l-.926 2.068c-.085.169-.314.634-.399.938a.534.534 0 0 0-.02.191.46.46 0 0 0 .23.378.981.981 0 0 0 .46.119h.59c.041 0-.688 1.482-.834 1.972a.53.53 0 0 0-.023.172.465.465 0 0 0 .23.398c.15.092.342.12.475.12l1.66-.001c.01 0 .02-.006.023-.015l.575-1.28a.025.025 0 0 0-.024-.035zm-6.93-4.937H3.1a.032.032 0 0 0-.034.033c0 1.048-.01 2.795-.01 6.829 0 .288-.269.262-.28.262h-.74c-.04.001-.044.004-.04.047.001.037.465 1.064.555 1.263.01.02.03.033.051.033.157.003.767.009.938-.014.153-.02.3-.06.438-.132.3-.156.49-.419.595-.765.052-.172.075-.353.075-.533.002-2.33 0-4.66-.007-6.991a.032.032 0 0 0-.032-.032zm11.784 6.896c0-.014-.01-.021-.024-.022h-1.465c-.048-.001-.049-.002-.05-.049v-4.66c0-.072-.005-.07.07-.07h.863c.08 0 .075.004.075-.074V8.393c0-.082.006-.076-.08-.076h-3.5c-.064 0-.075-.006-.075.073v1.445c0 .083-.006.077.08.077h.854c.075 0 .07-.004.07.07v4.624c0 .095.008.084-.085.084-.37 0-1.11-.002-1.304 0-.048.001-.06.03-.06.03l-.697 1.519s-.014.025-.008.036c.006.01.013.008.058.008 1.748.003 3.495.002 5.243.002.03-.001.034-.006.035-.033v-1.539zm4.177-3.43c0 .013-.007.023-.02.024-.346.006-.692.004-1.037.004-.014-.002-.022-.01-.022-.024-.005-.434-.007-.869-.01-1.303 0-.072-.006-.071.07-.07l.733-.003c.041 0 .081.002.12.015.093.025.16.107.165.204.006.431.002 1.153.001 1.153zm2.67.244a1.953 1.953 0 0 0-.883-.222h-.18c-.04-.001-.04-.003-.042-.04V10.21c0-.132-.007-.263-.025-.394a1.823 1.823 0 0 0-.153-.53 1.533 1.533 0 0 0-.677-.71 2.167 2.167 0 0 0-1-.258c-.153-.003-.567 0-.72 0-.07 0-.068.004-.068-.065V7.76c0-.031-.01-.041-.046-.039H17.93s-.016 0-.023.007c-.006.006-.008.012-.008.023v.546c-.008.036-.057.015-.082.022h-.95c-.022.002-.028.008-.03.032v1.481c0 .09-.004.082.082.082h.913c.082 0 .072.128.072.128V11.19s.003.117-.06.117h-1.482c-.068 0-.06.082-.06.082v1.445s-.01.068.064.068h1.457c.082 0 .076-.006.076.079v3.225c0 .088-.007.081.082.081h1.43c.09 0 .082.007.082-.08v-3.27c0-.029.006-.035.033-.035l2.323-.003c.098 0 .191.02.28.061a.46.46 0 0 1 .274.407c.008.395.003.79.003 1.185 0 .259-.107.367-.33.367h-1.218c-.023.002-.029.008-.028.033.184.437.374.871.57 1.303a.045.045 0 0 0 .04.026c.17.005.34.002.51.003.15-.002.517.004.666-.01a2.03 2.03 0 0 0 .408-.075c.59-.18.975-.698.976-1.313v-1.981c0-.128-.01-.254-.034-.38 0 .078-.029-.641-.724-.998z",
|
|
1881
|
+
bilibili:"M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z",
|
|
1882
|
+
linkedin:"M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z",
|
|
1883
|
+
unknown:"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"
|
|
1884
|
+
};
|
|
1885
|
+
var d=paths[p]||paths.unknown;
|
|
1886
|
+
return '<svg viewBox="0 0 24 24" width="'+s+'" height="'+s+'" fill="currentColor" style="vertical-align:-2px;flex-shrink:0;display:inline-block" aria-hidden="true"><path d="'+d+'"/></svg>';
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
function copyText(text){
|
|
1890
|
+
navigator.clipboard.writeText(text).catch(function(){});
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// โโโ Post Card โโโ
|
|
1894
|
+
// โโโ Media player markup โโโ
|
|
1895
|
+
// Video and slideshow render the SAME shell: same overlay furniture, same
|
|
1896
|
+
// control bar, same order. Only the stage contents differ โ a <video>, or a
|
|
1897
|
+
// stack of slides with the post's track behind them. Everything below the
|
|
1898
|
+
// stage is byte-for-byte identical between the two, which is the point:
|
|
1899
|
+
// controls must not move or change shape from one post to the next.
|
|
1900
|
+
var mpId=0;
|
|
1901
|
+
// Aspect class of the most recent player, so the enclosing card can adopt it.
|
|
1902
|
+
var lastPlayerAr="v";
|
|
1903
|
+
var lastVideoFallback="";
|
|
1904
|
+
function mediaPlayerHtml(p,images,video,platform,pickable){
|
|
1905
|
+
var id="mp"+(mpId++);
|
|
1906
|
+
var pickUrl=p.externalUrl||p.url||"";
|
|
1907
|
+
pickable=!!pickable&&!!pickUrl;
|
|
1908
|
+
// A photo post carries its slides AND a server-rendered slideshow video.
|
|
1909
|
+
// Judging by "no video" alone picked that video, so the stage was a black
|
|
1910
|
+
// rectangle while the separate audio element played on - the caller saw a
|
|
1911
|
+
// black screen with music. contentType is what actually says photo post,
|
|
1912
|
+
// and postCard already worked that out before calling this.
|
|
1913
|
+
var ctype=p.contentType||"";
|
|
1914
|
+
var isSlideshow=images.length>1&&(ctype==="slideshow"||ctype==="carousel"||!video);
|
|
1915
|
+
var music=p.musicUrl||p.musicProxyUrl||"";
|
|
1916
|
+
var musicFallback=(p.musicUrl&&(p.musicFallbackUrl||p.musicProxyUrl))||"";
|
|
1917
|
+
var thumbFallback=(p.thumbnailUrl&&(p.thumbnailFallbackUrl||p.thumbnailProxyUrl))||"";
|
|
1918
|
+
var thumb=p.thumbnailUrl||p.thumbnailProxyUrl||"";
|
|
1919
|
+
var songLabel=[p.musicTitle||"",p.musicAuthor||""].filter(Boolean).join(" ยท ");
|
|
1920
|
+
|
|
1921
|
+
// Shape: vertical is the short-form default; long-form and the desktop
|
|
1922
|
+
// networks are wide; carousels on those networks are square.
|
|
1923
|
+
var fmt=p.detectedFormat||"",dur=Number(p.duration)||0,ar="v";
|
|
1924
|
+
var wide=(platform==="youtube"&&!(dur>0&&dur<=60))||platform==="linkedin"||platform==="twitter"||platform==="x";
|
|
1925
|
+
if(fmt==="Long-form")wide=true;
|
|
1926
|
+
if(fmt==="Short-form")wide=false;
|
|
1927
|
+
if(wide)ar=isSlideshow?"sq":"h";
|
|
1928
|
+
|
|
1929
|
+
// Stage
|
|
1930
|
+
var stage="";
|
|
1931
|
+
if(video&&!isSlideshow){
|
|
1932
|
+
// A /media/resolve link is not a CDN URL: the server downloads the
|
|
1933
|
+
// whole video with yt-dlp before it answers. "metadata" preload starts
|
|
1934
|
+
// that for every card on screen, unprompted - eight Bilibili results
|
|
1935
|
+
// meant eight full downloads nobody asked for, which is what gets us
|
|
1936
|
+
// rate limited. Those wait for play; a real CDN URL still preloads,
|
|
1937
|
+
// because there it costs a range request.
|
|
1938
|
+
var pre=video.indexOf("/media/resolve")>=0?"none":"metadata";
|
|
1939
|
+
stage='<video src="'+esc(video)+'" preload="'+pre+'" playsinline'
|
|
1940
|
+
+(thumb?' poster="'+esc(thumb)+'"':"")
|
|
1941
|
+
+(thumbFallback?' data-poster-fallback="'+esc(thumbFallback)+'"':"")+"></video>";
|
|
1942
|
+
}else{
|
|
1943
|
+
// A failed slide had nowhere to go. The thumbnail carries a fallback and
|
|
1944
|
+
// self-heals through the resolver; slides carried none, so one dead
|
|
1945
|
+
// image left a black stage with the music still listed underneath and no
|
|
1946
|
+
// way back - permanently, since nothing retries. The post cover is not
|
|
1947
|
+
// the right slide, but it is the right shape and it heals the same way,
|
|
1948
|
+
// which beats a black card.
|
|
1949
|
+
var slideFb=p.thumbnailFallbackUrl||p.thumbnailProxyUrl||p.thumbnailUrl||"";
|
|
1950
|
+
for(var i=0;i<images.length;i++)
|
|
1951
|
+
stage+='<img class="mp-slide'+(i===0?" active":"")+'" src="'+esc(images[i])
|
|
1952
|
+
+'" alt="slide '+(i+1)+'" loading="'+(i===0?"eager":"lazy")+'"'
|
|
1953
|
+
+(slideFb&&slideFb!==images[i]?' data-fallback="'+esc(slideFb)+'"':"")+"/>";
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
// Overlay: brand badge, slide counter, engagement rail, caption block.
|
|
1957
|
+
var acts=[["heart",p.likes,"likes"],["comment",p.comments,"comments"],
|
|
1958
|
+
["share",p.shares,"shares"],["eye",p.views,"views"]]
|
|
1959
|
+
.filter(function(a){return Number(a[1])>0;})
|
|
1960
|
+
.map(function(a){
|
|
1961
|
+
return '<div class="mp-act" title="'+fmtNum(a[1])+" "+a[2]+'">'
|
|
1962
|
+
+'<div class="ic">'+mpIcon(a[0],19)+"</div>"
|
|
1963
|
+
+'<div class="n">'+fmtNum(a[1])+"</div></div>";
|
|
1964
|
+
}).join("");
|
|
1965
|
+
var handle=p.creatorHandle?'<div class="mp-handle">@'+esc(p.creatorHandle)+"</div>":"";
|
|
1966
|
+
var capText=p.caption||p.title||"";
|
|
1967
|
+
var cap=capText?'<div class="mp-cap">'+esc(capText)+"</div>":"";
|
|
1968
|
+
var song=songLabel||music
|
|
1969
|
+
? '<div class="mp-song"><span class="note">'+mpIcon("note",12)+"</span>"
|
|
1970
|
+
+'<span class="mp-song-t">'+esc(songLabel||"original sound")+"</span></div>"
|
|
1971
|
+
: "";
|
|
1972
|
+
var counter=isSlideshow?'<div class="mp-chip"><span class="mp-idx">1</span>/'+images.length+"</div>":"";
|
|
1973
|
+
var navs=isSlideshow
|
|
1974
|
+
? '<button class="mp-nav prev" type="button" data-dir="-1" aria-label="Previous slide">'+mpIcon("prev",20)+"</button>"
|
|
1975
|
+
+'<button class="mp-nav next" type="button" data-dir="1" aria-label="Next slide">'+mpIcon("next",20)+"</button>"
|
|
1976
|
+
: "";
|
|
1977
|
+
|
|
1978
|
+
// Seek widget: one segment for video, one per slide for a slideshow.
|
|
1979
|
+
var segCount=isSlideshow?images.length:1,segs="";
|
|
1980
|
+
for(var k=0;k<segCount;k++)segs+='<div class="mp-seg"><i></i></div>';
|
|
1981
|
+
|
|
1982
|
+
var label=(p.creatorHandle?"@"+p.creatorHandle+" โ ":"")+(isSlideshow?"slideshow":"video");
|
|
1983
|
+
lastPlayerAr=ar;
|
|
1984
|
+
return '<div class="mp '+ar+'" id="'+id+'" data-mp="'+(isSlideshow?"slides":"video")+'"'
|
|
1985
|
+
+(Number(p.duration)>0?' data-mp-dur="'+Math.floor(Number(p.duration))+'"':"")
|
|
1986
|
+
+(lastVideoFallback?' data-mp-fallback="'+esc(lastVideoFallback)+'"':"")
|
|
1987
|
+
+(isSlideshow?' data-slides="'+images.length+'"':"")
|
|
1988
|
+
+' tabindex="0" role="group" aria-label="'+esc(label)+'">'
|
|
1989
|
+
+'<div class="mp-stage">'+stage
|
|
1990
|
+
+'<div class="mp-spin" hidden><i></i></div>'
|
|
1991
|
+
+'<div class="mp-err" hidden>'+mpIcon("warn",22)
|
|
1992
|
+
+"<span>This media could not be loaded</span></div>"
|
|
1993
|
+
+"</div>"
|
|
1994
|
+
+'<div class="mp-ov">'
|
|
1995
|
+
+'<div class="mp-badge">'+pSvg(platform,12)+" "+esc(platform)+"</div>"
|
|
1996
|
+
+(pickable?'<button class="mp-pick" type="button" aria-pressed="false" data-pick-id="'+id+'" data-pick="'+esc(pickUrl)+'" title="Select for comparison">'+mpIcon("check",15)+"</button>":"")
|
|
1997
|
+
+(pickUrl?'<a class="mp-open'+(pickable?" with-pick":"")+'" href="'+esc(pickUrl)+'" target="_blank" rel="noopener" title="Open on '+esc(platform)+'" aria-label="Open on '+esc(platform)+'">'+mpIcon("open",14)+"</a>":"")
|
|
1998
|
+
+counter+navs
|
|
1999
|
+
+'<div class="mp-rail">'+acts+"</div>"
|
|
2000
|
+
+'<div class="mp-meta">'+handle+cap+song+"</div>"
|
|
2001
|
+
+'<button class="mp-tap" type="button" aria-label="Play"><span>'+mpIcon("play",28)+"</span></button>"
|
|
2002
|
+
+"</div>"
|
|
2003
|
+
+'<div class="mp-ctl">'
|
|
2004
|
+
+'<button class="mp-btn mp-play" type="button" aria-label="Play">'+mpIcon("play",16)+"</button>"
|
|
2005
|
+
+'<span class="mp-time mp-cur">0:00</span>'
|
|
2006
|
+
+'<div class="mp-seek" role="slider" tabindex="-1" aria-label="Seek"'
|
|
2007
|
+
+' aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">'
|
|
2008
|
+
+'<div class="mp-segs">'+segs+"</div></div>"
|
|
2009
|
+
+'<span class="mp-time mp-dur">0:00</span>'
|
|
2010
|
+
+'<button class="mp-icon mp-mute" type="button" aria-label="Mute" aria-pressed="false">'
|
|
2011
|
+
+mpIcon("volume",17)+"</button>"
|
|
2012
|
+
+"</div>"
|
|
2013
|
+
+(music?'<audio preload="metadata" src="'+esc(music)+'"'
|
|
2014
|
+
+(musicFallback?' data-fallback="'+esc(musicFallback)+'"':"")+"></audio>":"")
|
|
2015
|
+
+"</div>";
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
/**
|
|
2019
|
+
* The one line a competitor sweep or an underperformance check actually
|
|
2020
|
+
* exists to produce โ ratio to the creator's own median, and the verdict
|
|
2021
|
+
* that ratio earns. track_competitor and why_did_this_underperform attach
|
|
2022
|
+
* "standing" to every post they score (see performance.ts), and until this
|
|
2023
|
+
* existed postCard rendered the same view/like/comment pills any gallery
|
|
2024
|
+
* gets, so the computed comparison a person paid for never reached the
|
|
2025
|
+
* screen โ only the chat text carried it. Renders nothing when "standing"
|
|
2026
|
+
* is absent, which is every other tool's posts.
|
|
2027
|
+
*/
|
|
2028
|
+
function standingBadge(p){
|
|
2029
|
+
var st=p.standing;
|
|
2030
|
+
if(!st||typeof st!=="object"||st.ratio===null||st.ratio===undefined)return "";
|
|
2031
|
+
var ratio=Number(st.ratio),verdict=String(st.verdict||"");
|
|
2032
|
+
var sc=verdict==="breakout"||verdict==="above_baseline"?"var(--green)"
|
|
2033
|
+
:verdict==="below_baseline"||verdict==="flop"?"var(--red)"
|
|
2034
|
+
:verdict==="no_baseline"?"var(--muted)":"var(--amber)";
|
|
2035
|
+
var label={breakout:"Breakout",above_baseline:"Above baseline",typical:"Typical",
|
|
2036
|
+
below_baseline:"Below baseline",flop:"Underperformed",no_baseline:"No baseline"}[verdict]||"";
|
|
2037
|
+
return '<div style="padding:7px 12px;display:flex;align-items:center;gap:7px;'
|
|
2038
|
+
+'font-size:12.5px;border-top:1px solid var(--border)">'
|
|
2039
|
+
+'<span style="font-weight:700;color:'+sc+'">'+ratio+"ร median</span>"
|
|
2040
|
+
+(label?'<span style="color:var(--muted)">'+esc(label)+"</span>":"")
|
|
2041
|
+
+"</div>";
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
function postCard(p,wide,pickable,hideActions){
|
|
2045
|
+
var platform=p.platform||"unknown",color=pColor(platform),brandSvg=pSvg(platform);
|
|
2046
|
+
var tcolor=pColorText(platform);
|
|
2047
|
+
var title=p.title||p.caption||"",handle=p.creatorHandle||"",url=p.externalUrl||"";
|
|
2048
|
+
var thumb=p.thumbnailUrl||p.thumbnailProxyUrl||"",ct=p.contentType||"post";
|
|
2049
|
+
var views=p.views||0,likes=p.likes||0,comments=p.comments||0,shares=p.shares||0;
|
|
2050
|
+
var cls=wide?"card card-wide":"card";
|
|
2051
|
+
var statsHtml=[[views,"eye"],[likes,"heart"],[comments,"comment"],[shares,"share"]]
|
|
2052
|
+
.filter(function(s){return s[0]>0})
|
|
2053
|
+
.map(function(s){return'<span class="stat-pill">'+mpIcon(s[1],13)+" "+fmtNum(s[0])+"</span>";}).join("");
|
|
2054
|
+
var handleHtml=handle?'<span class="handle">@'+esc(handle)+"</span>":"";
|
|
2055
|
+
var linkHtml=url?'<a href="'+esc(url)+'" target="_blank" class="btn" style="background:'+color+'">View on '+esc(platform)+' โ</a>':"";
|
|
2056
|
+
// Prefer video playback over a static thumbnail when the post has one
|
|
2057
|
+
// (videoUrl, or a video mediaItem preview_url). Videos are proxied /
|
|
2058
|
+
// re-hosted to a permanent nooticr URL so they play inside the CSP.
|
|
2059
|
+
var video="",images=[];
|
|
2060
|
+
// Play the platform URL directly. Going through /media/proxy did not fix
|
|
2061
|
+
// playback in practice, and the direct link is one less hop that can fail.
|
|
2062
|
+
// The proxied companion is kept as a fallback: the player retries with it
|
|
2063
|
+
// once if the direct URL errors, which covers the CDNs that refuse a
|
|
2064
|
+
// cross-origin request from this frame.
|
|
2065
|
+
var rawVideo=typeof p.videoUrl==="string"?p.videoUrl:"";
|
|
2066
|
+
if(rawVideo)video=rawVideo;
|
|
2067
|
+
else if(typeof p.videoProxyUrl==="string"&&p.videoProxyUrl)video=p.videoProxyUrl;
|
|
2068
|
+
// Some feeds carry no stream at all - Douyin's search endpoint returns a
|
|
2069
|
+
// permalink and a poster and nothing else - but the post detail does. The
|
|
2070
|
+
// resolver fetches it on demand, so the card plays instead of sitting
|
|
2071
|
+
// there as a still image. Only offered where resolution actually works.
|
|
2072
|
+
else if(typeof p.videoFallbackUrl==="string"&&p.videoFallbackUrl
|
|
2073
|
+
&&(p.contentType==="video"||Number(p.duration)>0))video=p.videoFallbackUrl;
|
|
2074
|
+
// The retry target: the resolver re-fetches the post and hands back a
|
|
2075
|
+
// fresh link, which is the only thing that helps once the signed URL has
|
|
2076
|
+
// expired - a proxy of a dead link is just as dead.
|
|
2077
|
+
lastVideoFallback=(rawVideo&&(p.videoFallbackUrl||p.videoProxyUrl))||"";
|
|
2078
|
+
if(p.mediaItems&&p.mediaItems.length){
|
|
2079
|
+
for(var i=0;i<p.mediaItems.length;i++){
|
|
2080
|
+
var m=p.mediaItems[i];
|
|
2081
|
+
// "proxy_url" is the same asset served through Nooticr โ the raw
|
|
2082
|
+
// preview_url is a signed CDN link that this frame can't load.
|
|
2083
|
+
var mu=m.proxy_url||m.preview_url;
|
|
2084
|
+
if(m.kind==="video"&&mu){if(!video)video=mu;}
|
|
2085
|
+
else if(m.kind==="image"&&mu&&images.length<12)images.push(mu);
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
var mediaHtml="";
|
|
2089
|
+
var vh=wide?"340px":"200px";
|
|
2090
|
+
var bodyText=p.caption||p.text||"";
|
|
2091
|
+
var isSlideshow=(ct==="slideshow"||ct==="carousel")&&images.length>1;
|
|
2092
|
+
// Anything playable โ a video or a slideshow โ goes through the one
|
|
2093
|
+
// player, so the controls are the same on every card.
|
|
2094
|
+
if(video||isSlideshow||images.length>1){
|
|
2095
|
+
mediaHtml=mediaPlayerHtml(p,images,video,platform,pickable);
|
|
2096
|
+
// The player already carries the handle, caption and stats as overlay
|
|
2097
|
+
// chrome, so the card body underneath is just the outbound link.
|
|
2098
|
+
return '<div class="'+cls+' card-media '+lastPlayerAr+'">'+mediaHtml
|
|
2099
|
+
+standingBadge(p)
|
|
2100
|
+
+"</div>"
|
|
2101
|
+
// Only on the single-post view โ a gallery of these would be noise.
|
|
2102
|
+
+(wide&&!hideActions?postAiActions(p):"");
|
|
2103
|
+
}
|
|
2104
|
+
if(thumb){
|
|
2105
|
+
var tfb=(p.thumbnailUrl&&(p.thumbnailFallbackUrl||p.thumbnailProxyUrl))||"";
|
|
2106
|
+
mediaHtml='<img src="'+esc(thumb)+'" alt="thumbnail" loading="lazy"'
|
|
2107
|
+
+(tfb?' data-fallback="'+esc(tfb)+'"':"")+"/>";
|
|
2108
|
+
}else if(bodyText){
|
|
2109
|
+
// Text-only post (LinkedIn / X) โ styled quote block.
|
|
2110
|
+
mediaHtml='<div style="padding:16px 18px;border-bottom:1px solid var(--border);background:var(--card)">'
|
|
2111
|
+
+'<div style="font-size:13px;line-height:1.55;color:var(--fg);white-space:pre-line">'+esc(bodyText.length>400?bodyText.slice(0,400)+"โฆ":bodyText)+'</div></div>';
|
|
2112
|
+
}
|
|
2113
|
+
var ctTag=wide?'<span style="font-size:12px;color:var(--muted);text-transform:capitalize">'+esc(ct)+"</span>":"";
|
|
2114
|
+
return '<div class="'+cls+'">'+mediaHtml+'<div class="card-body">'
|
|
2115
|
+
+'<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">'
|
|
2116
|
+
+'<span class="badge" style="background:'+color+'15;color:'+tcolor+';display:inline-flex;align-items:center;gap:4px">'+brandSvg+" "+esc(platform)+"</span>"
|
|
2117
|
+
+ctTag+handleHtml+"</div>"
|
|
2118
|
+
+'<div class="title">'+esc(title.length>160?title.slice(0,160)+"โฆ":title)+"</div>"
|
|
2119
|
+
+'<div class="stats">'+statsHtml+"</div>"
|
|
2120
|
+
+linkHtml+"</div>"+standingBadge(p)+"</div>";
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// โโโ Creator Card โโโ
|
|
2124
|
+
function creatorCard(c){
|
|
2125
|
+
var platform=c.platform||"tiktok",color=pColor(platform),brandSvg=pSvg(platform,22);
|
|
2126
|
+
var username=c.username||c.uniqueId||"",nickname=c.nickname||c.displayName||username;
|
|
2127
|
+
var followers=c.followers||c.followerCount||0,sig=(c.signature||c.bio||"").slice(0,100);
|
|
2128
|
+
var verified=c.verified||false,avatar=c.avatarUrl||c.avatar_thumb||"";
|
|
2129
|
+
var avatarHtml=avatar
|
|
2130
|
+
?'<img class="avatar" src="'+esc(avatar)+'" alt="avatar" loading="lazy"/>'
|
|
2131
|
+
:'<div class="avatar-placeholder" style="background:'+color+'20">'+brandSvg+"</div>";
|
|
2132
|
+
var vBadge=verified?'<span class="verified" style="background:'+color+'">โ</span>':"";
|
|
2133
|
+
return '<div class="creator-card">'
|
|
2134
|
+
+'<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">'
|
|
2135
|
+
+avatarHtml+"<div>"
|
|
2136
|
+
+'<div style="display:flex;align-items:center"><span style="font-size:14px;font-weight:700">'+esc(nickname)+"</span>"+vBadge+"</div>"
|
|
2137
|
+
+'<div class="handle">@'+esc(username)+"</div></div></div>"
|
|
2138
|
+
+'<div style="font-size:13px;color:var(--fg);font-weight:600;margin-bottom:6px">'+fmtNum(followers)+" followers</div>"
|
|
2139
|
+
// Parenthesised: + binds tighter than the ternary, so without these the whole
|
|
2140
|
+
// concatenation became the condition and the function returned only the
|
|
2141
|
+
// bio โ no card, no avatar, no name, no follower count.
|
|
2142
|
+
+(sig?'<div style="font-size:12px;color:var(--muted);line-height:1.4">'+esc(sig)+"</div>":"")
|
|
2143
|
+
+vetting(c)
|
|
2144
|
+
+"</div>";
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
// โโโ Vetting strip โโโ
|
|
2148
|
+
//
|
|
2149
|
+
// Only show_collab_shortlist sends these fields, so every other creator card
|
|
2150
|
+
// โ search_creators, get_similar_creators, who_should_i_work_with โ renders
|
|
2151
|
+
// exactly as it did before. The score is the calling model's, never ours,
|
|
2152
|
+
// and the card says so on its face: a number next to a real person's name
|
|
2153
|
+
// that reads as a nooticr rating would be a claim we have not made and
|
|
2154
|
+
// cannot stand behind.
|
|
2155
|
+
function vetting(c){
|
|
2156
|
+
var hasScore=typeof c.score==="number";
|
|
2157
|
+
if(!hasScore&&!c.verdict&&!c.why&&!(c.links&&c.links.length))return "";
|
|
2158
|
+
var out='<div style="margin-top:10px;padding-top:9px;border-top:1px solid var(--border)">';
|
|
2159
|
+
|
|
2160
|
+
if(hasScore||c.verdict||c.rank){
|
|
2161
|
+
var vColor=c.verdict==="approach"?"var(--green)":c.verdict==="pass"?"var(--muted)":"var(--fg)";
|
|
2162
|
+
out+='<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:6px">'
|
|
2163
|
+
+(c.rank?'<span style="font-size:11px;font-weight:700;color:var(--muted)">#'+esc(String(c.rank))+"</span>":"")
|
|
2164
|
+
+(hasScore?'<span style="font-size:17px;font-weight:700;line-height:1">'+esc(String(Math.round(c.score)))
|
|
2165
|
+
+'<span style="font-size:11px;font-weight:600;color:var(--muted)">/100</span></span>':"")
|
|
2166
|
+
+(c.verdict?'<span style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:'+vColor
|
|
2167
|
+
+';border:1px solid '+vColor+';border-radius:4px;padding:1px 6px">'+esc(String(c.verdict))+"</span>":"")
|
|
2168
|
+
+"</div>";
|
|
2169
|
+
// Whose number this is, stated wherever a number is shown.
|
|
2170
|
+
if(hasScore){
|
|
2171
|
+
out+='<div style="font-size:10.5px;color:var(--muted);margin-bottom:6px">Scored by '
|
|
2172
|
+
+esc(String(c.scoredBy||"the assistant"))
|
|
2173
|
+
+(c.unverifiedScore?" ยท nothing was opened to reach this":"")+"</div>";
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
if(c.why)out+='<div style="font-size:12.5px;line-height:1.45;margin-bottom:6px">'+esc(String(c.why))+"</div>";
|
|
2178
|
+
|
|
2179
|
+
if(c.checked&&c.checked.length){
|
|
2180
|
+
out+='<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Read: '
|
|
2181
|
+
+c.checked.slice(0,4).map(function(x){return esc(String(x));}).join(" ยท ")+"</div>";
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
if(c.concerns&&c.concerns.length){
|
|
2185
|
+
out+=c.concerns.slice(0,3).map(function(x){
|
|
2186
|
+
return '<div style="font-size:11.5px;line-height:1.4;color:var(--fg);margin-bottom:3px">โ '+esc(String(x))+"</div>";
|
|
2187
|
+
}).join("");
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
if(c.links&&c.links.length){
|
|
2191
|
+
// Deliberately not anchors. These hosts came out of a bio a stranger
|
|
2192
|
+
// controls and are not on the platform-link allowlist, so a host that
|
|
2193
|
+
// enforces it would block the navigation silently and a host that did
|
|
2194
|
+
// not would follow an arbitrary URL from inside a widget. Shown as text
|
|
2195
|
+
// so a person can see what is there and decide themselves.
|
|
2196
|
+
out+='<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:7px">'
|
|
2197
|
+
+c.links.slice(0,6).map(function(l){
|
|
2198
|
+
var opaque=!!l.opaque;
|
|
2199
|
+
return '<span title="'+esc(String(l.readable||""))+'" style="font-size:10.5px;padding:1px 6px;border-radius:4px;'
|
|
2200
|
+
+'border:1px '+(opaque?"dashed":"solid")+' var(--border);color:var(--muted)">'
|
|
2201
|
+
+esc(String(l.kind||"link"))+" ยท "+esc(String(l.host||""))+(opaque?" ?":"")+"</span>";
|
|
2202
|
+
}).join("")+"</div>";
|
|
2203
|
+
}
|
|
2204
|
+
return out+"</div>";
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
// โโโ Expandable Section โโโ
|
|
2208
|
+
function section(label,contentHtml,openByDefault){
|
|
2209
|
+
var id="sec_"+Math.random().toString(36).slice(2,8);
|
|
2210
|
+
return '<div class="section">'
|
|
2211
|
+
+'<div class="section-header'+(openByDefault?" open":"")+'" onclick="toggleSection(this)">'
|
|
2212
|
+
+'<span class="section-label">'+esc(label)+'</span>'
|
|
2213
|
+
+'<span class="section-chevron">โผ</span></div>'
|
|
2214
|
+
+'<div class="section-content'+(openByDefault?" open":"")+'">'+contentHtml+'</div></div>';
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
// โโโ Analysis Card โโโ
|
|
2218
|
+
// โโโ AI analysis โโโ
|
|
2219
|
+
// Ordered for someone reading in a chat panel: the verdict in one glance,
|
|
2220
|
+
// then the things they can act on or copy, then reference material folded
|
|
2221
|
+
// away. Every field the model produces is surfaced somewhere โ the analysis
|
|
2222
|
+
// is the expensive part of the call, so discarding two thirds of it in the
|
|
2223
|
+
// view (as this previously did) is throwing away what the user paid for.
|
|
2224
|
+
function analysisCard(d){
|
|
2225
|
+
var a=d.analysis||{};
|
|
2226
|
+
var postHtml=postCard(d.post||d,true,false,true);
|
|
2227
|
+
var out="";
|
|
2228
|
+
|
|
2229
|
+
// โโ Verdict strip โโ
|
|
2230
|
+
var meters="";
|
|
2231
|
+
if(a.hookStrength!=null)meters+=meter("Hook strength",Number(a.hookStrength));
|
|
2232
|
+
if(a.commentBaitLevel!=null)meters+=meter("Comment bait",Number(a.commentBaitLevel));
|
|
2233
|
+
if(meters)out+='<div class="verdict">'+meters+"</div>";
|
|
2234
|
+
|
|
2235
|
+
var chips=[];
|
|
2236
|
+
if(a.niche)chips.push(chip(a.niche,"eye"));
|
|
2237
|
+
if(a.trendAlignment)chips.push(chip(a.trendAlignment,"share"));
|
|
2238
|
+
if(a.audioTrack)chips.push(chip(a.audioTrack,"note"));
|
|
2239
|
+
if(a.callToAction)chips.push(chip(a.callToAction,"comment"));
|
|
2240
|
+
if(chips.length)out+='<div class="chiprow">'+chips.join("")+"</div>";
|
|
2241
|
+
|
|
2242
|
+
if(a.summary)out+='<div class="lede-box">'+esc(a.summary)+"</div>";
|
|
2243
|
+
|
|
2244
|
+
// โโ Script structure: the most actionable single field โโ
|
|
2245
|
+
var sc=a.scriptStructure;
|
|
2246
|
+
if(sc&&(sc.hook||sc.buildUp||sc.payoff||sc.cta)){
|
|
2247
|
+
var steps=[["Hook",sc.hook],["Build-up",sc.buildUp],["Payoff",sc.payoff],["CTA",sc.cta]]
|
|
2248
|
+
.filter(function(x){return x[1];})
|
|
2249
|
+
.map(function(x){
|
|
2250
|
+
return '<div class="step-row"><span class="step-k">'+esc(x[0])+"</span>"
|
|
2251
|
+
+'<span class="step-v">'+esc(x[1])+"</span></div>";
|
|
2252
|
+
}).join("");
|
|
2253
|
+
out+=section("Script structure",'<div class="steps-list">'+steps+"</div>",true);
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
if(a.whyItWorks)out+=section("Why it works",expandable(a.whyItWorks),true);
|
|
2257
|
+
|
|
2258
|
+
// โโ Copyables: what a strategist actually lifts from the card โโ
|
|
2259
|
+
if(a.suggestedHook)out+=section("Suggested hook",quote(a.suggestedHook),true);
|
|
2260
|
+
if(a.keyQuotes&&a.keyQuotes.length)
|
|
2261
|
+
out+=section("Quotable lines",a.keyQuotes.slice(0,6).map(quote).join(""));
|
|
2262
|
+
if(a.suggestedHashtags&&a.suggestedHashtags.length){
|
|
2263
|
+
var tags=a.suggestedHashtags.slice(0,12).map(function(t){
|
|
2264
|
+
var c=String(t).replace(/^#/,"");
|
|
2265
|
+
return '<button class="tag copyable" data-copy="#'+esc(c)+'">#'+esc(c)+"</button>";
|
|
2266
|
+
}).join("");
|
|
2267
|
+
out+=section("Hashtags",'<div class="chiprow">'+tags
|
|
2268
|
+
+'<button class="tag copyable" data-copy="'+esc(a.suggestedHashtags.map(function(t){return "#"+String(t).replace(/^#/,"");}).join(" "))+'">Copy all</button></div>',true);
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
// โโ Who it is for โโ
|
|
2272
|
+
var ta=a.targetAudience;
|
|
2273
|
+
if(ta&&typeof ta==="object"){
|
|
2274
|
+
var rows=[["Demographics",ta.demographics],["Psychographics",ta.psychographics],["Interests",ta.interests]]
|
|
2275
|
+
.filter(function(x){return x[1];})
|
|
2276
|
+
.map(function(x){return '<div class="step-row"><span class="step-k">'+esc(x[0])+'</span><span class="step-v">'+esc(x[1])+"</span></div>";}).join("");
|
|
2277
|
+
if(rows)out+=section("Target audience",'<div class="steps-list">'+rows+"</div>");
|
|
2278
|
+
}else if(typeof ta==="string"&&ta){
|
|
2279
|
+
out+=section("Target audience",'<div class="section-text">'+esc(ta)+"</div>");
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
// โโ On-screen text: per-scene for slideshows โโ
|
|
2283
|
+
if(a.overlayTexts&&a.overlayTexts.length){
|
|
2284
|
+
var ov=a.overlayTexts.slice(0,20).map(function(t,i){
|
|
2285
|
+
return '<div class="step-row"><span class="step-k">'+(i+1)+'</span><span class="step-v">'+esc(t)+"</span></div>";
|
|
2286
|
+
}).join("");
|
|
2287
|
+
out+=section("On-screen text",'<div class="steps-list">'+ov+"</div>");
|
|
2288
|
+
}else if(a.overlayText){
|
|
2289
|
+
out+=section("On-screen text",'<div class="section-text">'+esc(a.overlayText)+"</div>");
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
// โโ Signals โโ
|
|
2293
|
+
if(a.viralTriggers&&a.viralTriggers.length)
|
|
2294
|
+
out+=section("Viral triggers",'<div class="chiprow">'
|
|
2295
|
+
+a.viralTriggers.slice(0,10).map(function(t){return '<button class="tag copyable" data-copy="'+esc(t)+'">'+esc(t)+"</button>";}).join("")+"</div>");
|
|
2296
|
+
var es=a.engagementSignals;
|
|
2297
|
+
if(es&&typeof es==="object"&&(es.commentSentiment||es.shareMotivation)){
|
|
2298
|
+
var er=[["Comments","commentSentiment"],["Why shared","shareMotivation"]]
|
|
2299
|
+
.filter(function(x){return es[x[1]];})
|
|
2300
|
+
.map(function(x){return '<div class="step-row"><span class="step-k">'+esc(x[0])+'</span><span class="step-v">'+esc(es[x[1]])+"</span></div>";}).join("");
|
|
2301
|
+
out+=section("Engagement signals",'<div class="steps-list">'+er+"</div>");
|
|
2302
|
+
}
|
|
2303
|
+
if(a.emotionalArc)out+=section("Emotional arc",'<div class="section-text">'+esc(a.emotionalArc)+"</div>");
|
|
2304
|
+
|
|
2305
|
+
// โโ What to fix, what to try โโ
|
|
2306
|
+
if(a.negativeSignals&&a.negativeSignals.length)
|
|
2307
|
+
out+=section("Weaknesses",'<div class="chiprow">'
|
|
2308
|
+
+a.negativeSignals.slice(0,8).map(function(n){return '<span class="tag warn">'+esc(n)+"</span>";}).join("")+"</div>",true);
|
|
2309
|
+
if(a.variationIdeas&&a.variationIdeas.length)
|
|
2310
|
+
out+=section("Variations to try",bullets(a.variationIdeas,8));
|
|
2311
|
+
|
|
2312
|
+
// โโ Reference, folded away โโ
|
|
2313
|
+
if(a.formatBreakdown)out+=section("Format breakdown",expandable(a.formatBreakdown));
|
|
2314
|
+
if(a.visualStyle)out+=section("Visual style",'<div class="section-text">'+esc(a.visualStyle)+"</div>");
|
|
2315
|
+
if(a.whatHappens)out+=section("What happens",expandable(a.whatHappens));
|
|
2316
|
+
if(a.coreNarrative)out+=section("Core narrative",expandable(a.coreNarrative));
|
|
2317
|
+
if(a.transcript)out+=section("Transcript",
|
|
2318
|
+
'<div class="section-text" style="white-space:pre-wrap">'+esc(a.transcript)+"</div>"
|
|
2319
|
+
+'<button class="tag copyable" style="margin-top:8px" data-copy="'+esc(a.transcript)+'">Copy transcript</button>');
|
|
2320
|
+
if(a.brandMentions&&a.brandMentions.length)
|
|
2321
|
+
out+=section("Brands mentioned",'<div class="chiprow">'+a.brandMentions.map(function(b){return '<span class="tag">'+esc(b)+"</span>";}).join("")+"</div>");
|
|
2322
|
+
if(a.contentSafetyFlags&&a.contentSafetyFlags.length)
|
|
2323
|
+
out+=section("Brand safety",'<div class="chiprow">'+a.contentSafetyFlags.map(function(f){return '<span class="tag warn">'+esc(f)+"</span>";}).join("")+"</div>");
|
|
2324
|
+
|
|
2325
|
+
// Turn the analysis into the next piece of work rather than ending on it.
|
|
2326
|
+
var src=(d.post||d);
|
|
2327
|
+
var srcUrl=src&&(src.externalUrl||src.url);
|
|
2328
|
+
var follow=srcUrl?'<div class="ai-actions">'
|
|
2329
|
+
+aiBtn("create_variants","Create variants",JSON.stringify({url:srcUrl}))
|
|
2330
|
+
+aiBtn("write_hooks","More hooks",JSON.stringify({url:srcUrl}))
|
|
2331
|
+
+aiBtn("repurpose_post","Repurpose",JSON.stringify({url:srcUrl}))
|
|
2332
|
+
+aiBtn("analyze_comments","Read comments",JSON.stringify({url:srcUrl}))
|
|
2333
|
+
+'</div><div class="ai-note">Each action runs a tool and spends the credits shown.</div>':"";
|
|
2334
|
+
|
|
2335
|
+
if(!out)return postHtml;
|
|
2336
|
+
return postHtml+'<div class="card card-wide fade-in" style="margin-top:10px"><div class="card-body">'
|
|
2337
|
+
+'<div class="an-title">'+mpIcon("eye",16)+"<span>AI analysis</span></div>"+out+follow+"</div></div>";
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
/** 0-10 score as a labelled bar. */
|
|
2341
|
+
function meter(label,val){
|
|
2342
|
+
var v=Math.max(0,Math.min(10,Number(val)||0));
|
|
2343
|
+
var c=v>=7?"var(--green)":v>=4?"var(--amber)":"var(--red)";
|
|
2344
|
+
return '<div class="meter"><div class="meter-top"><span>'+esc(label)+"</span>"
|
|
2345
|
+
+'<b style="color:'+c+'">'+v+"/10</b></div>"
|
|
2346
|
+
+'<div class="meter-track"><i style="width:'+(v*10)+"%;background:"+c+'"></i></div></div>';
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
function chip(text,icon){
|
|
2350
|
+
return '<button class="tag copyable" data-copy="'+esc(text)+'">'
|
|
2351
|
+
+(icon?mpIcon(icon,11):"")+"<span>"+esc(text)+"</span></button>";
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
function quote(text){
|
|
2355
|
+
return '<div class="quote-box">'+esc(text)
|
|
2356
|
+
+'<button class="copy-btn" data-copy="'+esc(text)+'">Copy</button></div>';
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
function bullets(arr,cap){
|
|
2360
|
+
return '<ul class="an-list">'+arr.slice(0,cap||6).map(function(x){
|
|
2361
|
+
return "<li>"+esc(x)+"</li>";}).join("")+"</ul>";
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
/** Long prose: clamp with an expander instead of cutting it off. */
|
|
2365
|
+
function expandable(text){
|
|
2366
|
+
var t=String(text||"");
|
|
2367
|
+
if(t.length<=260)return '<div class="section-text">'+esc(t)+"</div>";
|
|
2368
|
+
var id="x"+(mpId++);
|
|
2369
|
+
return '<div class="section-text clamp6" data-x="'+id+'">'+esc(t)+"</div>"
|
|
2370
|
+
+'<button class="cmore" data-xmore="'+id+'">Show more</button>';
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
|
|
2374
|
+
function extractResult(result){
|
|
2375
|
+
if(!result||typeof result!=="object")return result;
|
|
2376
|
+
var sc=result.structuredContent;
|
|
2377
|
+
if(sc&&typeof sc==="object"&&!Array.isArray(sc))return sc;
|
|
2378
|
+
if(Array.isArray(result.content)){
|
|
2379
|
+
var textBlock=null;
|
|
2380
|
+
for(var i=0;i<result.content.length;i++){
|
|
2381
|
+
var c=result.content[i];
|
|
2382
|
+
if(c&&c.type==="text"&&typeof c.text==="string"){textBlock=c.text;break;}
|
|
2383
|
+
}
|
|
2384
|
+
if(typeof textBlock==="string"&&textBlock!==""){
|
|
2385
|
+
// Split "<html> {json}" - if there is a JSON object after the HTML,
|
|
2386
|
+
// parse it; otherwise show the text itself. Deliberately not a regex:
|
|
2387
|
+
// this template is embedded in both a Rust raw string and a TS
|
|
2388
|
+
// template literal, and a backslash survives the first but is eaten by
|
|
2389
|
+
// the second, so the escaped pattern this used to carry matched
|
|
2390
|
+
// nothing in the copy the Worker serves.
|
|
2391
|
+
var NL=String.fromCharCode(10);
|
|
2392
|
+
for(var gap=textBlock.indexOf(NL+NL);gap!==-1;gap=textBlock.indexOf(NL+NL,gap+1)){
|
|
2393
|
+
var tail=textBlock.slice(gap+2).trim();
|
|
2394
|
+
if(tail.charAt(0)!=="{")continue;
|
|
2395
|
+
try{var parsed=JSON.parse(tail);if(parsed&&typeof parsed==="object")return parsed;}catch(e){}
|
|
2396
|
+
}
|
|
2397
|
+
// Single-line HTML (common): keep the whole block as text fallback.
|
|
2398
|
+
if(textBlock.indexOf("<")===0){return { _html: textBlock };}
|
|
2399
|
+
try{var j=JSON.parse(textBlock);if(j&&typeof j==="object")return j;}catch(e2){}
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
return result;
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
var galleryId=0;
|
|
2406
|
+
function galleryWrap(cardsHtml,count){
|
|
2407
|
+
var id="gal"+(galleryId++);
|
|
2408
|
+
if(count<=1)return cardsHtml;
|
|
2409
|
+
var dots="";
|
|
2410
|
+
for(var i=0;i<count;i++)dots+='<button class="dot'+(i===0?" active":"")+'" data-g="'+id+'" data-i="'+i+'"></button>';
|
|
2411
|
+
return '<div class="gallery-wrap fade-in">'
|
|
2412
|
+
+'<button class="gallery-nav prev" data-gnav="'+id+'" data-dir="-1">โน</button>'
|
|
2413
|
+
+'<div class="gallery" id="'+id+'">'+cardsHtml+'</div>'
|
|
2414
|
+
+'<button class="gallery-nav next" data-gnav="'+id+'" data-dir="1">โบ</button>'
|
|
2415
|
+
+'<div class="gallery-dots" id="'+id+'-dots">'+dots+'</div>'
|
|
2416
|
+
+'</div>';
|
|
2417
|
+
}
|
|
2418
|
+
function initGalleryNav(){
|
|
2419
|
+
document.addEventListener('click',function(e){
|
|
2420
|
+
var navBtn=e.target.closest('.gallery-nav[data-gnav]');
|
|
2421
|
+
if(navBtn){
|
|
2422
|
+
var g=document.getElementById(navBtn.getAttribute('data-gnav'));
|
|
2423
|
+
if(!g)return;
|
|
2424
|
+
var dir=Number(navBtn.getAttribute('data-dir'));
|
|
2425
|
+
var cardW=g.querySelector('.card')?.offsetWidth||380;
|
|
2426
|
+
g.scrollBy({left:dir*(cardW+12),behavior:'smooth'});
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
var dotBtn=e.target.closest('.dot[data-g]');
|
|
2430
|
+
if(dotBtn){
|
|
2431
|
+
var gId=dotBtn.getAttribute('data-g');
|
|
2432
|
+
var g=document.getElementById(gId);
|
|
2433
|
+
var dots=document.getElementById(gId+'-dots');
|
|
2434
|
+
if(!g||!dots)return;
|
|
2435
|
+
var idx=Number(dotBtn.getAttribute('data-i'));
|
|
2436
|
+
var cards=g.querySelectorAll('.card');
|
|
2437
|
+
if(cards[idx])cards[idx].scrollIntoView({behavior:'smooth',block:'nearest',inline:'start'});
|
|
2438
|
+
dots.querySelectorAll('.dot').forEach(function(d){d.classList.remove('active')});
|
|
2439
|
+
dotBtn.classList.add('active');
|
|
2440
|
+
}
|
|
2441
|
+
});
|
|
2442
|
+
document.addEventListener('scroll',function(e){
|
|
2443
|
+
if(!e.target.classList||!e.target.classList.contains('gallery'))return;
|
|
2444
|
+
var g=e.target;var gId=g.id;
|
|
2445
|
+
var dots=document.getElementById(gId+'-dots');
|
|
2446
|
+
if(!dots)return;
|
|
2447
|
+
var cards=g.querySelectorAll('.card');
|
|
2448
|
+
if(!cards.length)return;
|
|
2449
|
+
var scrollLeft=g.scrollLeft;var cardW=cards[0].offsetWidth+12;
|
|
2450
|
+
var active=Math.round(scrollLeft/cardW);
|
|
2451
|
+
active=Math.max(0,Math.min(active,cards.length-1));
|
|
2452
|
+
dots.querySelectorAll('.dot').forEach(function(d,i){d.classList.toggle('active',i===active)});
|
|
2453
|
+
},true);
|
|
2454
|
+
}
|
|
2455
|
+
initGalleryNav();
|
|
2456
|
+
|
|
2457
|
+
// Every render replaces the DOM wholesale, so player wiring has to run
|
|
2458
|
+
// after it โ one wrapper instead of a call at each of the many early
|
|
2459
|
+
// returns below.
|
|
2460
|
+
function render(result){
|
|
2461
|
+
try{renderView(result);}finally{initPlayers();setTimeout(reportSize,80);}
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
function renderView(result){
|
|
2465
|
+
var app=document.getElementById("app");if(!app)return;
|
|
2466
|
+
var d=extractResult(result);
|
|
2467
|
+
if(!d||typeof d!=="object"){app.innerHTML='<div class="json-block fade-in">'+esc(JSON.stringify(d,null,2))+"</div>";return;}
|
|
2468
|
+
// Raw HTML-only result โ inject it directly.
|
|
2469
|
+
if(d._html){app.innerHTML=d._html;setTimeout(reportSize,50);return;}
|
|
2470
|
+
|
|
2471
|
+
// Analysis
|
|
2472
|
+
if(d.analysis&&(d.post||d.url)){app.innerHTML=analysisCard(d);return;}
|
|
2473
|
+
// Post comparison
|
|
2474
|
+
if(d.comparison!==undefined&&d.posts&&Array.isArray(d.posts)){
|
|
2475
|
+
var c=d.comparison||{},win=Number(c.winner)||0;
|
|
2476
|
+
var cards=d.posts.map(function(p,i){
|
|
2477
|
+
var isw=win===i+1;
|
|
2478
|
+
return '<div style="padding:10px 12px;border:1px solid '+(isw?"var(--green)":"var(--border)")+';border-radius:10px;margin-bottom:8px">'
|
|
2479
|
+
+'<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px"><span style="font-size:11px;font-weight:700;color:var(--muted)">#'+(i+1)+"</span>"
|
|
2480
|
+
+(isw?'<span style="font-size:10px;font-weight:700;color:var(--green);border:1px solid var(--green);border-radius:4px;padding:0 5px">BEST</span>':"")+"</div>"
|
|
2481
|
+
+'<div style="font-size:13px;line-height:1.4">'+esc((p.title||p.caption||"").slice(0,140))+"</div>"
|
|
2482
|
+
+'<div style="font-size:11.5px;color:var(--muted);margin-top:4px">'+mpIcon("eye",11)+" "+fmtNum(p.views||0)+" ยท "+mpIcon("heart",11)+" "+fmtNum(p.likes||0)+"</div></div>";
|
|
2483
|
+
}).join("");
|
|
2484
|
+
var diffs=(c.differences||[]).slice(0,5).map(function(x){
|
|
2485
|
+
return '<div style="padding:7px 0;border-bottom:1px solid var(--border)"><span style="font-size:12.5px;font-weight:600">'+esc(x.factor||"")+"</span>"
|
|
2486
|
+
+'<div style="font-size:12.5px;color:var(--muted);margin-top:1px">'+esc(x.detail||"")+"</div></div>";
|
|
2487
|
+
}).join("");
|
|
2488
|
+
var lessons=(c.lessons||[]).length?'<div style="margin-top:14px"><div class="sec-label">Lessons</div><ul style="margin:0;padding-left:18px;font-size:13px;color:var(--muted)">'
|
|
2489
|
+
+c.lessons.slice(0,4).map(function(l){return '<li style="margin:4px 0">'+esc(l)+"</li>";}).join("")+"</ul></div>":"";
|
|
2490
|
+
app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
|
|
2491
|
+
+'<div style="font-size:16px;font-weight:700;margin-bottom:10px">โ๏ธ Comparing '+d.posts.length+" posts</div>"
|
|
2492
|
+
+cards
|
|
2493
|
+
+(c.winnerReason?'<div style="font-size:13px;margin-top:10px"><b>Why it won:</b> '+esc(c.winnerReason)+"</div>":"")
|
|
2494
|
+
+(diffs?'<div style="margin-top:14px"><div class="sec-label">What differed</div>'+diffs+"</div>":"")
|
|
2495
|
+
+lessons
|
|
2496
|
+
+(c.nextTest?'<div style="margin-top:14px;padding:10px 12px;background:var(--tag);border-radius:10px;font-size:13px"><b>Next test:</b> '+esc(c.nextTest)+"</div>":"")
|
|
2497
|
+
+"</div></div>";
|
|
2498
|
+
return;}
|
|
2499
|
+
|
|
2500
|
+
// Brand monitoring.
|
|
2501
|
+
//
|
|
2502
|
+
// This screen is a triage surface, not a feed. The job is to see what needs
|
|
2503
|
+
// answering, how loud it is, and where โ so three things drive the layout.
|
|
2504
|
+
//
|
|
2505
|
+
// The comment leads and its metadata sits under it, because the comment is
|
|
2506
|
+
// what you are reading; who said it matters only once the words have caught
|
|
2507
|
+
// your eye. The post carries its reach, because the same sentence under a
|
|
2508
|
+
// 25K-upvote thread and under one nobody saw are not the same problem, and
|
|
2509
|
+
// nothing else on screen tells you which you are looking at. And the counts
|
|
2510
|
+
// are filters rather than decoration: a nine-platform sweep is unreadable
|
|
2511
|
+
// as one list, and the chips were already the obvious place to narrow it.
|
|
2512
|
+
//
|
|
2513
|
+
// Deliberately not here: a sentiment badge. Nothing in this payload says
|
|
2514
|
+
// whether a comment is angry, and colouring it green or red from a keyword
|
|
2515
|
+
// guess would be confidently wrong about the one thing you are scanning
|
|
2516
|
+
// for. The signals shown are the ones that are real โ reach, repetition,
|
|
2517
|
+
// recency, likes โ and the reading is left to the model.
|
|
2518
|
+
if(d.term!==undefined&&Array.isArray(d.threads)){
|
|
2519
|
+
monitorState={
|
|
2520
|
+
term:String(d.term||""),
|
|
2521
|
+
threads:d.threads,
|
|
2522
|
+
counts:(d.byPlatform&&typeof d.byPlatform==="object")?d.byPlatform:{},
|
|
2523
|
+
since:d.since?String(d.since):"",
|
|
2524
|
+
// The badge used to print "since <date>" whenever a window was asked
|
|
2525
|
+
// for, which asserted a filter had happened. Most networks do not date
|
|
2526
|
+
// their comments, so it often had not. These two carry the truth.
|
|
2527
|
+
sinceApplied:!!d.sinceApplied,
|
|
2528
|
+
undatedMentions:Number(d.undatedMentions||0),
|
|
2529
|
+
total:Number(d.totalMentions||0),
|
|
2530
|
+
unavailable:Array.isArray(d.unavailable)?d.unavailable:[],
|
|
2531
|
+
hasMore:!!d.hasMore,
|
|
2532
|
+
nextOffset:d.nextOffset,
|
|
2533
|
+
// Present only on a show_comment_review: the model's own labels, and
|
|
2534
|
+
// the counts they roll up to.
|
|
2535
|
+
byCategory:(d.byCategory&&typeof d.byCategory==="object")?d.byCategory:null,
|
|
2536
|
+
summary:d.summary?String(d.summary):"",
|
|
2537
|
+
filter:"",
|
|
2538
|
+
sort:"loud",
|
|
2539
|
+
};
|
|
2540
|
+
// A new term addresses different comments, so a selection made against
|
|
2541
|
+
// the old one is stale rather than carried over.
|
|
2542
|
+
if(monitorTerm!==monitorState.term){monitorTerm=monitorState.term;pickedMentions=[];}
|
|
2543
|
+
renderMonitor();
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
// search_spoken_mentions โ term + a grouped list, like the brand sweep
|
|
2548
|
+
// above, but of transcript hits ("hits") rather than comment threads
|
|
2549
|
+
// ("threads"), so it draws through its own surface rather than that one.
|
|
2550
|
+
if(d.term!==undefined&&Array.isArray(d.hits)){
|
|
2551
|
+
spokenState={
|
|
2552
|
+
term:String(d.term||""),
|
|
2553
|
+
platforms:Array.isArray(d.platforms)?d.platforms.map(String):[],
|
|
2554
|
+
hits:d.hits,
|
|
2555
|
+
considered:Number(d.candidatesConsidered||0),
|
|
2556
|
+
transcribed:Number(d.transcribed||0),
|
|
2557
|
+
transcriptsAvailable:Number(d.transcriptsAvailable||0),
|
|
2558
|
+
matched:Number(d.matched||0),
|
|
2559
|
+
maxTranscripts:Number(d.maxTranscripts||0),
|
|
2560
|
+
ceilingReached:!!d.ceilingReached,
|
|
2561
|
+
unavailableCount:Array.isArray(d.unavailable)?d.unavailable.length:0,
|
|
2562
|
+
filter:"",
|
|
2563
|
+
sort:"loud",
|
|
2564
|
+
};
|
|
2565
|
+
renderSpoken();
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
// Posts gallery
|
|
2570
|
+
if(d.posts&&Array.isArray(d.posts)){
|
|
2571
|
+
if(!d.posts.length){app.innerHTML='<div class="empty-state fade-in"><div class="icon">๐</div><div class="text">No posts found</div></div>';return;}
|
|
2572
|
+
app.innerHTML=galleryWrap(d.posts.map(function(p){return postCard(p,false,true);}).join(""),d.posts.length)+pickBarHtml();initPicks();return;}
|
|
2573
|
+
// Creators
|
|
2574
|
+
if(d.creators&&Array.isArray(d.creators)){
|
|
2575
|
+
if(!d.creators.length){app.innerHTML='<div class="empty-state fade-in"><div class="icon">๐ค</div><div class="text">No creators found</div></div>';return;}
|
|
2576
|
+
app.innerHTML=galleryWrap(d.creators.map(function(c){return creatorCard(c);}).join(""),d.creators.length);return;}
|
|
2577
|
+
// Transcript
|
|
2578
|
+
if(d.transcript!==undefined||d.available!==undefined&&d.wordCount!==undefined){
|
|
2579
|
+
// A real transcript string is itself evidence of availability โ
|
|
2580
|
+
// generate_captions returns {transcript, cues, ...} with no
|
|
2581
|
+
// available flag at all, and used to render "No transcript
|
|
2582
|
+
// available." over real captions because of it.
|
|
2583
|
+
if(!d.available&&!d.transcript){
|
|
2584
|
+
app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
|
|
2585
|
+
+'<div style="display:flex;align-items:center;gap:7px;font-size:16px;font-weight:700;margin-bottom:6px">'+mpIcon("note",16)+"<span>Transcript</span></div>"
|
|
2586
|
+
+'<div class="muted" style="font-size:13px;color:var(--muted)">'+esc(d.reason||"No transcript available.")+"</div></div></div>";
|
|
2587
|
+
return;}
|
|
2588
|
+
var wc=d.wordCount||(d.transcript?String(d.transcript).trim().split(/\s+/).filter(Boolean).length:0);
|
|
2589
|
+
var meta=[wc+" words"];
|
|
2590
|
+
if(d.language)meta.push(esc(d.language));
|
|
2591
|
+
if(d.autoGenerated)meta.push("auto-generated");
|
|
2592
|
+
app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
|
|
2593
|
+
+'<div style="display:flex;align-items:center;justify-content:space-between;gap:8px">'
|
|
2594
|
+
+'<div style="display:flex;align-items:center;gap:7px;font-size:16px;font-weight:700">'+mpIcon("note",16)+"<span>Transcript</span></div>"
|
|
2595
|
+
+'<button class="btn btn-sm copyable" style="background:var(--tag);color:var(--fg)" data-copy="'+esc(d.transcript||"")+'">Copy</button></div>'
|
|
2596
|
+
+'<div class="faint" style="font-size:11.5px;color:var(--muted);margin:3px 0 12px">'+meta.join(" ยท ")+"</div>"
|
|
2597
|
+
+'<div style="font-size:13.5px;line-height:1.65;white-space:pre-wrap">'+esc(d.transcript||"")+"</div>"
|
|
2598
|
+
+"</div></div>";
|
|
2599
|
+
return;}
|
|
2600
|
+
|
|
2601
|
+
// Trending hashtags
|
|
2602
|
+
if(d.hashtags&&Array.isArray(d.hashtags)){
|
|
2603
|
+
if(!d.hashtags.length){app.innerHTML='<div class="empty-state fade-in"><div class="icon">#</div><div class="text">No trending hashtags found</div></div>';return;}
|
|
2604
|
+
var hr=d.hashtags.slice(0,30).map(function(t){
|
|
2605
|
+
var dir=t.trend==="rising"?["โฒ","var(--green)"]:t.trend==="cooling"?["โผ","var(--red)"]:["โฌ","var(--muted)"];
|
|
2606
|
+
return '<a href="'+esc(t.url||"#")+'" target="_blank" rel="noopener" class="comment-row" style="text-decoration:none">'
|
|
2607
|
+
+'<div style="flex:1;min-width:0"><div style="font-size:13.5px;font-weight:600">#'+esc(t.hashtag)+"</div>"
|
|
2608
|
+
+'<div style="font-size:11.5px;color:var(--muted)">'+fmtNum(t.posts||0)+" posts ยท "+fmtNum(t.views||0)+" views</div></div>"
|
|
2609
|
+
+'<span style="font-size:11px;font-weight:700;color:'+dir[1]+';white-space:nowrap">'+dir[0]+" "+esc(t.trend||"")+"</span></a>";
|
|
2610
|
+
}).join("");
|
|
2611
|
+
app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
|
|
2612
|
+
+'<div style="display:flex;align-items:center;gap:7px;font-size:16px;font-weight:700">#<span>Trending hashtags</span></div>'
|
|
2613
|
+
+'<div class="faint" style="font-size:11.5px;color:var(--muted);margin:2px 0 10px">TikTok ยท '+esc(d.country||"US")+" ยท last "+(d.days||7)+" days</div>"
|
|
2614
|
+
+hr+"</div></div>";
|
|
2615
|
+
return;}
|
|
2616
|
+
|
|
2617
|
+
// Comment analysis
|
|
2618
|
+
if(d.report&&(d.commentsAnalyzed!==undefined)){
|
|
2619
|
+
var r=d.report;
|
|
2620
|
+
var col=r.sentiment==="positive"?"var(--green)":r.sentiment==="negative"?"var(--red)":"var(--amber)";
|
|
2621
|
+
var list=function(arr,label){
|
|
2622
|
+
if(!arr||!arr.length)return "";
|
|
2623
|
+
return '<div style="margin-top:14px"><div class="sec-label">'+esc(label)+'</div><ul style="margin:0;padding-left:18px;font-size:13px;color:var(--muted)">'
|
|
2624
|
+
+arr.slice(0,6).map(function(x){return '<li style="margin:4px 0">'+esc(x)+"</li>";}).join("")+"</ul></div>";
|
|
2625
|
+
};
|
|
2626
|
+
var th=(r.topThemes||[]).slice(0,5).map(function(t){
|
|
2627
|
+
return '<div style="padding:8px 0;border-bottom:1px solid var(--border-soft,var(--border))">'
|
|
2628
|
+
+'<div style="font-size:13px;font-weight:600">'+esc(t.theme||"")+"</div>"
|
|
2629
|
+
+'<div style="font-size:12.5px;color:var(--muted);margin-top:2px">'+esc(t.summary||"")+"</div></div>";
|
|
2630
|
+
}).join("");
|
|
2631
|
+
app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
|
|
2632
|
+
+'<div style="display:flex;align-items:center;gap:7px;font-size:16px;font-weight:700;margin-bottom:8px">'+mpIcon("comment",16)+"<span>Comment analysis</span></div>"
|
|
2633
|
+
+'<span style="display:inline-block;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:'+col+';border:1px solid '+col+';border-radius:999px;padding:2px 10px">'+esc(r.sentiment||"")+"</span>"
|
|
2634
|
+
+'<div style="font-size:13px;margin-top:8px">'+esc(r.sentimentNote||"")+"</div>"
|
|
2635
|
+
+(th?'<div style="margin-top:14px"><div class="sec-label">Themes</div>'+th+"</div>":"")
|
|
2636
|
+
+list(r.questions,"Questions asked")
|
|
2637
|
+
+list(r.objections,"Objections")
|
|
2638
|
+
+list(r.contentRequests,"Content requests")
|
|
2639
|
+
+list(r.nextVideoIdeas,"Next video ideas")
|
|
2640
|
+
+'<div class="faint" style="font-size:11px;color:var(--muted);margin-top:14px">Based on '+(d.commentsAnalyzed||0)+" comments</div>"
|
|
2641
|
+
+"</div></div>";
|
|
2642
|
+
return;}
|
|
2643
|
+
|
|
2644
|
+
// Comments
|
|
2645
|
+
if(d.comments&&Array.isArray(d.comments)){
|
|
2646
|
+
// Themes first: on a post with thousands of comments, "what do people
|
|
2647
|
+
// keep saying" is the answer someone actually wants. The raw list is
|
|
2648
|
+
// the evidence underneath it.
|
|
2649
|
+
var themes="";
|
|
2650
|
+
if(d.themes&&d.themes.length){
|
|
2651
|
+
themes='<div style="margin-bottom:14px"><div class="sec-label">What people keep mentioning</div><div>'
|
|
2652
|
+
+d.themes.slice(0,12).map(function(t){
|
|
2653
|
+
return '<span class="theme-chip">'+esc(t.keyword||t)
|
|
2654
|
+
+(t.count?"<b>"+t.count+"</b>":"")+"</span>";
|
|
2655
|
+
}).join("")+"</div></div>";
|
|
2656
|
+
}
|
|
2657
|
+
var ch=d.comments.slice(0,25).map(function(c,i){
|
|
2658
|
+
var user=c.username||c.user||"";
|
|
2659
|
+
var text=c.text||c.comment||"";
|
|
2660
|
+
var likes=c.likes||c.likeCount||0, replies=c.replies||0;
|
|
2661
|
+
var badges="";
|
|
2662
|
+
if(c.pinned)badges+='<span class="cbadge pin">PINNED</span>';
|
|
2663
|
+
if(c.creatorLiked)badges+='<span class="cbadge liked">CREATOR LIKED</span>';
|
|
2664
|
+
var meta=[];
|
|
2665
|
+
if(likes>0)meta.push(mpIcon("heart",11)+" "+fmtNum(likes));
|
|
2666
|
+
if(replies>0)meta.push(mpIcon("comment",11)+" "+fmtNum(replies));
|
|
2667
|
+
if(c.postedAt)meta.push(esc(relTime(c.postedAt)));
|
|
2668
|
+
// Long comments clamp to three lines with an expander rather than
|
|
2669
|
+
// being cut mid-sentence โ in a chat there is nowhere else to read it.
|
|
2670
|
+
var longish=text.length>180;
|
|
2671
|
+
return '<div class="comment-row" style="flex-direction:column;align-items:stretch">'
|
|
2672
|
+
+'<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">'
|
|
2673
|
+
+'<span style="font-size:12px;font-weight:600">@'+esc(user)+"</span>"+badges+"</div>"
|
|
2674
|
+
+'<div class="ctext'+(longish?" clamp":"")+'" data-c="'+i+'">'+esc(text)+"</div>"
|
|
2675
|
+
+(longish?'<button class="cmore" data-more="'+i+'">Show more</button>':"")
|
|
2676
|
+
+(meta.length?'<div class="cmeta">'+meta.join('<span style="opacity:.5">ยท</span>')+"</div>":"")
|
|
2677
|
+
+"</div>";
|
|
2678
|
+
}).join("");
|
|
2679
|
+
var extra=d.comments.length>25?'<div class="faint" style="text-align:center;font-size:12px;padding-top:10px;color:var(--muted)">Showing 25 of '+d.comments.length+"</div>":"";
|
|
2680
|
+
app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
|
|
2681
|
+
+'<div style="display:flex;align-items:center;gap:7px;font-size:16px;font-weight:700">'+mpIcon("comment",17)+"<span>Comments ("+d.comments.length+")</span></div>"
|
|
2682
|
+
+(d.summary?'<div class="muted" style="font-size:12.5px;margin:5px 0 14px;color:var(--muted)">'+esc(d.summary)+"</div>":'<div style="height:12px"></div>')
|
|
2683
|
+
+themes+ch+extra+"</div></div>";
|
|
2684
|
+
return;}
|
|
2685
|
+
// Sounds
|
|
2686
|
+
if(d.sounds&&Array.isArray(d.sounds)){
|
|
2687
|
+
app.innerHTML=galleryWrap(d.sounds.map(function(s,i){
|
|
2688
|
+
var color=pColor(s.platform||"tiktok");
|
|
2689
|
+
var coverSrc=s.coverUrl||s.coverProxyUrl||"";
|
|
2690
|
+
var cover=coverSrc?'<img class="sound-cover" src="'+esc(coverSrc)+'" alt="cover" loading="lazy"/>'
|
|
2691
|
+
:'<div class="sound-cover" style="background:'+color+'15;display:flex;align-items:center;justify-content:center;font-size:26px">๐ต</div>';
|
|
2692
|
+
// The raw CDN URL is signed and cross-origin โ only the proxied one
|
|
2693
|
+
// actually plays in this sandboxed frame. A bare link never played
|
|
2694
|
+
// anything in-place, which is why sound cards were silent.
|
|
2695
|
+
var play=s.playUrl||s.playProxyUrl||s.url||"";
|
|
2696
|
+
var secs=Number(s.duration)||0;
|
|
2697
|
+
var meta=[s.artist||s.author||"",secs>0?fmtTime(secs):""].filter(Boolean).join(" โข ");
|
|
2698
|
+
var player=play
|
|
2699
|
+
? '<div class="au" data-au="au'+i+'">'
|
|
2700
|
+
+'<button class="au-play" style="background:'+color+'" aria-label="Play sound">'+mpIcon("play",14)+"</button>"
|
|
2701
|
+
+'<div class="au-body"><div class="au-seek"><i></i></div>'
|
|
2702
|
+
+'<div class="au-time"><span class="au-cur">0:00</span><span class="au-dur">'
|
|
2703
|
+
+(secs>0?fmtTime(secs):"--:--")+"</span></div></div>"
|
|
2704
|
+
+'<audio preload="metadata" src="'+esc(play)+'"></audio></div>'
|
|
2705
|
+
: '<div class="au-missing">'+mpIcon("warn",13)+"<span>No preview for this track</span></div>";
|
|
2706
|
+
return '<div class="sound-card"><div style="display:flex;gap:12px;align-items:center">'
|
|
2707
|
+
+cover+'<div style="flex:1;min-width:0">'
|
|
2708
|
+
+'<div style="font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(s.title||"")+"</div>"
|
|
2709
|
+
+'<div style="font-size:12px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(meta)+"</div>"
|
|
2710
|
+
+(s.videoCount?'<div style="font-size:11px;color:var(--muted);margin-top:2px">'+fmtNum(s.videoCount)+" videos</div>":"")
|
|
2711
|
+
+"</div></div>"+player+"</div>";
|
|
2712
|
+
}).join(""),d.sounds.length);return;}
|
|
2713
|
+
// Credits
|
|
2714
|
+
if(d.balance!=null||d.tier){
|
|
2715
|
+
var bal=Number(d.balance)||0,tier=d.tier||"",ff=d.firstFreeTools||[];
|
|
2716
|
+
var freeHtml="";
|
|
2717
|
+
if(ff.length){var tags=ff.map(function(t){return'<span class="free-tag">โจ '+esc(t)+" free</span>";}).join("");
|
|
2718
|
+
freeHtml='<div style="margin-top:12px"><div class="section-label" style="margin-bottom:6px">Free first uses remaining</div><div style="display:flex;flex-wrap:wrap;gap:4px">'+tags+"</div></div>";}
|
|
2719
|
+
app.innerHTML='<div class="credits-card fade-in">'
|
|
2720
|
+
+'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">'
|
|
2721
|
+
+'<div style="font-size:16px;font-weight:700">๐ฐ Your Credits</div>'
|
|
2722
|
+
+(tier?'<span class="tier-badge">'+esc(tier)+"</span>":"")+"</div>"
|
|
2723
|
+
+'<div style="font-size:36px;font-weight:800;margin:10px 0;letter-spacing:-1px">'+bal+"</div>"
|
|
2724
|
+
+'<div style="font-size:13px;color:var(--muted)">credits remaining</div>'+freeHtml+"</div>";return;}
|
|
2725
|
+
// Checkout. Real packs/prices, not the three hardcoded tiers this used
|
|
2726
|
+
// to show regardless of what the backend actually returned โ and each
|
|
2727
|
+
// card is a real <a href> now, so the existing generic anchor handler
|
|
2728
|
+
// (search this file for "cannot navigate the top-level window") opens
|
|
2729
|
+
// the real checkoutUrl via ui/open-link. There was no click handler at
|
|
2730
|
+
// all before this: .pack{cursor:pointer} in the CSS promised a click
|
|
2731
|
+
// that did nothing.
|
|
2732
|
+
if(d.checkoutUrl||d.packs){
|
|
2733
|
+
var checkoutUrl=d.checkoutUrl||"#";
|
|
2734
|
+
var packList=Array.isArray(d.packs)?d.packs:[];
|
|
2735
|
+
var packHtml=function(p,featured){
|
|
2736
|
+
var name=(p&&(p.name||p.label||p.title))||"Credits";
|
|
2737
|
+
var price=(p&&(p.price||p.priceLabel||p.cost))||"";
|
|
2738
|
+
var credits=p&&p.credits!=null?fmtNum(p.credits)+" credits":(p&&(p.description||p.credit))||"";
|
|
2739
|
+
return '<a href="'+esc(checkoutUrl)+'" target="_blank" rel="noopener" class="'+(featured?"pack-featured":"pack")+'" style="text-decoration:none;color:inherit;display:block">'
|
|
2740
|
+
+'<div class="text-sm font-semibold">'+esc(name)+'</div>'
|
|
2741
|
+
+(price?'<div class="mt-2 text-2xl font-extrabold">'+esc(price)+"</div>":"")
|
|
2742
|
+
+(credits?'<div class="mt-2 text-xs text-muted">'+esc(credits)+"</div>":"")+"</a>";
|
|
2743
|
+
};
|
|
2744
|
+
var cards=packList.length
|
|
2745
|
+
? packList.map(function(p,i){return packHtml(p,i===1);}).join("")
|
|
2746
|
+
: packHtml({name:"Buy credits",description:"Opens Stripe Checkout"},true);
|
|
2747
|
+
app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
|
|
2748
|
+
+'<div class="mb-3 text-base font-bold">๐ Credit Packs</div>'
|
|
2749
|
+
+'<div class="grid grid-cols-3 gap-2">'+cards
|
|
2750
|
+
+"</div></div></div>";return;}
|
|
2751
|
+
// Hooks (show_hooks) โ quote() gives each one its own Copy button.
|
|
2752
|
+
if(d.hooks&&Array.isArray(d.hooks)){
|
|
2753
|
+
if(!d.hooks.length){app.innerHTML='<div class="empty-state fade-in"><div class="icon">๐ฌ</div><div class="text">No hooks yet</div></div>';return;}
|
|
2754
|
+
var hooksHtml=d.hooks.map(function(h){
|
|
2755
|
+
var meta=(h.mechanism?chip(h.mechanism,"note"):"")+(h.why?'<div class="section-text" style="margin-top:2px">'+esc(h.why)+"</div>":"");
|
|
2756
|
+
return quote(h.hook||"")+(meta?'<div style="margin:-4px 0 10px">'+meta+"</div>":"");
|
|
2757
|
+
}).join("");
|
|
2758
|
+
app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
|
|
2759
|
+
+'<div style="display:flex;align-items:center;gap:7px;font-size:16px;font-weight:700;margin-bottom:4px">'+mpIcon("note",16)+"<span>Hooks</span></div>"
|
|
2760
|
+
+(d.topic?'<div class="faint" style="font-size:11.5px;color:var(--muted);margin-bottom:10px">'+esc(d.topic)+"</div>":"")
|
|
2761
|
+
+hooksHtml+"</div></div>";return;}
|
|
2762
|
+
|
|
2763
|
+
// Variants (show_variants)
|
|
2764
|
+
if(d.variants&&Array.isArray(d.variants)){
|
|
2765
|
+
if(!d.variants.length){app.innerHTML='<div class="empty-state fade-in"><div class="icon">๐ฌ</div><div class="text">No variants yet</div></div>';return;}
|
|
2766
|
+
var srcCard=(d.post&&(d.post.externalUrl||d.post.platform))?postCard(d.post,true,false,true)+'<div style="height:10px"></div>':"";
|
|
2767
|
+
var variantsHtml=d.variants.map(function(v,i){
|
|
2768
|
+
var beats=(v.beats&&v.beats.length)?bullets(v.beats,8):"";
|
|
2769
|
+
var body=(v.hook?quote(v.hook):"")
|
|
2770
|
+
+(v.angle?'<div class="section-text"><b>Angle:</b> '+esc(v.angle)+"</div>":"")
|
|
2771
|
+
+beats
|
|
2772
|
+
+(v.cta?'<div class="section-text"><b>CTA:</b> '+esc(v.cta)+"</div>":"")
|
|
2773
|
+
+(v.whyItCouldWork?'<div class="section-text" style="color:var(--muted)">'+esc(v.whyItCouldWork)+"</div>":"");
|
|
2774
|
+
return section((i+1)+". "+esc(v.title||"Variant"),body,i===0);
|
|
2775
|
+
}).join("");
|
|
2776
|
+
app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
|
|
2777
|
+
+'<div style="display:flex;align-items:center;gap:7px;font-size:16px;font-weight:700;margin-bottom:10px">'+mpIcon("share",16)+"<span>Variants</span></div>"
|
|
2778
|
+
+srcCard+variantsHtml+"</div></div>";return;}
|
|
2779
|
+
|
|
2780
|
+
// Repurposed copy (show_repurposed_post) โ expandable() clamps long
|
|
2781
|
+
// rewrites instead of dumping a whole thread/description onto the
|
|
2782
|
+
// screen at once; the copyable button matches analysisCard's own
|
|
2783
|
+
// "Copy transcript" pattern above rather than inventing a third one.
|
|
2784
|
+
if(d.versions&&Array.isArray(d.versions)){
|
|
2785
|
+
if(!d.versions.length){app.innerHTML='<div class="empty-state fade-in"><div class="icon">โ๏ธ</div><div class="text">Nothing repurposed yet</div></div>';return;}
|
|
2786
|
+
var versionsHtml=d.versions.map(function(v,i){
|
|
2787
|
+
return section(esc(v.surface||"Version "+(i+1)),
|
|
2788
|
+
expandable(v.content||"")+'<button class="tag copyable" style="margin-top:8px" data-copy="'+esc(v.content||"")+'">Copy</button>',
|
|
2789
|
+
i===0);
|
|
2790
|
+
}).join("");
|
|
2791
|
+
app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
|
|
2792
|
+
+'<div style="display:flex;align-items:center;gap:7px;font-size:16px;font-weight:700;margin-bottom:10px">'+mpIcon("share",16)+"<span>Repurposed</span></div>"
|
|
2793
|
+
+versionsHtml+"</div></div>";return;}
|
|
2794
|
+
|
|
2795
|
+
// Single post
|
|
2796
|
+
if(d.post||d.platform){app.innerHTML=postCard(d.post||d,true);return;}
|
|
2797
|
+
// Fallback
|
|
2798
|
+
app.innerHTML='<div class="json-block fade-in">'+esc(JSON.stringify(d,null,2))+"</div>";
|
|
2799
|
+
setTimeout(reportSize,50);
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
function updateProgress(params){
|
|
2803
|
+
var p=params||{};
|
|
2804
|
+
var toolName=p.name||p.toolName||"";
|
|
2805
|
+
if(toolName)currentTool=toolName;
|
|
2806
|
+
renderLoading(currentTool,p.arguments);
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
// Global helpers
|
|
2810
|
+
window.copyText=copyText;
|
|
2811
|
+
document.addEventListener('click',function(e){
|
|
2812
|
+
var btn=e.target.closest('.copy-btn[data-copy]');
|
|
2813
|
+
if(btn)copyText(btn.getAttribute('data-copy'));
|
|
2814
|
+
});
|
|
2815
|
+
// Analysis prose expanders and copy chips.
|
|
2816
|
+
document.addEventListener("click",function(e){
|
|
2817
|
+
if(!e.target.closest)return;
|
|
2818
|
+
var x=e.target.closest(".cmore[data-xmore]");
|
|
2819
|
+
if(x){
|
|
2820
|
+
var t=document.querySelector('.section-text[data-x="'+x.getAttribute("data-xmore")+'"]');
|
|
2821
|
+
if(t){
|
|
2822
|
+
var clamped=t.classList.toggle("clamp6");
|
|
2823
|
+
x.textContent=clamped?"Show more":"Show less";
|
|
2824
|
+
setTimeout(reportSize,60);
|
|
2825
|
+
}
|
|
2826
|
+
return;
|
|
2827
|
+
}
|
|
2828
|
+
var c=e.target.closest(".copyable[data-copy]");
|
|
2829
|
+
if(c){
|
|
2830
|
+
copyText(c.getAttribute("data-copy"));
|
|
2831
|
+
var label=c.textContent;
|
|
2832
|
+
c.textContent="Copied";
|
|
2833
|
+
setTimeout(function(){c.textContent=label;},1200);
|
|
2834
|
+
}
|
|
2835
|
+
});
|
|
2836
|
+
|
|
2837
|
+
// Comment expanders.
|
|
2838
|
+
document.addEventListener("click",function(e){
|
|
2839
|
+
var b=e.target.closest&&e.target.closest(".cmore[data-more]");
|
|
2840
|
+
if(!b)return;
|
|
2841
|
+
var t=document.querySelector('.ctext[data-c="'+b.getAttribute("data-more")+'"]');
|
|
2842
|
+
if(!t)return;
|
|
2843
|
+
var open=t.classList.toggle("clamp");
|
|
2844
|
+
b.textContent=open?"Show more":"Show less";
|
|
2845
|
+
setTimeout(reportSize,60);
|
|
2846
|
+
});
|
|
2847
|
+
window.toggleSection=function(header){
|
|
2848
|
+
header.classList.toggle("open");
|
|
2849
|
+
var content=header.nextElementSibling;
|
|
2850
|
+
if(content)content.classList.toggle("open");
|
|
2851
|
+
};
|
|
2852
|
+
})();
|
|
2853
|
+
</script>
|
|
2854
|
+
</body>
|
|
2855
|
+
</html>
|
|
2856
|
+
`;
|