@volter/twin-tiktok 0.1.37 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/README.md +25 -308
  2. package/business/spec/SOURCE.md +17 -0
  3. package/business/spec/openapi.json +1045 -0
  4. package/business/spec/sources.json +84 -0
  5. package/business/src/fetch.ts +14 -0
  6. package/business/src/generated/surface.gen.json +1 -0
  7. package/business/src/generated/ui.gen.json +1 -0
  8. package/business/src/index.ts +3 -0
  9. package/business/src/manifest.ts +38 -0
  10. package/business/src/semantics/index.ts +16 -0
  11. package/business/src/semantics/open_api.ts +142 -0
  12. package/business/src/semantics/shared.ts +119 -0
  13. package/business/src/semantics/states.ts +3 -0
  14. package/dist/business/spec/SOURCE.md +17 -0
  15. package/dist/business/spec/openapi.json +1045 -0
  16. package/dist/business/spec/sources.json +84 -0
  17. package/dist/business/src/fetch.d.ts +2 -0
  18. package/dist/business/src/fetch.js +13 -0
  19. package/dist/business/src/fetch.ts +14 -0
  20. package/dist/business/src/generated/surface.gen.json +1 -0
  21. package/dist/business/src/generated/ui.gen.json +1 -0
  22. package/dist/business/src/index.d.ts +2 -0
  23. package/dist/business/src/index.js +3 -0
  24. package/dist/business/src/index.ts +3 -0
  25. package/dist/business/src/manifest.d.ts +2 -0
  26. package/dist/business/src/manifest.js +33 -0
  27. package/dist/business/src/manifest.ts +38 -0
  28. package/dist/business/src/semantics/index.d.ts +2 -0
  29. package/dist/business/src/semantics/index.js +12 -0
  30. package/dist/business/src/semantics/index.ts +16 -0
  31. package/dist/business/src/semantics/open_api.d.ts +10 -0
  32. package/dist/business/src/semantics/open_api.js +162 -0
  33. package/dist/business/src/semantics/open_api.ts +142 -0
  34. package/dist/business/src/semantics/shared.d.ts +31 -0
  35. package/dist/business/src/semantics/shared.js +109 -0
  36. package/dist/business/src/semantics/shared.ts +119 -0
  37. package/dist/business/src/semantics/states.d.ts +8 -0
  38. package/dist/business/src/semantics/states.js +3 -0
  39. package/dist/business/src/semantics/states.ts +3 -0
  40. package/dist/generated/pack-facts.json +55 -0
  41. package/dist/journeys/coverage.json +220 -0
  42. package/dist/journeys/customer-life.json +2950 -0
  43. package/dist/journeys/customer-life.outline.md +59 -0
  44. package/dist/journeys/decisions.json +161 -0
  45. package/dist/journeys/demand.json +142 -0
  46. package/dist/journeys/score.json +294 -0
  47. package/dist/journeys/unreachable.json +13 -0
  48. package/dist/open/spec/SOURCE.md +20 -0
  49. package/dist/open/spec/openapi.json +1417 -0
  50. package/dist/open/spec/sources.json +124 -0
  51. package/dist/open/src/engine/mp4.d.ts +6 -0
  52. package/dist/open/src/engine/mp4.js +55 -0
  53. package/dist/open/src/engine/mp4.ts +48 -0
  54. package/dist/open/src/fetch.d.ts +2 -0
  55. package/dist/open/src/fetch.js +17 -0
  56. package/dist/open/src/fetch.ts +18 -0
  57. package/dist/open/src/generated/surface.gen.json +1 -0
  58. package/dist/open/src/generated/ui.gen.json +1 -0
  59. package/dist/open/src/index.d.ts +2 -0
  60. package/dist/open/src/index.js +3 -0
  61. package/dist/open/src/index.ts +3 -0
  62. package/dist/open/src/manifest.d.ts +2 -0
  63. package/dist/open/src/manifest.js +53 -0
  64. package/dist/open/src/manifest.ts +59 -0
  65. package/dist/open/src/screens/authorize.d.ts +2 -0
  66. package/dist/open/src/screens/authorize.js +53 -0
  67. package/dist/open/src/screens/authorize.ts +53 -0
  68. package/dist/open/src/semantics/doors.d.ts +10 -0
  69. package/dist/open/src/semantics/doors.js +118 -0
  70. package/dist/open/src/semantics/doors.ts +121 -0
  71. package/dist/open/src/semantics/index.d.ts +2 -0
  72. package/dist/open/src/semantics/index.js +16 -0
  73. package/dist/open/src/semantics/index.ts +20 -0
  74. package/dist/open/src/semantics/oauth.d.ts +2 -0
  75. package/dist/open/src/semantics/oauth.js +30 -0
  76. package/dist/open/src/semantics/oauth.ts +28 -0
  77. package/dist/open/src/semantics/post.d.ts +6 -0
  78. package/dist/open/src/semantics/post.js +66 -0
  79. package/dist/open/src/semantics/post.ts +60 -0
  80. package/dist/open/src/semantics/shared.d.ts +51 -0
  81. package/dist/open/src/semantics/shared.js +196 -0
  82. package/dist/open/src/semantics/shared.ts +197 -0
  83. package/dist/open/src/semantics/states.d.ts +20 -0
  84. package/dist/open/src/semantics/states.js +15 -0
  85. package/dist/open/src/semantics/states.ts +23 -0
  86. package/dist/open/src/semantics/user.d.ts +2 -0
  87. package/dist/open/src/semantics/user.js +24 -0
  88. package/dist/open/src/semantics/user.ts +24 -0
  89. package/dist/open/src/semantics/video.d.ts +4 -0
  90. package/dist/open/src/semantics/video.js +112 -0
  91. package/dist/open/src/semantics/video.ts +97 -0
  92. package/dist/src/cli.js +11 -34
  93. package/dist/src/fetch.d.ts +2 -0
  94. package/dist/src/fetch.js +9 -0
  95. package/dist/src/index.d.ts +5 -22
  96. package/dist/src/index.js +13 -167
  97. package/dist/src/manifest.d.ts +2 -0
  98. package/dist/src/manifest.js +28 -0
  99. package/dist/src/semantics/shared.d.ts +49 -0
  100. package/dist/src/semantics/shared.js +61 -0
  101. package/dist/src/server.d.ts +7 -0
  102. package/dist/src/server.js +7 -0
  103. package/generated/pack-facts.json +55 -0
  104. package/journeys/coverage.json +220 -0
  105. package/journeys/customer-life.json +2950 -0
  106. package/journeys/customer-life.outline.md +59 -0
  107. package/journeys/decisions.json +161 -0
  108. package/journeys/demand.json +142 -0
  109. package/journeys/score.json +294 -0
  110. package/journeys/unreachable.json +13 -0
  111. package/open/spec/SOURCE.md +20 -0
  112. package/open/spec/openapi.json +1417 -0
  113. package/open/spec/sources.json +124 -0
  114. package/open/src/engine/mp4.ts +48 -0
  115. package/open/src/fetch.ts +18 -0
  116. package/open/src/generated/surface.gen.json +1 -0
  117. package/open/src/generated/ui.gen.json +1 -0
  118. package/open/src/index.ts +3 -0
  119. package/open/src/manifest.ts +59 -0
  120. package/open/src/screens/authorize.ts +53 -0
  121. package/open/src/semantics/doors.ts +121 -0
  122. package/open/src/semantics/index.ts +20 -0
  123. package/open/src/semantics/oauth.ts +28 -0
  124. package/open/src/semantics/post.ts +60 -0
  125. package/open/src/semantics/shared.ts +197 -0
  126. package/open/src/semantics/states.ts +23 -0
  127. package/open/src/semantics/user.ts +24 -0
  128. package/open/src/semantics/video.ts +97 -0
  129. package/package.json +13 -41
  130. package/src/cli.ts +11 -32
  131. package/src/fetch.ts +10 -0
  132. package/src/index.ts +15 -270
  133. package/src/manifest.ts +33 -0
  134. package/src/semantics/shared.ts +78 -0
  135. package/src/server.ts +8 -0
  136. package/client/tiktok-consent.tsx +0 -154
  137. package/client/tiktok-mirror.css +0 -137
  138. package/client/tiktok-mirror.tsx +0 -497
  139. package/dist/client/tiktok-consent.bundle.js +0 -18
  140. package/dist/client/tiktok-consent.d.ts +0 -47
  141. package/dist/client/tiktok-consent.js +0 -20
  142. package/dist/client/tiktok-consent.tsx +0 -154
  143. package/dist/client/tiktok-mirror.bundle.js +0 -484
  144. package/dist/client/tiktok-mirror.css +0 -137
  145. package/dist/client/tiktok-mirror.d.ts +0 -42
  146. package/dist/client/tiktok-mirror.js +0 -325
  147. package/dist/client/tiktok-mirror.tsx +0 -497
  148. package/dist/src/tiktok-blobs.d.ts +0 -66
  149. package/dist/src/tiktok-blobs.js +0 -161
  150. package/dist/src/tiktok-budget.d.ts +0 -56
  151. package/dist/src/tiktok-budget.js +0 -136
  152. package/dist/src/tiktok-capabilities.d.ts +0 -7
  153. package/dist/src/tiktok-capabilities.js +0 -1855
  154. package/dist/src/tiktok-conformance.d.ts +0 -11
  155. package/dist/src/tiktok-conformance.js +0 -498
  156. package/dist/src/tiktok-connector.d.ts +0 -158
  157. package/dist/src/tiktok-connector.js +0 -600
  158. package/dist/src/tiktok-consent-ui.d.ts +0 -19
  159. package/dist/src/tiktok-consent-ui.js +0 -127
  160. package/dist/src/tiktok-errors.d.ts +0 -78
  161. package/dist/src/tiktok-errors.js +0 -175
  162. package/dist/src/tiktok-ids.d.ts +0 -16
  163. package/dist/src/tiktok-ids.js +0 -48
  164. package/dist/src/tiktok-media.d.ts +0 -7
  165. package/dist/src/tiktok-media.js +0 -86
  166. package/dist/src/tiktok-mirror-ui.d.ts +0 -61
  167. package/dist/src/tiktok-mirror-ui.js +0 -187
  168. package/dist/src/tiktok-pkce.d.ts +0 -25
  169. package/dist/src/tiktok-pkce.js +0 -56
  170. package/dist/src/tiktok-posting.d.ts +0 -100
  171. package/dist/src/tiktok-posting.js +0 -599
  172. package/dist/src/tiktok-sample-mp4.d.ts +0 -10
  173. package/dist/src/tiktok-sample-mp4.js +0 -55
  174. package/dist/src/tiktok-scopes.d.ts +0 -29
  175. package/dist/src/tiktok-scopes.js +0 -106
  176. package/dist/src/tiktok-server.d.ts +0 -28
  177. package/dist/src/tiktok-server.js +0 -99
  178. package/dist/src/tiktok-store.d.ts +0 -164
  179. package/dist/src/tiktok-store.js +0 -451
  180. package/dist/src/tiktok-twin.d.ts +0 -70
  181. package/dist/src/tiktok-twin.js +0 -1197
  182. package/dist/src/tiktok-user.d.ts +0 -28
  183. package/dist/src/tiktok-user.js +0 -174
  184. package/src/tiktok-blobs.ts +0 -217
  185. package/src/tiktok-budget.ts +0 -163
  186. package/src/tiktok-capabilities.ts +0 -2022
  187. package/src/tiktok-conformance.ts +0 -526
  188. package/src/tiktok-connector.ts +0 -637
  189. package/src/tiktok-consent-ui.ts +0 -146
  190. package/src/tiktok-errors.ts +0 -197
  191. package/src/tiktok-ids.ts +0 -51
  192. package/src/tiktok-journey.uitest.ts +0 -305
  193. package/src/tiktok-media.ts +0 -89
  194. package/src/tiktok-mirror-ui.ts +0 -193
  195. package/src/tiktok-pkce.ts +0 -61
  196. package/src/tiktok-posting.ts +0 -617
  197. package/src/tiktok-sample-mp4.ts +0 -54
  198. package/src/tiktok-scopes.ts +0 -122
  199. package/src/tiktok-server.ts +0 -110
  200. package/src/tiktok-store.ts +0 -543
  201. package/src/tiktok-twin.ts +0 -1361
  202. package/src/tiktok-user.ts +0 -137
@@ -1,137 +0,0 @@
1
- /* TikTok mirror — tiktok.com's own look, copied on purpose. A vendor-mirror stylesheet: exempt from the
2
- Volter brand (the .githooks brand check reads only the Volter-branded sheets). */
3
- :root {
4
- --bg: #fff;
5
- --text: #161823;
6
- --muted: rgba(22, 24, 35, 0.5);
7
- --line: rgba(22, 24, 35, 0.12);
8
- --fill: rgba(22, 24, 35, 0.06);
9
- --fill-hover: rgba(22, 24, 35, 0.12);
10
- --red: #fe2c55;
11
- --red-hover: #ef2950;
12
- --cyan: #25f4ee;
13
- --font: "TikTokFont", "ProximaNova", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
14
- }
15
- * { box-sizing: border-box; }
16
- html, body { margin: 0; background: var(--bg); color: var(--text); font-family: var(--font); font-size: 16px; line-height: 1.35; }
17
- a { color: inherit; text-decoration: none; }
18
- button { font-family: inherit; color: inherit; }
19
- code { font-family: ui-monospace, Menlo, monospace; font-size: 12px; }
20
- .muted { color: var(--muted); }
21
- .loading { min-height: 100vh; display: flex; align-items: center; justify-content: center; }
22
-
23
- /* ── the logo ────────────────────────────────────────────────────────────── */
24
- .logo { display: inline-flex; align-items: center; gap: 4px; color: #000; }
25
- .logo-word { font-size: 27px; font-weight: 800; letter-spacing: -0.8px; }
26
-
27
- /* ── layout: the left nav and the main column ────────────────────────────── */
28
- .layout { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
29
- .sidenav { position: sticky; top: 0; height: 100vh; padding: 16px 8px 12px 16px; display: flex; flex-direction: column; gap: 2px; border-right: 1px solid var(--line); }
30
- .nav-logo { padding: 2px 8px 14px; }
31
- .nav-search { margin: 0 8px 10px 0; height: 40px; border-radius: 92px; background: var(--fill); color: var(--muted); display: flex; align-items: center; padding: 0 16px; font-size: 15px; }
32
- .nav-item { display: flex; align-items: center; gap: 12px; height: 48px; padding: 0 8px; border: 0; background: transparent; border-radius: 6px; font-size: 18px; font-weight: 700; cursor: pointer; text-align: left; }
33
- .nav-item:hover { background: var(--fill); }
34
- .nav-item.active { color: var(--red); }
35
- .nav-item .avatar { font-size: 14px !important; }
36
- .nav-foot { margin-top: auto; padding: 12px 8px 0; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 8px; font-size: 12px; }
37
- .nav-signout { border: 0; background: transparent; padding: 0; text-align: left; font-size: 13px; font-weight: 600; color: var(--muted); cursor: pointer; }
38
- .nav-signout:hover { color: var(--text); }
39
- .main { min-width: 0; padding: 32px 24px 48px 40px; }
40
-
41
- /* ── avatars, badges ─────────────────────────────────────────────────────── */
42
- .avatar { flex: none; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 700; user-select: none; }
43
- .verified { position: relative; display: inline-flex; color: #20d5ec; flex: none; }
44
- .verified-check { position: absolute; inset: 0; color: #fff; }
45
-
46
- /* ── the profile header ──────────────────────────────────────────────────── */
47
- .profile { max-width: 1320px; }
48
- .profile-header { display: flex; gap: 28px; align-items: flex-start; }
49
- .profile-info { display: flex; flex-direction: column; gap: 14px; padding-top: 6px; min-width: 0; }
50
- .profile-names { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
51
- .profile-username { margin: 0; font-size: 32px; line-height: 38px; font-weight: 700; }
52
- .profile-nickname { margin: 0; font-size: 18px; font-weight: 600; }
53
- .profile-buttons { display: flex; gap: 8px; }
54
- .btn { height: 40px; min-width: 104px; padding: 0 20px; border-radius: 8px; border: 0; font-size: 16px; font-weight: 600; cursor: pointer; }
55
- .btn-red { background: var(--red); color: #fff; }
56
- .btn-red:hover { background: var(--red-hover); }
57
- .btn-gray { background: var(--fill); }
58
- .btn-gray:hover { background: var(--fill-hover); }
59
- .profile-stats { display: flex; gap: 20px; font-size: 16px; }
60
- .stat strong { font-weight: 700; margin-right: 6px; }
61
- .stat-label { color: var(--muted); }
62
- .profile-bio { margin: 0; white-space: pre-wrap; max-width: 560px; }
63
- .profile-missing { padding: 120px 0; text-align: center; }
64
-
65
- /* ── tabs, sort ──────────────────────────────────────────────────────────── */
66
- .profile-tabs { margin-top: 28px; display: flex; align-items: flex-end; border-bottom: 1px solid var(--line); }
67
- .ptab { display: inline-flex; align-items: center; gap: 6px; height: 44px; min-width: 140px; justify-content: center; border: 0; background: transparent; font-size: 18px; font-weight: 600; color: var(--muted); cursor: pointer; position: relative; }
68
- .ptab:hover { color: var(--text); }
69
- .ptab.active { color: var(--text); }
70
- .ptab.active::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--text); }
71
- .sort { margin-left: auto; margin-bottom: 6px; display: flex; gap: 2px; padding: 2px; border-radius: 6px; background: var(--fill); font-size: 14px; font-weight: 600; }
72
- .sort span { padding: 6px 12px; border-radius: 4px; color: var(--muted); }
73
- .sort .sort-on { background: #fff; color: var(--text); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); }
74
-
75
- /* ── the grid: 9:16 tiles ────────────────────────────────────────────────── */
76
- .grid { margin-top: 16px; display: grid; grid-template-columns: repeat(auto-fill, minmax(184px, 1fr)); gap: 24px 16px; }
77
- .tile { position: relative; aspect-ratio: 9 / 16; border-radius: 4px; overflow: hidden; background: #000; cursor: pointer; }
78
- .tile-video { width: 100%; height: 100%; object-fit: cover; display: block; }
79
- .tile::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 30%; background: linear-gradient(transparent, rgba(0, 0, 0, 0.5)); pointer-events: none; }
80
- .tile-views { position: absolute; z-index: 1; left: 10px; bottom: 10px; display: inline-flex; align-items: center; gap: 4px; color: #fff; font-size: 16px; font-weight: 700; }
81
- .tile-views svg { fill: none; stroke: #fff; }
82
- .tile-fallback { position: absolute; inset: 0; padding: 16px; display: flex; align-items: center; justify-content: center; text-align: center; color: #fff; font-weight: 600; background: linear-gradient(160deg, #2b2b35, #111); }
83
- .caption .tag { font-weight: 700; }
84
- .empty { padding: 96px 0; text-align: center; }
85
- .empty h3 { margin: 0 0 8px; font-size: 24px; }
86
- .show-more { margin: 24px auto 0; display: block; height: 40px; padding: 0 24px; border: 0; border-radius: 8px; background: var(--fill); font-weight: 600; cursor: pointer; }
87
-
88
- /* ── the vertical player ─────────────────────────────────────────────────── */
89
- .player-page { position: fixed; inset: 0; background: #000; color: #fff; overflow: hidden; }
90
- .player-backdrop { position: absolute; inset: -40px; width: calc(100% + 80px); height: calc(100% + 80px); object-fit: cover; filter: blur(40px) brightness(0.3) saturate(0.8); transform: scale(1.1); }
91
- .player-close { position: absolute; z-index: 3; top: 20px; left: 20px; width: 40px; height: 40px; border: 0; border-radius: 50%; background: rgba(84, 84, 84, 0.5); color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; }
92
- .player-close:hover { background: rgba(120, 120, 120, 0.6); }
93
- .player-logo { position: absolute; z-index: 3; top: 22px; left: 76px; }
94
- .player-logo .logo { color: #fff; }
95
- .player-logo .logo-word { font-size: 24px; }
96
- .player-stage { position: relative; z-index: 2; height: 100%; display: flex; align-items: flex-end; justify-content: center; gap: 16px; padding: 12px 0; }
97
- .player-frame { position: relative; height: 100%; aspect-ratio: 9 / 16; border-radius: 16px; overflow: hidden; background: #000; cursor: pointer; }
98
- .player-video { width: 100%; height: 100%; object-fit: cover; display: block; }
99
- .player-sound-toggle { position: absolute; z-index: 2; top: 14px; left: 14px; width: 36px; height: 36px; border: 0; border-radius: 50%; background: rgba(84, 84, 84, 0.5); color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; }
100
- .player-paused { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: rgba(255, 255, 255, 0.85); pointer-events: none; }
101
- .player-shade { position: absolute; left: 0; right: 0; bottom: 0; height: 40%; background: linear-gradient(transparent, rgba(0, 0, 0, 0.6)); pointer-events: none; }
102
- .player-caption { position: absolute; left: 16px; right: 16px; bottom: 44px; display: flex; flex-direction: column; gap: 6px; font-size: 15px; line-height: 20px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); pointer-events: none; }
103
- .player-author { display: inline-flex; align-items: center; gap: 4px; font-size: 17px; font-weight: 700; }
104
- .player-text { max-height: 60px; overflow: hidden; }
105
- .player-sound { display: inline-flex; align-items: center; gap: 6px; font-size: 14px; }
106
- .player-bar { position: absolute; left: 12px; right: 12px; bottom: 12px; display: flex; align-items: center; gap: 10px; }
107
- .player-toggle { width: 24px; height: 24px; border: 0; padding: 0; background: transparent; color: #fff; cursor: pointer; }
108
- .player-track { flex: 1; height: 3px; border-radius: 3px; background: rgba(255, 255, 255, 0.34); overflow: hidden; }
109
- .player-progress { height: 100%; background: #fff; }
110
- .player-time { font-size: 12px; font-variant-numeric: tabular-nums; }
111
- .player-missing { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 600; }
112
- .rail { display: flex; flex-direction: column; align-items: center; gap: 14px; padding-bottom: 40px; }
113
- .rail-avatar { position: relative; margin-bottom: 10px; }
114
- .rail-avatar .avatar { border: 1px solid #fff; }
115
- .rail-plus { position: absolute; left: 50%; bottom: -10px; transform: translateX(-50%); width: 22px; height: 22px; border-radius: 50%; background: var(--red); color: #fff; font-size: 18px; font-weight: 700; line-height: 21px; text-align: center; }
116
- .rail-item { display: flex; flex-direction: column; align-items: center; gap: 4px; font-size: 12px; min-height: 70px; }
117
- .rail-btn { width: 48px; height: 48px; border-radius: 50%; background: rgba(84, 84, 84, 0.5); display: flex; align-items: center; justify-content: center; color: #fff; }
118
- .player-nav { position: absolute; z-index: 3; right: 24px; top: 50%; transform: translateY(-50%); display: flex; flex-direction: column; gap: 16px; }
119
- .player-nav button { width: 48px; height: 48px; border: 0; border-radius: 50%; background: rgba(84, 84, 84, 0.5); color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; }
120
- .player-nav button:disabled { opacity: 0.35; cursor: default; }
121
-
122
- /* ── the login form ──────────────────────────────────────────────────────── */
123
- .login-backdrop { min-height: 100vh; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; }
124
- .login { width: 483px; max-width: 94vw; background: #fff; border-radius: 8px; padding: 48px 48px 0; display: flex; flex-direction: column; align-items: stretch; box-shadow: 0 8px 40px rgba(0, 0, 0, 0.25); }
125
- .login-logo { display: flex; justify-content: center; margin-bottom: 8px; }
126
- .login-form { display: flex; flex-direction: column; gap: 12px; padding-bottom: 32px; }
127
- .login-form h1 { margin: 8px 0 12px; text-align: center; font-size: 32px; font-weight: 700; }
128
- .login-sub { font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
129
- .login-input { height: 44px; padding: 0 12px; border-radius: 4px; border: 1px solid rgba(22, 24, 35, 0.12); background: rgba(22, 24, 35, 0.06); font-size: 16px; font-family: inherit; outline: none; }
130
- .login-input:focus { border-color: rgba(22, 24, 35, 0.2); }
131
- .login-error { color: var(--red); font-size: 13px; }
132
- .btn-login { height: 46px; border: 0; border-radius: 4px; background: var(--red); color: #fff; font-size: 16px; font-weight: 600; cursor: pointer; margin-top: 12px; }
133
- .btn-login:disabled { background: rgba(22, 24, 35, 0.06); color: rgba(22, 24, 35, 0.34); cursor: default; }
134
- .login-note { font-size: 12px; line-height: 16px; }
135
- .link { border: 0; background: transparent; color: var(--muted); font-size: 13px; cursor: pointer; padding: 0; text-decoration: underline; }
136
- .login-foot { margin: 0 -48px; padding: 18px; border-top: 1px solid var(--line); text-align: center; font-size: 15px; }
137
- .link-red { color: var(--red); font-weight: 600; }
@@ -1,497 +0,0 @@
1
- // TikTok mirror — React/TSX client. tiktok.com's own view of the twin's state: log in as the creator,
2
- // read their profile over the Videos grid, and open a post in the vertical player. Every read is the
3
- // twin's OWN Display API on the same origin (GET /v2/user/info/, POST /v2/video/list/); the player
4
- // plays the posted bytes from the twin's media route. Nothing is kept but the signed-in username and
5
- // token, in the tab's sessionStorage.
6
- import { createRoot } from 'react-dom/client';
7
- import { useCallback, useEffect, useRef, useState } from 'react';
8
- import {
9
- avatarHue, avatarInitial, BASIC_FIELDS, captionOf, captionSegments, clock, compactCount, mediaSource, PROFILE_FIELDS, profileStats,
10
- VIDEO_PAGE_SIZE, VIDEO_READ_FIELDS, type TtRow,
11
- } from '../src/tiktok-mirror-ui.ts';
12
-
13
- // WHERE THIS MIRROR LIVES. Served at a vendor root the base is '' (fetches are root-relative); served
14
- // under a path prefix with a <base> tag, every read resolves inside that prefix.
15
- const WIRE_BASE = typeof document === 'undefined' || document.querySelector('base[href]') === null ? '' : new URL('.', document.baseURI).pathname.replace(/\/$/, '');
16
-
17
- const SESSION_HANDLE = 'tiktok-mirror.username';
18
- const SESSION_TOKEN = 'tiktok-mirror.token';
19
-
20
- type Session = { handle: string; token: string };
21
- type Wire = { status: number; body: TtRow };
22
-
23
- function readSession(): Session | null {
24
- if (typeof sessionStorage === 'undefined') return null;
25
- // the World signed these screens in as the account its config names (`signIn.as`): taken once, as a sign-in would
26
- // (off the page once taken, and not again in this tab once the person signs out)
27
- const w = window as { __VOLTER_SIGN_IN__?: { account: string; token: string } };
28
- const given = sessionStorage.getItem('volter.signedOut') ? undefined : w.__VOLTER_SIGN_IN__; delete w.__VOLTER_SIGN_IN__;
29
- if (given && !sessionStorage.getItem(SESSION_TOKEN)) { sessionStorage.setItem(SESSION_HANDLE, given.account); sessionStorage.setItem(SESSION_TOKEN, given.token); }
30
- const handle = sessionStorage.getItem(SESSION_HANDLE);
31
- const token = sessionStorage.getItem(SESSION_TOKEN);
32
- return handle && token ? { handle, token } : null;
33
- }
34
-
35
- async function wire(token: string, path: string, init?: { method?: string; body?: unknown }): Promise<Wire> {
36
- const res = await fetch(`${WIRE_BASE}${path}`, {
37
- method: init?.method ?? 'GET',
38
- headers: { authorization: `Bearer ${token}`, ...(init?.body !== undefined ? { 'content-type': 'application/json' } : {}) },
39
- ...(init?.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
40
- });
41
- let body: TtRow = {};
42
- try { body = await res.json() as TtRow; } catch { body = {}; }
43
- return { status: res.status, body };
44
- }
45
-
46
- /** TikTok's nested error, as one sentence for the screen. */
47
- const errorText = (w: Wire): string => String(w.body?.error?.message ?? `HTTP ${w.status}`);
48
- const errorCode = (w: Wire): string => String(w.body?.error?.code ?? '');
49
-
50
- /** The signed-in creator: every profile field, or — for a token without the profile/stats scopes —
51
- * the basic ones, so the header shows what the twin returns and nothing it does not. */
52
- async function readMe(token: string): Promise<Wire> {
53
- const full = await wire(token, `/v2/user/info/?fields=${PROFILE_FIELDS}`);
54
- if (full.status === 401 && errorCode(full) === 'scope_not_authorized') return wire(token, `/v2/user/info/?fields=${BASIC_FIELDS}`);
55
- return full;
56
- }
57
-
58
- // ── routing: hash routes, so the shell answers every screen under any mount ─────────────────────
59
- type Route = { page: 'login' } | { page: 'profile'; handle: string } | { page: 'video'; handle: string; id: string };
60
-
61
- function parseRoute(hash: string): Route | null {
62
- const parts = hash.replace(/^#\/?/, '').split('/').filter(Boolean);
63
- if (parts.length === 0) return null;
64
- if (parts[0] === 'login') return { page: 'login' };
65
- const handle = decodeURIComponent(parts[0]!).replace(/^@/, '');
66
- if (parts[1] === 'video' && parts[2]) return { page: 'video', handle, id: parts[2] };
67
- return { page: 'profile', handle };
68
- }
69
- const go = (path: string) => { location.hash = `#/${path}`; };
70
-
71
- // ── icons (vendor mirror: tiktok.com's look, drawn here) ────────────────────────────────────────
72
- const NOTE = 'M16.6 5.82s.51.5 0 0A4.28 4.28 0 0 1 15.54 3h-3.09v12.4a2.59 2.59 0 0 1-2.59 2.5c-1.42 0-2.6-1.16-2.6-2.6 0-1.72 1.66-3.01 3.37-2.48V9.66c-3.45-.46-6.47 2.22-6.47 5.64 0 3.33 2.76 5.7 5.69 5.7 3.14 0 5.69-2.55 5.69-5.7V9.01a7.35 7.35 0 0 0 4.3 1.38V7.3s-1.88.09-3.24-1.48z';
73
- export function Logo({ size = 34, word = true }: { size?: number; word?: boolean }) {
74
- return (
75
- <span className="logo">
76
- <svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true">
77
- <path d={NOTE} fill="#25F4EE" transform="translate(-0.9 -0.7)" />
78
- <path d={NOTE} fill="#FE2C55" transform="translate(0.9 0.7)" />
79
- <path d={NOTE} fill="currentColor" />
80
- </svg>
81
- {word ? <span className="logo-word">TikTok</span> : null}
82
- </span>
83
- );
84
- }
85
- function Icon({ d, size = 24, fill = false, className }: { d: string[]; size?: number; fill?: boolean; className?: string }) {
86
- return (
87
- <svg className={className} viewBox="0 0 24 24" width={size} height={size} aria-hidden="true"
88
- fill={fill ? 'currentColor' : 'none'} stroke={fill ? 'none' : 'currentColor'} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
89
- {d.map((p) => <path key={p} d={p} />)}
90
- </svg>
91
- );
92
- }
93
- const I_HOME = ['M3 10.5 12 3l9 7.5V21h-6v-6H9v6H3z'];
94
- const I_EXPLORE = ['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z', 'm16.2 7.8-2.1 6.3-6.3 2.1 2.1-6.3z'];
95
- const I_FOLLOWING = ['M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2', 'M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z', 'M19 8v6', 'M22 11h-6'];
96
- const I_FRIENDS = ['M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2', 'M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z', 'M23 21v-2a4 4 0 0 0-3-3.87', 'M16 3.13a4 4 0 0 1 0 7.75'];
97
- const I_LIVE = ['M4 6h16v12H4z', 'm10 9 5 3-5 3z'];
98
- const I_UPLOAD = ['M12 5v14', 'M5 12h14'];
99
- const I_PROFILE = ['M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2', 'M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z'];
100
- const I_PLAY = ['M7 4.5v15l12-7.5z'];
101
- const I_PAUSE = ['M7 4h3.5v16H7z', 'M13.5 4H17v16h-3.5z'];
102
- const I_CLOSE = ['M18 6 6 18', 'm6 6 12 12'];
103
- const I_UP = ['m18 15-6-6-6 6'];
104
- const I_DOWN = ['m6 9 6 6 6-6'];
105
- const I_SHARE_ARROW = ['M14 4.5V9C7 9 3.5 13 3 19.5c2-3.5 5-5 11-5V19l7-7.25z'];
106
- const I_HEART = ['M12 21s-7.5-4.6-9.6-9.2C.9 8.4 2.9 4.5 6.7 4.5c2.2 0 3.7 1.2 5.3 3 1.6-1.8 3.1-3 5.3-3 3.8 0 5.8 3.9 4.3 7.3C19.5 16.4 12 21 12 21z'];
107
- const I_COMMENT = ['M12 3C6.5 3 2 6.9 2 11.7c0 2.6 1.3 4.9 3.4 6.5L4.7 22l4.6-2.4c.9.2 1.8.3 2.7.3 5.5 0 10-3.9 10-8.7S17.5 3 12 3z'];
108
- const I_BOOKMARK = ['M6 2.5h12a1 1 0 0 1 1 1V22l-7-4.5L5 22V3.5a1 1 0 0 1 1-1z'];
109
- const I_VERIFIED = ['M12 1.5 14.6 3.4l3.2-.2.9 3.1 2.8 1.6-1 3.1 1 3.1-2.8 1.6-.9 3.1-3.2-.2L12 22.5l-2.6-1.9-3.2.2-.9-3.1-2.8-1.6 1-3.1-1-3.1 2.8-1.6.9-3.1 3.2.2z'];
110
- const I_CHECK = ['m7.5 12.2 3 3 6-6.4'];
111
- const I_MUSIC = ['M9 18V5l12-2v13', 'M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z', 'M18 19a3 3 0 1 0 0-6 3 3 0 0 0 0 6z'];
112
- const I_VOLUME = ['M11 5 6 9H2v6h4l5 4z', 'M15.5 8.5a5 5 0 0 1 0 7', 'M19 5a10 10 0 0 1 0 14'];
113
- const I_MUTED = ['M11 5 6 9H2v6h4l5 4z', 'm22 9-6 6', 'm16 9 6 6'];
114
- const I_LOCK = ['M5 11h14v10H5z', 'M8 11V7a4 4 0 0 1 8 0v4'];
115
- const I_GRID = ['M4 4h7v7H4z', 'M13 4h7v7h-7z', 'M4 13h7v7H4z', 'M13 13h7v7h-7z'];
116
-
117
- // ── pieces ───────────────────────────────────────────────────────────────────────────────────────
118
- export function Avatar({ user, size }: { user: TtRow | undefined; size: number }) {
119
- return (
120
- <div className="avatar" style={{ width: size, height: size, fontSize: size * 0.42, background: `hsl(${avatarHue(user?.union_id ?? user?.username)} 55% 45%)` }} aria-hidden="true">
121
- {avatarInitial(user)}
122
- </div>
123
- );
124
- }
125
-
126
- function Verified({ size = 20 }: { size?: number }) {
127
- return (
128
- <span className="verified" title="Verified account">
129
- <Icon d={I_VERIFIED} fill size={size} />
130
- <Icon d={I_CHECK} size={size} className="verified-check" />
131
- </span>
132
- );
133
- }
134
-
135
- export function Caption({ text }: { text: unknown }) {
136
- return (
137
- <span className="caption">
138
- {captionSegments(text).map((s, i) => s.kind === 'text' ? <span key={i}>{s.value}</span> : <strong key={i} className="tag">{s.value}</strong>)}
139
- </span>
140
- );
141
- }
142
-
143
- /** The profile header: avatar, @username (h1) with the verified tick, the nickname, the buttons a
144
- * creator sees on their own page, the three counts (only those the twin returned) and the bio. */
145
- export function ProfileHeader({ user, own }: { user: TtRow; own: boolean }) {
146
- const stats = profileStats(user);
147
- return (
148
- <header className="profile-header">
149
- <Avatar user={user} size={212} />
150
- <div className="profile-info">
151
- <div className="profile-names">
152
- <h1 className="profile-username">{String(user.username ?? user.display_name ?? '')}</h1>
153
- {user.is_verified === true ? <Verified /> : null}
154
- <h2 className="profile-nickname">{String(user.display_name ?? '')}</h2>
155
- </div>
156
- <div className="profile-buttons">
157
- {own ? (
158
- <>
159
- <button className="btn btn-red">Edit profile</button>
160
- <button className="btn btn-gray">Promote post</button>
161
- </>
162
- ) : (
163
- <>
164
- <button className="btn btn-red">Follow</button>
165
- <button className="btn btn-gray">Message</button>
166
- </>
167
- )}
168
- </div>
169
- {stats.length > 0 ? (
170
- <div className="profile-stats">
171
- {stats.map((s) => <span key={s.label} className="stat" data-stat={s.label}><strong>{s.value}</strong><span className="stat-label">{s.label}</span></span>)}
172
- </div>
173
- ) : null}
174
- {typeof user.bio_description === 'string' && user.bio_description !== '' ? <p className="profile-bio">{user.bio_description}</p> : <p className="profile-bio muted">No bio yet.</p>}
175
- </div>
176
- </header>
177
- );
178
- }
179
-
180
- /** One grid tile: the post's own first frame (the twin serves no cover image), 9:16, with the play
181
- * count at the bottom left. A post whose bytes the twin does not hold shows its caption instead. */
182
- export function VideoTile({ video, src, onOpen }: { video: TtRow; src: string; onOpen: () => void }) {
183
- const [broken, setBroken] = useState(false);
184
- return (
185
- <div className="tile" data-video-id={String(video.id)} onClick={onOpen} role="link" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter') onOpen(); }}>
186
- {broken ? <div className="tile-fallback"><Caption text={captionOf(video)} /></div> : (
187
- <video className="tile-video" src={`${src}#t=0.1`} preload="metadata" muted playsInline onError={() => setBroken(true)}
188
- onMouseEnter={(e) => { void e.currentTarget.play().catch(() => undefined); }}
189
- onMouseLeave={(e) => { e.currentTarget.pause(); }} />
190
- )}
191
- {typeof video.view_count === 'number' ? (
192
- <span className="tile-views"><Icon d={I_PLAY} size={16} />{compactCount(video.view_count)}</span>
193
- ) : null}
194
- </div>
195
- );
196
- }
197
-
198
- export function VideoGrid({ videos, sourceOf, onOpen }: { videos: TtRow[]; sourceOf: (id: string) => string; onOpen: (id: string) => void }) {
199
- return (
200
- <div className="grid">
201
- {videos.map((v) => <VideoTile key={String(v.id)} video={v} src={sourceOf(String(v.id))} onOpen={() => onOpen(String(v.id))} />)}
202
- </div>
203
- );
204
- }
205
-
206
- /** The player's overlay: @handle, the caption with its tags, and the sound line TikTok prints for a
207
- * video's own audio ("original sound - <nickname>"). */
208
- export function PlayerCaption({ user, video }: { user: TtRow; video: TtRow }) {
209
- return (
210
- <div className="player-caption">
211
- <div className="player-author">@{String(user.username ?? user.display_name ?? '')}{user.is_verified === true ? <Verified size={16} /> : null}</div>
212
- <div className="player-text"><Caption text={captionOf(video)} /></div>
213
- <div className="player-sound"><Icon d={I_MUSIC} size={14} /><span>original sound - {String(user.display_name ?? user.username ?? '')}</span></div>
214
- </div>
215
- );
216
- }
217
-
218
- /** The right-hand action rail: the creator's avatar, then like / comment / save / share, each with the
219
- * count the Video Object carries — and no number where the Display API returns none (saves). */
220
- export function ActionRail({ user, video }: { user: TtRow; video: TtRow }) {
221
- const count = (raw: unknown) => (typeof raw === 'number' ? compactCount(raw) : '');
222
- return (
223
- <div className="rail">
224
- <div className="rail-avatar"><Avatar user={user} size={48} /><span className="rail-plus">+</span></div>
225
- <div className="rail-item"><span className="rail-btn"><Icon d={I_HEART} fill size={26} /></span><strong>{count(video.like_count)}</strong></div>
226
- <div className="rail-item"><span className="rail-btn"><Icon d={I_COMMENT} fill size={26} /></span><strong>{count(video.comment_count)}</strong></div>
227
- <div className="rail-item"><span className="rail-btn"><Icon d={I_BOOKMARK} fill size={24} /></span><strong /></div>
228
- <div className="rail-item"><span className="rail-btn"><Icon d={I_SHARE_ARROW} fill size={26} /></span><strong>{count(video.share_count)}</strong></div>
229
- </div>
230
- );
231
- }
232
-
233
- /** The full-height vertical player: the post playing, looped, in a 9:16 frame as tall as the window,
234
- * over a blurred copy of itself; tap to pause; the caption and the rail over it; up/down to the
235
- * neighbouring posts; close back to the profile. */
236
- function Player({ user, video, src, prev, next, onClose }: { user: TtRow; video: TtRow; src: string; prev?: string; next?: string; onClose: () => void }) {
237
- const ref = useRef<HTMLVideoElement | null>(null);
238
- const [paused, setPaused] = useState(false);
239
- const [muted, setMuted] = useState(false);
240
- const [time, setTime] = useState({ at: 0, of: Number(video.duration) || 0 });
241
- const toggle = () => {
242
- const el = ref.current;
243
- if (!el) return;
244
- if (el.paused) { void el.play().catch(() => undefined); setPaused(false); } else { el.pause(); setPaused(true); }
245
- };
246
- useEffect(() => {
247
- const onKey = (e: KeyboardEvent) => {
248
- if (e.key === 'Escape') onClose();
249
- if (e.key === 'ArrowUp' && prev) go(`@${user.username}/video/${prev}`);
250
- if (e.key === 'ArrowDown' && next) go(`@${user.username}/video/${next}`);
251
- };
252
- window.addEventListener('keydown', onKey);
253
- return () => window.removeEventListener('keydown', onKey);
254
- }, [prev, next, onClose, user.username]);
255
- return (
256
- <div className="player-page" data-video-id={String(video.id)}>
257
- <video className="player-backdrop" src={src} muted autoPlay loop playsInline aria-hidden="true" />
258
- <button className="player-close" aria-label="Close" onClick={onClose}><Icon d={I_CLOSE} size={24} /></button>
259
- <div className="player-logo"><Logo size={30} /></div>
260
- <div className="player-stage">
261
- <div className="player-frame" onClick={toggle}>
262
- <button className="player-sound-toggle" aria-label={muted ? 'Unmute' : 'Mute'} onClick={(e) => { e.stopPropagation(); setMuted(!muted); }}><Icon d={muted ? I_MUTED : I_VOLUME} size={20} /></button>
263
- <video ref={ref} className="player-video" src={src} autoPlay loop playsInline muted={muted}
264
- onTimeUpdate={(e) => setTime({ at: e.currentTarget.currentTime, of: e.currentTarget.duration || time.of })}
265
- onPlay={() => setPaused(false)} onPause={() => setPaused(true)} />
266
- {paused ? <span className="player-paused"><Icon d={I_PLAY} fill size={64} /></span> : null}
267
- <div className="player-shade" />
268
- <PlayerCaption user={user} video={video} />
269
- <div className="player-bar" onClick={(e) => e.stopPropagation()}>
270
- <button className="player-toggle" aria-label={paused ? 'Play' : 'Pause'} onClick={toggle}><Icon d={paused ? I_PLAY : I_PAUSE} fill size={18} /></button>
271
- <div className="player-track"><div className="player-progress" style={{ width: `${time.of > 0 ? Math.min(100, (time.at / time.of) * 100) : 0}%` }} /></div>
272
- <span className="player-time">{clock(time.at)}/{clock(time.of)}</span>
273
- </div>
274
- </div>
275
- <ActionRail user={user} video={video} />
276
- </div>
277
- <div className="player-nav">
278
- <button aria-label="Previous video" disabled={!prev} onClick={() => prev && go(`@${user.username}/video/${prev}`)}><Icon d={I_UP} size={26} /></button>
279
- <button aria-label="Next video" disabled={!next} onClick={() => next && go(`@${user.username}/video/${next}`)}><Icon d={I_DOWN} size={26} /></button>
280
- </div>
281
- </div>
282
- );
283
- }
284
-
285
- // ── the creator's videos: POST /v2/video/list/, newest first, paged on the vendor's own cursor ────
286
- function useVideos(session: Session | null, reloadKey: number): { videos: TtRow[]; error: string | null; more: (() => void) | null; loaded: boolean } {
287
- const [videos, setVideos] = useState<TtRow[]>([]);
288
- const [cursor, setCursor] = useState<number | null>(null);
289
- const [error, setError] = useState<string | null>(null);
290
- const [loaded, setLoaded] = useState(false);
291
- const load = useCallback(async (from: number | null, prev: TtRow[]) => {
292
- if (!session) return;
293
- const w = await wire(session.token, `/v2/video/list/?fields=${VIDEO_READ_FIELDS}`, { method: 'POST', body: { max_count: VIDEO_PAGE_SIZE, ...(from !== null ? { cursor: from } : {}) } });
294
- setLoaded(true);
295
- if (w.status !== 200) { setError(errorCode(w) === 'scope_not_authorized' ? "This token can't read the account's videos (it needs video.list)." : errorText(w)); return; }
296
- setError(null);
297
- const page = Array.isArray(w.body?.data?.videos) ? w.body.data.videos as TtRow[] : [];
298
- setVideos([...prev, ...page]);
299
- setCursor(w.body?.data?.has_more === true ? Number(w.body.data.cursor) : null);
300
- }, [session]);
301
- useEffect(() => { setVideos([]); setCursor(null); setLoaded(false); void load(null, []); }, [load, reloadKey]);
302
- return { videos, error, loaded, more: cursor !== null ? () => void load(cursor, videos) : null };
303
- }
304
-
305
- // ── screens ──────────────────────────────────────────────────────────────────────────────────────
306
- function SideNav({ me, active, onSignOut }: { me: TtRow | null; active: 'profile' | 'none'; onSignOut: () => void }) {
307
- const item = (icon: string[], label: string, on = false, onClick?: () => void) => (
308
- <button className={on ? 'nav-item active' : 'nav-item'} onClick={onClick}><Icon d={icon} size={26} /><span>{label}</span></button>
309
- );
310
- return (
311
- <nav className="sidenav">
312
- <a className="nav-logo" href={me ? `#/@${me.username}` : '#/'}><Logo /></a>
313
- <div className="nav-search"><span>Search</span></div>
314
- {item(I_HOME, 'For You')}
315
- {item(I_EXPLORE, 'Explore')}
316
- {item(I_FOLLOWING, 'Following')}
317
- {item(I_FRIENDS, 'Friends')}
318
- {item(I_UPLOAD, 'Upload')}
319
- {item(I_LIVE, 'LIVE')}
320
- {me ? (
321
- <button className={active === 'profile' ? 'nav-item active' : 'nav-item'} onClick={() => go(`@${me.username}`)}>
322
- <Avatar user={me} size={32} /><span>Profile</span>
323
- </button>
324
- ) : item(I_PROFILE, 'Profile')}
325
- <div className="nav-foot">
326
- {me ? <button className="nav-signout" onClick={onSignOut}>Log out @{String(me.username ?? '')}</button> : null}
327
- <div className="muted">© 2026 TikTok</div>
328
- </div>
329
- </nav>
330
- );
331
- }
332
-
333
- function ProfileScreen({ session, me, handle }: { session: Session; me: TtRow; handle: string }) {
334
- const own = handle === String(me.username ?? '');
335
- const read = useVideos(own ? session : null, 0);
336
- const [tab, setTab] = useState<'videos' | 'reposts' | 'favorites' | 'liked'>('videos');
337
- if (!own) {
338
- // The Display API reads only the token's own account; another creator's page is not readable here.
339
- return <div className="profile-missing"><h2>Couldn't find this account</h2><p className="muted">Looking for videos? Try browsing our trending creators, hashtags, and sounds.</p></div>;
340
- }
341
- return (
342
- <div className="profile">
343
- <ProfileHeader user={me} own />
344
- <div className="profile-tabs">
345
- <button className={tab === 'videos' ? 'ptab active' : 'ptab'} onClick={() => setTab('videos')}><Icon d={I_GRID} size={18} />Videos</button>
346
- <button className={tab === 'reposts' ? 'ptab active' : 'ptab'} onClick={() => setTab('reposts')}>Reposts</button>
347
- <button className={tab === 'favorites' ? 'ptab active' : 'ptab'} onClick={() => setTab('favorites')}><Icon d={I_LOCK} size={16} />Favorites</button>
348
- <button className={tab === 'liked' ? 'ptab active' : 'ptab'} onClick={() => setTab('liked')}><Icon d={I_LOCK} size={16} />Liked</button>
349
- {tab === 'videos' ? <div className="sort"><span className="sort-on">Latest</span><span>Popular</span><span>Oldest</span></div> : null}
350
- </div>
351
- {tab !== 'videos' ? <div className="empty"><h3>Nothing here yet</h3><p className="muted">This twin models the account's own posts.</p></div>
352
- : read.error ? <div className="empty" role="alert">{read.error}</div>
353
- : read.loaded && read.videos.length === 0 ? <div className="empty"><h3>Upload your first video</h3><p className="muted">Your videos will appear here</p></div>
354
- : (
355
- <>
356
- <VideoGrid videos={read.videos} sourceOf={(id) => mediaSource(WIRE_BASE, id, session.token)} onOpen={(id) => go(`@${me.username}/video/${id}`)} />
357
- {read.more ? <button className="show-more" onClick={read.more}>Load more</button> : null}
358
- </>
359
- )}
360
- </div>
361
- );
362
- }
363
-
364
- function VideoScreen({ session, me, id }: { session: Session; me: TtRow; id: string }) {
365
- const read = useVideos(session, 0);
366
- const at = read.videos.findIndex((v) => String(v.id) === id);
367
- const close = useCallback(() => go(`@${me.username}`), [me.username]);
368
- if (!read.loaded) return <div className="player-page" />;
369
- if (at < 0) return <div className="player-page"><div className="player-missing">Video currently unavailable</div><button className="player-close" aria-label="Close" onClick={close}><Icon d={I_CLOSE} size={24} /></button></div>;
370
- return (
371
- <Player key={id} user={me} video={read.videos[at]!} src={mediaSource(WIRE_BASE, id, session.token)}
372
- {...(at > 0 ? { prev: String(read.videos[at - 1]!.id) } : {})}
373
- {...(at < read.videos.length - 1 ? { next: String(read.videos[at + 1]!.id) } : {})}
374
- onClose={close} />
375
- );
376
- }
377
-
378
- function LoginScreen({ onSignedIn }: { onSignedIn: (s: Session, me: TtRow) => void }) {
379
- const [step, setStep] = useState<'username' | 'token'>('username');
380
- const [handle, setHandle] = useState('');
381
- const [token, setToken] = useState('');
382
- const [error, setError] = useState<string | null>(null);
383
- const [busy, setBusy] = useState(false);
384
- const clean = handle.trim().replace(/^@/, '');
385
- const next = () => {
386
- if (!/^[A-Za-z0-9_.]{2,24}$/.test(clean)) { setError("Couldn't find this account"); return; }
387
- setError(null); setStep('token');
388
- };
389
- const login = async () => {
390
- setBusy(true);
391
- const w = await readMe(token.trim());
392
- setBusy(false);
393
- if (w.status === 401 && errorCode(w) === 'access_token_invalid') { setError('Incorrect account or password'); return; }
394
- if (w.status !== 200 || !w.body?.data?.user) { setError(errorText(w)); return; }
395
- const me = w.body.data.user as TtRow;
396
- // Is this token THIS account's? The Display API answers the token's own account, so the answer says.
397
- if (me.username !== undefined && String(me.username) !== clean) { setError(`That token does not log in as @${clean}.`); return; }
398
- onSignedIn({ handle: clean, token: token.trim() }, { ...me, username: me.username ?? clean });
399
- };
400
- return (
401
- <div className="login-backdrop">
402
- <div className="login">
403
- <div className="login-logo"><Logo size={40} word={false} /></div>
404
- {step === 'username' ? (
405
- <form className="login-form" onSubmit={(e) => { e.preventDefault(); next(); }}>
406
- <h1>Log in to TikTok</h1>
407
- <div className="login-sub">Username</div>
408
- <input autoFocus name="username" className="login-input" placeholder="Username" value={handle} onChange={(e) => setHandle(e.target.value)} autoComplete="username" />
409
- {error ? <div className="login-error" role="alert">{error}</div> : null}
410
- <button className="btn-login" type="submit" disabled={clean === ''}>Next</button>
411
- <div className="muted login-note">This twin logs you in with the access token it issued for the account (<code>/_twin/tokens</code>), in place of a password.</div>
412
- </form>
413
- ) : (
414
- <form className="login-form" onSubmit={(e) => { e.preventDefault(); void login(); }}>
415
- <h1>Log in</h1>
416
- <div className="login-sub">@{clean} <button type="button" className="link" onClick={() => { setStep('username'); setError(null); }}>Change</button></div>
417
- <input autoFocus name="token" className="login-input" type="password" placeholder="Access token" value={token} onChange={(e) => setToken(e.target.value)} autoComplete="current-password" />
418
- {error ? <div className="login-error" role="alert">{error}</div> : null}
419
- <button className="btn-login" type="submit" disabled={token.trim() === '' || busy}>Log in</button>
420
- </form>
421
- )}
422
- <div className="login-foot">Don't have an account? <span className="link-red">Sign up</span></div>
423
- </div>
424
- </div>
425
- );
426
- }
427
-
428
- function App() {
429
- const [session, setSession] = useState<Session | null>(readSession);
430
- const [me, setMe] = useState<TtRow | null>(null);
431
- const [route, setRoute] = useState<Route | null>(() => parseRoute(typeof location === 'undefined' ? '' : location.hash));
432
-
433
- useEffect(() => {
434
- const onHash = () => { setRoute(parseRoute(location.hash)); window.scrollTo(0, 0); };
435
- // a `#/…` link is one of the mirror's own routes; under a World's <base> the browser would resolve
436
- // it against the base and leave the shell, so it moves the hash here
437
- const onClick = (e: MouseEvent) => {
438
- const a = (e.target as Element | null)?.closest?.('a[href^="#/"]');
439
- if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
440
- e.preventDefault();
441
- location.hash = a.getAttribute('href')!;
442
- };
443
- window.addEventListener('hashchange', onHash);
444
- document.addEventListener('click', onClick, true);
445
- return () => { window.removeEventListener('hashchange', onHash); document.removeEventListener('click', onClick, true); };
446
- }, []);
447
-
448
- const signOut = useCallback(() => {
449
- sessionStorage.removeItem(SESSION_HANDLE);
450
- sessionStorage.removeItem(SESSION_TOKEN); sessionStorage.setItem('volter.signedOut', '1');
451
- setSession(null);
452
- setMe(null);
453
- go('login');
454
- }, []);
455
-
456
- // who am I: the signed-in creator, read back through the vendor's own user read
457
- useEffect(() => {
458
- if (!session) { setMe(null); return; }
459
- void readMe(session.token).then((w) => {
460
- if (w.status === 200 && w.body?.data?.user) setMe({ ...w.body.data.user, username: w.body.data.user.username ?? session.handle });
461
- else if (w.status === 401) signOut();
462
- });
463
- }, [session, signOut]);
464
-
465
- const signIn = (s: Session, user: TtRow) => {
466
- sessionStorage.setItem(SESSION_HANDLE, s.handle);
467
- sessionStorage.setItem(SESSION_TOKEN, s.token);
468
- setSession(s);
469
- setMe(user);
470
- go(`@${s.handle}`);
471
- };
472
-
473
- if (!session || route?.page === 'login') {
474
- return session && route?.page === 'login' ? <LoginRedirect handle={session.handle} /> : <LoginScreen onSignedIn={signIn} />;
475
- }
476
- if (!me) return <div className="loading"><Logo /></div>;
477
- const current: Route = route ?? { page: 'profile', handle: String(me.username) };
478
- if (current.page === 'video') return <VideoScreen session={session} me={me} id={current.id} />;
479
- return (
480
- <div className="layout">
481
- <SideNav me={me} active={current.page === 'profile' && current.handle === me.username ? 'profile' : 'none'} onSignOut={signOut} />
482
- <main className="main">
483
- <ProfileScreen session={session} me={me} handle={current.page === 'profile' ? current.handle : String(me.username)} />
484
- </main>
485
- </div>
486
- );
487
- }
488
-
489
- function LoginRedirect({ handle }: { handle: string }) {
490
- useEffect(() => { go(`@${handle}`); }, [handle]);
491
- return null;
492
- }
493
-
494
- if (typeof document !== 'undefined') {
495
- const el = document.getElementById('root');
496
- if (el) createRoot(el).render(<App />);
497
- }