@scalemule/nextjs 0.1.24 → 0.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -560,6 +560,7 @@ var ScaleMuleClient = class {
560
560
  this.anonymousId = null;
561
561
  this.sessionPool = /* @__PURE__ */ new Map();
562
562
  this.knownAccounts = /* @__PURE__ */ new Map();
563
+ this.refreshPromise = null;
563
564
  this.apiKey = config.apiKey;
564
565
  this.applicationId = config.applicationId || null;
565
566
  this.baseUrl = config.baseUrl || GATEWAY_URLS[config.environment || "prod"];
@@ -578,6 +579,9 @@ var ScaleMuleClient = class {
578
579
  this.multiSessionEnabled = config.enableMultiSession || false;
579
580
  this.accountSwitcherEnabled = config.enableAccountSwitcher || false;
580
581
  this.accountSwitcherPrivacy = config.accountSwitcherPrivacy || "full";
582
+ this.onRefreshStart = config.onRefreshStart;
583
+ this.onRefreshEnd = config.onRefreshEnd;
584
+ this.onAutoRefreshFailed = config.onAutoRefreshFailed;
581
585
  }
582
586
  // --------------------------------------------------------------------------
583
587
  // Session Management
@@ -863,34 +867,28 @@ var ScaleMuleClient = class {
863
867
  const method = (init.method || "GET").toUpperCase();
864
868
  const timeout = init.timeout || this.defaultTimeout;
865
869
  const maxRetries = init.skipRetry ? 0 : init.retries ?? this.maxRetries;
866
- const headers = {
867
- "x-api-key": this.apiKey,
868
- "User-Agent": `ScaleMule-SDK-TypeScript/${SDK_VERSION}`,
869
- ...init.headers
870
- };
871
- if (!init.skipAuth && this.sessionToken) {
872
- headers["Authorization"] = `Bearer ${this.sessionToken}`;
873
- }
874
- if (this.workspaceId) {
875
- headers["x-sm-workspace-id"] = this.workspaceId;
876
- }
877
- if (!this.sessionToken && this.anonymousId) {
878
- headers["x-anonymous-id"] = this.anonymousId;
879
- }
880
- let bodyStr;
881
- if (init.body !== void 0 && init.body !== null) {
882
- bodyStr = typeof init.body === "string" ? init.body : JSON.stringify(init.body);
883
- if (!headers["Content-Type"]) {
884
- headers["Content-Type"] = "application/json";
885
- }
886
- }
887
- if (this.debug) {
888
- console.log(`[ScaleMule] ${method} ${path}`);
889
- }
890
- const idempotencyKey = method === "POST" ? generateIdempotencyKey() : void 0;
870
+ const bodyStr = init.body ? JSON.stringify(init.body) : void 0;
891
871
  let lastError = null;
872
+ let idempotencyKey = null;
892
873
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
893
- if (attempt > 0 && idempotencyKey) {
874
+ const headers = {
875
+ "x-api-key": this.apiKey,
876
+ "User-Agent": `ScaleMule-SDK-TypeScript/${SDK_VERSION}`,
877
+ ...init.headers
878
+ };
879
+ if (!init.skipAuth && this.sessionToken) {
880
+ headers["Authorization"] = `Bearer ${this.sessionToken}`;
881
+ }
882
+ if (this.workspaceId) {
883
+ headers["x-sm-workspace-id"] = this.workspaceId;
884
+ }
885
+ if (!this.sessionToken && this.anonymousId) {
886
+ headers["x-anonymous-id"] = this.anonymousId;
887
+ }
888
+ if (attempt > 0 && (method === "POST" || method === "PUT" || method === "PATCH")) {
889
+ if (!idempotencyKey) {
890
+ idempotencyKey = generateIdempotencyKey();
891
+ }
894
892
  headers["x-idempotency-key"] = idempotencyKey;
895
893
  }
896
894
  const controller = new AbortController();
@@ -910,6 +908,10 @@ var ScaleMuleClient = class {
910
908
  signal: controller.signal
911
909
  });
912
910
  clearTimeout(timeoutId);
911
+ const rotatedToken = response.headers.get("x-rotated-session-token");
912
+ if (rotatedToken && this.sessionToken && this.userId) {
913
+ this.setSession(rotatedToken, this.userId);
914
+ }
913
915
  if (this.rateLimitQueue) {
914
916
  this.rateLimitQueue.updateFromHeaders(response.headers);
915
917
  }
@@ -932,6 +934,45 @@ var ScaleMuleClient = class {
932
934
  status: response.status,
933
935
  details: responseData?.error?.details || responseData?.details
934
936
  };
937
+ if (response.status === 401 && this.sessionToken && !init.isAutoRefresh) {
938
+ if (this.debug) console.log("[ScaleMule] 401 received, attempting auto-refresh...");
939
+ let isPrimaryRefresh = false;
940
+ if (!this.refreshPromise) {
941
+ isPrimaryRefresh = true;
942
+ this.onRefreshStart?.();
943
+ this.refreshPromise = this.auth?.refreshSession ? this.auth.refreshSession() : this.post("/auth/refresh", {}, { isAutoRefresh: true });
944
+ }
945
+ const currentRefreshPromise = this.refreshPromise;
946
+ try {
947
+ const refreshResult = await currentRefreshPromise;
948
+ if (!refreshResult || refreshResult.error) {
949
+ if (this.debug) console.log("[ScaleMule] Auto-refresh failed:", refreshResult?.error);
950
+ const apiError = refreshResult?.error || {
951
+ code: "refresh_failed",
952
+ message: "Auto-refresh failed",
953
+ status: 400
954
+ };
955
+ init.onAutoRefreshFailed?.(apiError);
956
+ this.onAutoRefreshFailed?.(apiError);
957
+ return { data: null, error };
958
+ }
959
+ if (this.debug) console.log("[ScaleMule] Auto-refresh succeeded, retrying original request...");
960
+ const newToken = refreshResult.data?.session_token || refreshResult.data?.access_token;
961
+ if (newToken) {
962
+ this.sessionToken = newToken;
963
+ if (this.userId) {
964
+ await this.storage.setItem(SESSION_STORAGE_KEY, newToken);
965
+ }
966
+ }
967
+ attempt--;
968
+ continue;
969
+ } finally {
970
+ if (isPrimaryRefresh && this.refreshPromise === currentRefreshPromise) {
971
+ this.refreshPromise = null;
972
+ this.onRefreshEnd?.();
973
+ }
974
+ }
975
+ }
935
976
  if (response.status === 429) {
936
977
  const retryAfter = response.headers.get("Retry-After");
937
978
  if (retryAfter) {
@@ -5715,7 +5756,7 @@ var AudioService = class extends ServiceModule {
5715
5756
  constructor(client, storage) {
5716
5757
  super(client);
5717
5758
  this.storage = storage;
5718
- this.basePath = "/v1/audios";
5759
+ this.basePath = "/v1/audio";
5719
5760
  }
5720
5761
  /**
5721
5762
  * Register a storage-uploaded audio asset with the audio service.
@@ -5772,6 +5813,32 @@ var AudioService = class extends ServiceModule {
5772
5813
  };
5773
5814
  }
5774
5815
  };
5816
+ var TtsService = class extends ServiceModule {
5817
+ constructor() {
5818
+ super(...arguments);
5819
+ this.basePath = "/v1/tts";
5820
+ }
5821
+ async synthesize(params, options) {
5822
+ return this.post(
5823
+ "/synthesize",
5824
+ {
5825
+ text: params.text,
5826
+ voice: params.voice,
5827
+ model: params.model,
5828
+ provider: params.provider,
5829
+ async: params.async,
5830
+ access_mode: params.accessMode
5831
+ },
5832
+ options
5833
+ );
5834
+ }
5835
+ async getJob(id, options) {
5836
+ return this._get(`/jobs/${id}`, options);
5837
+ }
5838
+ async listVoices(params, options) {
5839
+ return this._get(this.withQuery("/voices", { provider: params?.provider }), options);
5840
+ }
5841
+ };
5775
5842
  var DeadLetterApi = class extends ServiceModule {
5776
5843
  constructor() {
5777
5844
  super(...arguments);
@@ -6592,6 +6659,7 @@ var ScaleMule = class {
6592
6659
  this.functions = new FunctionsService(this._client);
6593
6660
  this.photo = new PhotoService(this._client, this.storage);
6594
6661
  this.audio = new AudioService(this._client, this.storage);
6662
+ this.tts = new TtsService(this._client);
6595
6663
  this.flagContent = new FlagContentService(this._client);
6596
6664
  this.creatorMaker = new CreatorMakerService(this._client);
6597
6665
  this.compliance = new ComplianceService(this._client);
@@ -7158,6 +7226,24 @@ var ScaleMuleClient2 = class {
7158
7226
  console.log("[ScaleMule] Session set for user:", userId);
7159
7227
  }
7160
7228
  }
7229
+ /**
7230
+ * Token-only setter for member-auth surfaces.
7231
+ *
7232
+ * Unlike `setSession(token, userId)`, this does NOT persist a userId or
7233
+ * write to local storage — useful when the host platform owns identity
7234
+ * (e.g. ScaleMule's member dashboards use `MemberAuthProvider` and the
7235
+ * token comes from a `${env}_member_access_token` cookie). The token is
7236
+ * applied in-memory and used as a `Bearer` Authorization header on
7237
+ * subsequent requests.
7238
+ *
7239
+ * Pass `null` to clear without touching userId/storage.
7240
+ */
7241
+ setSessionToken(token) {
7242
+ this.sessionToken = token;
7243
+ if (this.debug) {
7244
+ console.log("[ScaleMule] Session token", token ? "set (token-only)" : "cleared (token-only)");
7245
+ }
7246
+ }
7161
7247
  /**
7162
7248
  * Clear session on logout
7163
7249
  */
@@ -7222,6 +7308,8 @@ var ScaleMuleClient2 = class {
7222
7308
  console.log(`[ScaleMule] ${options.method || "GET"} ${path}`);
7223
7309
  }
7224
7310
  let lastError = null;
7311
+ const MAX_REFRESH_ATTEMPTS = 1;
7312
+ let refreshAttempts = 0;
7225
7313
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
7226
7314
  const controller = new AbortController();
7227
7315
  const timeoutId = setTimeout(() => controller.abort(), timeout);
@@ -7245,7 +7333,8 @@ var ScaleMuleClient2 = class {
7245
7333
  if (!response.ok) {
7246
7334
  const rawError = responseData?.error;
7247
7335
  const error = rawError && typeof rawError === "object" ? rawError : { code: `HTTP_${response.status}`, message: typeof rawError === "string" ? rawError : responseData?.message || text || response.statusText };
7248
- if (response.status === 401 && this.sessionToken && !options.isAutoRefresh) {
7336
+ if (response.status === 401 && this.sessionToken && !options.isAutoRefresh && refreshAttempts < MAX_REFRESH_ATTEMPTS) {
7337
+ refreshAttempts++;
7249
7338
  if (this.debug) console.log("[ScaleMule] 401 received, attempting auto-refresh...");
7250
7339
  if (!this.refreshPromise) {
7251
7340
  this.onRefreshStart?.();
@@ -7614,8 +7703,12 @@ function ScaleMuleProvider({
7614
7703
  onLogout,
7615
7704
  onAuthError,
7616
7705
  bootstrapFlags,
7617
- mediaPolicy
7706
+ mediaPolicy,
7707
+ getToken,
7708
+ userResolver,
7709
+ memberTokenPollMs
7618
7710
  }) {
7711
+ const memberMode = typeof getToken === "function";
7619
7712
  const [user, setUser] = useState(null);
7620
7713
  const [initializing, setInitializing] = useState(true);
7621
7714
  const [error, setError] = useState(null);
@@ -7632,9 +7725,13 @@ function ScaleMuleProvider({
7632
7725
  gatewayUrl: resolvedGatewayUrl,
7633
7726
  debug,
7634
7727
  storage,
7635
- pendingSessionInit: !!authProxyUrl
7728
+ // Make outbound API calls wait for the first token to land in any
7729
+ // mode where the provider is responsible for populating the
7730
+ // session asynchronously: auth-proxy fetch, or member-mode
7731
+ // getToken() callback. Resolved in the init effect below.
7732
+ pendingSessionInit: !!authProxyUrl || memberMode
7636
7733
  }),
7637
- [apiKey, applicationId, environment, resolvedGatewayUrl, debug, storage, authProxyUrl]
7734
+ [apiKey, applicationId, environment, resolvedGatewayUrl, debug, storage, authProxyUrl, memberMode]
7638
7735
  );
7639
7736
  const money = useMemo(
7640
7737
  () => createMoneyClient({
@@ -7656,8 +7753,10 @@ function ScaleMuleProvider({
7656
7753
  });
7657
7754
  }, [apiKey, applicationId, environment, resolvedGatewayUrl, debug]);
7658
7755
  const [fetchedPolicy, setFetchedPolicy] = useState(void 0);
7756
+ const [tokenVersion, setTokenVersion] = useState(0);
7659
7757
  useEffect(() => {
7660
7758
  if (mediaPolicy) return;
7759
+ if (memberMode && tokenVersion === 0) return;
7661
7760
  let mounted = true;
7662
7761
  void (async () => {
7663
7762
  try {
@@ -7675,7 +7774,7 @@ function ScaleMuleProvider({
7675
7774
  return () => {
7676
7775
  mounted = false;
7677
7776
  };
7678
- }, [baseClient, mediaPolicy]);
7777
+ }, [baseClient, mediaPolicy, memberMode, tokenVersion]);
7679
7778
  const effectiveMediaPolicy = mediaPolicy ?? fetchedPolicy;
7680
7779
  useEffect(() => {
7681
7780
  const token = client.getSessionToken();
@@ -7693,6 +7792,43 @@ function ScaleMuleProvider({
7693
7792
  try {
7694
7793
  await client.initialize();
7695
7794
  const cachedUser = getCachedUser();
7795
+ if (memberMode) {
7796
+ try {
7797
+ const token = await Promise.resolve(getToken()) ?? null;
7798
+ if (mounted) {
7799
+ if (token) {
7800
+ client.setSessionToken(token);
7801
+ baseClient.setAccessToken(token);
7802
+ money.setAccessToken(token);
7803
+ } else {
7804
+ client.setSessionToken(null);
7805
+ baseClient.clearAccessToken();
7806
+ money.setAccessToken(void 0);
7807
+ }
7808
+ setTokenVersion((v) => v + 1);
7809
+ }
7810
+ if (userResolver) {
7811
+ try {
7812
+ const resolvedUser = await userResolver();
7813
+ if (mounted) {
7814
+ setUser(resolvedUser);
7815
+ setCachedUser(resolvedUser);
7816
+ }
7817
+ } catch (resolveErr) {
7818
+ if (mounted && debug) {
7819
+ console.debug("[ScaleMule] userResolver() failed:", resolveErr);
7820
+ }
7821
+ }
7822
+ }
7823
+ } catch (memberErr) {
7824
+ if (mounted && debug) {
7825
+ console.debug("[ScaleMule] getToken() failed:", memberErr);
7826
+ }
7827
+ } finally {
7828
+ client.resolveSessionPending();
7829
+ }
7830
+ return;
7831
+ }
7696
7832
  if (authProxyUrl) {
7697
7833
  if (cachedUser && mounted) {
7698
7834
  setUser(cachedUser);
@@ -7764,7 +7900,43 @@ function ScaleMuleProvider({
7764
7900
  return () => {
7765
7901
  mounted = false;
7766
7902
  };
7767
- }, [client, debug, onAuthError, authProxyUrl]);
7903
+ }, [client, baseClient, money, debug, onAuthError, authProxyUrl, memberMode, getToken, userResolver]);
7904
+ useEffect(() => {
7905
+ if (!memberMode) return;
7906
+ if (memberTokenPollMs === null) return;
7907
+ const interval = memberTokenPollMs ?? 6e4;
7908
+ if (interval <= 0) return;
7909
+ let cancelled = false;
7910
+ const id = setInterval(async () => {
7911
+ try {
7912
+ const token = await Promise.resolve(getToken()) ?? null;
7913
+ if (cancelled) return;
7914
+ const current = client.getSessionToken();
7915
+ if (token === current) return;
7916
+ if (token) {
7917
+ client.setSessionToken(token);
7918
+ baseClient.setAccessToken(token);
7919
+ money.setAccessToken(token);
7920
+ } else {
7921
+ client.setSessionToken(null);
7922
+ baseClient.clearAccessToken();
7923
+ money.setAccessToken(void 0);
7924
+ if (debug) {
7925
+ console.debug("[ScaleMule] Member token cleared on refresh");
7926
+ }
7927
+ }
7928
+ setTokenVersion((v) => v + 1);
7929
+ } catch (err) {
7930
+ if (debug) {
7931
+ console.debug("[ScaleMule] Member token refresh failed:", err);
7932
+ }
7933
+ }
7934
+ }, interval);
7935
+ return () => {
7936
+ cancelled = true;
7937
+ clearInterval(id);
7938
+ };
7939
+ }, [memberMode, memberTokenPollMs, getToken, client, baseClient, money, debug]);
7768
7940
  const handleSetUser = useCallback(
7769
7941
  (newUser) => {
7770
7942
  setUser(newUser);
@@ -8246,8 +8418,8 @@ function useAuth() {
8246
8418
  const getLinkedAccounts = useCallback(async () => {
8247
8419
  setError(null);
8248
8420
  try {
8249
- const data = await client.get("/v1/auth/oauth/accounts");
8250
- return data.accounts;
8421
+ const data = await client.get("/v1/auth/oauth/providers");
8422
+ return data.providers;
8251
8423
  } catch (err) {
8252
8424
  if (err instanceof ScaleMuleApiError) {
8253
8425
  setError(err);
@@ -8256,41 +8428,30 @@ function useAuth() {
8256
8428
  }
8257
8429
  }, [client, setError]);
8258
8430
  const linkAccount = useCallback(
8259
- async (config) => {
8431
+ async (_config) => {
8260
8432
  setError(null);
8261
8433
  if (!user) {
8262
- const err = {
8434
+ const err2 = {
8263
8435
  code: "NOT_AUTHENTICATED",
8264
8436
  message: "Must be logged in to link accounts"
8265
8437
  };
8266
- setError(err);
8267
- throw err;
8268
- }
8269
- let linkData;
8270
- try {
8271
- linkData = await client.post("/v1/auth/oauth/link", {
8272
- provider: config.provider,
8273
- redirect_url: config.redirectUrl,
8274
- scopes: config.scopes
8275
- });
8276
- } catch (err) {
8277
- if (err instanceof ScaleMuleApiError) {
8278
- setError(err);
8279
- }
8280
- throw err;
8281
- }
8282
- if (typeof sessionStorage !== "undefined") {
8283
- sessionStorage.setItem("scalemule_oauth_state", linkData.state);
8438
+ setError(err2);
8439
+ throw err2;
8284
8440
  }
8285
- return linkData;
8441
+ const err = {
8442
+ code: "NOT_IMPLEMENTED",
8443
+ message: "OAuth account linking from a logged-in session is not yet supported. Use the standard OAuth sign-in flow instead."
8444
+ };
8445
+ setError(err);
8446
+ throw err;
8286
8447
  },
8287
- [client, user, setError]
8448
+ [user, setError]
8288
8449
  );
8289
8450
  const unlinkAccount = useCallback(
8290
8451
  async (provider) => {
8291
8452
  setError(null);
8292
8453
  try {
8293
- await client.delete(`/v1/auth/oauth/accounts/${provider}`);
8454
+ await client.delete(`/v1/auth/oauth/providers/${encodeURIComponent(provider)}`);
8294
8455
  } catch (err) {
8295
8456
  if (err instanceof ScaleMuleApiError) {
8296
8457
  setError(err);
@@ -10900,6 +11061,75 @@ function useFeedback(options = {}) {
10900
11061
  );
10901
11062
  return { items, loading, error, submit, refresh };
10902
11063
  }
11064
+ var ZERO = { value: 0, up_count: 0, down_count: 0, score: 0 };
11065
+ function useVote({
11066
+ targetType,
11067
+ targetId,
11068
+ initialState,
11069
+ refetchOnMount = false,
11070
+ enabled = true
11071
+ }) {
11072
+ const { client } = useScaleMule();
11073
+ const [state, setState] = useState(initialState ?? ZERO);
11074
+ const [isLoading, setIsLoading] = useState(!initialState && enabled);
11075
+ const [error, setError] = useState(null);
11076
+ const inFlight = useRef(null);
11077
+ const path = `/v1/social/${encodeURIComponent(targetType)}/${encodeURIComponent(targetId)}/vote`;
11078
+ const refetch = useCallback(async () => {
11079
+ if (!enabled) return;
11080
+ setIsLoading(true);
11081
+ setError(null);
11082
+ try {
11083
+ const data = await client.get(path);
11084
+ setState(data);
11085
+ } catch (e) {
11086
+ setError(e);
11087
+ } finally {
11088
+ setIsLoading(false);
11089
+ }
11090
+ }, [client, path, enabled]);
11091
+ useEffect(() => {
11092
+ if (!enabled) return;
11093
+ if (initialState && !refetchOnMount) return;
11094
+ void refetch();
11095
+ }, [enabled, refetch, initialState, refetchOnMount]);
11096
+ const cast = useCallback(
11097
+ async (value) => {
11098
+ const prev = state;
11099
+ const optimistic = applyVote(prev, value);
11100
+ setState(optimistic);
11101
+ setError(null);
11102
+ const send = async () => {
11103
+ try {
11104
+ const data = await client.put(path, { value });
11105
+ setState(data);
11106
+ } catch (e) {
11107
+ setState(prev);
11108
+ setError(e);
11109
+ }
11110
+ };
11111
+ const p = inFlight.current ? inFlight.current.then(send) : send();
11112
+ inFlight.current = p;
11113
+ await p;
11114
+ if (inFlight.current === p) inFlight.current = null;
11115
+ },
11116
+ [client, path, state]
11117
+ );
11118
+ return { state, isLoading, error, cast, refetch };
11119
+ }
11120
+ function applyVote(prev, next) {
11121
+ let { up_count, down_count } = prev;
11122
+ if (prev.value === 1) up_count = Math.max(0, up_count - 1);
11123
+ if (prev.value === -1) down_count = Math.max(0, down_count - 1);
11124
+ if (next === 1) up_count += 1;
11125
+ if (next === -1) down_count += 1;
11126
+ return {
11127
+ value: next,
11128
+ up_count,
11129
+ down_count,
11130
+ score: up_count - down_count
11131
+ };
11132
+ }
10903
11133
  var TYPE_LABELS = {
10904
11134
  bug_report: "Bug",
10905
11135
  feature_request: "Feature request",
@@ -11268,449 +11498,128 @@ function FeedbackWidget(props) {
11268
11498
  ) })
11269
11499
  ] });
11270
11500
  }
11271
- var SPEEDS = [1, 1.25, 1.5, 2];
11272
- var EXPIRY_REFRESH_WINDOW_MS = 3e4;
11273
- var styles = {
11501
+ var ARROW_UP = "\u25B2";
11502
+ var ARROW_DOWN = "\u25BC";
11503
+ var STYLES2 = {
11274
11504
  root: {
11275
- borderRadius: "1.75rem",
11276
- border: "1px solid rgba(226, 232, 240, 0.8)",
11277
- background: "linear-gradient(180deg, #f8fbff 0%, #edf4ff 100%)",
11278
- padding: "1rem",
11279
- color: "#0f172b",
11280
- boxShadow: "0 22px 70px rgba(4, 12, 24, 0.28)"
11281
- },
11282
- layout: {
11283
- display: "flex",
11284
- flexWrap: "wrap",
11285
- alignItems: "center",
11286
- gap: "1rem"
11287
- },
11288
- playButton: {
11289
- display: "flex",
11290
- height: "4.5rem",
11291
- width: "4.5rem",
11292
- flexShrink: 0,
11505
+ display: "inline-flex",
11293
11506
  alignItems: "center",
11294
- justifyContent: "center",
11295
- borderRadius: "9999px",
11296
- border: "none",
11297
- backgroundColor: "#020618",
11298
- color: "#ffffff",
11299
- boxShadow: "0 10px 28px rgba(15, 23, 42, 0.25)",
11300
- cursor: "pointer"
11301
- },
11302
- playIcon: {
11303
- display: "block",
11304
- width: "1.75rem",
11305
- height: "1.75rem",
11306
- fill: "currentColor"
11307
- },
11308
- playIconOffset: {
11309
- marginLeft: "0.25rem"
11310
- },
11311
- content: {
11312
- minWidth: "16rem",
11313
- flex: "1 1 18rem"
11507
+ gap: 6
11314
11508
  },
11315
- metaRow: {
11316
- display: "flex",
11317
- alignItems: "center",
11318
- justifyContent: "space-between",
11319
- gap: "0.75rem",
11320
- fontSize: "0.8rem",
11321
- fontWeight: 500,
11322
- color: "#62748e"
11323
- },
11324
- metaRight: {
11325
- display: "flex",
11509
+ rootStacked: {
11510
+ display: "inline-flex",
11511
+ flexDirection: "column",
11326
11512
  alignItems: "center",
11327
- gap: "0.75rem"
11513
+ gap: 2
11328
11514
  },
11329
- badge: {
11330
- borderRadius: "9999px",
11331
- backgroundColor: "#dbeafe",
11332
- padding: "0.25rem 0.75rem",
11333
- fontSize: "0.65rem",
11334
- fontWeight: 600,
11335
- letterSpacing: "0.24em",
11336
- textTransform: "uppercase",
11337
- color: "#1447e6"
11338
- },
11339
- scrubber: {
11340
- position: "relative",
11341
- marginTop: "1rem",
11342
- height: "3rem",
11515
+ button: {
11516
+ border: "1px solid rgba(255,255,255,0.12)",
11517
+ background: "rgba(255,255,255,0.04)",
11518
+ color: "inherit",
11343
11519
  cursor: "pointer",
11344
- overflow: "hidden",
11345
- borderRadius: "1rem",
11346
- backgroundColor: "rgba(226, 232, 240, 0.9)",
11347
- paddingInline: "0.25rem"
11348
- },
11349
- waveform: {
11350
- pointerEvents: "none",
11351
- position: "absolute",
11352
- inset: 0,
11353
- height: "100%",
11354
- width: "100%"
11355
- },
11356
- fallbackTrack: {
11357
- position: "absolute",
11358
- inset: 0,
11359
- backgroundColor: "rgba(226, 232, 240, 0.9)"
11360
- },
11361
- fallbackProgress: {
11362
- position: "absolute",
11363
- insetBlock: 0,
11364
- left: 0,
11365
- backgroundColor: "#155dfc"
11520
+ borderRadius: 9999,
11521
+ padding: "6px 10px",
11522
+ lineHeight: 1,
11523
+ fontSize: 14
11366
11524
  },
11367
- chipsRow: {
11368
- marginTop: "1rem",
11369
- display: "flex",
11370
- flexWrap: "wrap",
11371
- alignItems: "center",
11372
- gap: "0.5rem",
11373
- fontSize: "0.72rem",
11374
- fontWeight: 500,
11375
- color: "#62748e"
11376
- },
11377
- chipStrong: {
11378
- borderRadius: "9999px",
11379
- border: "1px solid #e2e8f0",
11380
- backgroundColor: "rgba(255, 255, 255, 0.85)",
11381
- padding: "0.25rem 0.75rem",
11382
- color: "#334155"
11383
- },
11384
- chipMuted: {
11385
- borderRadius: "9999px",
11386
- border: "1px solid #e2e8f0",
11387
- backgroundColor: "rgba(255, 255, 255, 0.65)",
11388
- padding: "0.25rem 0.75rem"
11389
- },
11390
- controls: {
11391
- display: "flex",
11392
- flexShrink: 0,
11393
- alignItems: "center",
11394
- gap: "0.5rem",
11395
- alignSelf: "flex-start"
11525
+ buttonCompact: {
11526
+ padding: "3px 7px",
11527
+ fontSize: 12
11396
11528
  },
11397
- secondaryButton: {
11398
- borderRadius: "9999px",
11399
- border: "1px solid #cad5e2",
11400
- backgroundColor: "rgba(255, 255, 255, 0.9)",
11401
- padding: "0.5rem 1rem",
11402
- fontSize: "1rem",
11403
- fontWeight: 600,
11404
- color: "#334155",
11405
- boxShadow: "0 1px 3px rgba(15, 23, 42, 0.08)",
11406
- cursor: "pointer"
11529
+ buttonActiveUp: {
11530
+ background: "rgba(34,197,94,0.18)",
11531
+ borderColor: "rgba(34,197,94,0.45)",
11532
+ color: "#86efac"
11407
11533
  },
11408
- refreshButton: {
11409
- borderRadius: "9999px",
11410
- border: "1px solid #cad5e2",
11411
- backgroundColor: "rgba(255, 255, 255, 0.75)",
11412
- padding: "0.5rem 1rem",
11413
- fontSize: "0.875rem",
11414
- fontWeight: 600,
11415
- color: "#334155",
11416
- cursor: "pointer"
11534
+ buttonActiveDown: {
11535
+ background: "rgba(239,68,68,0.18)",
11536
+ borderColor: "rgba(239,68,68,0.45)",
11537
+ color: "#fca5a5"
11417
11538
  },
11418
- disabled: {
11419
- cursor: "not-allowed",
11420
- opacity: 0.6
11539
+ score: {
11540
+ minWidth: 24,
11541
+ textAlign: "center",
11542
+ fontVariantNumeric: "tabular-nums",
11543
+ fontWeight: 600
11421
11544
  }
11422
11545
  };
11423
- function formatTime(seconds) {
11424
- if (!Number.isFinite(seconds) || seconds <= 0) {
11425
- return "0:00";
11426
- }
11427
- const wholeSeconds = Math.floor(seconds);
11428
- const minutes = Math.floor(wholeSeconds / 60);
11429
- const remainder = wholeSeconds % 60;
11430
- return `${minutes}:${remainder.toString().padStart(2, "0")}`;
11431
- }
11432
- function getNextSpeed(currentSpeed) {
11433
- const currentIndex = SPEEDS.findIndex((speed) => speed === currentSpeed);
11434
- if (currentIndex === -1) {
11435
- return SPEEDS[0];
11436
- }
11437
- return SPEEDS[(currentIndex + 1) % SPEEDS.length];
11438
- }
11439
- function NarrationPlayer({
11440
- audio,
11441
- className,
11442
- providerLabel = "ScaleMule",
11443
- narrationLabel = "AI-Narrated",
11444
- refreshing = false,
11445
- onRefresh,
11446
- showRefreshButton = false,
11447
- onPlaybackError
11546
+ function VoteButton({
11547
+ targetType,
11548
+ targetId,
11549
+ initialState,
11550
+ layout = "horizontal",
11551
+ size = "regular",
11552
+ onSignInRequired,
11553
+ onError,
11554
+ classNames,
11555
+ upLabel = "Upvote",
11556
+ downLabel = "Downvote"
11448
11557
  }) {
11449
- const audioRef = useRef(null);
11450
- const scrubRef = useRef(null);
11451
- const pendingResumeRef = useRef(false);
11452
- const pendingResumeTimeRef = useRef(null);
11453
- const recoveredUrlRef = useRef(null);
11454
- const [playing, setPlaying] = useState(false);
11455
- const [currentTime, setCurrentTime] = useState(0);
11456
- const [duration, setDuration] = useState(audio.duration_ms ? audio.duration_ms / 1e3 : 0);
11457
- const [speed, setSpeed] = useState(1.5);
11458
- const peaks = useMemo(() => {
11459
- if (!Array.isArray(audio.waveform_peaks)) {
11460
- return [];
11461
- }
11462
- const sampled = audio.waveform_peaks.filter((_, index) => index % 3 === 0).slice(0, 220);
11463
- return sampled.length > 0 ? sampled : audio.waveform_peaks.slice(0, 220);
11464
- }, [audio.waveform_peaks]);
11465
- useEffect(() => {
11466
- const element = audioRef.current;
11467
- if (!element) return;
11468
- recoveredUrlRef.current = null;
11469
- const resumeTime = pendingResumeTimeRef.current;
11470
- setCurrentTime(resumeTime ?? 0);
11471
- setPlaying(false);
11472
- element.pause();
11473
- try {
11474
- element.currentTime = resumeTime ?? 0;
11475
- } catch {
11476
- element.currentTime = 0;
11477
- }
11478
- if (pendingResumeRef.current) {
11479
- pendingResumeRef.current = false;
11480
- pendingResumeTimeRef.current = null;
11481
- element.play().catch(() => {
11482
- setPlaying(false);
11483
- onPlaybackError?.();
11484
- });
11485
- return;
11486
- }
11487
- pendingResumeTimeRef.current = null;
11488
- }, [audio.url]);
11489
- useEffect(() => {
11490
- const element = audioRef.current;
11491
- if (!element) return;
11492
- element.playbackRate = speed;
11493
- element.preservesPitch = true;
11494
- }, [speed]);
11495
- function isUrlExpiringSoon() {
11496
- if (!audio.expires_at) {
11497
- return false;
11498
- }
11499
- const expiresAtMs = Date.parse(audio.expires_at);
11500
- if (!Number.isFinite(expiresAtMs)) {
11501
- return false;
11502
- }
11503
- return expiresAtMs - Date.now() <= EXPIRY_REFRESH_WINDOW_MS;
11504
- }
11505
- async function refreshAndResume() {
11506
- if (!onRefresh || refreshing) {
11507
- return false;
11508
- }
11509
- if (recoveredUrlRef.current === (audio.url ?? null)) {
11510
- return false;
11511
- }
11512
- recoveredUrlRef.current = audio.url ?? null;
11513
- pendingResumeRef.current = true;
11514
- pendingResumeTimeRef.current = currentTime;
11515
- try {
11516
- await Promise.resolve(onRefresh());
11517
- return true;
11518
- } catch {
11519
- pendingResumeRef.current = false;
11520
- pendingResumeTimeRef.current = null;
11521
- onPlaybackError?.();
11522
- return false;
11523
- }
11524
- }
11525
- function togglePlayback() {
11526
- const element = audioRef.current;
11527
- if (!element) return;
11528
- if (element.paused) {
11529
- if (refreshing) {
11530
- pendingResumeRef.current = true;
11531
- pendingResumeTimeRef.current = currentTime;
11558
+ const { isAuthenticated } = useAuth();
11559
+ const { state, cast, error } = useVote({
11560
+ targetType,
11561
+ targetId,
11562
+ initialState: initialState ?? null,
11563
+ enabled: isAuthenticated
11564
+ });
11565
+ const handleVote = useCallback(
11566
+ async (next) => {
11567
+ if (!isAuthenticated) {
11568
+ onSignInRequired?.();
11532
11569
  return;
11533
11570
  }
11534
- if (onRefresh && isUrlExpiringSoon()) {
11535
- void refreshAndResume();
11536
- return;
11571
+ const desired = state.value === next ? 0 : next;
11572
+ try {
11573
+ await cast(desired);
11574
+ } catch (e) {
11575
+ onError?.(e);
11537
11576
  }
11538
- element.play().catch(() => {
11539
- setPlaying(false);
11540
- onPlaybackError?.();
11541
- });
11542
- return;
11577
+ },
11578
+ [cast, isAuthenticated, onSignInRequired, onError, state.value]
11579
+ );
11580
+ useEffect(() => {
11581
+ if (error) onError?.(error);
11582
+ }, [error, onError]);
11583
+ const compactStyle = size === "compact" ? STYLES2.buttonCompact : {};
11584
+ const upActive = state.value === 1;
11585
+ const downActive = state.value === -1;
11586
+ const upBtn = /* @__PURE__ */ jsx(
11587
+ "button",
11588
+ {
11589
+ type: "button",
11590
+ "aria-label": upLabel,
11591
+ "aria-pressed": upActive,
11592
+ title: isAuthenticated ? upLabel : "Sign in to vote",
11593
+ className: [classNames?.upButton, upActive ? classNames?.upButtonActive : void 0].filter(Boolean).join(" ") || void 0,
11594
+ onClick: () => void handleVote(1),
11595
+ style: classNames?.upButton ? void 0 : { ...STYLES2.button, ...compactStyle, ...upActive ? STYLES2.buttonActiveUp : {} },
11596
+ children: ARROW_UP
11543
11597
  }
11544
- element.pause();
11545
- }
11546
- function seekTo(clientX) {
11547
- const element = audioRef.current;
11548
- const scrubber = scrubRef.current;
11549
- if (!element || !scrubber || !duration) return;
11550
- const bounds = scrubber.getBoundingClientRect();
11551
- const ratio = Math.max(0, Math.min(1, (clientX - bounds.left) / bounds.width));
11552
- const nextTime = ratio * duration;
11553
- element.currentTime = nextTime;
11554
- setCurrentTime(nextTime);
11555
- }
11556
- const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0;
11557
- const expiresLabel = audio.expires_at ? new Date(audio.expires_at).toLocaleTimeString([], {
11558
- hour: "numeric",
11559
- minute: "2-digit"
11560
- }) : null;
11561
- if (!audio.url) {
11562
- return null;
11563
- }
11564
- return /* @__PURE__ */ jsxs("div", { className, style: styles.root, children: [
11565
- /* @__PURE__ */ jsx(
11566
- "audio",
11567
- {
11568
- ref: audioRef,
11569
- src: audio.url ?? void 0,
11570
- preload: "metadata",
11571
- onLoadedMetadata: (event) => {
11572
- setDuration(event.currentTarget.duration || 0);
11573
- },
11574
- onTimeUpdate: (event) => {
11575
- setCurrentTime(event.currentTarget.currentTime);
11576
- },
11577
- onPlay: () => setPlaying(true),
11578
- onPause: () => setPlaying(false),
11579
- onEnded: () => setPlaying(false),
11580
- onError: () => {
11581
- if (onRefresh) {
11582
- void refreshAndResume().then((didRefresh) => {
11583
- if (!didRefresh) {
11584
- onPlaybackError?.();
11585
- }
11586
- });
11587
- return;
11588
- }
11589
- onPlaybackError?.();
11590
- }
11591
- }
11592
- ),
11593
- /* @__PURE__ */ jsxs("div", { style: styles.layout, children: [
11594
- /* @__PURE__ */ jsx(
11595
- "button",
11596
- {
11597
- type: "button",
11598
- onClick: togglePlayback,
11599
- "aria-label": playing ? "Pause narration" : "Play narration",
11600
- style: styles.playButton,
11601
- children: playing ? /* @__PURE__ */ jsxs("svg", { viewBox: "0 0 24 24", style: styles.playIcon, "aria-hidden": "true", children: [
11602
- /* @__PURE__ */ jsx("rect", { x: "6", y: "5", width: "4", height: "14", rx: "1.2" }),
11603
- /* @__PURE__ */ jsx("rect", { x: "14", y: "5", width: "4", height: "14", rx: "1.2" })
11604
- ] }) : /* @__PURE__ */ jsx(
11605
- "svg",
11606
- {
11607
- viewBox: "0 0 24 24",
11608
- style: { ...styles.playIcon, ...styles.playIconOffset },
11609
- "aria-hidden": "true",
11610
- children: /* @__PURE__ */ jsx("path", { d: "M7 5.2c0-1.02 1.12-1.65 2-.98l10.2 7.78a1.24 1.24 0 0 1 0 1.98L9 21.76c-.88.67-2-.04-2-1.08V5.2Z" })
11611
- }
11612
- )
11613
- }
11614
- ),
11615
- /* @__PURE__ */ jsxs("div", { style: styles.content, children: [
11616
- /* @__PURE__ */ jsxs("div", { style: styles.metaRow, children: [
11617
- /* @__PURE__ */ jsx("span", { children: formatTime(currentTime / speed) }),
11618
- /* @__PURE__ */ jsxs("div", { style: styles.metaRight, children: [
11619
- /* @__PURE__ */ jsx("span", { style: styles.badge, children: narrationLabel }),
11620
- /* @__PURE__ */ jsx("span", { children: formatTime(duration / speed) })
11621
- ] })
11622
- ] }),
11623
- /* @__PURE__ */ jsx(
11624
- "div",
11625
- {
11626
- ref: scrubRef,
11627
- role: "slider",
11628
- "aria-label": "Narration progress",
11629
- "aria-valuemin": 0,
11630
- "aria-valuemax": Math.max(1, Math.floor(duration)),
11631
- "aria-valuenow": Math.floor(currentTime),
11632
- tabIndex: 0,
11633
- onClick: (event) => {
11634
- seekTo(event.clientX);
11635
- },
11636
- style: styles.scrubber,
11637
- children: peaks.length > 0 ? /* @__PURE__ */ jsx(
11638
- "svg",
11639
- {
11640
- viewBox: `0 0 ${peaks.length} 40`,
11641
- preserveAspectRatio: "none",
11642
- style: styles.waveform,
11643
- children: peaks.map((peak, index) => {
11644
- const height = Math.max(2, Math.round(Math.abs(peak) * 34));
11645
- const y = (40 - height) / 2;
11646
- const filled = index / peaks.length < progress;
11647
- return /* @__PURE__ */ jsx(
11648
- "rect",
11649
- {
11650
- x: index,
11651
- y,
11652
- width: 0.82,
11653
- height,
11654
- rx: 0.4,
11655
- fill: filled ? "#155dfc" : "#90c5ff"
11656
- },
11657
- `${audio.id}-${index}`
11658
- );
11659
- })
11660
- }
11661
- ) : /* @__PURE__ */ jsxs(Fragment, { children: [
11662
- /* @__PURE__ */ jsx("div", { style: styles.fallbackTrack }),
11663
- /* @__PURE__ */ jsx(
11664
- "div",
11665
- {
11666
- style: { ...styles.fallbackProgress, width: `${progress * 100}%` }
11667
- }
11668
- )
11669
- ] })
11670
- }
11671
- ),
11672
- /* @__PURE__ */ jsxs("div", { style: styles.chipsRow, children: [
11673
- /* @__PURE__ */ jsxs("span", { style: styles.chipStrong, children: [
11674
- "Provider: ",
11675
- providerLabel
11676
- ] }),
11677
- duration > 0 && /* @__PURE__ */ jsxs("span", { style: styles.chipMuted, children: [
11678
- "Length ",
11679
- formatTime(duration / speed)
11680
- ] }),
11681
- expiresLabel && /* @__PURE__ */ jsxs("span", { style: styles.chipMuted, children: [
11682
- "Expires ",
11683
- expiresLabel
11684
- ] })
11685
- ] })
11686
- ] }),
11687
- /* @__PURE__ */ jsxs("div", { style: styles.controls, children: [
11688
- /* @__PURE__ */ jsxs(
11689
- "button",
11690
- {
11691
- type: "button",
11692
- onClick: () => {
11693
- setSpeed((currentSpeed) => getNextSpeed(currentSpeed));
11694
- },
11695
- style: styles.secondaryButton,
11696
- children: [
11697
- speed,
11698
- "\xD7"
11699
- ]
11700
- }
11701
- ),
11702
- onRefresh && showRefreshButton && /* @__PURE__ */ jsx(
11703
- "button",
11704
- {
11705
- type: "button",
11706
- onClick: onRefresh,
11707
- disabled: refreshing,
11708
- style: refreshing ? { ...styles.refreshButton, ...styles.disabled } : styles.refreshButton,
11709
- children: refreshing ? "Refreshing\u2026" : "Refresh"
11710
- }
11711
- )
11712
- ] })
11713
- ] })
11598
+ );
11599
+ const downBtn = /* @__PURE__ */ jsx(
11600
+ "button",
11601
+ {
11602
+ type: "button",
11603
+ "aria-label": downLabel,
11604
+ "aria-pressed": downActive,
11605
+ title: isAuthenticated ? downLabel : "Sign in to vote",
11606
+ className: [classNames?.downButton, downActive ? classNames?.downButtonActive : void 0].filter(Boolean).join(" ") || void 0,
11607
+ onClick: () => void handleVote(-1),
11608
+ style: classNames?.downButton ? void 0 : { ...STYLES2.button, ...compactStyle, ...downActive ? STYLES2.buttonActiveDown : {} },
11609
+ children: ARROW_DOWN
11610
+ }
11611
+ );
11612
+ const scoreClass = [
11613
+ classNames?.score,
11614
+ state.score > 0 ? classNames?.scorePositive : void 0,
11615
+ state.score < 0 ? classNames?.scoreNegative : void 0
11616
+ ].filter(Boolean).join(" ") || void 0;
11617
+ const scoreEl = /* @__PURE__ */ jsx("span", { className: scoreClass, style: classNames?.score ? void 0 : STYLES2.score, children: state.score });
11618
+ const rootStyle = layout === "stacked" ? STYLES2.rootStacked : STYLES2.root;
11619
+ return /* @__PURE__ */ jsxs("div", { className: classNames?.root, style: classNames?.root ? void 0 : rootStyle, children: [
11620
+ upBtn,
11621
+ scoreEl,
11622
+ downBtn
11714
11623
  ] });
11715
11624
  }
11716
11625
 
@@ -12005,4 +11914,4 @@ function createSafeLogger(prefix) {
12005
11914
  };
12006
11915
  }
12007
11916
 
12008
- export { FeedbackWidget, NarrationPlayer, ScaleMuleApiError, ScaleMuleClient2 as ScaleMuleClient, ScaleMuleMedia, ScaleMuleProvider, composePhone, createClient, createSafeLogger, normalizePhone, phoneCountries, sanitizeForLog, useAnalytics, useAudio, useAuth, useBilling, useContent, useFeatureFlags, useFeedback, useFileStatus, useMedia, useMoney, useMoneyClient, usePushNotifications, useRealtime, useScaleMule, useScaleMuleClient, useShare, useTtsJob, useUser, validateForm, validators };
11917
+ export { FeedbackWidget, ScaleMuleApiError, ScaleMuleClient2 as ScaleMuleClient, ScaleMuleMedia, ScaleMuleProvider, VoteButton, composePhone, createClient, createSafeLogger, normalizePhone, phoneCountries, sanitizeForLog, useAnalytics, useAudio, useAuth, useBilling, useContent, useFeatureFlags, useFeedback, useFileStatus, useMedia, useMoney, useMoneyClient, usePushNotifications, useRealtime, useScaleMule, useScaleMuleClient, useShare, useTtsJob, useUser, useVote, validateForm, validators };