@volter/twin-tiktok 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +310 -0
- package/client/tiktok-consent.tsx +154 -0
- package/client/tiktok-mirror.css +137 -0
- package/client/tiktok-mirror.tsx +492 -0
- package/dist/client/tiktok-consent.bundle.js +18 -0
- package/dist/client/tiktok-consent.d.ts +47 -0
- package/dist/client/tiktok-consent.js +20 -0
- package/dist/client/tiktok-consent.tsx +154 -0
- package/dist/client/tiktok-mirror.bundle.js +487 -0
- package/dist/client/tiktok-mirror.css +137 -0
- package/dist/client/tiktok-mirror.d.ts +42 -0
- package/dist/client/tiktok-mirror.js +315 -0
- package/dist/client/tiktok-mirror.tsx +492 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +44 -0
- package/dist/src/index.d.ts +22 -0
- package/dist/src/index.js +167 -0
- package/dist/src/tiktok-blobs.d.ts +66 -0
- package/dist/src/tiktok-blobs.js +161 -0
- package/dist/src/tiktok-budget.d.ts +56 -0
- package/dist/src/tiktok-budget.js +136 -0
- package/dist/src/tiktok-capabilities.d.ts +7 -0
- package/dist/src/tiktok-capabilities.js +1855 -0
- package/dist/src/tiktok-conformance.d.ts +11 -0
- package/dist/src/tiktok-conformance.js +498 -0
- package/dist/src/tiktok-connector.d.ts +158 -0
- package/dist/src/tiktok-connector.js +600 -0
- package/dist/src/tiktok-consent-ui.d.ts +19 -0
- package/dist/src/tiktok-consent-ui.js +127 -0
- package/dist/src/tiktok-errors.d.ts +78 -0
- package/dist/src/tiktok-errors.js +175 -0
- package/dist/src/tiktok-ids.d.ts +16 -0
- package/dist/src/tiktok-ids.js +48 -0
- package/dist/src/tiktok-media.d.ts +7 -0
- package/dist/src/tiktok-media.js +86 -0
- package/dist/src/tiktok-mirror-ui.d.ts +49 -0
- package/dist/src/tiktok-mirror-ui.js +159 -0
- package/dist/src/tiktok-pkce.d.ts +25 -0
- package/dist/src/tiktok-pkce.js +56 -0
- package/dist/src/tiktok-posting.d.ts +100 -0
- package/dist/src/tiktok-posting.js +599 -0
- package/dist/src/tiktok-sample-mp4.d.ts +10 -0
- package/dist/src/tiktok-sample-mp4.js +55 -0
- package/dist/src/tiktok-scopes.d.ts +29 -0
- package/dist/src/tiktok-scopes.js +106 -0
- package/dist/src/tiktok-server.d.ts +28 -0
- package/dist/src/tiktok-server.js +89 -0
- package/dist/src/tiktok-store.d.ts +164 -0
- package/dist/src/tiktok-store.js +451 -0
- package/dist/src/tiktok-twin.d.ts +70 -0
- package/dist/src/tiktok-twin.js +1197 -0
- package/dist/src/tiktok-user.d.ts +28 -0
- package/dist/src/tiktok-user.js +174 -0
- package/package.json +74 -0
- package/src/cli.ts +43 -0
- package/src/index.ts +270 -0
- package/src/tiktok-blobs.ts +217 -0
- package/src/tiktok-budget.ts +163 -0
- package/src/tiktok-capabilities.ts +2022 -0
- package/src/tiktok-conformance.ts +526 -0
- package/src/tiktok-connector.ts +637 -0
- package/src/tiktok-consent-ui.ts +146 -0
- package/src/tiktok-errors.ts +197 -0
- package/src/tiktok-ids.ts +51 -0
- package/src/tiktok-journey.uitest.ts +305 -0
- package/src/tiktok-media.ts +89 -0
- package/src/tiktok-mirror-ui.ts +167 -0
- package/src/tiktok-pkce.ts +61 -0
- package/src/tiktok-posting.ts +617 -0
- package/src/tiktok-sample-mp4.ts +54 -0
- package/src/tiktok-scopes.ts +122 -0
- package/src/tiktok-server.ts +100 -0
- package/src/tiktok-store.ts +543 -0
- package/src/tiktok-twin.ts +1361 -0
- package/src/tiktok-user.ts +137 -0
|
@@ -0,0 +1,137 @@
|
|
|
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; }
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type TtRow } from '../src/tiktok-mirror-ui.js';
|
|
2
|
+
export declare function Logo({ size, word }: {
|
|
3
|
+
size?: number;
|
|
4
|
+
word?: boolean;
|
|
5
|
+
}): import("react").JSX.Element;
|
|
6
|
+
export declare function Avatar({ user, size }: {
|
|
7
|
+
user: TtRow | undefined;
|
|
8
|
+
size: number;
|
|
9
|
+
}): import("react").JSX.Element;
|
|
10
|
+
export declare function Caption({ text }: {
|
|
11
|
+
text: unknown;
|
|
12
|
+
}): import("react").JSX.Element;
|
|
13
|
+
/** The profile header: avatar, @username (h1) with the verified tick, the nickname, the buttons a
|
|
14
|
+
* creator sees on their own page, the three counts (only those the twin returned) and the bio. */
|
|
15
|
+
export declare function ProfileHeader({ user, own }: {
|
|
16
|
+
user: TtRow;
|
|
17
|
+
own: boolean;
|
|
18
|
+
}): import("react").JSX.Element;
|
|
19
|
+
/** One grid tile: the post's own first frame (the twin serves no cover image), 9:16, with the play
|
|
20
|
+
* count at the bottom left. A post whose bytes the twin does not hold shows its caption instead. */
|
|
21
|
+
export declare function VideoTile({ video, src, onOpen }: {
|
|
22
|
+
video: TtRow;
|
|
23
|
+
src: string;
|
|
24
|
+
onOpen: () => void;
|
|
25
|
+
}): import("react").JSX.Element;
|
|
26
|
+
export declare function VideoGrid({ videos, sourceOf, onOpen }: {
|
|
27
|
+
videos: TtRow[];
|
|
28
|
+
sourceOf: (id: string) => string;
|
|
29
|
+
onOpen: (id: string) => void;
|
|
30
|
+
}): import("react").JSX.Element;
|
|
31
|
+
/** The player's overlay: @handle, the caption with its tags, and the sound line TikTok prints for a
|
|
32
|
+
* video's own audio ("original sound - <nickname>"). */
|
|
33
|
+
export declare function PlayerCaption({ user, video }: {
|
|
34
|
+
user: TtRow;
|
|
35
|
+
video: TtRow;
|
|
36
|
+
}): import("react").JSX.Element;
|
|
37
|
+
/** The right-hand action rail: the creator's avatar, then like / comment / save / share, each with the
|
|
38
|
+
* count the Video Object carries — and no number where the Display API returns none (saves). */
|
|
39
|
+
export declare function ActionRail({ user, video }: {
|
|
40
|
+
user: TtRow;
|
|
41
|
+
video: TtRow;
|
|
42
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// TikTok mirror — React/TSX client. tiktok.com's own view of the twin's state: log in as the creator,
|
|
3
|
+
// read their profile over the Videos grid, and open a post in the vertical player. Every read is the
|
|
4
|
+
// twin's OWN Display API on the same origin (GET /v2/user/info/, POST /v2/video/list/); the player
|
|
5
|
+
// plays the posted bytes from the twin's media route. Nothing is kept but the signed-in username and
|
|
6
|
+
// token, in the tab's sessionStorage.
|
|
7
|
+
import { createRoot } from 'react-dom/client';
|
|
8
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
9
|
+
import { avatarHue, avatarInitial, BASIC_FIELDS, captionOf, captionSegments, clock, compactCount, mediaSource, PROFILE_FIELDS, profileStats, VIDEO_PAGE_SIZE, VIDEO_READ_FIELDS, } from "../src/tiktok-mirror-ui.js";
|
|
10
|
+
// WHERE THIS MIRROR LIVES. Served at a vendor root the base is '' (fetches are root-relative); served
|
|
11
|
+
// under a path prefix with a <base> tag, every read resolves inside that prefix.
|
|
12
|
+
const WIRE_BASE = typeof document === 'undefined' || document.querySelector('base[href]') === null ? '' : new URL('.', document.baseURI).pathname.replace(/\/$/, '');
|
|
13
|
+
const SESSION_HANDLE = 'tiktok-mirror.username';
|
|
14
|
+
const SESSION_TOKEN = 'tiktok-mirror.token';
|
|
15
|
+
function readSession() {
|
|
16
|
+
if (typeof sessionStorage === 'undefined')
|
|
17
|
+
return null;
|
|
18
|
+
const handle = sessionStorage.getItem(SESSION_HANDLE);
|
|
19
|
+
const token = sessionStorage.getItem(SESSION_TOKEN);
|
|
20
|
+
return handle && token ? { handle, token } : null;
|
|
21
|
+
}
|
|
22
|
+
async function wire(token, path, init) {
|
|
23
|
+
const res = await fetch(`${WIRE_BASE}${path}`, {
|
|
24
|
+
method: init?.method ?? 'GET',
|
|
25
|
+
headers: { authorization: `Bearer ${token}`, ...(init?.body !== undefined ? { 'content-type': 'application/json' } : {}) },
|
|
26
|
+
...(init?.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
|
|
27
|
+
});
|
|
28
|
+
let body = {};
|
|
29
|
+
try {
|
|
30
|
+
body = await res.json();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
body = {};
|
|
34
|
+
}
|
|
35
|
+
return { status: res.status, body };
|
|
36
|
+
}
|
|
37
|
+
/** TikTok's nested error, as one sentence for the screen. */
|
|
38
|
+
const errorText = (w) => String(w.body?.error?.message ?? `HTTP ${w.status}`);
|
|
39
|
+
const errorCode = (w) => String(w.body?.error?.code ?? '');
|
|
40
|
+
/** The signed-in creator: every profile field, or — for a token without the profile/stats scopes —
|
|
41
|
+
* the basic ones, so the header shows what the twin returns and nothing it does not. */
|
|
42
|
+
async function readMe(token) {
|
|
43
|
+
const full = await wire(token, `/v2/user/info/?fields=${PROFILE_FIELDS}`);
|
|
44
|
+
if (full.status === 401 && errorCode(full) === 'scope_not_authorized')
|
|
45
|
+
return wire(token, `/v2/user/info/?fields=${BASIC_FIELDS}`);
|
|
46
|
+
return full;
|
|
47
|
+
}
|
|
48
|
+
function parseRoute(hash) {
|
|
49
|
+
const parts = hash.replace(/^#\/?/, '').split('/').filter(Boolean);
|
|
50
|
+
if (parts.length === 0)
|
|
51
|
+
return null;
|
|
52
|
+
if (parts[0] === 'login')
|
|
53
|
+
return { page: 'login' };
|
|
54
|
+
const handle = decodeURIComponent(parts[0]).replace(/^@/, '');
|
|
55
|
+
if (parts[1] === 'video' && parts[2])
|
|
56
|
+
return { page: 'video', handle, id: parts[2] };
|
|
57
|
+
return { page: 'profile', handle };
|
|
58
|
+
}
|
|
59
|
+
const go = (path) => { location.hash = `#/${path}`; };
|
|
60
|
+
// ── icons (vendor mirror: tiktok.com's look, drawn here) ────────────────────────────────────────
|
|
61
|
+
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';
|
|
62
|
+
export function Logo({ size = 34, word = true }) {
|
|
63
|
+
return (_jsxs("span", { className: "logo", children: [_jsxs("svg", { viewBox: "0 0 24 24", width: size, height: size, "aria-hidden": "true", children: [_jsx("path", { d: NOTE, fill: "#25F4EE", transform: "translate(-0.9 -0.7)" }), _jsx("path", { d: NOTE, fill: "#FE2C55", transform: "translate(0.9 0.7)" }), _jsx("path", { d: NOTE, fill: "currentColor" })] }), word ? _jsx("span", { className: "logo-word", children: "TikTok" }) : null] }));
|
|
64
|
+
}
|
|
65
|
+
function Icon({ d, size = 24, fill = false, className }) {
|
|
66
|
+
return (_jsx("svg", { className: className, viewBox: "0 0 24 24", width: size, height: size, "aria-hidden": "true", fill: fill ? 'currentColor' : 'none', stroke: fill ? 'none' : 'currentColor', strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", children: d.map((p) => _jsx("path", { d: p }, p)) }));
|
|
67
|
+
}
|
|
68
|
+
const I_HOME = ['M3 10.5 12 3l9 7.5V21h-6v-6H9v6H3z'];
|
|
69
|
+
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'];
|
|
70
|
+
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'];
|
|
71
|
+
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'];
|
|
72
|
+
const I_LIVE = ['M4 6h16v12H4z', 'm10 9 5 3-5 3z'];
|
|
73
|
+
const I_UPLOAD = ['M12 5v14', 'M5 12h14'];
|
|
74
|
+
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'];
|
|
75
|
+
const I_PLAY = ['M7 4.5v15l12-7.5z'];
|
|
76
|
+
const I_PAUSE = ['M7 4h3.5v16H7z', 'M13.5 4H17v16h-3.5z'];
|
|
77
|
+
const I_CLOSE = ['M18 6 6 18', 'm6 6 12 12'];
|
|
78
|
+
const I_UP = ['m18 15-6-6-6 6'];
|
|
79
|
+
const I_DOWN = ['m6 9 6 6 6-6'];
|
|
80
|
+
const I_SHARE_ARROW = ['M14 4.5V9C7 9 3.5 13 3 19.5c2-3.5 5-5 11-5V19l7-7.25z'];
|
|
81
|
+
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'];
|
|
82
|
+
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'];
|
|
83
|
+
const I_BOOKMARK = ['M6 2.5h12a1 1 0 0 1 1 1V22l-7-4.5L5 22V3.5a1 1 0 0 1 1-1z'];
|
|
84
|
+
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'];
|
|
85
|
+
const I_CHECK = ['m7.5 12.2 3 3 6-6.4'];
|
|
86
|
+
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'];
|
|
87
|
+
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'];
|
|
88
|
+
const I_MUTED = ['M11 5 6 9H2v6h4l5 4z', 'm22 9-6 6', 'm16 9 6 6'];
|
|
89
|
+
const I_LOCK = ['M5 11h14v10H5z', 'M8 11V7a4 4 0 0 1 8 0v4'];
|
|
90
|
+
const I_GRID = ['M4 4h7v7H4z', 'M13 4h7v7h-7z', 'M4 13h7v7H4z', 'M13 13h7v7h-7z'];
|
|
91
|
+
// ── pieces ───────────────────────────────────────────────────────────────────────────────────────
|
|
92
|
+
export function Avatar({ user, size }) {
|
|
93
|
+
return (_jsx("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", children: avatarInitial(user) }));
|
|
94
|
+
}
|
|
95
|
+
function Verified({ size = 20 }) {
|
|
96
|
+
return (_jsxs("span", { className: "verified", title: "Verified account", children: [_jsx(Icon, { d: I_VERIFIED, fill: true, size: size }), _jsx(Icon, { d: I_CHECK, size: size, className: "verified-check" })] }));
|
|
97
|
+
}
|
|
98
|
+
export function Caption({ text }) {
|
|
99
|
+
return (_jsx("span", { className: "caption", children: captionSegments(text).map((s, i) => s.kind === 'text' ? _jsx("span", { children: s.value }, i) : _jsx("strong", { className: "tag", children: s.value }, i)) }));
|
|
100
|
+
}
|
|
101
|
+
/** The profile header: avatar, @username (h1) with the verified tick, the nickname, the buttons a
|
|
102
|
+
* creator sees on their own page, the three counts (only those the twin returned) and the bio. */
|
|
103
|
+
export function ProfileHeader({ user, own }) {
|
|
104
|
+
const stats = profileStats(user);
|
|
105
|
+
return (_jsxs("header", { className: "profile-header", children: [_jsx(Avatar, { user: user, size: 212 }), _jsxs("div", { className: "profile-info", children: [_jsxs("div", { className: "profile-names", children: [_jsx("h1", { className: "profile-username", children: String(user.username ?? user.display_name ?? '') }), user.is_verified === true ? _jsx(Verified, {}) : null, _jsx("h2", { className: "profile-nickname", children: String(user.display_name ?? '') })] }), _jsx("div", { className: "profile-buttons", children: own ? (_jsxs(_Fragment, { children: [_jsx("button", { className: "btn btn-red", children: "Edit profile" }), _jsx("button", { className: "btn btn-gray", children: "Promote post" })] })) : (_jsxs(_Fragment, { children: [_jsx("button", { className: "btn btn-red", children: "Follow" }), _jsx("button", { className: "btn btn-gray", children: "Message" })] })) }), stats.length > 0 ? (_jsx("div", { className: "profile-stats", children: stats.map((s) => _jsxs("span", { className: "stat", "data-stat": s.label, children: [_jsx("strong", { children: s.value }), _jsx("span", { className: "stat-label", children: s.label })] }, s.label)) })) : null, typeof user.bio_description === 'string' && user.bio_description !== '' ? _jsx("p", { className: "profile-bio", children: user.bio_description }) : _jsx("p", { className: "profile-bio muted", children: "No bio yet." })] })] }));
|
|
106
|
+
}
|
|
107
|
+
/** One grid tile: the post's own first frame (the twin serves no cover image), 9:16, with the play
|
|
108
|
+
* count at the bottom left. A post whose bytes the twin does not hold shows its caption instead. */
|
|
109
|
+
export function VideoTile({ video, src, onOpen }) {
|
|
110
|
+
const [broken, setBroken] = useState(false);
|
|
111
|
+
return (_jsxs("div", { className: "tile", "data-video-id": String(video.id), onClick: onOpen, role: "link", tabIndex: 0, onKeyDown: (e) => { if (e.key === 'Enter')
|
|
112
|
+
onOpen(); }, children: [broken ? _jsx("div", { className: "tile-fallback", children: _jsx(Caption, { text: captionOf(video) }) }) : (_jsx("video", { className: "tile-video", src: `${src}#t=0.1`, preload: "metadata", muted: true, playsInline: true, onError: () => setBroken(true), onMouseEnter: (e) => { void e.currentTarget.play().catch(() => undefined); }, onMouseLeave: (e) => { e.currentTarget.pause(); } })), typeof video.view_count === 'number' ? (_jsxs("span", { className: "tile-views", children: [_jsx(Icon, { d: I_PLAY, size: 16 }), compactCount(video.view_count)] })) : null] }));
|
|
113
|
+
}
|
|
114
|
+
export function VideoGrid({ videos, sourceOf, onOpen }) {
|
|
115
|
+
return (_jsx("div", { className: "grid", children: videos.map((v) => _jsx(VideoTile, { video: v, src: sourceOf(String(v.id)), onOpen: () => onOpen(String(v.id)) }, String(v.id))) }));
|
|
116
|
+
}
|
|
117
|
+
/** The player's overlay: @handle, the caption with its tags, and the sound line TikTok prints for a
|
|
118
|
+
* video's own audio ("original sound - <nickname>"). */
|
|
119
|
+
export function PlayerCaption({ user, video }) {
|
|
120
|
+
return (_jsxs("div", { className: "player-caption", children: [_jsxs("div", { className: "player-author", children: ["@", String(user.username ?? user.display_name ?? ''), user.is_verified === true ? _jsx(Verified, { size: 16 }) : null] }), _jsx("div", { className: "player-text", children: _jsx(Caption, { text: captionOf(video) }) }), _jsxs("div", { className: "player-sound", children: [_jsx(Icon, { d: I_MUSIC, size: 14 }), _jsxs("span", { children: ["original sound - ", String(user.display_name ?? user.username ?? '')] })] })] }));
|
|
121
|
+
}
|
|
122
|
+
/** The right-hand action rail: the creator's avatar, then like / comment / save / share, each with the
|
|
123
|
+
* count the Video Object carries — and no number where the Display API returns none (saves). */
|
|
124
|
+
export function ActionRail({ user, video }) {
|
|
125
|
+
const count = (raw) => (typeof raw === 'number' ? compactCount(raw) : '');
|
|
126
|
+
return (_jsxs("div", { className: "rail", children: [_jsxs("div", { className: "rail-avatar", children: [_jsx(Avatar, { user: user, size: 48 }), _jsx("span", { className: "rail-plus", children: "+" })] }), _jsxs("div", { className: "rail-item", children: [_jsx("span", { className: "rail-btn", children: _jsx(Icon, { d: I_HEART, fill: true, size: 26 }) }), _jsx("strong", { children: count(video.like_count) })] }), _jsxs("div", { className: "rail-item", children: [_jsx("span", { className: "rail-btn", children: _jsx(Icon, { d: I_COMMENT, fill: true, size: 26 }) }), _jsx("strong", { children: count(video.comment_count) })] }), _jsxs("div", { className: "rail-item", children: [_jsx("span", { className: "rail-btn", children: _jsx(Icon, { d: I_BOOKMARK, fill: true, size: 24 }) }), _jsx("strong", {})] }), _jsxs("div", { className: "rail-item", children: [_jsx("span", { className: "rail-btn", children: _jsx(Icon, { d: I_SHARE_ARROW, fill: true, size: 26 }) }), _jsx("strong", { children: count(video.share_count) })] })] }));
|
|
127
|
+
}
|
|
128
|
+
/** The full-height vertical player: the post playing, looped, in a 9:16 frame as tall as the window,
|
|
129
|
+
* over a blurred copy of itself; tap to pause; the caption and the rail over it; up/down to the
|
|
130
|
+
* neighbouring posts; close back to the profile. */
|
|
131
|
+
function Player({ user, video, src, prev, next, onClose }) {
|
|
132
|
+
const ref = useRef(null);
|
|
133
|
+
const [paused, setPaused] = useState(false);
|
|
134
|
+
const [muted, setMuted] = useState(false);
|
|
135
|
+
const [time, setTime] = useState({ at: 0, of: Number(video.duration) || 0 });
|
|
136
|
+
const toggle = () => {
|
|
137
|
+
const el = ref.current;
|
|
138
|
+
if (!el)
|
|
139
|
+
return;
|
|
140
|
+
if (el.paused) {
|
|
141
|
+
void el.play().catch(() => undefined);
|
|
142
|
+
setPaused(false);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
el.pause();
|
|
146
|
+
setPaused(true);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
const onKey = (e) => {
|
|
151
|
+
if (e.key === 'Escape')
|
|
152
|
+
onClose();
|
|
153
|
+
if (e.key === 'ArrowUp' && prev)
|
|
154
|
+
go(`@${user.username}/video/${prev}`);
|
|
155
|
+
if (e.key === 'ArrowDown' && next)
|
|
156
|
+
go(`@${user.username}/video/${next}`);
|
|
157
|
+
};
|
|
158
|
+
window.addEventListener('keydown', onKey);
|
|
159
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
160
|
+
}, [prev, next, onClose, user.username]);
|
|
161
|
+
return (_jsxs("div", { className: "player-page", "data-video-id": String(video.id), children: [_jsx("video", { className: "player-backdrop", src: src, muted: true, autoPlay: true, loop: true, playsInline: true, "aria-hidden": "true" }), _jsx("button", { className: "player-close", "aria-label": "Close", onClick: onClose, children: _jsx(Icon, { d: I_CLOSE, size: 24 }) }), _jsx("div", { className: "player-logo", children: _jsx(Logo, { size: 30 }) }), _jsxs("div", { className: "player-stage", children: [_jsxs("div", { className: "player-frame", onClick: toggle, children: [_jsx("button", { className: "player-sound-toggle", "aria-label": muted ? 'Unmute' : 'Mute', onClick: (e) => { e.stopPropagation(); setMuted(!muted); }, children: _jsx(Icon, { d: muted ? I_MUTED : I_VOLUME, size: 20 }) }), _jsx("video", { ref: ref, className: "player-video", src: src, autoPlay: true, loop: true, playsInline: true, muted: muted, onTimeUpdate: (e) => setTime({ at: e.currentTarget.currentTime, of: e.currentTarget.duration || time.of }), onPlay: () => setPaused(false), onPause: () => setPaused(true) }), paused ? _jsx("span", { className: "player-paused", children: _jsx(Icon, { d: I_PLAY, fill: true, size: 64 }) }) : null, _jsx("div", { className: "player-shade" }), _jsx(PlayerCaption, { user: user, video: video }), _jsxs("div", { className: "player-bar", onClick: (e) => e.stopPropagation(), children: [_jsx("button", { className: "player-toggle", "aria-label": paused ? 'Play' : 'Pause', onClick: toggle, children: _jsx(Icon, { d: paused ? I_PLAY : I_PAUSE, fill: true, size: 18 }) }), _jsx("div", { className: "player-track", children: _jsx("div", { className: "player-progress", style: { width: `${time.of > 0 ? Math.min(100, (time.at / time.of) * 100) : 0}%` } }) }), _jsxs("span", { className: "player-time", children: [clock(time.at), "/", clock(time.of)] })] })] }), _jsx(ActionRail, { user: user, video: video })] }), _jsxs("div", { className: "player-nav", children: [_jsx("button", { "aria-label": "Previous video", disabled: !prev, onClick: () => prev && go(`@${user.username}/video/${prev}`), children: _jsx(Icon, { d: I_UP, size: 26 }) }), _jsx("button", { "aria-label": "Next video", disabled: !next, onClick: () => next && go(`@${user.username}/video/${next}`), children: _jsx(Icon, { d: I_DOWN, size: 26 }) })] })] }));
|
|
162
|
+
}
|
|
163
|
+
// ── the creator's videos: POST /v2/video/list/, newest first, paged on the vendor's own cursor ────
|
|
164
|
+
function useVideos(session, reloadKey) {
|
|
165
|
+
const [videos, setVideos] = useState([]);
|
|
166
|
+
const [cursor, setCursor] = useState(null);
|
|
167
|
+
const [error, setError] = useState(null);
|
|
168
|
+
const [loaded, setLoaded] = useState(false);
|
|
169
|
+
const load = useCallback(async (from, prev) => {
|
|
170
|
+
if (!session)
|
|
171
|
+
return;
|
|
172
|
+
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 } : {}) } });
|
|
173
|
+
setLoaded(true);
|
|
174
|
+
if (w.status !== 200) {
|
|
175
|
+
setError(errorCode(w) === 'scope_not_authorized' ? "This token can't read the account's videos (it needs video.list)." : errorText(w));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
setError(null);
|
|
179
|
+
const page = Array.isArray(w.body?.data?.videos) ? w.body.data.videos : [];
|
|
180
|
+
setVideos([...prev, ...page]);
|
|
181
|
+
setCursor(w.body?.data?.has_more === true ? Number(w.body.data.cursor) : null);
|
|
182
|
+
}, [session]);
|
|
183
|
+
useEffect(() => { setVideos([]); setCursor(null); setLoaded(false); void load(null, []); }, [load, reloadKey]);
|
|
184
|
+
return { videos, error, loaded, more: cursor !== null ? () => void load(cursor, videos) : null };
|
|
185
|
+
}
|
|
186
|
+
// ── screens ──────────────────────────────────────────────────────────────────────────────────────
|
|
187
|
+
function SideNav({ me, active, onSignOut }) {
|
|
188
|
+
const item = (icon, label, on = false, onClick) => (_jsxs("button", { className: on ? 'nav-item active' : 'nav-item', onClick: onClick, children: [_jsx(Icon, { d: icon, size: 26 }), _jsx("span", { children: label })] }));
|
|
189
|
+
return (_jsxs("nav", { className: "sidenav", children: [_jsx("a", { className: "nav-logo", href: me ? `#/@${me.username}` : '#/', children: _jsx(Logo, {}) }), _jsx("div", { className: "nav-search", children: _jsx("span", { children: "Search" }) }), item(I_HOME, 'For You'), item(I_EXPLORE, 'Explore'), item(I_FOLLOWING, 'Following'), item(I_FRIENDS, 'Friends'), item(I_UPLOAD, 'Upload'), item(I_LIVE, 'LIVE'), me ? (_jsxs("button", { className: active === 'profile' ? 'nav-item active' : 'nav-item', onClick: () => go(`@${me.username}`), children: [_jsx(Avatar, { user: me, size: 32 }), _jsx("span", { children: "Profile" })] })) : item(I_PROFILE, 'Profile'), _jsxs("div", { className: "nav-foot", children: [me ? _jsxs("button", { className: "nav-signout", onClick: onSignOut, children: ["Log out @", String(me.username ?? '')] }) : null, _jsx("div", { className: "muted", children: "\u00A9 2026 TikTok" })] })] }));
|
|
190
|
+
}
|
|
191
|
+
function ProfileScreen({ session, me, handle }) {
|
|
192
|
+
const own = handle === String(me.username ?? '');
|
|
193
|
+
const read = useVideos(own ? session : null, 0);
|
|
194
|
+
const [tab, setTab] = useState('videos');
|
|
195
|
+
if (!own) {
|
|
196
|
+
// The Display API reads only the token's own account; another creator's page is not readable here.
|
|
197
|
+
return _jsxs("div", { className: "profile-missing", children: [_jsx("h2", { children: "Couldn't find this account" }), _jsx("p", { className: "muted", children: "Looking for videos? Try browsing our trending creators, hashtags, and sounds." })] });
|
|
198
|
+
}
|
|
199
|
+
return (_jsxs("div", { className: "profile", children: [_jsx(ProfileHeader, { user: me, own: true }), _jsxs("div", { className: "profile-tabs", children: [_jsxs("button", { className: tab === 'videos' ? 'ptab active' : 'ptab', onClick: () => setTab('videos'), children: [_jsx(Icon, { d: I_GRID, size: 18 }), "Videos"] }), _jsx("button", { className: tab === 'reposts' ? 'ptab active' : 'ptab', onClick: () => setTab('reposts'), children: "Reposts" }), _jsxs("button", { className: tab === 'favorites' ? 'ptab active' : 'ptab', onClick: () => setTab('favorites'), children: [_jsx(Icon, { d: I_LOCK, size: 16 }), "Favorites"] }), _jsxs("button", { className: tab === 'liked' ? 'ptab active' : 'ptab', onClick: () => setTab('liked'), children: [_jsx(Icon, { d: I_LOCK, size: 16 }), "Liked"] }), tab === 'videos' ? _jsxs("div", { className: "sort", children: [_jsx("span", { className: "sort-on", children: "Latest" }), _jsx("span", { children: "Popular" }), _jsx("span", { children: "Oldest" })] }) : null] }), tab !== 'videos' ? _jsxs("div", { className: "empty", children: [_jsx("h3", { children: "Nothing here yet" }), _jsx("p", { className: "muted", children: "This twin models the account's own posts." })] })
|
|
200
|
+
: read.error ? _jsx("div", { className: "empty", role: "alert", children: read.error })
|
|
201
|
+
: read.loaded && read.videos.length === 0 ? _jsxs("div", { className: "empty", children: [_jsx("h3", { children: "Upload your first video" }), _jsx("p", { className: "muted", children: "Your videos will appear here" })] })
|
|
202
|
+
: (_jsxs(_Fragment, { children: [_jsx(VideoGrid, { videos: read.videos, sourceOf: (id) => mediaSource(WIRE_BASE, id, session.token), onOpen: (id) => go(`@${me.username}/video/${id}`) }), read.more ? _jsx("button", { className: "show-more", onClick: read.more, children: "Load more" }) : null] }))] }));
|
|
203
|
+
}
|
|
204
|
+
function VideoScreen({ session, me, id }) {
|
|
205
|
+
const read = useVideos(session, 0);
|
|
206
|
+
const at = read.videos.findIndex((v) => String(v.id) === id);
|
|
207
|
+
const close = useCallback(() => go(`@${me.username}`), [me.username]);
|
|
208
|
+
if (!read.loaded)
|
|
209
|
+
return _jsx("div", { className: "player-page" });
|
|
210
|
+
if (at < 0)
|
|
211
|
+
return _jsxs("div", { className: "player-page", children: [_jsx("div", { className: "player-missing", children: "Video currently unavailable" }), _jsx("button", { className: "player-close", "aria-label": "Close", onClick: close, children: _jsx(Icon, { d: I_CLOSE, size: 24 }) })] });
|
|
212
|
+
return (_jsx(Player, { user: me, video: read.videos[at], src: mediaSource(WIRE_BASE, id, session.token), ...(at > 0 ? { prev: String(read.videos[at - 1].id) } : {}), ...(at < read.videos.length - 1 ? { next: String(read.videos[at + 1].id) } : {}), onClose: close }, id));
|
|
213
|
+
}
|
|
214
|
+
function LoginScreen({ onSignedIn }) {
|
|
215
|
+
const [step, setStep] = useState('username');
|
|
216
|
+
const [handle, setHandle] = useState('');
|
|
217
|
+
const [token, setToken] = useState('');
|
|
218
|
+
const [error, setError] = useState(null);
|
|
219
|
+
const [busy, setBusy] = useState(false);
|
|
220
|
+
const clean = handle.trim().replace(/^@/, '');
|
|
221
|
+
const next = () => {
|
|
222
|
+
if (!/^[A-Za-z0-9_.]{2,24}$/.test(clean)) {
|
|
223
|
+
setError("Couldn't find this account");
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
setError(null);
|
|
227
|
+
setStep('token');
|
|
228
|
+
};
|
|
229
|
+
const login = async () => {
|
|
230
|
+
setBusy(true);
|
|
231
|
+
const w = await readMe(token.trim());
|
|
232
|
+
setBusy(false);
|
|
233
|
+
if (w.status === 401 && errorCode(w) === 'access_token_invalid') {
|
|
234
|
+
setError('Incorrect account or password');
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (w.status !== 200 || !w.body?.data?.user) {
|
|
238
|
+
setError(errorText(w));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const me = w.body.data.user;
|
|
242
|
+
// Is this token THIS account's? The Display API answers the token's own account, so the answer says.
|
|
243
|
+
if (me.username !== undefined && String(me.username) !== clean) {
|
|
244
|
+
setError(`That token does not log in as @${clean}.`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
onSignedIn({ handle: clean, token: token.trim() }, { ...me, username: me.username ?? clean });
|
|
248
|
+
};
|
|
249
|
+
return (_jsx("div", { className: "login-backdrop", children: _jsxs("div", { className: "login", children: [_jsx("div", { className: "login-logo", children: _jsx(Logo, { size: 40, word: false }) }), step === 'username' ? (_jsxs("form", { className: "login-form", onSubmit: (e) => { e.preventDefault(); next(); }, children: [_jsx("h1", { children: "Log in to TikTok" }), _jsx("div", { className: "login-sub", children: "Username" }), _jsx("input", { autoFocus: true, name: "username", className: "login-input", placeholder: "Username", value: handle, onChange: (e) => setHandle(e.target.value), autoComplete: "username" }), error ? _jsx("div", { className: "login-error", role: "alert", children: error }) : null, _jsx("button", { className: "btn-login", type: "submit", disabled: clean === '', children: "Next" }), _jsxs("div", { className: "muted login-note", children: ["This twin logs you in with the access token it issued for the account (", _jsx("code", { children: "/_twin/tokens" }), "), in place of a password."] })] })) : (_jsxs("form", { className: "login-form", onSubmit: (e) => { e.preventDefault(); void login(); }, children: [_jsx("h1", { children: "Log in" }), _jsxs("div", { className: "login-sub", children: ["@", clean, " ", _jsx("button", { type: "button", className: "link", onClick: () => { setStep('username'); setError(null); }, children: "Change" })] }), _jsx("input", { autoFocus: true, name: "token", className: "login-input", type: "password", placeholder: "Access token", value: token, onChange: (e) => setToken(e.target.value), autoComplete: "current-password" }), error ? _jsx("div", { className: "login-error", role: "alert", children: error }) : null, _jsx("button", { className: "btn-login", type: "submit", disabled: token.trim() === '' || busy, children: "Log in" })] })), _jsxs("div", { className: "login-foot", children: ["Don't have an account? ", _jsx("span", { className: "link-red", children: "Sign up" })] })] }) }));
|
|
250
|
+
}
|
|
251
|
+
function App() {
|
|
252
|
+
const [session, setSession] = useState(readSession);
|
|
253
|
+
const [me, setMe] = useState(null);
|
|
254
|
+
const [route, setRoute] = useState(() => parseRoute(typeof location === 'undefined' ? '' : location.hash));
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
const onHash = () => { setRoute(parseRoute(location.hash)); window.scrollTo(0, 0); };
|
|
257
|
+
// a `#/…` link is one of the mirror's own routes; under a World's <base> the browser would resolve
|
|
258
|
+
// it against the base and leave the shell, so it moves the hash here
|
|
259
|
+
const onClick = (e) => {
|
|
260
|
+
const a = e.target?.closest?.('a[href^="#/"]');
|
|
261
|
+
if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
|
|
262
|
+
return;
|
|
263
|
+
e.preventDefault();
|
|
264
|
+
location.hash = a.getAttribute('href');
|
|
265
|
+
};
|
|
266
|
+
window.addEventListener('hashchange', onHash);
|
|
267
|
+
document.addEventListener('click', onClick, true);
|
|
268
|
+
return () => { window.removeEventListener('hashchange', onHash); document.removeEventListener('click', onClick, true); };
|
|
269
|
+
}, []);
|
|
270
|
+
const signOut = useCallback(() => {
|
|
271
|
+
sessionStorage.removeItem(SESSION_HANDLE);
|
|
272
|
+
sessionStorage.removeItem(SESSION_TOKEN);
|
|
273
|
+
setSession(null);
|
|
274
|
+
setMe(null);
|
|
275
|
+
go('login');
|
|
276
|
+
}, []);
|
|
277
|
+
// who am I: the signed-in creator, read back through the vendor's own user read
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
if (!session) {
|
|
280
|
+
setMe(null);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
void readMe(session.token).then((w) => {
|
|
284
|
+
if (w.status === 200 && w.body?.data?.user)
|
|
285
|
+
setMe({ ...w.body.data.user, username: w.body.data.user.username ?? session.handle });
|
|
286
|
+
else if (w.status === 401)
|
|
287
|
+
signOut();
|
|
288
|
+
});
|
|
289
|
+
}, [session, signOut]);
|
|
290
|
+
const signIn = (s, user) => {
|
|
291
|
+
sessionStorage.setItem(SESSION_HANDLE, s.handle);
|
|
292
|
+
sessionStorage.setItem(SESSION_TOKEN, s.token);
|
|
293
|
+
setSession(s);
|
|
294
|
+
setMe(user);
|
|
295
|
+
go(`@${s.handle}`);
|
|
296
|
+
};
|
|
297
|
+
if (!session || route?.page === 'login') {
|
|
298
|
+
return session && route?.page === 'login' ? _jsx(LoginRedirect, { handle: session.handle }) : _jsx(LoginScreen, { onSignedIn: signIn });
|
|
299
|
+
}
|
|
300
|
+
if (!me)
|
|
301
|
+
return _jsx("div", { className: "loading", children: _jsx(Logo, {}) });
|
|
302
|
+
const current = route ?? { page: 'profile', handle: String(me.username) };
|
|
303
|
+
if (current.page === 'video')
|
|
304
|
+
return _jsx(VideoScreen, { session: session, me: me, id: current.id });
|
|
305
|
+
return (_jsxs("div", { className: "layout", children: [_jsx(SideNav, { me: me, active: current.page === 'profile' && current.handle === me.username ? 'profile' : 'none', onSignOut: signOut }), _jsx("main", { className: "main", children: _jsx(ProfileScreen, { session: session, me: me, handle: current.page === 'profile' ? current.handle : String(me.username) }) })] }));
|
|
306
|
+
}
|
|
307
|
+
function LoginRedirect({ handle }) {
|
|
308
|
+
useEffect(() => { go(`@${handle}`); }, [handle]);
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
if (typeof document !== 'undefined') {
|
|
312
|
+
const el = document.getElementById('root');
|
|
313
|
+
if (el)
|
|
314
|
+
createRoot(el).render(_jsx(App, {}));
|
|
315
|
+
}
|