+3
.gitignore
+3
.gitignore
+10
-5
angular.json
+10
-5
angular.json
···
13
13
"build": {
14
14
"builder": "@angular-devkit/build-angular:application",
15
15
"options": {
16
-
"outputPath": "dist/consolesky",
16
+
"outputPath": {
17
+
"base": "docs",
18
+
"browser": ""
19
+
},
17
20
"index": "src/index.html",
18
21
"browser": "src/main.ts",
19
22
"polyfills": [
···
27
30
}
28
31
],
29
32
"styles": [
30
-
"src/styles.css"
33
+
"src/styles.css",
34
+
"https://unpkg.com/video.js@8.22.0/dist/video-js.min.css"
31
35
],
32
36
"scripts": []
33
37
},
···
36
40
"budgets": [
37
41
{
38
42
"type": "initial",
39
-
"maximumWarning": "500kB",
40
-
"maximumError": "1MB"
43
+
"maximumWarning": "2MB",
44
+
"maximumError": "5MB"
41
45
},
42
46
{
43
47
"type": "anyComponentStyle",
···
85
89
}
86
90
],
87
91
"styles": [
88
-
"src/styles.css"
92
+
"src/styles.css",
93
+
"https://unpkg.com/video.js@8.22.0/dist/video-js.min.css"
89
94
],
90
95
"scripts": []
91
96
}
+39
main.js
+39
main.js
···
1
+
const {app, BrowserWindow} = require('electron')
2
+
const url = require("url");
3
+
const path = require("path");
4
+
5
+
let mainWindow
6
+
7
+
function createWindow () {
8
+
mainWindow = new BrowserWindow({
9
+
width: 800,
10
+
height: 600,
11
+
webPreferences: {
12
+
nodeIntegration: true
13
+
}
14
+
})
15
+
16
+
mainWindow.loadURL(
17
+
url.format({
18
+
pathname: path.join(__dirname, `docs/index.html`),
19
+
protocol: "file:",
20
+
slashes: true
21
+
})
22
+
);
23
+
// Open the DevTools.
24
+
mainWindow.webContents.openDevTools()
25
+
26
+
mainWindow.on('closed', function () {
27
+
mainWindow = null
28
+
})
29
+
}
30
+
31
+
app.on('ready', createWindow)
32
+
33
+
app.on('window-all-closed', function () {
34
+
if (process.platform !== 'darwin') app.quit()
35
+
})
36
+
37
+
app.on('activate', function () {
38
+
if (mainWindow === null) createWindow()
39
+
})
+16
-2
package.json
+16
-2
package.json
···
1
1
{
2
2
"name": "consolesky",
3
3
"version": "0.0.0",
4
+
"main": "main.js",
4
5
"scripts": {
5
6
"ng": "ng",
6
7
"start": "ng serve",
7
8
"build": "ng build",
8
9
"watch": "ng build --watch --configuration development",
9
-
"test": "ng test"
10
+
"test": "ng test",
11
+
"electron": "ng build --base-href ./ && electron ."
10
12
},
11
13
"private": true,
12
14
"dependencies": {
15
+
"@angular/cdk": "^19.2.8",
13
16
"@angular/common": "^19.2.0",
14
17
"@angular/compiler": "^19.2.0",
15
18
"@angular/core": "^19.2.0",
···
17
20
"@angular/platform-browser": "^19.2.0",
18
21
"@angular/platform-browser-dynamic": "^19.2.0",
19
22
"@angular/router": "^19.2.0",
23
+
"@angular/youtube-player": "^19.2.8",
24
+
"@atproto/api": "^0.14.16",
25
+
"@tailwindcss/postcss": "^4.0.17",
26
+
"angular-mentions": "^1.5.0",
27
+
"date-fns": "^4.1.0",
28
+
"ngx-image-compress": "^18.1.5",
29
+
"postcss": "^8.5.3",
20
30
"rxjs": "~7.8.0",
31
+
"tailwindcss": "^4.0.17",
21
32
"tslib": "^2.3.0",
33
+
"uuid": "^11.1.0",
22
34
"zone.js": "~0.15.0"
23
35
},
24
36
"devDependencies": {
···
26
38
"@angular/cli": "^19.2.5",
27
39
"@angular/compiler-cli": "^19.2.0",
28
40
"@types/jasmine": "~5.1.0",
41
+
"electron": "^35.1.2",
29
42
"jasmine-core": "~5.6.0",
30
43
"karma": "~6.4.0",
31
44
"karma-chrome-launcher": "~3.2.0",
32
45
"karma-coverage": "~2.2.0",
33
46
"karma-jasmine": "~5.1.0",
34
47
"karma-jasmine-html-reporter": "~2.1.0",
35
-
"typescript": "~5.7.2"
48
+
"typescript": "~5.7.2",
49
+
"video.js": "^8.22.0"
36
50
}
37
51
}
+3
-336
src/app/app.component.html
+3
-336
src/app/app.component.html
···
1
-
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
2
-
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
3
-
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
4
-
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
5
-
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
6
-
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
7
-
<!-- * * * * * * * to get started with your project! * * * * * * * -->
8
-
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
9
-
10
-
<style>
11
-
:host {
12
-
--bright-blue: oklch(51.01% 0.274 263.83);
13
-
--electric-violet: oklch(53.18% 0.28 296.97);
14
-
--french-violet: oklch(47.66% 0.246 305.88);
15
-
--vivid-pink: oklch(69.02% 0.277 332.77);
16
-
--hot-red: oklch(61.42% 0.238 15.34);
17
-
--orange-red: oklch(63.32% 0.24 31.68);
18
-
19
-
--gray-900: oklch(19.37% 0.006 300.98);
20
-
--gray-700: oklch(36.98% 0.014 302.71);
21
-
--gray-400: oklch(70.9% 0.015 304.04);
22
-
23
-
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
24
-
180deg,
25
-
var(--orange-red) 0%,
26
-
var(--vivid-pink) 50%,
27
-
var(--electric-violet) 100%
28
-
);
29
-
30
-
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
31
-
90deg,
32
-
var(--orange-red) 0%,
33
-
var(--vivid-pink) 50%,
34
-
var(--electric-violet) 100%
35
-
);
36
-
37
-
--pill-accent: var(--bright-blue);
38
-
39
-
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
40
-
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
41
-
"Segoe UI Symbol";
42
-
box-sizing: border-box;
43
-
-webkit-font-smoothing: antialiased;
44
-
-moz-osx-font-smoothing: grayscale;
45
-
}
46
-
47
-
h1 {
48
-
font-size: 3.125rem;
49
-
color: var(--gray-900);
50
-
font-weight: 500;
51
-
line-height: 100%;
52
-
letter-spacing: -0.125rem;
53
-
margin: 0;
54
-
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
55
-
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
56
-
"Segoe UI Symbol";
57
-
}
58
-
59
-
p {
60
-
margin: 0;
61
-
color: var(--gray-700);
62
-
}
63
-
64
-
main {
65
-
width: 100%;
66
-
min-height: 100%;
67
-
display: flex;
68
-
justify-content: center;
69
-
align-items: center;
70
-
padding: 1rem;
71
-
box-sizing: inherit;
72
-
position: relative;
73
-
}
74
-
75
-
.angular-logo {
76
-
max-width: 9.2rem;
77
-
}
78
-
79
-
.content {
80
-
display: flex;
81
-
justify-content: space-around;
82
-
width: 100%;
83
-
max-width: 700px;
84
-
margin-bottom: 3rem;
85
-
}
86
-
87
-
.content h1 {
88
-
margin-top: 1.75rem;
89
-
}
90
-
91
-
.content p {
92
-
margin-top: 1.5rem;
93
-
}
94
-
95
-
.divider {
96
-
width: 1px;
97
-
background: var(--red-to-pink-to-purple-vertical-gradient);
98
-
margin-inline: 0.5rem;
99
-
}
100
-
101
-
.pill-group {
102
-
display: flex;
103
-
flex-direction: column;
104
-
align-items: start;
105
-
flex-wrap: wrap;
106
-
gap: 1.25rem;
107
-
}
108
-
109
-
.pill {
110
-
display: flex;
111
-
align-items: center;
112
-
--pill-accent: var(--bright-blue);
113
-
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
114
-
color: var(--pill-accent);
115
-
padding-inline: 0.75rem;
116
-
padding-block: 0.375rem;
117
-
border-radius: 2.75rem;
118
-
border: 0;
119
-
transition: background 0.3s ease;
120
-
font-family: var(--inter-font);
121
-
font-size: 0.875rem;
122
-
font-style: normal;
123
-
font-weight: 500;
124
-
line-height: 1.4rem;
125
-
letter-spacing: -0.00875rem;
126
-
text-decoration: none;
127
-
}
128
-
129
-
.pill:hover {
130
-
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
131
-
}
132
-
133
-
.pill-group .pill:nth-child(6n + 1) {
134
-
--pill-accent: var(--bright-blue);
135
-
}
136
-
.pill-group .pill:nth-child(6n + 2) {
137
-
--pill-accent: var(--french-violet);
138
-
}
139
-
.pill-group .pill:nth-child(6n + 3),
140
-
.pill-group .pill:nth-child(6n + 4),
141
-
.pill-group .pill:nth-child(6n + 5) {
142
-
--pill-accent: var(--hot-red);
143
-
}
144
-
145
-
.pill-group svg {
146
-
margin-inline-start: 0.25rem;
147
-
}
148
-
149
-
.social-links {
150
-
display: flex;
151
-
align-items: center;
152
-
gap: 0.73rem;
153
-
margin-top: 1.5rem;
154
-
}
155
-
156
-
.social-links path {
157
-
transition: fill 0.3s ease;
158
-
fill: var(--gray-400);
159
-
}
160
-
161
-
.social-links a:hover svg path {
162
-
fill: var(--gray-900);
163
-
}
164
-
165
-
@media screen and (max-width: 650px) {
166
-
.content {
167
-
flex-direction: column;
168
-
width: max-content;
169
-
}
170
-
171
-
.divider {
172
-
height: 1px;
173
-
width: 100%;
174
-
background: var(--red-to-pink-to-purple-horizontal-gradient);
175
-
margin-block: 1.5rem;
176
-
}
177
-
}
178
-
</style>
179
-
180
-
<main class="main">
181
-
<div class="content">
182
-
<div class="left-side">
183
-
<svg
184
-
xmlns="http://www.w3.org/2000/svg"
185
-
viewBox="0 0 982 239"
186
-
fill="none"
187
-
class="angular-logo"
188
-
>
189
-
<g clip-path="url(#a)">
190
-
<path
191
-
fill="url(#b)"
192
-
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
193
-
/>
194
-
<path
195
-
fill="url(#c)"
196
-
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
197
-
/>
198
-
</g>
199
-
<defs>
200
-
<radialGradient
201
-
id="c"
202
-
cx="0"
203
-
cy="0"
204
-
r="1"
205
-
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
206
-
gradientUnits="userSpaceOnUse"
207
-
>
208
-
<stop stop-color="#FF41F8" />
209
-
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
210
-
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
211
-
</radialGradient>
212
-
<linearGradient
213
-
id="b"
214
-
x1="0"
215
-
x2="982"
216
-
y1="192"
217
-
y2="192"
218
-
gradientUnits="userSpaceOnUse"
219
-
>
220
-
<stop stop-color="#F0060B" />
221
-
<stop offset="0" stop-color="#F0070C" />
222
-
<stop offset=".526" stop-color="#CC26D5" />
223
-
<stop offset="1" stop-color="#7702FF" />
224
-
</linearGradient>
225
-
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
226
-
</defs>
227
-
</svg>
228
-
<h1>Hello, {{ title }}</h1>
229
-
<p>Congratulations! Your app is running. 🎉</p>
230
-
</div>
231
-
<div class="divider" role="separator" aria-label="Divider"></div>
232
-
<div class="right-side">
233
-
<div class="pill-group">
234
-
@for (item of [
235
-
{ title: 'Explore the Docs', link: 'https://angular.dev' },
236
-
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
237
-
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
238
-
{ title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
239
-
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
240
-
]; track item.title) {
241
-
<a
242
-
class="pill"
243
-
[href]="item.link"
244
-
target="_blank"
245
-
rel="noopener"
246
-
>
247
-
<span>{{ item.title }}</span>
248
-
<svg
249
-
xmlns="http://www.w3.org/2000/svg"
250
-
height="14"
251
-
viewBox="0 -960 960 960"
252
-
width="14"
253
-
fill="currentColor"
254
-
>
255
-
<path
256
-
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
257
-
/>
258
-
</svg>
259
-
</a>
260
-
}
261
-
</div>
262
-
<div class="social-links">
263
-
<a
264
-
href="https://github.com/angular/angular"
265
-
aria-label="Github"
266
-
target="_blank"
267
-
rel="noopener"
268
-
>
269
-
<svg
270
-
width="25"
271
-
height="24"
272
-
viewBox="0 0 25 24"
273
-
fill="none"
274
-
xmlns="http://www.w3.org/2000/svg"
275
-
alt="Github"
276
-
>
277
-
<path
278
-
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
279
-
/>
280
-
</svg>
281
-
</a>
282
-
<a
283
-
href="https://twitter.com/angular"
284
-
aria-label="Twitter"
285
-
target="_blank"
286
-
rel="noopener"
287
-
>
288
-
<svg
289
-
width="24"
290
-
height="24"
291
-
viewBox="0 0 24 24"
292
-
fill="none"
293
-
xmlns="http://www.w3.org/2000/svg"
294
-
alt="Twitter"
295
-
>
296
-
<path
297
-
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
298
-
/>
299
-
</svg>
300
-
</a>
301
-
<a
302
-
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
303
-
aria-label="Youtube"
304
-
target="_blank"
305
-
rel="noopener"
306
-
>
307
-
<svg
308
-
width="29"
309
-
height="20"
310
-
viewBox="0 0 29 20"
311
-
fill="none"
312
-
xmlns="http://www.w3.org/2000/svg"
313
-
alt="Youtube"
314
-
>
315
-
<path
316
-
fill-rule="evenodd"
317
-
clip-rule="evenodd"
318
-
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
319
-
/>
320
-
</svg>
321
-
</a>
322
-
</div>
323
-
</div>
324
-
</div>
325
-
</main>
326
-
327
-
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
328
-
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
329
-
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
330
-
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
331
-
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
332
-
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
333
-
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
334
-
335
-
336
-
<router-outlet />
1
+
<div class="app-container">
2
+
<router-outlet></router-outlet>
3
+
</div>
+2
-2
src/app/app.component.spec.ts
+2
-2
src/app/app.component.spec.ts
+20
-3
src/app/app.component.ts
+20
-3
src/app/app.component.ts
···
1
-
import { Component } from '@angular/core';
2
-
import { RouterOutlet } from '@angular/router';
1
+
import {Component} from '@angular/core';
2
+
import {Router, RouterOutlet} from '@angular/router';
3
+
import {AuthService} from '@core/auth/auth.service';
4
+
import {takeWhile} from 'rxjs';
3
5
4
6
@Component({
5
7
selector: 'app-root',
···
8
10
styleUrl: './app.component.css'
9
11
})
10
12
export class AppComponent {
11
-
title = 'consolesky';
13
+
constructor(
14
+
private authService: AuthService,
15
+
private router: Router
16
+
) {
17
+
this.initApp();
18
+
}
19
+
20
+
initApp() {
21
+
this.authService.authenticationState.pipe(takeWhile(res => !res, true)).subscribe(state => {
22
+
if (state) {
23
+
this.router.navigate(['']);
24
+
} else {
25
+
this.router.navigate(['login']);
26
+
}
27
+
});
28
+
}
12
29
}
+9
-4
src/app/app.config.ts
+9
-4
src/app/app.config.ts
···
1
-
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
2
-
import { provideRouter } from '@angular/router';
1
+
import {ApplicationConfig, provideZoneChangeDetection} from '@angular/core';
2
+
import {provideRouter} from '@angular/router';
3
3
4
-
import { routes } from './app.routes';
4
+
import {routes} from './app.routes';
5
+
import {provideHttpClient} from '@angular/common/http';
5
6
6
7
export const appConfig: ApplicationConfig = {
7
-
providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes)]
8
+
providers: [
9
+
provideZoneChangeDetection({ eventCoalescing: true }),
10
+
provideRouter(routes),
11
+
provideHttpClient(),
12
+
]
8
13
};
+16
-2
src/app/app.routes.ts
+16
-2
src/app/app.routes.ts
···
1
-
import { Routes } from '@angular/router';
1
+
import {Routes} from '@angular/router';
2
+
import {AuthGuard} from '@core/auth/auth.guard';
3
+
import {DashboardComponent} from '@views/dashboard/dashboard.component';
4
+
import {LoginComponent} from '@views/login/login.component';
2
5
3
-
export const routes: Routes = [];
6
+
export const routes: Routes = [
7
+
{
8
+
path: '',
9
+
canActivate: [AuthGuard],
10
+
component: DashboardComponent,
11
+
pathMatch: 'full'
12
+
},
13
+
{
14
+
path: 'login',
15
+
component: LoginComponent
16
+
}
17
+
];
+220
src/app/components/cards/notification-card/notification-card.component.html
+220
src/app/components/cards/notification-card/notification-card.component.html
···
1
+
<div
2
+
class="flex px-2 py-3 gap-2"
3
+
(click)="onClick.emit(notification())"
4
+
>
5
+
@if (
6
+
(notification() | isLikeNotification) ||
7
+
(notification() | isFollowNotification) ||
8
+
(notification() | isRepostNotification) ||
9
+
(notification() | isStarterPackNotification)
10
+
) {
11
+
<ng-container
12
+
[ngTemplateOutlet]="template"
13
+
/>
14
+
}
15
+
</div>
16
+
17
+
<ng-template
18
+
#template
19
+
>
20
+
<div
21
+
class="w-12 flex justify-center shrink-0"
22
+
>
23
+
@if (notification() | isLikeNotification) {
24
+
<span
25
+
class="material-icons-outlined !text-4xl !flex items-center justify-center w-8 h-8"
26
+
>favorite_border</span>
27
+
}
28
+
@else if (notification() | isFollowNotification) {
29
+
<span
30
+
class="material-icons-outlined !text-4xl !flex items-center justify-center w-8 h-8"
31
+
>person_add_alt</span>
32
+
}
33
+
@else if (notification() | isRepostNotification) {
34
+
<span
35
+
class="material-icons-outlined !text-4xl !flex items-center justify-center w-8 h-8"
36
+
>repeat</span>
37
+
}
38
+
@else {
39
+
starterpack
40
+
}
41
+
</div>
42
+
43
+
<div
44
+
class="flex flex-col min-w-0 grow gap-2"
45
+
>
46
+
<ng-container
47
+
[ngTemplateOutlet]="authors"
48
+
/>
49
+
50
+
<ng-container
51
+
[ngTemplateOutlet]="label"
52
+
/>
53
+
54
+
@if (
55
+
notification().reason == 'like' ||
56
+
notification().reason == 'repost'
57
+
) {
58
+
<ng-container
59
+
[ngTemplateOutlet]="postPreview"
60
+
[ngTemplateOutletContext]="{attachedPost: post ? post() : undefined}"
61
+
/>
62
+
}
63
+
</div>
64
+
</ng-template>
65
+
66
+
<ng-template
67
+
#authors
68
+
>
69
+
<div
70
+
class="flex gap-2"
71
+
>
72
+
@for (author of notification().authors | slice : 0: 5; track author.did) {
73
+
<a
74
+
(click)="openAuthor($event, author.did)"
75
+
class="h-8 w-8 relative cursor-pointer"
76
+
>
77
+
<avatar
78
+
[src]="author.avatar"
79
+
/>
80
+
</a>
81
+
}
82
+
@if (notification().authors.length > 5) {
83
+
<span
84
+
class="h-8 w-8 flex items-center justify-center"
85
+
>
86
+
+{{notification().authors.length - 5}}
87
+
</span>
88
+
}
89
+
</div>
90
+
</ng-template>
91
+
92
+
<ng-template
93
+
#label
94
+
>
95
+
<span
96
+
>
97
+
@switch (notification().authors.length) {
98
+
@case (1) {
99
+
<a
100
+
(click)="openAuthor($event, notification().authors[0].did)"
101
+
class="font-bold hover:underline cursor-pointer"
102
+
>{{notification().authors[0] | displayName}}</a>
103
+
}
104
+
@case (2) {
105
+
<a
106
+
(click)="openAuthor($event, notification().authors[0].did)"
107
+
class="font-bold hover:underline cursor-pointer"
108
+
>{{notification().authors[0] | displayName}}</a>
109
+
110
+
and
111
+
112
+
<a
113
+
(click)="openAuthor($event, notification().authors[1].did)"
114
+
class="font-bold hover:underline cursor-pointer"
115
+
>{{notification().authors[1] | displayName}}</a>
116
+
}
117
+
@default {
118
+
<a
119
+
(click)="openAuthor($event, notification().authors[0].did)"
120
+
class="font-bold hover:underline cursor-pointer"
121
+
>{{notification().authors[0] | displayName}}</a>
122
+
123
+
and
124
+
125
+
<a
126
+
class="font-bold hover:underline cursor-pointer"
127
+
[href]=""
128
+
>{{notification().authors.length - 1}} more</a>
129
+
}
130
+
}
131
+
132
+
@if (notification() | isLikeNotification) {
133
+
liked your post
134
+
}
135
+
@else if (notification() | isFollowNotification) {
136
+
followed you
137
+
}
138
+
@else if (notification() | isRepostNotification) {
139
+
reposted your post
140
+
}
141
+
@else {
142
+
added you to a starter pack
143
+
}
144
+
</span>
145
+
</ng-template>
146
+
147
+
<ng-template
148
+
#postPreview
149
+
let-attachedPost="attachedPost"
150
+
>
151
+
<div
152
+
class="flex"
153
+
>
154
+
<div
155
+
class="overflow-hidden shrink-0 h-5 w-9 flex items-center justify-center"
156
+
>
157
+
<span class="material-icons !text-[2.25em]">format_quote</span>
158
+
</div>
159
+
160
+
<div
161
+
class="flex flex-col flex-1 min-w-0 gap-2 text-primary/50"
162
+
>
163
+
@if (attachedPost.record?.text?.length) {
164
+
<span
165
+
class="text-primary/50 text-sm"
166
+
>
167
+
{{attachedPost.record?.text}}
168
+
</span>
169
+
}
170
+
171
+
@if (attachedPost.embed) {
172
+
@if (attachedPost.embed | isEmbedImagesView) {
173
+
<div
174
+
class="flex gap-2"
175
+
>
176
+
@for (image of attachedPost.embed.images; track image.thumb) {
177
+
<img
178
+
[src]="image.thumb"
179
+
[alt]="image.alt"
180
+
class="h-16 w-16"
181
+
/>
182
+
}
183
+
</div>
184
+
}
185
+
186
+
@if (attachedPost.embed | isEmbedVideoView) {
187
+
<img
188
+
[src]="attachedPost.embed.thumbnail"
189
+
[alt]="attachedPost.embed.alt"
190
+
class="h-16 w-16"
191
+
/>
192
+
}
193
+
194
+
@if (attachedPost.embed | isEmbedRecordWithMediaView) {
195
+
@if (attachedPost.embed.media | isEmbedImagesView) {
196
+
<div
197
+
class="flex gap-2"
198
+
>
199
+
@for (image of attachedPost.embed.media.images; track image.thumb) {
200
+
<img
201
+
[src]="image.thumb"
202
+
[alt]="image.alt"
203
+
class="h-16 w-16"
204
+
/>
205
+
}
206
+
</div>
207
+
}
208
+
209
+
@if (attachedPost.embed.media | isEmbedVideoView) {
210
+
<img
211
+
[src]="attachedPost.embed.media.thumbnail"
212
+
[alt]="attachedPost.embed.media.alt"
213
+
class="h-16 w-16"
214
+
/>
215
+
}
216
+
}
217
+
}
218
+
</div>
219
+
</div>
220
+
</ng-template>
+58
src/app/components/cards/notification-card/notification-card.component.ts
+58
src/app/components/cards/notification-card/notification-card.component.ts
···
1
+
import {
2
+
ChangeDetectionStrategy,
3
+
ChangeDetectorRef,
4
+
Component,
5
+
input,
6
+
OnInit,
7
+
output,
8
+
WritableSignal
9
+
} from '@angular/core';
10
+
import {Notification} from '@models/notification';
11
+
import {AvatarComponent} from '@components/shared/avatar/avatar.component';
12
+
import {IsLikeNotificationPipe} from '@shared/pipes/type-guards/notifications/is-like-notification.pipe';
13
+
import {IsFollowNotificationPipe} from '@shared/pipes/type-guards/notifications/is-follow-notification.pipe';
14
+
import {IsRepostNotificationPipe} from '@shared/pipes/type-guards/notifications/is-repost-notification.pipe';
15
+
import {IsStarterPackNotificationPipe} from '@shared/pipes/type-guards/notifications/is-starterpack-notification.pipe';
16
+
import {NgTemplateOutlet, SlicePipe} from '@angular/common';
17
+
import {DisplayNamePipe} from '@shared/pipes/display-name.pipe';
18
+
import {AppBskyFeedDefs} from '@atproto/api';
19
+
import {IsEmbedImagesViewPipe} from '@shared/pipes/type-guards/is-embed-images-view.pipe';
20
+
import {IsEmbedVideoViewPipe} from '@shared/pipes/type-guards/is-embed-video-view.pipe';
21
+
import {IsEmbedRecordWithMediaViewPipe} from '@shared/pipes/type-guards/is-embed-recordwithmedia-view.pipe';
22
+
23
+
@Component({
24
+
selector: 'notification-card',
25
+
imports: [
26
+
AvatarComponent,
27
+
IsLikeNotificationPipe,
28
+
IsFollowNotificationPipe,
29
+
IsRepostNotificationPipe,
30
+
IsStarterPackNotificationPipe,
31
+
NgTemplateOutlet,
32
+
SlicePipe,
33
+
DisplayNamePipe,
34
+
IsEmbedImagesViewPipe,
35
+
IsEmbedVideoViewPipe,
36
+
IsEmbedRecordWithMediaViewPipe
37
+
],
38
+
templateUrl: './notification-card.component.html',
39
+
changeDetection: ChangeDetectionStrategy.OnPush
40
+
})
41
+
export class NotificationCardComponent implements OnInit {
42
+
notification = input<Notification>();
43
+
onClick = output<Notification>();
44
+
post: WritableSignal<AppBskyFeedDefs.PostView>;
45
+
46
+
constructor(
47
+
private cdRef: ChangeDetectorRef
48
+
) {}
49
+
50
+
ngOnInit() {
51
+
this.post = this.notification().post;
52
+
this.cdRef.markForCheck();
53
+
}
54
+
55
+
openAuthor(event: Event, did: string) {
56
+
//TODO: OpenAuthor
57
+
}
58
+
}
+296
src/app/components/cards/post-card/post-card.component.html
+296
src/app/components/cards/post-card/post-card.component.html
···
1
+
<div
2
+
class="flex px-2 py-3 gap-2"
3
+
>
4
+
<avatar
5
+
[src]="post().author.avatar"
6
+
class="h-12 w-12 shrink-0"
7
+
/>
8
+
<div
9
+
class="flex flex-col w-full min-w-0"
10
+
>
11
+
12
+
<ng-container
13
+
[ngTemplateOutlet]="header"
14
+
[ngTemplateOutletContext]="{author: post().author, reply: reply(), record: post().record, reason: reason()}"
15
+
/>
16
+
17
+
<ng-container
18
+
[ngTemplateOutlet]="subheader"
19
+
[ngTemplateOutletContext]="{reply: reply(), reason: reason()}"
20
+
/>
21
+
22
+
<ng-container
23
+
[ngTemplateOutlet]="record"
24
+
[ngTemplateOutletContext]="{record: post().record}"
25
+
/>
26
+
27
+
<ng-container
28
+
[ngTemplateOutlet]="embed"
29
+
[ngTemplateOutletContext]="{embed: post().embed}"
30
+
/>
31
+
32
+
<ng-container
33
+
[ngTemplateOutlet]="info"
34
+
/>
35
+
</div>
36
+
</div>
37
+
38
+
<ng-template
39
+
#header
40
+
let-author="author"
41
+
let-reply="reply"
42
+
let-record="record"
43
+
let-reason="reason"
44
+
>
45
+
<div
46
+
class="flex mt-[0.15rem] w-full min-w-0"
47
+
>
48
+
<span
49
+
class="font-bold [text-box:trim-both_cap_alphabetic] mr-1 grow-0 shrink-1 min-w-0 overflow-y-visible overflow-x-clip overflow-ellipsis whitespace-nowrap"
50
+
>{{author | displayName}}</span>
51
+
52
+
@if (reason | isFeedDefsReasonRepost) {
53
+
<div
54
+
class="h-0"
55
+
>
56
+
<span
57
+
class="material-icons-outlined -translate-y-1 mr-1 text-repost"
58
+
>repeat</span>
59
+
</div>
60
+
61
+
<span
62
+
class="font-bold text-primary/50 [text-box:trim-both_cap_alphabetic] grow-1 shrink-0 max-w-1/2 min-w-0 overflow-y-visible overflow-x-clip overflow-ellipsis whitespace-nowrap"
63
+
>{{reason.by | displayName}}</span>
64
+
}
65
+
66
+
<!-- @if (author.displayName?.length) {-->
67
+
<!-- <span-->
68
+
<!-- class="text-secondary [text-box:trim-both_cap_alphabetic] ml-2 shrink min-w-0 overflow-y-visible overflow-x-clip hidden whitespace-nowrap text-ellipsis"-->
69
+
<!-- >{{'@' + author.handle}}</span>-->
70
+
<!-- }-->
71
+
72
+
@if (record | isFeedPostRecord) {
73
+
<a
74
+
[href]="post().uri | linkExtractor: author.handle"
75
+
target="_blank"
76
+
class="text-sm text-primary/50 hover:underline [text-box:trim-both_cap_alphabetic] shrink-0 ml-auto pl-3"
77
+
>{{record.createdAt | dateFormatter}}</a>
78
+
}
79
+
</div>
80
+
</ng-template>
81
+
82
+
<ng-template
83
+
#subheader
84
+
let-reason="reason"
85
+
let-reply="reply"
86
+
>
87
+
@if (reason || reply) {
88
+
<div
89
+
class="flex flex-col w-full"
90
+
>
91
+
@if (reply) {
92
+
<div
93
+
class="flex w-full min-w-0 mt-2.5"
94
+
>
95
+
<div
96
+
class="h-0"
97
+
>
98
+
<span
99
+
class="material-icons-outlined -translate-y-1.5 mr-1"
100
+
>subdirectory_arrow_right</span>
101
+
</div>
102
+
103
+
@if (reply.parent | isFeedDefsPostView) {
104
+
<span
105
+
class="font-bold text-primary/50 [text-box:trim-both_cap_alphabetic] shrink-0 grow basis-0 min-w-0 overflow-y-visible overflow-x-clip overflow-ellipsis whitespace-nowrap"
106
+
>{{reply.parent.author | displayName}}</span>
107
+
}
108
+
</div>
109
+
}
110
+
</div>
111
+
}
112
+
</ng-template>
113
+
114
+
<ng-template
115
+
#record
116
+
let-record="record"
117
+
>
118
+
@if ((record | isFeedPostRecord) && record.text?.length) {
119
+
<rich-text
120
+
[text]="record.text"
121
+
[facets]="record.facets"
122
+
class="mt-2 text-sm"
123
+
/>
124
+
}
125
+
</ng-template>
126
+
127
+
<ng-template
128
+
#embed
129
+
let-embed="embed"
130
+
>
131
+
@if (embed | isEmbedRecordView) {
132
+
<record-embed
133
+
[record]="embed.record"
134
+
class="mt-2 p-2 hover:bg-primary/2"
135
+
/>
136
+
}
137
+
138
+
@if (embed | isEmbedImagesView) {
139
+
<images-embed
140
+
[images]="embed.images"
141
+
class="mb-1"
142
+
[class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'"
143
+
/>
144
+
}
145
+
146
+
@if (embed | isEmbedVideoView) {
147
+
<video-embed
148
+
[embed]="embed"
149
+
[class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'"
150
+
/>
151
+
}
152
+
153
+
@if (embed | isEmbedExternalView) {
154
+
<external-embed
155
+
[external]="embed.external"
156
+
[class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'"
157
+
/>
158
+
}
159
+
160
+
@if (embed | isEmbedRecordWithMediaView) {
161
+
@if (embed.media | isEmbedImagesView) {
162
+
<images-embed
163
+
[images]="embed.media.images"
164
+
class="mb-1"
165
+
[class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'"
166
+
/>
167
+
}
168
+
169
+
@if (embed.media | isEmbedVideoView) {
170
+
<video-embed
171
+
[embed]="embed.media"
172
+
[class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'"
173
+
/>
174
+
}
175
+
176
+
@if (embed.media | isEmbedExternalView) {
177
+
<external-embed
178
+
[external]="embed.media.external"
179
+
[class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'"
180
+
/>
181
+
}
182
+
183
+
<record-embed
184
+
[record]="embed.record.record"
185
+
class="mt-2 p-2 hover:bg-primary/2"
186
+
/>
187
+
}
188
+
</ng-template>
189
+
190
+
<ng-template
191
+
#info
192
+
>
193
+
<div
194
+
class="flex mt-1 gap-4"
195
+
>
196
+
<div
197
+
class="w-16"
198
+
>
199
+
<button
200
+
class="flex w-fit h-7 p-2 items-center gap-1 hover:bg-primary/3 cursor-pointer"
201
+
(click)="replyAction($event)"
202
+
>
203
+
<span
204
+
class="material-icons-outlined !text-[14px]"
205
+
>mode_comment</span>
206
+
207
+
@if (post().replyCount) {
208
+
<span
209
+
class="[text-box:trim-both_cap_alphabetic]"
210
+
>{{post().replyCount | numberFormatter}}</span>
211
+
}
212
+
</button>
213
+
</div>
214
+
215
+
<div
216
+
class="w-16"
217
+
>
218
+
<button
219
+
cdkOverlayOrigin
220
+
#trigger="cdkOverlayOrigin"
221
+
class="flex w-fit h-7 p-2 items-center gap-1 border-t border-l border-r border-transparent hover:bg-primary/3 cursor-pointer"
222
+
[ngClass]="{'bg-primary/3 !border-primary' : rtMenuVisible}"
223
+
(click)="!processingAction ? rtMenuVisible = !rtMenuVisible : undefined"
224
+
>
225
+
<span
226
+
class="material-icons-outlined !text-[17px]"
227
+
[class]="post().viewer.repost ? 'text-repost' : undefined"
228
+
>repeat</span>
229
+
230
+
@if (post().repostCount) {
231
+
<span
232
+
class="[text-box:trim-both_cap_alphabetic]"
233
+
>{{post().repostCount | numberFormatter}}</span>
234
+
}
235
+
</button>
236
+
237
+
<ng-template
238
+
cdkConnectedOverlay
239
+
[cdkConnectedOverlayOrigin]="trigger"
240
+
[cdkConnectedOverlayOpen]="rtMenuVisible"
241
+
(detach)="rtMenuVisible = false"
242
+
(overlayOutsideClick)="rtMenuVisible = !rtMenuVisible"
243
+
>
244
+
<ul role="listbox" class="border border-primary">
245
+
<li>
246
+
<button
247
+
class="btn-dropdown"
248
+
(click)="repostAction($event)"
249
+
>
250
+
{{post().viewer.repost ? 'Undo Repost' : 'Repost'}}
251
+
</button>
252
+
</li>
253
+
254
+
@if (post().viewer.repost) {
255
+
<li>
256
+
<button
257
+
class="btn-dropdown"
258
+
(click)="refreshRepostAction($event)"
259
+
>
260
+
Repost again
261
+
</button>
262
+
</li>
263
+
}
264
+
265
+
<li>
266
+
<button
267
+
class="btn-dropdown"
268
+
>
269
+
Quote post
270
+
</button>
271
+
</li>
272
+
</ul>
273
+
</ng-template>
274
+
</div>
275
+
276
+
<div
277
+
class="w-16"
278
+
>
279
+
<button
280
+
class="flex w-fit h-7 p-2 items-center gap-1 hover:bg-primary/3 cursor-pointer"
281
+
(click)="likeAction($event)"
282
+
>
283
+
<span
284
+
class="material-icons-outlined transition"
285
+
[class]="post().viewer.like ? 'text-like' : undefined"
286
+
>{{post().viewer.like ? 'favorite' : 'favorite_border'}}</span>
287
+
288
+
@if (post().likeCount) {
289
+
<span
290
+
class="[text-box:trim-both_cap_alphabetic]"
291
+
>{{post().likeCount | numberFormatter}}</span>
292
+
}
293
+
</button>
294
+
</div>
295
+
</div>
296
+
</ng-template>
+140
src/app/components/cards/post-card/post-card.component.ts
+140
src/app/components/cards/post-card/post-card.component.ts
···
1
+
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, input, model, OnDestroy, OnInit} from '@angular/core';
2
+
import {AppBskyFeedDefs} from '@atproto/api';
3
+
import {AvatarComponent} from '@components/shared/avatar/avatar.component';
4
+
import {DisplayNamePipe} from '@shared/pipes/display-name.pipe';
5
+
import {IsFeedPostRecordPipe} from '@shared/pipes/type-guards/is-feed-post-record';
6
+
import {RichTextComponent} from '@components/shared/rich-text/rich-text.component';
7
+
import {NgClass, NgTemplateOutlet} from '@angular/common';
8
+
import {IsFeedDefsPostViewPipe} from '@shared/pipes/type-guards/is-feed-defs-postview';
9
+
import {DateFormatterPipe} from '@shared/pipes/date-formatter.pipe';
10
+
import {IsEmbedRecordViewPipe} from '@shared/pipes/type-guards/is-embed-record-view.pipe';
11
+
import {RecordEmbedComponent} from '@components/embeds/record-embed/record-embed.component';
12
+
import {IsFeedDefsReasonRepostPipe} from '@shared/pipes/type-guards/is-feed-defs-reasonrepost';
13
+
import {IsEmbedImagesViewPipe} from '@shared/pipes/type-guards/is-embed-images-view.pipe';
14
+
import {ImagesEmbedComponent} from '@components/embeds/images-embed/images-embed.component';
15
+
import {LinkExtractorPipe} from '@shared/pipes/link-extractor.pipe';
16
+
import {IsEmbedVideoViewPipe} from '@shared/pipes/type-guards/is-embed-video-view.pipe';
17
+
import {VideoEmbedComponent} from '@components/embeds/video-embed/video-embed.component';
18
+
import {IsEmbedRecordWithMediaViewPipe} from '@shared/pipes/type-guards/is-embed-recordwithmedia-view.pipe';
19
+
import {NumberFormatterPipe} from '@shared/pipes/number-formatter.pipe';
20
+
import {PostService} from '@services/post.service';
21
+
import {OverlayModule} from '@angular/cdk/overlay';
22
+
import {ExternalEmbedComponent} from '@components/embeds/external-embed/external-embed.component';
23
+
import {IsEmbedExternalViewPipe} from '@shared/pipes/type-guards/is-embed-external-view.pipe';
24
+
25
+
@Component({
26
+
selector: 'post-card',
27
+
imports: [
28
+
AvatarComponent,
29
+
DisplayNamePipe,
30
+
IsFeedPostRecordPipe,
31
+
RichTextComponent,
32
+
NgTemplateOutlet,
33
+
IsFeedDefsPostViewPipe,
34
+
DateFormatterPipe,
35
+
IsEmbedRecordViewPipe,
36
+
RecordEmbedComponent,
37
+
IsFeedDefsReasonRepostPipe,
38
+
IsEmbedImagesViewPipe,
39
+
ImagesEmbedComponent,
40
+
LinkExtractorPipe,
41
+
IsEmbedVideoViewPipe,
42
+
VideoEmbedComponent,
43
+
IsEmbedRecordWithMediaViewPipe,
44
+
NumberFormatterPipe,
45
+
OverlayModule,
46
+
NgClass,
47
+
ExternalEmbedComponent,
48
+
IsEmbedExternalViewPipe
49
+
],
50
+
templateUrl: './post-card.component.html',
51
+
changeDetection: ChangeDetectionStrategy.OnPush
52
+
})
53
+
export class PostCardComponent implements OnInit, OnDestroy {
54
+
post = model<AppBskyFeedDefs.PostView>();
55
+
reply = input<AppBskyFeedDefs.ReplyRef>();
56
+
reason = input<AppBskyFeedDefs.ReasonRepost | AppBskyFeedDefs.ReasonPin | { [k: string]: unknown; $type: string; }>();
57
+
58
+
refreshInterval: ReturnType<typeof setInterval>;
59
+
processingAction = false;
60
+
rtMenuVisible = false;
61
+
62
+
constructor(
63
+
private postService: PostService,
64
+
private cdRef: ChangeDetectorRef
65
+
) {}
66
+
67
+
ngOnInit() {
68
+
this.refreshInterval = setInterval(() => this.cdRef.markForCheck(), 5e3);
69
+
}
70
+
71
+
ngOnDestroy() {
72
+
clearInterval(this.refreshInterval);
73
+
}
74
+
75
+
replyAction(event: Event) {
76
+
event.stopPropagation();
77
+
this.postService.replyPost(this.post().uri);
78
+
}
79
+
80
+
likeAction(event: Event) {
81
+
event.stopPropagation();
82
+
if (this.processingAction) return;
83
+
this.processingAction = true;
84
+
let promise: Promise<void>;
85
+
86
+
if (this.post().viewer.like) {
87
+
promise = this.postService.deleteLike(this.post);
88
+
} else {
89
+
promise = this.postService.like(this.post);
90
+
}
91
+
92
+
promise
93
+
.then(() => {
94
+
this.cdRef.markForCheck();
95
+
})
96
+
.catch(err => {
97
+
//TODO: MessageService
98
+
})
99
+
.finally(() => this.processingAction = false);
100
+
}
101
+
102
+
repostAction(event: Event) {
103
+
event.stopPropagation();
104
+
if (this.processingAction) return;
105
+
this.rtMenuVisible = false;
106
+
this.processingAction = true;
107
+
let promise: Promise<void>;
108
+
109
+
if (this.post().viewer.repost) {
110
+
promise = this.postService.deleteRepost(this.post);
111
+
} else {
112
+
promise = this.postService.repost(this.post);
113
+
}
114
+
115
+
promise
116
+
.then(() => {
117
+
this.cdRef.markForCheck();
118
+
})
119
+
.catch(err => {
120
+
//TODO: MessageService
121
+
})
122
+
.finally(() => this.processingAction = false);
123
+
}
124
+
125
+
refreshRepostAction(event: Event) {
126
+
event.stopPropagation();
127
+
if (this.processingAction) return;
128
+
this.rtMenuVisible = false;
129
+
this.processingAction = true;
130
+
131
+
this.postService.refreshRepost(this.post)
132
+
.then(() => {
133
+
this.cdRef.markForCheck();
134
+
})
135
+
.catch(err => {
136
+
//TODO: MessageService
137
+
})
138
+
.finally(() => this.processingAction = false);
139
+
}
140
+
}
+18
src/app/components/deck-columns/notification-deck-column/notification-deck-column.component.html
+18
src/app/components/deck-columns/notification-deck-column/notification-deck-column.component.html
···
1
+
<div
2
+
class="flex flex-col h-full w-full"
3
+
>
4
+
<div
5
+
class="flex h-9 w-full border-b shrink-0"
6
+
>
7
+
<span
8
+
class="bg-primary text-bg text-xl font-medium flex items-center px-3 lowercase font-mono"
9
+
>{{column().title}}</span>
10
+
</div>
11
+
<div
12
+
class="flex-1 min-h-0"
13
+
>
14
+
<notification-feed
15
+
class="block h-full"
16
+
/>
17
+
</div>
18
+
</div>
+21
src/app/components/deck-columns/notification-deck-column/notification-deck-column.component.ts
+21
src/app/components/deck-columns/notification-deck-column/notification-deck-column.component.ts
···
1
+
import {booleanAttribute, ChangeDetectionStrategy, Component, input, model, output} from '@angular/core';
2
+
import {NotificationDeckColumn} from '@models/deck-column';
3
+
import {NotificationFeedComponent} from '@components/feeds/notification-feed/notification-feed.component';
4
+
5
+
@Component({
6
+
selector: 'notification-deck-column',
7
+
imports: [
8
+
NotificationFeedComponent
9
+
],
10
+
templateUrl: './notification-deck-column.component.html',
11
+
changeDetection: ChangeDetectionStrategy.OnPush
12
+
})
13
+
export class NotificationDeckColumnComponent {
14
+
column = model.required<NotificationDeckColumn>();
15
+
firstIndex = input(false, {transform: booleanAttribute});
16
+
lastIndex = input(false, {transform: booleanAttribute});
17
+
reorderNext = output();
18
+
reorderPrev = output();
19
+
delete = output();
20
+
widthChange = output<number>();
21
+
}
+18
src/app/components/deck-columns/timeline-deck-column/timeline-deck-column.component.html
+18
src/app/components/deck-columns/timeline-deck-column/timeline-deck-column.component.html
···
1
+
<div
2
+
class="flex flex-col h-full w-full"
3
+
>
4
+
<div
5
+
class="flex h-9 w-full border-b shrink-0"
6
+
>
7
+
<span
8
+
class="bg-primary text-bg text-xl font-medium flex items-center px-3 lowercase font-mono"
9
+
>{{column().title}}</span>
10
+
</div>
11
+
<div
12
+
class="flex-1 min-h-0"
13
+
>
14
+
<timeline-feed
15
+
class="block h-full"
16
+
/>
17
+
</div>
18
+
</div>
+21
src/app/components/deck-columns/timeline-deck-column/timeline-deck-column.component.ts
+21
src/app/components/deck-columns/timeline-deck-column/timeline-deck-column.component.ts
···
1
+
import {booleanAttribute, ChangeDetectionStrategy, Component, input, model, output} from '@angular/core';
2
+
import {TimelineDeckColumn} from '@models/deck-column';
3
+
import {TimelineFeedComponent} from '@components/feeds/timeline-feed/timeline-feed.component';
4
+
5
+
@Component({
6
+
selector: 'timeline-deck-column',
7
+
imports: [
8
+
TimelineFeedComponent
9
+
],
10
+
templateUrl: './timeline-deck-column.component.html',
11
+
changeDetection: ChangeDetectionStrategy.OnPush
12
+
})
13
+
export class TimelineDeckColumnComponent {
14
+
column = model.required<TimelineDeckColumn>();
15
+
firstIndex = input(false, {transform: booleanAttribute});
16
+
lastIndex = input(false, {transform: booleanAttribute});
17
+
reorderNext = output();
18
+
reorderPrev = output();
19
+
delete = output();
20
+
widthChange = output<number>();
21
+
}
+55
src/app/components/embeds/external-embed/external-embed.component.html
+55
src/app/components/embeds/external-embed/external-embed.component.html
···
1
+
@if (snippet.type == LinkSnippetType) {
2
+
<a
3
+
[href]="external().uri"
4
+
target="_blank"
5
+
(click)="$event.stopPropagation()"
6
+
class="flex w-full bg-primary/2"
7
+
>
8
+
@if (external().thumb) {
9
+
<img
10
+
[ngSrc]="external().thumb"
11
+
alt="thumb"
12
+
width="1000"
13
+
height="1000"
14
+
class="h-16 w-16 object-cover"
15
+
/>
16
+
}
17
+
18
+
<div
19
+
class="flex flex-col justify-center px-2 hover:bg-primary/2"
20
+
>
21
+
<span
22
+
class="font-semibold line-clamp-2 leading-[1.15]"
23
+
>{{ external().title }}</span>
24
+
<span
25
+
class="text-xs"
26
+
>{{ snippet.domain }}</span>
27
+
</div>
28
+
</a>
29
+
}
30
+
31
+
@if (snippet.type == BlueskyGifSnippetType) {
32
+
<video
33
+
#target
34
+
class="video-js vjs-show-big-play-button-on-pause rounded-md overflow-hidden"
35
+
(click)="$event.stopPropagation()"
36
+
></video>
37
+
}
38
+
39
+
@if (snippet.type == IframeSnippetType) {
40
+
@if (snippet.source == YoutubeSnippetSource) {
41
+
<youtube-player
42
+
[videoId]="snippet.url"
43
+
class="aspect-video"
44
+
(click)="$event.stopPropagation()"
45
+
/>
46
+
} @else {
47
+
<iframe
48
+
[src]="safeURL"
49
+
width="100%"
50
+
allow="fullscreen"
51
+
class="aspect-video"
52
+
(click)="$event.stopPropagation()"
53
+
></iframe>
54
+
}
55
+
}
+102
src/app/components/embeds/external-embed/external-embed.component.ts
+102
src/app/components/embeds/external-embed/external-embed.component.ts
···
1
+
import {
2
+
AfterViewInit,
3
+
ChangeDetectionStrategy,
4
+
Component,
5
+
ElementRef,
6
+
input,
7
+
OnDestroy,
8
+
OnInit,
9
+
viewChildren
10
+
} from '@angular/core';
11
+
import {AppBskyEmbedExternal} from "@atproto/api";
12
+
import {DomSanitizer, SafeResourceUrl} from "@angular/platform-browser";
13
+
import videojs from "video.js";
14
+
import type Player from "video.js/dist/types/player";
15
+
import {NgOptimizedImage} from "@angular/common";
16
+
import {BlueskyGifSnippet, IframeSnippet, LinkSnippet, SnippetSource, SnippetType} from '@models/snippet';
17
+
import {SnippetUtils} from '@shared/utils/snippet-utils';
18
+
import {YouTubePlayer} from '@angular/youtube-player';
19
+
20
+
type Options = typeof videojs.options;
21
+
22
+
@Component({
23
+
selector: 'external-embed',
24
+
imports: [
25
+
YouTubePlayer,
26
+
NgOptimizedImage
27
+
],
28
+
templateUrl: './external-embed.component.html',
29
+
styles: `
30
+
:host(::ng-deep youtube-player) {
31
+
display: flex;
32
+
}
33
+
:host(::ng-deep youtube-player) youtube-player-placeholder {
34
+
width: 100% !important;
35
+
height: unset !important;
36
+
}
37
+
:host(::ng-deep youtube-player) > div {
38
+
width: 100%;
39
+
}
40
+
:host(::ng-deep youtube-player) > div iframe {
41
+
height: unset;
42
+
width: 100%;
43
+
aspect-ratio: 16 / 9;
44
+
}
45
+
46
+
`,
47
+
changeDetection: ChangeDetectionStrategy.OnPush
48
+
})
49
+
export class ExternalEmbedComponent implements OnInit, OnDestroy, AfterViewInit {
50
+
external = input<AppBskyEmbedExternal.ViewExternal>();
51
+
target = viewChildren<ElementRef<HTMLVideoElement>>('target');
52
+
53
+
player: Player;
54
+
options: Options;
55
+
snippet: LinkSnippet | BlueskyGifSnippet | IframeSnippet;
56
+
safeURL: SafeResourceUrl;
57
+
58
+
protected readonly LinkSnippetType = SnippetType.LINK;
59
+
protected readonly BlueskyGifSnippetType = SnippetType.BLUESKY_GIF;
60
+
protected readonly IframeSnippetType = SnippetType.IFRAME;
61
+
protected readonly YoutubeSnippetSource = SnippetSource.YOUTUBE;
62
+
63
+
constructor(
64
+
private sanitizer: DomSanitizer,
65
+
) {}
66
+
67
+
ngOnInit() {
68
+
this.snippet = SnippetUtils.detectSnippet(this.external());
69
+
70
+
if (this.snippet.type === SnippetType.IFRAME) {
71
+
this.safeURL = this.sanitizer.bypassSecurityTrustResourceUrl(this.snippet.url);
72
+
}
73
+
}
74
+
75
+
ngAfterViewInit() {
76
+
if (this.snippet.type === SnippetType.BLUESKY_GIF) {
77
+
this.options = {
78
+
fluid: true,
79
+
aspectRatio: this.snippet.ratio,
80
+
autoplay: true,
81
+
loop: true,
82
+
sources: {
83
+
src: this.snippet.url,
84
+
type: 'video/webm'
85
+
},
86
+
controls: true,
87
+
muted: true,
88
+
playsinline: true,
89
+
preload: 'none',
90
+
bigPlayButton: true,
91
+
controlBar: false,
92
+
};
93
+
94
+
this.player = videojs(this.target()[0].nativeElement, this.options);
95
+
}
96
+
}
97
+
98
+
ngOnDestroy() {
99
+
this.player?.dispose();
100
+
}
101
+
102
+
}
+54
src/app/components/embeds/images-embed/images-embed.component.html
+54
src/app/components/embeds/images-embed/images-embed.component.html
···
1
+
@switch (images().length) {
2
+
@case (1) {
3
+
<img
4
+
[ngSrc]="images()[0].thumb"
5
+
[alt]="images()[0].alt"
6
+
width="1000"
7
+
height="1000"
8
+
class="min-w-full min-h-full max-h-full w-auto h-auto object-cover pointer"
9
+
(click)="imgClick(0, $event)"
10
+
/>
11
+
}
12
+
@case (2) {
13
+
<div class="grid grid-cols-[repeat(2,_1fr)] grid-rows-[repeat(1,_1fr)] gap-2 aspect-video overflow-hidden">
14
+
@for (image of images(); track image.thumb; let i = $index) {
15
+
<img
16
+
[ngSrc]="images()[i].thumb"
17
+
[alt]="images()[i].alt"
18
+
width="1000"
19
+
height="1000"
20
+
class="min-w-full min-h-full max-h-full w-auto h-auto object-cover pointer"
21
+
(click)="imgClick(i, $event)"
22
+
/>
23
+
}
24
+
</div>
25
+
}
26
+
@case (3) {
27
+
<div class="grid grid-cols-[repeat(2,_1fr)] grid-rows-[repeat(2,_1fr)] gap-2 aspect-video overflow-hidden">
28
+
@for (image of images(); track image.thumb; let i = $index) {
29
+
<img
30
+
[ngSrc]="images()[i].thumb"
31
+
[alt]="images()[i].alt"
32
+
width="1000"
33
+
height="1000"
34
+
class="min-w-full min-h-full max-h-full w-auto h-auto object-cover pointer first:row-span-2 last:col-start-2 overflow-hidden"
35
+
(click)="imgClick(i, $event)"
36
+
/>
37
+
}
38
+
</div>
39
+
}
40
+
@case (4) {
41
+
<div class="grid grid-cols-[repeat(2,_1fr)] grid-rows-[repeat(2,_1fr)] gap-2 aspect-[4/3] overflow-hidden">
42
+
@for (image of images(); track image.thumb; let i = $index) {
43
+
<img
44
+
[ngSrc]="images()[i].thumb"
45
+
[alt]="images()[i].alt"
46
+
width="1000"
47
+
height="1000"
48
+
class="min-w-full min-h-full max-h-full w-auto h-auto object-cover pointer"
49
+
(click)="imgClick(i, $event)"
50
+
/>
51
+
}
52
+
</div>
53
+
}
54
+
}
+20
src/app/components/embeds/images-embed/images-embed.component.ts
+20
src/app/components/embeds/images-embed/images-embed.component.ts
···
1
+
import {ChangeDetectionStrategy, Component, input, output} from '@angular/core';
2
+
import {AppBskyEmbedImages} from '@atproto/api';
3
+
import {NgOptimizedImage} from '@angular/common';
4
+
5
+
@Component({
6
+
selector: 'images-embed',
7
+
imports: [
8
+
NgOptimizedImage
9
+
],
10
+
templateUrl: './images-embed.component.html',
11
+
changeDetection: ChangeDetectionStrategy.OnPush
12
+
})
13
+
export class ImagesEmbedComponent {
14
+
images = input<AppBskyEmbedImages.ViewImage[]>();
15
+
onClick = output<number>();
16
+
17
+
imgClick(index: number, event: Event) {
18
+
19
+
}
20
+
}
+191
src/app/components/embeds/record-embed/record-embed.component.html
+191
src/app/components/embeds/record-embed/record-embed.component.html
···
1
+
@let postRecord = record();
2
+
3
+
<div
4
+
class="flex"
5
+
>
6
+
<div
7
+
class="overflow-hidden shrink-0 h-5 w-9 flex items-center justify-center"
8
+
>
9
+
<span class="material-icons !text-[2.25em]">format_quote</span>
10
+
</div>
11
+
12
+
<div
13
+
class="flex flex-col flex-1 min-w-0 mt-1"
14
+
>
15
+
@if (postRecord | isEmbedRecordViewRecord) {
16
+
<ng-container
17
+
[ngTemplateOutlet]="viewRecord"
18
+
[ngTemplateOutletContext]="{record: postRecord, media: postRecord.embeds ? postRecord.embeds[0] : undefined }"
19
+
/>
20
+
}
21
+
22
+
@if (postRecord | isEmbedRecordViewBlocked) {
23
+
<span>
24
+
Post blocked
25
+
</span>
26
+
}
27
+
28
+
@if (postRecord | isEmbedRecordViewNotFound) {
29
+
<span>
30
+
Post not found
31
+
</span>
32
+
}
33
+
34
+
@if (postRecord | isEmbedRecordViewDetached) {
35
+
<span>
36
+
Post detached
37
+
</span>
38
+
}
39
+
40
+
@if (postRecord | isFeedDefsGeneratorView) {
41
+
<ng-container
42
+
[ngTemplateOutlet]="feed"
43
+
[ngTemplateOutletContext]="{feed: postRecord}"
44
+
/>
45
+
}
46
+
47
+
@if (postRecord | isGraphDefsListView) {
48
+
<ng-container
49
+
[ngTemplateOutlet]="userList"
50
+
[ngTemplateOutletContext]="{list: postRecord}"
51
+
/>
52
+
}
53
+
54
+
<!-- Apparently there's no actual support yet? -->
55
+
@if (postRecord | isLabelerDefsLabelerView) {
56
+
<span>
57
+
Labeler record
58
+
</span>
59
+
}
60
+
61
+
@if (postRecord | isGraphDefsStarterPackViewBasic) {
62
+
<ng-container
63
+
[ngTemplateOutlet]="starterPack"
64
+
[ngTemplateOutletContext]="{starterPack: postRecord}"
65
+
/>
66
+
}
67
+
</div>
68
+
</div>
69
+
70
+
<ng-template
71
+
#viewRecord
72
+
let-record="record"
73
+
let-media="media"
74
+
>
75
+
<span
76
+
class="font-bold [text-box:trim-both_cap_alphabetic] shrink-1 grow-0 min-w-0 overflow-y-visible overflow-x-clip overflow-ellipsis whitespace-nowrap"
77
+
>{{record.author | displayName}}</span>
78
+
79
+
@if ((record.value | isFeedPostRecord) && record.value.text.length) {
80
+
<rich-text
81
+
[text]="record.value.text"
82
+
class="mt-2"
83
+
/>
84
+
}
85
+
86
+
@if (media) {
87
+
<ng-container
88
+
[ngTemplateOutlet]="mediaEmbeds"
89
+
[ngTemplateOutletContext]="{
90
+
media: media,
91
+
margin: (record.value | isFeedPostRecord) && record.value.text.length ? 'mt-2' : 'mt-3'
92
+
}"
93
+
/>
94
+
}
95
+
96
+
</ng-template>
97
+
98
+
<ng-template
99
+
#mediaEmbeds
100
+
let-media="media"
101
+
let-margin="margin"
102
+
>
103
+
@if (media | isEmbedImagesView) {
104
+
<images-embed
105
+
[images]="media.images"
106
+
[class]="margin"
107
+
/>
108
+
}
109
+
110
+
@if (media | isEmbedVideoView) {
111
+
<video-embed
112
+
[embed]="media"
113
+
[class]="margin"
114
+
/>
115
+
}
116
+
117
+
@if (media | isEmbedRecordWithMediaView) {
118
+
@if (media.media | isEmbedImagesView) {
119
+
<images-embed
120
+
[images]="media.media.images"
121
+
[class]="margin"
122
+
/>
123
+
}
124
+
125
+
@if (media.media | isEmbedVideoView) {
126
+
<video-embed
127
+
[embed]="media.media"
128
+
[class]="margin"
129
+
/>
130
+
}
131
+
}
132
+
</ng-template>
133
+
134
+
<ng-template
135
+
#feed
136
+
let-feed="feed"
137
+
>
138
+
<span
139
+
class="text-bold overflow-hidden whitespace-nowrap text-ellipsis"
140
+
>{{feed.displayName}}</span>
141
+
142
+
<span
143
+
class="overflow-hidden whitespace-nowrap text-ellipsis"
144
+
>
145
+
@switch (feed.contentMode) {
146
+
@case (AppBskyFeedDefs.CONTENTMODEVIDEO) {
147
+
Video feed by {{ feed.creator | displayName }}
148
+
}
149
+
@default {
150
+
Feed by {{ feed.creator | displayName }}
151
+
}
152
+
}
153
+
</span>
154
+
</ng-template>
155
+
156
+
<ng-template
157
+
#userList
158
+
let-list="list"
159
+
>
160
+
<span
161
+
class="text-bold overflow-hidden whitespace-nowrap text-ellipsis"
162
+
>{{list.name}}</span>
163
+
164
+
<span
165
+
class="overflow-hidden whitespace-nowrap text-ellipsis"
166
+
>
167
+
@switch (list.purpose) {
168
+
@case (AppBskyGraphDefs.MODLIST) {
169
+
Mute list by {{ list.creator | displayName }}
170
+
}
171
+
@default {
172
+
List by {{ list.creator | displayName }}
173
+
}
174
+
}
175
+
</span>
176
+
</ng-template>
177
+
178
+
<ng-template
179
+
#starterPack
180
+
let-starterpack="starterPack"
181
+
>
182
+
<span
183
+
class="text-bold overflow-hidden whitespace-nowrap text-ellipsis"
184
+
>{{starterpack.record.name}}</span>
185
+
186
+
<span
187
+
class="overflow-hidden whitespace-nowrap text-ellipsis"
188
+
>
189
+
Starter pack by {{ starterpack.creator | displayName }}
190
+
</span>
191
+
</ng-template>
+59
src/app/components/embeds/record-embed/record-embed.component.ts
+59
src/app/components/embeds/record-embed/record-embed.component.ts
···
1
+
import {ChangeDetectionStrategy, Component, input} from '@angular/core';
2
+
import {$Typed, AppBskyEmbedRecord, AppBskyFeedDefs, AppBskyGraphDefs, AppBskyLabelerDefs} from '@atproto/api';
3
+
import {DisplayNamePipe} from '@shared/pipes/display-name.pipe';
4
+
import {IsEmbedRecordViewRecordPipe} from '@shared/pipes/type-guards/is-embed-record-viewrecord.pipe';
5
+
import {NgTemplateOutlet} from '@angular/common';
6
+
import {IsFeedPostRecordPipe} from '@shared/pipes/type-guards/is-feed-post-record';
7
+
import {RichTextComponent} from '@components/shared/rich-text/rich-text.component';
8
+
import {IsEmbedImagesViewPipe} from '@shared/pipes/type-guards/is-embed-images-view.pipe';
9
+
import {ImagesEmbedComponent} from '@components/embeds/images-embed/images-embed.component';
10
+
import {IsEmbedVideoViewPipe} from '@shared/pipes/type-guards/is-embed-video-view.pipe';
11
+
import {VideoEmbedComponent} from '@components/embeds/video-embed/video-embed.component';
12
+
import {IsEmbedRecordWithMediaViewPipe} from '@shared/pipes/type-guards/is-embed-recordwithmedia-view.pipe';
13
+
import {IsEmbedRecordViewBlockedPipe} from '@shared/pipes/type-guards/is-embed-record-viewblocked.pipe';
14
+
import {IsEmbedRecordViewNotFoundPipe} from '@shared/pipes/type-guards/is-embed-record-viewnotfound.pipe';
15
+
import {IsEmbedRecordViewDetachedPipe} from '@shared/pipes/type-guards/is-embed-record-viewdetached.pipe';
16
+
import {IsFeedDefsGeneratorViewPipe} from '@shared/pipes/type-guards/is-feed-defs-generator-view';
17
+
import {IsGraphDefsListViewPipe} from '@shared/pipes/type-guards/is-graph-defs-list-view';
18
+
import {IsLabelerDefsLabelerViewPipe} from '@shared/pipes/type-guards/is-labeler-defs-labeler-view';
19
+
import {IsGraphDefsStarterPackViewBasicPipe} from '@shared/pipes/type-guards/is-graph-defs-starterpack-viewbasic';
20
+
21
+
@Component({
22
+
selector: 'record-embed',
23
+
imports: [
24
+
DisplayNamePipe,
25
+
IsEmbedRecordViewRecordPipe,
26
+
NgTemplateOutlet,
27
+
IsFeedPostRecordPipe,
28
+
RichTextComponent,
29
+
IsEmbedImagesViewPipe,
30
+
ImagesEmbedComponent,
31
+
IsEmbedVideoViewPipe,
32
+
VideoEmbedComponent,
33
+
IsEmbedRecordWithMediaViewPipe,
34
+
IsEmbedRecordViewBlockedPipe,
35
+
IsEmbedRecordViewNotFoundPipe,
36
+
IsEmbedRecordViewDetachedPipe,
37
+
IsFeedDefsGeneratorViewPipe,
38
+
IsGraphDefsListViewPipe,
39
+
IsLabelerDefsLabelerViewPipe,
40
+
IsGraphDefsStarterPackViewBasicPipe
41
+
],
42
+
templateUrl: './record-embed.component.html',
43
+
changeDetection: ChangeDetectionStrategy.OnPush
44
+
})
45
+
export class RecordEmbedComponent {
46
+
record = input<
47
+
| $Typed<AppBskyEmbedRecord.ViewRecord>
48
+
| $Typed<AppBskyEmbedRecord.ViewNotFound>
49
+
| $Typed<AppBskyEmbedRecord.ViewBlocked>
50
+
| $Typed<AppBskyEmbedRecord.ViewDetached>
51
+
| $Typed<AppBskyFeedDefs.GeneratorView>
52
+
| $Typed<AppBskyGraphDefs.ListView>
53
+
| $Typed<AppBskyLabelerDefs.LabelerView>
54
+
| $Typed<AppBskyGraphDefs.StarterPackViewBasic>
55
+
| { $type: string }
56
+
>();
57
+
protected readonly AppBskyFeedDefs = AppBskyFeedDefs;
58
+
protected readonly AppBskyGraphDefs = AppBskyGraphDefs;
59
+
}
+8
src/app/components/embeds/video-embed/video-embed.component.html
+8
src/app/components/embeds/video-embed/video-embed.component.html
+74
src/app/components/embeds/video-embed/video-embed.component.ts
+74
src/app/components/embeds/video-embed/video-embed.component.ts
···
1
+
import {ChangeDetectionStrategy, Component, ElementRef, input, OnDestroy, OnInit, viewChild} from '@angular/core';
2
+
import {AppBskyEmbedVideo} from "@atproto/api";
3
+
import videojs from "video.js";
4
+
import type Player from "video.js/dist/types/player";
5
+
6
+
type Options = typeof videojs.options;
7
+
8
+
@Component({
9
+
selector: 'video-embed',
10
+
templateUrl: './video-embed.component.html',
11
+
styles:
12
+
`
13
+
::ng-deep .video-js .vjs-control-bar {
14
+
background: linear-gradient(to top, rgba(43, 51, 63, 0.7), transparent);
15
+
}
16
+
::ng-deep .video-js > .vjs-remaining-time {
17
+
height: 0;
18
+
position: absolute;
19
+
bottom: 0;
20
+
right: 0;
21
+
font-size: 0.75rem;
22
+
font-family: 'Inter', sans-serif;
23
+
opacity: 0;
24
+
}
25
+
::ng-deep .video-js.vjs-user-inactive .vjs-remaining-time {
26
+
height: 2rem;
27
+
opacity: 1;
28
+
transition: 1.5s opacity ease;
29
+
}
30
+
`,
31
+
changeDetection: ChangeDetectionStrategy.OnPush,
32
+
})
33
+
export class VideoEmbedComponent implements OnInit, OnDestroy {
34
+
embed = input<AppBskyEmbedVideo.View>();
35
+
target = viewChild('target', {read: ElementRef});
36
+
37
+
player: Player;
38
+
options: Options;
39
+
interacted = false;
40
+
41
+
ngOnInit() {
42
+
this.options = {
43
+
fluid: true,
44
+
aspectRatio: this.embed().aspectRatio ? `${this.embed().aspectRatio.width}:${this.embed().aspectRatio.height}` : '16:9',
45
+
autoplay: true,
46
+
sources: {
47
+
src: this.embed().playlist,
48
+
type: 'application/x-mpegURL'
49
+
},
50
+
playsinline: true,
51
+
preload: 'auto',
52
+
loop: true,
53
+
inactivityTimeout: 1000,
54
+
userActions: {
55
+
click: () => {
56
+
if (this.interacted) {
57
+
this.player.paused() ? this.player.play() : this.player.pause();
58
+
} else {
59
+
this.player.loop(false);
60
+
this.player.muted(false);
61
+
this.interacted = true;
62
+
}
63
+
}
64
+
}
65
+
};
66
+
67
+
this.player = videojs(this.target().nativeElement, this.options);
68
+
this.player.addChild('RemainingTimeDisplay', {});
69
+
}
70
+
71
+
ngOnDestroy() {
72
+
this.player?.dispose();
73
+
}
74
+
}
+39
src/app/components/feeds/notification-feed/notification-feed.component.html
+39
src/app/components/feeds/notification-feed/notification-feed.component.html
···
1
+
<div
2
+
#feed
3
+
class="w-full h-full min-h-0 flex flex-col margin-[0_auto] overflow-hidden hover:overflow-y-auto transition items-center"
4
+
vScroll
5
+
(scrollEnding)="nextData(); manageRefresh();"
6
+
(scrollTop)="manageRefresh();"
7
+
>
8
+
@if (notifications) {
9
+
@for (notification of notifications; track notification.uuid) {
10
+
@if (notification | isPostNotification) {
11
+
<post-card
12
+
[post]="notification.post()"
13
+
(postChange)="notification.post.set($event)"
14
+
class="cursor-pointer hover:bg-primary/2 w-full"
15
+
/>
16
+
} @else {
17
+
<notification-card
18
+
[notification]="notification"
19
+
(onClick)="openNotification($event)"
20
+
class="cursor-pointer hover:bg-primary/2 w-full"
21
+
/>
22
+
}
23
+
<div
24
+
class="border-b border-b-primary/10 w-9/10"
25
+
style="mask-image: linear-gradient(to right, transparent, #000000 50%, transparent);"
26
+
></div>
27
+
}
28
+
<!-- } @else {-->
29
+
<!-- <div-->
30
+
<!-- class="h-full w-full flex items-center justify-center"-->
31
+
<!-- >-->
32
+
<!-- <p-progress-spinner-->
33
+
<!-- class="h-12"-->
34
+
<!-- strokeWidth="5"-->
35
+
<!-- [style]="{height: '3rem', width: '3rem'}"-->
36
+
<!-- />-->
37
+
<!-- </div>-->
38
+
}
39
+
</div>
+146
src/app/components/feeds/notification-feed/notification-feed.component.ts
+146
src/app/components/feeds/notification-feed/notification-feed.component.ts
···
1
+
import {
2
+
ChangeDetectionStrategy,
3
+
ChangeDetectorRef,
4
+
Component,
5
+
ElementRef,
6
+
OnDestroy,
7
+
OnInit,
8
+
viewChild,
9
+
} from '@angular/core';
10
+
import {CommonModule} from "@angular/common";
11
+
import {agent} from '@core/bsky.api';
12
+
import {ScrollDirective} from '@shared/directives/scroll.directive';
13
+
import {PostService} from '@services/post.service';
14
+
import {from} from 'rxjs';
15
+
import {PostCardComponent} from '@components/cards/post-card/post-card.component';
16
+
import NotificationUtils from '@shared/utils/notification-utils';
17
+
import {Notification} from '@models/notification';
18
+
import {IsNotificationArrayPipe} from '@shared/pipes/type-guards/notifications/is-post-notification';
19
+
import {NotificationCardComponent} from '@components/cards/notification-card/notification-card.component';
20
+
21
+
@Component({
22
+
selector: 'notification-feed',
23
+
imports: [
24
+
CommonModule,
25
+
ScrollDirective,
26
+
PostCardComponent,
27
+
IsNotificationArrayPipe,
28
+
NotificationCardComponent,
29
+
],
30
+
templateUrl: './notification-feed.component.html',
31
+
changeDetection: ChangeDetectionStrategy.OnPush
32
+
})
33
+
export class NotificationFeedComponent implements OnInit, OnDestroy {
34
+
feed = viewChild<ElementRef>('feed');
35
+
36
+
notifications: Notification[];
37
+
cursor: string;
38
+
loading = true;
39
+
reloadReady = false;
40
+
reloadTimeout: ReturnType<typeof setTimeout>;
41
+
42
+
constructor(
43
+
private postService: PostService,
44
+
public cdRef: ChangeDetectorRef
45
+
) {}
46
+
47
+
ngOnInit() {
48
+
this.initData();
49
+
}
50
+
51
+
ngOnDestroy() {
52
+
clearTimeout(this.reloadTimeout);
53
+
}
54
+
55
+
initData() {
56
+
this.loading = true;
57
+
from(agent.listNotifications({
58
+
limit: 15
59
+
})).subscribe({
60
+
next: response => {
61
+
this.cursor = response.data.cursor;
62
+
NotificationUtils.parseNotifications(response.data.notifications, this.postService)
63
+
.then(notifications => {
64
+
this.notifications = notifications;
65
+
this.cdRef.markForCheck();
66
+
setTimeout(() => {
67
+
this.loading = false;
68
+
this.manageRefresh();
69
+
}, 500);
70
+
});
71
+
//TODO: MessageService
72
+
}, error: err => console.log(err.message)
73
+
});
74
+
}
75
+
76
+
nextData() {
77
+
if (this.loading) return;
78
+
this.loading = true;
79
+
80
+
from(agent.listNotifications({
81
+
cursor: this.cursor,
82
+
limit: 15
83
+
})).subscribe({
84
+
next: response => {
85
+
this.cursor = response.data.cursor;
86
+
NotificationUtils.parseNotifications(response.data.notifications, this.postService)
87
+
.then(notifications => {
88
+
this.notifications = [...this.notifications, ...notifications];
89
+
this.cdRef.markForCheck();
90
+
setTimeout(() => {
91
+
this.loading = false;
92
+
this.manageRefresh();
93
+
}, 500);
94
+
});
95
+
setTimeout(() => {
96
+
this.loading = false;
97
+
}, 500);
98
+
//TODO: MessageService
99
+
}, error: err => console.log(err.message)
100
+
});
101
+
}
102
+
103
+
openNotification(notification: Notification) {
104
+
//TODO: OpenNotification
105
+
// Mute all video players
106
+
// this.feed().nativeElement.querySelectorAll('video').forEach((video: HTMLVideoElement) => {
107
+
// video.muted = true;
108
+
// });
109
+
//
110
+
// this.dialogService.openThread(uri, this.feed().nativeElement);
111
+
}
112
+
113
+
manageRefresh() {
114
+
if (this.loading) return;
115
+
116
+
if (!this.reloadReady && !this.reloadTimeout) {
117
+
this.reloadTimeout = setTimeout(() => {
118
+
this.reloadTimeout = undefined;
119
+
120
+
if (this.feed().nativeElement.scrollTop == 0) {
121
+
this.reloadReady = false;
122
+
from(agent.listNotifications({
123
+
limit: 1
124
+
})).subscribe({
125
+
next: response => {
126
+
const notification = response.data.notifications[0];
127
+
const lastNotification = this.notifications[0];
128
+
129
+
if (notification?.indexedAt !== lastNotification?.notification.indexedAt) {
130
+
this.initData();
131
+
} else {
132
+
this.manageRefresh();
133
+
}
134
+
}
135
+
});
136
+
} else {
137
+
this.reloadReady = true;
138
+
}
139
+
}, 30e3);
140
+
// Timer in seconds
141
+
} else if (this.reloadReady && this.feed().nativeElement.scrollTop == 0) {
142
+
this.reloadReady = false;
143
+
this.initData();
144
+
}
145
+
}
146
+
}
+33
src/app/components/feeds/timeline-feed/timeline-feed.component.html
+33
src/app/components/feeds/timeline-feed/timeline-feed.component.html
···
1
+
<div
2
+
#feed
3
+
class="w-full h-full min-h-0 flex flex-col margin-[0_auto] overflow-hidden hover:overflow-y-auto transition items-center"
4
+
vScroll
5
+
(scrollEnding)="nextData(); manageRefresh();"
6
+
(scrollTop)="manageRefresh();"
7
+
>
8
+
@if (posts) {
9
+
@for (post of posts; track post.uuid) {
10
+
<post-card
11
+
[post]="post.post()"
12
+
[reply]="post.reply"
13
+
[reason]="post.reason"
14
+
(postChange)="post.post.set($event)"
15
+
class="cursor-pointer hover:bg-primary/2 w-full"
16
+
/>
17
+
<div
18
+
class="border-b border-b-primary/10 w-9/10"
19
+
style="mask-image: linear-gradient(to right, transparent, #000000 50%, transparent);"
20
+
></div>
21
+
}
22
+
<!-- } @else {-->
23
+
<!-- <div-->
24
+
<!-- class="h-full w-full flex items-center justify-center"-->
25
+
<!-- >-->
26
+
<!-- <p-progress-spinner-->
27
+
<!-- class="h-12"-->
28
+
<!-- strokeWidth="5"-->
29
+
<!-- [style]="{height: '3rem', width: '3rem'}"-->
30
+
<!-- />-->
31
+
<!-- </div>-->
32
+
}
33
+
</div>
+161
src/app/components/feeds/timeline-feed/timeline-feed.component.ts
+161
src/app/components/feeds/timeline-feed/timeline-feed.component.ts
···
1
+
import {
2
+
ChangeDetectionStrategy,
3
+
ChangeDetectorRef,
4
+
Component,
5
+
ElementRef,
6
+
OnDestroy,
7
+
OnInit,
8
+
viewChild,
9
+
} from '@angular/core';
10
+
import {CommonModule} from "@angular/common";
11
+
import {agent} from '@core/bsky.api';
12
+
import {ScrollDirective} from '@shared/directives/scroll.directive';
13
+
import {$Typed} from '@atproto/api';
14
+
import {ReasonRepost} from '@atproto/api/dist/client/types/app/bsky/feed/defs';
15
+
import {PostService} from '@services/post.service';
16
+
import {PostUtils} from '@shared/utils/post-utils';
17
+
import {SignalizedFeedViewPost} from '@models/signalized-feed-view-post';
18
+
import {from} from 'rxjs';
19
+
import {PostCardComponent} from '@components/cards/post-card/post-card.component';
20
+
21
+
@Component({
22
+
selector: 'timeline-feed',
23
+
imports: [
24
+
CommonModule,
25
+
ScrollDirective,
26
+
PostCardComponent,
27
+
],
28
+
templateUrl: './timeline-feed.component.html',
29
+
changeDetection: ChangeDetectionStrategy.OnPush
30
+
})
31
+
export class TimelineFeedComponent implements OnInit, OnDestroy {
32
+
feed = viewChild<ElementRef>('feed');
33
+
34
+
posts: SignalizedFeedViewPost[];
35
+
cursor: string;
36
+
loading = true;
37
+
reloadReady = false;
38
+
reloadTimeout: ReturnType<typeof setTimeout>;
39
+
40
+
constructor(
41
+
private postService: PostService,
42
+
// private dialogService: MskyDialogService,
43
+
public cdRef: ChangeDetectorRef
44
+
) {}
45
+
46
+
ngOnInit() {
47
+
this.initData();
48
+
49
+
// Listen to new posts to refresh
50
+
this.postService.refreshFeeds.subscribe({
51
+
next: () => {
52
+
if (this.feed().nativeElement.scrollTop == 0) {
53
+
this.initData();
54
+
} else {
55
+
this.reloadReady = true;
56
+
}
57
+
}
58
+
});
59
+
}
60
+
61
+
ngOnDestroy() {
62
+
this.postService.refreshFeeds.unsubscribe();
63
+
clearTimeout(this.reloadTimeout);
64
+
}
65
+
66
+
initData() {
67
+
this.loading = true;
68
+
from(agent.getTimeline({
69
+
limit: 15
70
+
})).subscribe({
71
+
next: response => {
72
+
this.cursor = response.data.cursor;
73
+
this.posts = response.data.feed.map(fvp => PostUtils.parseFeedViewPost(fvp, this.postService));
74
+
this.cdRef.markForCheck();
75
+
setTimeout(() => {
76
+
this.loading = false;
77
+
this.manageRefresh();
78
+
}, 500);
79
+
//TODO: MessageService
80
+
}, error: err => console.log(err.message)
81
+
});
82
+
}
83
+
84
+
nextData() {
85
+
if (this.loading) return;
86
+
this.loading = true;
87
+
88
+
from(agent.getTimeline({
89
+
cursor: this.cursor,
90
+
limit: 15
91
+
})).subscribe({
92
+
next: response => {
93
+
this.cursor = response.data.cursor;
94
+
const newPosts = response.data.feed.map(fvp => PostUtils.parseFeedViewPost(fvp, this.postService));
95
+
this.posts = [...this.posts, ...newPosts];
96
+
this.cdRef.markForCheck();
97
+
setTimeout(() => {
98
+
this.loading = false;
99
+
}, 500);
100
+
//TODO: MessageService
101
+
}, error: err => console.log(err.message)
102
+
});
103
+
}
104
+
105
+
openPost(uri: string) {
106
+
//TODO: OpenPost
107
+
// Mute all video players
108
+
// this.feed().nativeElement.querySelectorAll('video').forEach((video: HTMLVideoElement) => {
109
+
// video.muted = true;
110
+
// });
111
+
//
112
+
// this.dialogService.openThread(uri, this.feed().nativeElement);
113
+
}
114
+
115
+
manageRefresh() {
116
+
if (this.loading) return;
117
+
118
+
if (!this.reloadReady && !this.reloadTimeout) {
119
+
this.reloadTimeout = setTimeout(() => {
120
+
this.reloadTimeout = undefined;
121
+
122
+
if (this.feed().nativeElement.scrollTop == 0) {
123
+
this.reloadReady = false;
124
+
from(agent.getTimeline({
125
+
limit: 1
126
+
})).subscribe({
127
+
next: response => {
128
+
const post = response.data.feed[0];
129
+
const lastPost = this.posts[0];
130
+
let isNewPost = false;
131
+
132
+
if (post) {
133
+
if (post.reason) {
134
+
const reason = post.reason as $Typed<ReasonRepost>;
135
+
if (!lastPost.reason) isNewPost = true;
136
+
if (reason.indexedAt !== (lastPost.reason as $Typed<ReasonRepost>)?.indexedAt) isNewPost = true;
137
+
} else {
138
+
if (lastPost.reason) isNewPost = true;
139
+
if (post.post.indexedAt !== lastPost.post().indexedAt) isNewPost = true;
140
+
}
141
+
}
142
+
143
+
if (isNewPost) {
144
+
this.initData();
145
+
} else {
146
+
this.manageRefresh();
147
+
}
148
+
//TODO: MessageService
149
+
}, error: err => console.log(err.message)
150
+
});
151
+
} else {
152
+
this.reloadReady = true;
153
+
}
154
+
}, 30e3);
155
+
// Timer in seconds
156
+
} else if (this.reloadReady && this.feed().nativeElement.scrollTop == 0) {
157
+
this.reloadReady = false;
158
+
this.initData();
159
+
}
160
+
}
161
+
}
+21
src/app/core/auth/auth.guard.ts
+21
src/app/core/auth/auth.guard.ts
···
1
+
import {Injectable} from '@angular/core';
2
+
import {CanActivate, Router} from '@angular/router';
3
+
import {AuthService} from './auth.service';
4
+
5
+
@Injectable({
6
+
providedIn: 'root'
7
+
})
8
+
export class AuthGuard implements CanActivate {
9
+
10
+
constructor(private auth: AuthService,
11
+
private router: Router) {}
12
+
13
+
canActivate(): boolean {
14
+
if (this.auth.isAuthenticated()) {
15
+
return true;
16
+
} else {
17
+
this.router.navigate(['login']);
18
+
return false;
19
+
}
20
+
}
21
+
}
+83
src/app/core/auth/auth.service.ts
+83
src/app/core/auth/auth.service.ts
···
1
+
import {Injectable, signal} from '@angular/core';
2
+
import {BehaviorSubject, from} from 'rxjs';
3
+
import {AppBskyActorDefs, AtpAgentLoginOpts} from "@atproto/api";
4
+
import {Router} from "@angular/router";
5
+
import {HttpErrorResponse} from "@angular/common/http";
6
+
import {StorageKeys} from '@core/storage-keys';
7
+
import {agent} from '@core/bsky.api';
8
+
import {StorageService} from '@services/storage.service';
9
+
// import {MskyMessageService} from '@services/msky-message.service';
10
+
import {ColumnService} from '@services/column.service';
11
+
12
+
@Injectable({
13
+
providedIn: 'root',
14
+
})
15
+
export class AuthService {
16
+
authenticationState = new BehaviorSubject(false);
17
+
loggedUser = signal<AppBskyActorDefs.ProfileViewDetailed>(undefined);
18
+
19
+
constructor(
20
+
private router: Router,
21
+
private storageService: StorageService,
22
+
// private messageService: MskyMessageService,
23
+
private columnService: ColumnService
24
+
) {
25
+
this.checkToken();
26
+
}
27
+
28
+
checkToken() {
29
+
const sessionData = this.storageService.get(StorageKeys.TOKEN_KEY);
30
+
if (sessionData) {
31
+
agent.resumeSession(JSON.parse(sessionData)).then(
32
+
() => {
33
+
this.storageService.set(StorageKeys.TOKEN_KEY, JSON.stringify(agent.session));
34
+
35
+
agent.getProfile({actor: agent.session.did}).then(response => {
36
+
this.loggedUser.set(response.data);
37
+
this.columnService.checkColumns();
38
+
39
+
this.authenticationState.next(true);
40
+
});
41
+
}
42
+
);
43
+
}
44
+
}
45
+
46
+
login(credentials: AtpAgentLoginOpts) {
47
+
from(agent.login(credentials)).subscribe({
48
+
next: () => {
49
+
this.storageService.set(StorageKeys.TOKEN_KEY, JSON.stringify(agent.session));
50
+
51
+
from(agent.getProfile({
52
+
actor: agent.session.did
53
+
})).subscribe({
54
+
next: response => {
55
+
this.loggedUser.set(response.data);
56
+
this.columnService.checkColumns();
57
+
58
+
this.authenticationState.next(true);
59
+
this.router.navigate(['']);
60
+
//TODO: MessageService
61
+
}, error: err => console.log(err.message)
62
+
});
63
+
},
64
+
error: (err: HttpErrorResponse) => {
65
+
//TODO: MessageService
66
+
// this.messageService.warn(err.message, 'Oops!');
67
+
}
68
+
});
69
+
}
70
+
71
+
logout() {
72
+
agent.logout().then(
73
+
() => {
74
+
this.storageService.remove(StorageKeys.TOKEN_KEY);
75
+
this.authenticationState.next(false);
76
+
}
77
+
);
78
+
}
79
+
80
+
isAuthenticated() {
81
+
return this.authenticationState.value;
82
+
}
83
+
}
+5
src/app/core/bsky.api.ts
+5
src/app/core/bsky.api.ts
+5
src/app/core/storage-keys.ts
+5
src/app/core/storage-keys.ts
+57
src/app/models/deck-column.ts
+57
src/app/models/deck-column.ts
···
1
+
import * as uuid from "uuid";
2
+
3
+
export class DeckColumn {
4
+
uuid: string = uuid.v4();
5
+
title: string = '';
6
+
width: number = 400;
7
+
index: number;
8
+
}
9
+
10
+
export class TimelineDeckColumn extends DeckColumn {
11
+
type: DeckColumnType.TIMELINE = DeckColumnType.TIMELINE;
12
+
}
13
+
14
+
export class NotificationDeckColumn extends DeckColumn {
15
+
type: DeckColumnType.NOTIFICATION = DeckColumnType.NOTIFICATION;
16
+
}
17
+
18
+
export class AuthorDeckColumn extends DeckColumn {
19
+
type: DeckColumnType.AUTHOR = DeckColumnType.AUTHOR;
20
+
did: string;
21
+
handle: string;
22
+
displayName: string;
23
+
mode: AuthorDeckColumnMode = AuthorDeckColumnMode.POSTS;
24
+
}
25
+
26
+
export class ListDeckColumn extends DeckColumn {
27
+
type: DeckColumnType.LIST = DeckColumnType.LIST;
28
+
did: string;
29
+
}
30
+
31
+
export class GeneratorDeckColumn extends DeckColumn {
32
+
type: DeckColumnType.GENERATOR = DeckColumnType.GENERATOR;
33
+
uri: string;
34
+
avatar: string;
35
+
}
36
+
37
+
export class SearchDeckColumn extends DeckColumn {
38
+
type: DeckColumnType.SEARCH = DeckColumnType.SEARCH;
39
+
query: string;
40
+
sort: 'top' | 'latest';
41
+
}
42
+
43
+
export enum DeckColumnType {
44
+
TIMELINE = 'TIMELINE',
45
+
NOTIFICATION = 'NOTIFICATION',
46
+
AUTHOR = 'AUTHOR',
47
+
LIST = 'LIST',
48
+
GENERATOR = 'GENERATOR',
49
+
SEARCH = 'SEARCH'
50
+
}
51
+
52
+
export enum AuthorDeckColumnMode {
53
+
POSTS = 'posts_no_replies',
54
+
REPLIES = 'posts_with_replies',
55
+
MEDIA = 'posts_with_media',
56
+
VIDEO = 'posts_with_video'
57
+
}
+67
src/app/models/embed.ts
+67
src/app/models/embed.ts
···
1
+
import {BlueskyGifSnippet, IframeSnippet, LinkSnippet} from '@models/snippet';
2
+
import {UrlMetadata} from '@models/url-metadata';
3
+
4
+
5
+
export const enum EmbedType {
6
+
IMAGE = 'IMAGE',
7
+
VIDEO = 'VIDEO',
8
+
EXTERNAL = 'EXTERNAL',
9
+
RECORD = 'RECORD',
10
+
}
11
+
export const enum RecordEmbedType {
12
+
POST = 'POST',
13
+
FEED = 'FEED',
14
+
LIST = 'LIST',
15
+
STARTER_PACK = 'STARTER_PACK',
16
+
}
17
+
18
+
interface Embed {
19
+
type: EmbedType.IMAGE | EmbedType.VIDEO | EmbedType.EXTERNAL | EmbedType.RECORD;
20
+
}
21
+
22
+
export class ImageEmbed implements Embed {
23
+
type: EmbedType.IMAGE = EmbedType.IMAGE;
24
+
/** Image array */
25
+
images: {data: string, alt: string}[] = [];
26
+
}
27
+
28
+
export class VideoEmbed implements Embed {
29
+
type: EmbedType.VIDEO = EmbedType.VIDEO;
30
+
/** Video file */
31
+
file: File;
32
+
thumbnail: string;
33
+
34
+
constructor(file: File, thumbnail: string) {
35
+
this.file = file;
36
+
this.thumbnail = thumbnail;
37
+
}
38
+
}
39
+
40
+
export class ExternalEmbed implements Embed {
41
+
type: EmbedType.EXTERNAL = EmbedType.EXTERNAL;
42
+
/** Url */
43
+
url: string;
44
+
/** Extended info */
45
+
metadata: UrlMetadata;
46
+
/** Extended info */
47
+
snippet: LinkSnippet | BlueskyGifSnippet | IframeSnippet;
48
+
49
+
constructor(url: string) {
50
+
this.url = url;
51
+
}
52
+
}
53
+
54
+
export class RecordEmbed implements Embed {
55
+
type: EmbedType.RECORD = EmbedType.RECORD;
56
+
/** Record type */
57
+
recordType: RecordEmbedType;
58
+
/** Record info */
59
+
author: string;
60
+
rkey: string;
61
+
62
+
constructor(recordType: RecordEmbedType, author: string, rkey: string) {
63
+
this.recordType = recordType;
64
+
this.author = author;
65
+
this.rkey = rkey;
66
+
}
67
+
}
+18
src/app/models/notification.ts
+18
src/app/models/notification.ts
···
1
+
import {AppBskyActorDefs, AppBskyFeedDefs, AppBskyNotificationListNotifications} from "@atproto/api";
2
+
import * as uuid from "uuid";
3
+
import {WritableSignal} from '@angular/core';
4
+
5
+
export class Notification {
6
+
/** Notification list object */
7
+
notification: AppBskyNotificationListNotifications.Notification;
8
+
/** Notification reason */
9
+
reason: "like" | "repost" | "follow" | "mention" | "reply" | "quote" | "starterpack-joined" | string;
10
+
/** Authors' profile */
11
+
authors: AppBskyActorDefs.ProfileView[] = [];
12
+
/** Record URI */
13
+
uri?: string;
14
+
/** Record */
15
+
post?: WritableSignal<AppBskyFeedDefs.PostView>;
16
+
/** Uuid */
17
+
uuid: string = uuid.v4();
18
+
}
+23
src/app/models/post-compose.ts
+23
src/app/models/post-compose.ts
···
1
+
import {signal, WritableSignal} from "@angular/core";
2
+
import {$Typed, AppBskyEmbedRecord, AppBskyFeedDefs, AppBskyFeedPost, AppBskyGraphDefs} from "@atproto/api";
3
+
import {ExternalEmbed, ImageEmbed, VideoEmbed} from "@models/embed";
4
+
5
+
type Record = AppBskyFeedPost.Record;
6
+
7
+
export class PostCompose {
8
+
post: WritableSignal<AppBskyFeedPost.Record> = signal(undefined);
9
+
reply: WritableSignal<AppBskyFeedDefs.PostView> = signal(undefined);
10
+
recordEmbed: WritableSignal<$Typed<AppBskyEmbedRecord.ViewRecord> | $Typed<AppBskyFeedDefs.GeneratorView> | $Typed<AppBskyGraphDefs.ListView> | $Typed<AppBskyGraphDefs.StarterPackView>> = signal(undefined);
11
+
mediaEmbed: WritableSignal<ImageEmbed | VideoEmbed | ExternalEmbed> = signal(undefined);
12
+
13
+
constructor() {
14
+
this.post.set({
15
+
$type: 'app.bsky.feed.post',
16
+
text: '',
17
+
facets: [],
18
+
createdAt: '',
19
+
langs: [],
20
+
tags: [],
21
+
} as Record);
22
+
}
23
+
}
+12
src/app/models/signalized-feed-view-post.ts
+12
src/app/models/signalized-feed-view-post.ts
···
1
+
import {AppBskyFeedDefs} from "@atproto/api";
2
+
import {WritableSignal} from "@angular/core";
3
+
import * as uuid from "uuid";
4
+
5
+
export class SignalizedFeedViewPost {
6
+
[k: string]: unknown;
7
+
post: WritableSignal<AppBskyFeedDefs.PostView>;
8
+
reply?: AppBskyFeedDefs.ReplyRef | undefined;
9
+
reason?: AppBskyFeedDefs.ReasonRepost | AppBskyFeedDefs.ReasonPin | { [k: string]: unknown; $type: string; } | undefined;
10
+
feedContext?: string | undefined;
11
+
uuid: string = uuid.v4();
12
+
}
+72
src/app/models/snippet.ts
+72
src/app/models/snippet.ts
···
1
+
export const enum SnippetType {
2
+
LINK= 'LINK',
3
+
BLUESKY_GIF = 'BLUESKY_GIF',
4
+
IFRAME = 'IFRAME',
5
+
}
6
+
7
+
export const enum SnippetSource {
8
+
YOUTUBE = 'YOUTUBE',
9
+
SOUNDCLOUD = 'SOUNDCLOUD',
10
+
}
11
+
12
+
interface Snippet {
13
+
type: SnippetType.LINK | SnippetType.BLUESKY_GIF | SnippetType.IFRAME;
14
+
/** Domain name */
15
+
domain: string;
16
+
/** URL */
17
+
url: string;
18
+
}
19
+
20
+
export class LinkSnippet implements Snippet {
21
+
type: SnippetType.LINK = SnippetType.LINK;
22
+
/** Domain name */
23
+
domain: string;
24
+
/** URL */
25
+
url: string;
26
+
27
+
constructor(domain: string, url: string) {
28
+
this.domain = domain;
29
+
this.url = url;
30
+
}
31
+
}
32
+
33
+
export class BlueskyGifSnippet implements Snippet {
34
+
type: SnippetType.BLUESKY_GIF = SnippetType.BLUESKY_GIF;
35
+
/** Domain name */
36
+
domain: string;
37
+
/** Video URL */
38
+
url: string;
39
+
/** Aspect ratio */
40
+
ratio: string;
41
+
/** Alt text description */
42
+
description: string;
43
+
44
+
constructor(domain: string, url: string, ratio: string, description: string) {
45
+
this.domain = domain;
46
+
this.url = url;
47
+
this.ratio = ratio;
48
+
this.description = description;
49
+
}
50
+
}
51
+
52
+
export class IframeSnippet implements Snippet {
53
+
type: SnippetType.IFRAME = SnippetType.IFRAME;
54
+
/** Source domain */
55
+
domain: string;
56
+
/** Iframe URL or Youtube ID */
57
+
url: string;
58
+
/** Aspect ratio */
59
+
ratio: string;
60
+
/** Source type */
61
+
source: SnippetSource;
62
+
/** The second is supposed to start at in a Youtube video */
63
+
seek: number;
64
+
65
+
constructor(domain: string, url: string, ratio: string, source: SnippetSource, seek?: number) {
66
+
this.domain = domain;
67
+
this.url = url;
68
+
this.ratio = ratio;
69
+
this.source = source;
70
+
this.seek = seek;
71
+
}
72
+
}
+14
src/app/models/thread-reply.ts
+14
src/app/models/thread-reply.ts
···
1
+
import {SignalizedFeedViewPost} from "@models/signalized-feed-view-post";
2
+
import * as uuid from "uuid";
3
+
import {AppBskyFeedDefs} from "@atproto/api";
4
+
5
+
export class ThreadReply {
6
+
post: SignalizedFeedViewPost;
7
+
replies: Array<ThreadReply | AppBskyFeedDefs.NotFoundPost | AppBskyFeedDefs.BlockedPost>;
8
+
/** Uuid */
9
+
uuid: string = uuid.v4();
10
+
11
+
constructor(post: SignalizedFeedViewPost) {
12
+
this.post = post;
13
+
}
14
+
}
+8
src/app/models/url-metadata.ts
+8
src/app/models/url-metadata.ts
+111
src/app/services/column.service.ts
+111
src/app/services/column.service.ts
···
1
+
import {StorageKeys} from "@core/storage-keys";
2
+
import {
3
+
AuthorDeckColumn,
4
+
DeckColumn,
5
+
GeneratorDeckColumn,
6
+
NotificationDeckColumn,
7
+
SearchDeckColumn,
8
+
TimelineDeckColumn
9
+
} from "@models/deck-column";
10
+
import {Injectable, signal, WritableSignal} from "@angular/core";
11
+
import * as uuid from "uuid";
12
+
13
+
const columns: WritableSignal<Partial<DeckColumn>[]> = signal([]);
14
+
15
+
@Injectable({
16
+
providedIn: 'root'
17
+
})
18
+
export class ColumnService {
19
+
public addColumn(column: Partial<DeckColumn>) {
20
+
columns.update(columns => [...columns, column]);
21
+
this.saveColumns();
22
+
}
23
+
24
+
public updateColumn(column: Partial<DeckColumn>) {
25
+
columns.update(columns => {
26
+
columns[columns.findIndex(col => col.uuid == column.uuid)] = column;
27
+
return columns;
28
+
});
29
+
this.saveColumns();
30
+
}
31
+
32
+
public deleteColumn(uuid: string) {
33
+
columns.update(columns => {
34
+
columns.splice(columns.findIndex(col => col.uuid == uuid), 1);
35
+
columns.forEach((column, index) => column.index = index);
36
+
return columns;
37
+
});
38
+
this.saveColumns();
39
+
}
40
+
41
+
public getColumns(): WritableSignal<Partial<DeckColumn>[]> {
42
+
return columns;
43
+
}
44
+
45
+
public saveColumns() {
46
+
localStorage.setItem(StorageKeys.DECK_COLUMNS, JSON.stringify(columns()));
47
+
}
48
+
49
+
public checkColumns() {
50
+
let storageColumns: Partial<DeckColumn>[] = JSON.parse(localStorage.getItem(StorageKeys.DECK_COLUMNS));
51
+
52
+
if (!storageColumns || !storageColumns.length) {
53
+
this.initColumns();
54
+
return;
55
+
}
56
+
57
+
storageColumns.forEach((column: any) => {
58
+
if (!column.width) column.width = 400;
59
+
if (!column.uuid) column.uuid = uuid.v4();
60
+
});
61
+
62
+
localStorage.setItem(StorageKeys.DECK_COLUMNS, JSON.stringify(storageColumns));
63
+
columns.set(storageColumns);
64
+
}
65
+
66
+
public initColumns() {
67
+
this.createTimelineColumn();
68
+
this.createNotificationsColumn();
69
+
this.createAuthorColumn(JSON.parse(localStorage.getItem(StorageKeys.LOGGED_USER)));
70
+
}
71
+
72
+
public createTimelineColumn() {
73
+
let column = new TimelineDeckColumn();
74
+
column.title = 'Home';
75
+
column.index = columns().length;
76
+
this.addColumn(column);
77
+
}
78
+
79
+
public createNotificationsColumn() {
80
+
let column = new NotificationDeckColumn();
81
+
column.title = 'Notifications';
82
+
column.index = columns().length;
83
+
this.addColumn(column);
84
+
}
85
+
86
+
public createAuthorColumn(author: Partial<{did: string, handle: string, displayName: string}>) {
87
+
let column = new AuthorDeckColumn();
88
+
column.did = author.did;
89
+
column.handle = author.handle;
90
+
column.displayName = author.displayName;
91
+
column.index = columns().length;
92
+
this.addColumn(column);
93
+
}
94
+
95
+
public createGeneratorColumn(generator: Partial<{uri: string, avatar: string, displayName: string}>) {
96
+
let column = new GeneratorDeckColumn();
97
+
column.title = generator.displayName;
98
+
column.uri = generator.uri;
99
+
column.avatar = generator.avatar;
100
+
column.index = columns().length;
101
+
this.addColumn(column);
102
+
}
103
+
104
+
public createSearchColumn(query: string, sort: 'top' | 'latest') {
105
+
let column = new SearchDeckColumn();
106
+
column.query = query;
107
+
column.sort = sort;
108
+
column.index = columns().length;
109
+
this.addColumn(column);
110
+
}
111
+
}
+33
src/app/services/embed.service.ts
+33
src/app/services/embed.service.ts
···
1
+
import {Injectable} from '@angular/core';
2
+
import {HttpClient} from "@angular/common/http";
3
+
import {map, Observable} from "rxjs";
4
+
import {UrlMetadata} from "@models/url-metadata";
5
+
6
+
@Injectable({
7
+
providedIn: 'root'
8
+
})
9
+
export class EmbedService {
10
+
constructor(
11
+
private httpClient: HttpClient
12
+
) {}
13
+
14
+
getUrlMetadata(url: string): Observable<UrlMetadata> {
15
+
if (!url.startsWith('https://') && !url.startsWith('http://')) {
16
+
url = 'https://' + url;
17
+
}
18
+
19
+
return this.httpClient.get(`https://cardyb.bsky.app/v1/extract?url=${url}`)
20
+
.pipe(
21
+
map((res: any) => {
22
+
const metadata = new UrlMetadata();
23
+
metadata.url = res.url;
24
+
metadata.title = res.title;
25
+
metadata.description = res.description;
26
+
metadata.imageUrl = res.image;
27
+
metadata.likelyType = res.likely_type;
28
+
metadata.error = res.error;
29
+
return metadata;
30
+
})
31
+
);
32
+
}
33
+
}
+440
src/app/services/post.service.ts
+440
src/app/services/post.service.ts
···
1
+
import {Injectable, signal, WritableSignal} from "@angular/core";
2
+
import {
3
+
$Typed,
4
+
AppBskyEmbedExternal,
5
+
AppBskyEmbedImages,
6
+
AppBskyEmbedRecord,
7
+
AppBskyEmbedRecordWithMedia,
8
+
AppBskyEmbedVideo,
9
+
AppBskyFeedDefs,
10
+
RichText
11
+
} from "@atproto/api";
12
+
import {from, Subject} from "rxjs";
13
+
import {EmbedType, ExternalEmbed, ImageEmbed, VideoEmbed} from "@models/embed";
14
+
import {DOC_ORIENTATION, NgxImageCompressService} from "ngx-image-compress";
15
+
import {HttpErrorResponse} from "@angular/common/http";
16
+
import {PostCompose} from '@models/post-compose';
17
+
import {agent} from '@core/bsky.api';
18
+
19
+
export const posts: Map<string, WritableSignal<AppBskyFeedDefs.PostView>> =
20
+
new Map<string, WritableSignal<AppBskyFeedDefs.PostView>>();
21
+
22
+
@Injectable({
23
+
providedIn: 'root'
24
+
})
25
+
export class PostService {
26
+
public postCompose: WritableSignal<PostCompose> = signal(undefined);
27
+
public refreshFeeds: Subject<void> = new Subject<void>();
28
+
29
+
constructor(
30
+
// private dialogService: MskyDialogService,
31
+
private imageCompressService: NgxImageCompressService
32
+
) {}
33
+
34
+
setPost(post: AppBskyFeedDefs.PostView): WritableSignal<AppBskyFeedDefs.PostView> {
35
+
const existingPost = posts.get(post.uri);
36
+
if (existingPost) {
37
+
existingPost.set(post);
38
+
return existingPost;
39
+
} else {
40
+
const newPost = signal(post);
41
+
posts.set(post.uri, newPost);
42
+
return newPost;
43
+
}
44
+
}
45
+
46
+
getPost(uri: string): WritableSignal<AppBskyFeedDefs.PostView> | undefined {
47
+
return posts.get(uri);
48
+
}
49
+
50
+
createPost() {
51
+
if (this.postCompose()) return;
52
+
53
+
this.postCompose.set(new PostCompose());
54
+
// this.dialogService.openPostComposer(this.postCompose);
55
+
}
56
+
57
+
like(post: WritableSignal<AppBskyFeedDefs.PostView>): Promise<void> {
58
+
return new Promise<void>((resolve, reject) => {
59
+
// Update UI
60
+
post.update(post => {
61
+
post.viewer.like = 'placeholder';
62
+
return post;
63
+
});
64
+
65
+
// API call (delayed to not step over placeholder change)
66
+
from(agent.like(post().uri, post().cid)).subscribe({
67
+
next: () => {
68
+
setTimeout(() => {
69
+
from(agent.getPosts({
70
+
uris: [post().uri]
71
+
})).subscribe({
72
+
next: response => {
73
+
post.set(response.data.posts[0]);
74
+
resolve();
75
+
},
76
+
error: err => reject(err)
77
+
});
78
+
}, 100);
79
+
},
80
+
error: err => {
81
+
post.update(post => {
82
+
post.viewer.like = undefined;
83
+
return post;
84
+
});
85
+
reject(err);
86
+
}
87
+
});
88
+
});
89
+
}
90
+
91
+
deleteLike(post: WritableSignal<AppBskyFeedDefs.PostView>): Promise<void> {
92
+
return new Promise<void>((resolve, reject) => {
93
+
// Update UI
94
+
const likeRef = post().viewer.like;
95
+
post.update(post => {
96
+
post.viewer.like = undefined;
97
+
return post;
98
+
});
99
+
100
+
// API call (delayed to not step over placeholder change)
101
+
from(agent.deleteLike(likeRef)).subscribe({
102
+
next: () => {
103
+
setTimeout(() => {
104
+
from(agent.getPosts({
105
+
uris: [post().uri]
106
+
})).subscribe({
107
+
next: response => {
108
+
post.set(response.data.posts[0]);
109
+
resolve();
110
+
},
111
+
error: err => reject(err)
112
+
});
113
+
}, 200);
114
+
},
115
+
error: err => {
116
+
post.update(post => {
117
+
post.viewer.like = likeRef;
118
+
return post;
119
+
});
120
+
reject(err);
121
+
}
122
+
});
123
+
});
124
+
}
125
+
126
+
repost(post: WritableSignal<AppBskyFeedDefs.PostView>): Promise<void> {
127
+
return new Promise<void>((resolve, reject) => {
128
+
// Update UI
129
+
post.update(post => {
130
+
post.viewer.repost = 'placeholder';
131
+
return post;
132
+
});
133
+
134
+
// API call (delayed to not step over placeholder change)
135
+
from(agent.repost(post().uri, post().cid)).subscribe({
136
+
next: () => {
137
+
setTimeout(() => {
138
+
from(agent.getPosts({
139
+
uris: [post().uri]
140
+
})).subscribe({
141
+
next: response => {
142
+
post.set(response.data.posts[0]);
143
+
resolve();
144
+
},
145
+
error: err => reject(err)
146
+
});
147
+
}, 100);
148
+
},
149
+
error: err => {
150
+
post.update(post => {
151
+
post.viewer.repost = undefined;
152
+
return post;
153
+
});
154
+
reject(err);
155
+
}
156
+
});
157
+
});
158
+
}
159
+
160
+
deleteRepost(post: WritableSignal<AppBskyFeedDefs.PostView>): Promise<void> {
161
+
return new Promise<void>((resolve, reject) => {
162
+
// Update UI
163
+
const rtRef = post().viewer.repost;
164
+
post.update(post => {
165
+
post.viewer.repost = undefined;
166
+
return post;
167
+
});
168
+
169
+
// API call (delayed to not step over placeholder change)
170
+
from(agent.deleteRepost(rtRef)).subscribe({
171
+
next: () => {
172
+
setTimeout(() => {
173
+
from(agent.getPosts({
174
+
uris: [post().uri]
175
+
})).subscribe({
176
+
next: response => {
177
+
post.set(response.data.posts[0]);
178
+
resolve();
179
+
},
180
+
error: err => reject(err)
181
+
});
182
+
}, 200);
183
+
},
184
+
error: err => {
185
+
post.update(post => {
186
+
post.viewer.repost = rtRef;
187
+
return post;
188
+
});
189
+
reject(err);
190
+
}
191
+
});
192
+
});
193
+
}
194
+
195
+
refreshRepost(post: WritableSignal<AppBskyFeedDefs.PostView>): Promise<void> {
196
+
return new Promise<void>((resolve, reject) => {
197
+
from(agent.deleteRepost(post().viewer.repost)).subscribe({
198
+
next: () => this.repost(post).then(() => resolve()).catch(err => reject(err)),
199
+
error: err => reject(err)
200
+
});
201
+
});
202
+
}
203
+
204
+
replyPost(uri: string) {
205
+
if (!this.postCompose()) this.createPost();
206
+
207
+
agent.getPostThread({
208
+
uri: uri
209
+
}).then(response => {
210
+
if (!AppBskyFeedDefs.isThreadViewPost(response.data.thread)) return;
211
+
this.setPost(response.data.thread.post as AppBskyFeedDefs.PostView);
212
+
213
+
let root;
214
+
if (AppBskyFeedDefs.isThreadViewPost(response.data.thread.parent)) {
215
+
root = response.data.thread.parent;
216
+
217
+
while (AppBskyFeedDefs.isThreadViewPost(root.parent)) {
218
+
root = root.parent;
219
+
}
220
+
221
+
root = root.post;
222
+
} else {
223
+
root = response.data.thread.post;
224
+
}
225
+
226
+
let replyRef = {
227
+
parent: {
228
+
uri: (response.data.thread.post).uri,
229
+
cid: (response.data.thread.post).cid
230
+
},
231
+
root: {
232
+
uri: root.uri,
233
+
cid: root.cid
234
+
},
235
+
};
236
+
237
+
this.postCompose().post.update(post => {
238
+
post.reply = replyRef;
239
+
return post;
240
+
});
241
+
242
+
this.postCompose().reply.set(response.data.thread.post);
243
+
});
244
+
}
245
+
246
+
quotePost(uri:string) {
247
+
if (!this.postCompose()) this.createPost();
248
+
249
+
agent.getPosts({
250
+
uris: [uri]
251
+
}).then(response => {
252
+
if (!response.data.posts[0]) return;
253
+
const quotedPost = this.setPost(response.data.posts[0]);
254
+
255
+
if (!this.postCompose()) this.createPost();
256
+
this.postCompose().recordEmbed.set({
257
+
$type: 'app.bsky.embed.record#viewRecord',
258
+
uri: quotedPost().uri,
259
+
cid: quotedPost().cid,
260
+
author: quotedPost().author,
261
+
indexedAt: quotedPost().indexedAt,
262
+
value: quotedPost().record,
263
+
embeds: [quotedPost().embed]
264
+
} as $Typed<AppBskyEmbedRecord.ViewRecord>);
265
+
});
266
+
}
267
+
268
+
attachMedia(files: File[]) {
269
+
if (!files.length) return;
270
+
if (!this.postCompose()) this.createPost();
271
+
272
+
//Fix array methods because it comes as FileList
273
+
files = Array.from(files);
274
+
275
+
if (files.some(f => f.type.includes('image'))) {
276
+
//Filelist has images
277
+
if (!this.postCompose().mediaEmbed()) {
278
+
this.postCompose().mediaEmbed.set(new ImageEmbed());
279
+
}
280
+
if (this.postCompose().mediaEmbed().type == EmbedType.IMAGE) {
281
+
const imageEmbed = this.postCompose().mediaEmbed as WritableSignal<ImageEmbed>;
282
+
283
+
//Our embed list is for images
284
+
files.forEach(file => {
285
+
if (file.type.includes('image') && imageEmbed().images.length < 4) {
286
+
const reader = new FileReader();
287
+
reader.onload = (event: any) => {
288
+
const newEmbed = new ImageEmbed();
289
+
newEmbed.images = [...imageEmbed().images, {data: event.srcElement.result, alt: ''}];
290
+
imageEmbed.set(newEmbed);
291
+
};
292
+
reader.readAsDataURL(file);
293
+
}
294
+
})
295
+
}
296
+
} else if (files.some(f => f.type.includes('video'))) {
297
+
const videoEmbed = this.postCompose().mediaEmbed as WritableSignal<VideoEmbed>;
298
+
299
+
//Filelist has video
300
+
while (!videoEmbed()) {
301
+
files.forEach(file => {
302
+
if (file.type.includes('video')) {
303
+
videoEmbed.set(new VideoEmbed(file, undefined));
304
+
}
305
+
});
306
+
}
307
+
}
308
+
}
309
+
310
+
publishPost(): Promise<void> {
311
+
return new Promise((resolve, reject) => {
312
+
const rt = new RichText({
313
+
text: this.postCompose().post().text
314
+
});
315
+
316
+
Promise.all([
317
+
this.prepareRecord(),
318
+
this.prepareMedia(),
319
+
rt.detectFacets(agent)
320
+
]).then(([record, media]) => {
321
+
if (record && media) {
322
+
this.postCompose().post().embed = {
323
+
$type: 'app.bsky.embed.recordWithMedia',
324
+
record: record,
325
+
media: media
326
+
} as $Typed<AppBskyEmbedRecordWithMedia.Main>;
327
+
} else {
328
+
this.postCompose().post().embed = record ?? media;
329
+
}
330
+
this.postCompose().post.update(post => {
331
+
post.text = rt.text;
332
+
post.facets = rt.facets;
333
+
post.createdAt = new Date().toISOString();
334
+
return post;
335
+
});
336
+
337
+
from(agent.post(this.postCompose().post())).subscribe({
338
+
next: () => {
339
+
this.postCompose.set(undefined);
340
+
341
+
setTimeout(() => {
342
+
this.refreshFeeds.next();
343
+
}, 1e3);
344
+
345
+
resolve();
346
+
},
347
+
error: (err: HttpErrorResponse) => {
348
+
reject(err);
349
+
}
350
+
});
351
+
});
352
+
});
353
+
}
354
+
355
+
private prepareRecord(): Promise<$Typed<AppBskyEmbedRecord.Main>> {
356
+
return new Promise(resolve => {
357
+
if (!this.postCompose().recordEmbed()) {
358
+
resolve(undefined)
359
+
} else {
360
+
resolve({
361
+
$type: 'app.bsky.embed.record',
362
+
record: {
363
+
uri: this.postCompose().recordEmbed().uri,
364
+
cid: this.postCompose().recordEmbed().cid
365
+
}
366
+
});
367
+
}
368
+
});
369
+
}
370
+
371
+
private prepareMedia(): Promise<$Typed<AppBskyEmbedImages.Main> | $Typed<AppBskyEmbedVideo.Main> | $Typed<AppBskyEmbedExternal.Main>> {
372
+
return new Promise((resolve, reject) => {
373
+
if (!this.postCompose().mediaEmbed()) resolve(undefined);
374
+
375
+
if (this.postCompose().mediaEmbed()?.type == EmbedType.IMAGE) {
376
+
const imageEmbed = this.postCompose().mediaEmbed as WritableSignal<ImageEmbed>;
377
+
378
+
from(Promise.all(
379
+
imageEmbed().images.map(i => {
380
+
return this.imageCompressService.compressFile(i.data, DOC_ORIENTATION.Default, undefined, undefined, 2000, 2000);
381
+
})
382
+
)).subscribe({
383
+
next: images64 => {
384
+
from(
385
+
Promise.all(images64.map(image => fetch(image).then(res => res.blob())))
386
+
).subscribe({
387
+
next: blobs => {
388
+
from(
389
+
Promise.all(blobs.map(b => agent.uploadBlob(b)))
390
+
).subscribe({
391
+
next: upload => {
392
+
resolve({
393
+
$type: 'app.bsky.embed.images',
394
+
images: upload.map(response => {
395
+
return {
396
+
alt: '',
397
+
image: response.data.blob
398
+
}
399
+
})
400
+
} as $Typed<AppBskyEmbedImages.Main>);
401
+
},
402
+
error: err => reject(err)
403
+
})
404
+
},
405
+
error: err => reject(err)
406
+
})
407
+
},
408
+
error: err => reject(err)
409
+
});
410
+
}
411
+
412
+
if (this.postCompose().mediaEmbed()?.type == EmbedType.VIDEO) {
413
+
// const videoEmbed = this.postCompose().mediaEmbed as WritableSignal<VideoEmbed>;
414
+
resolve(undefined);
415
+
}
416
+
417
+
if (this.postCompose().mediaEmbed().type == EmbedType.EXTERNAL) {
418
+
const externalEmbed = this.postCompose().mediaEmbed as WritableSignal<ExternalEmbed>;
419
+
420
+
from(
421
+
fetch(externalEmbed().metadata.imageUrl)
422
+
.then(res => res.blob())
423
+
.then(blob => agent.uploadBlob(blob))
424
+
).subscribe({
425
+
next: response => {
426
+
resolve({
427
+
$type: 'app.bsky.embed.external',
428
+
external: {
429
+
uri: externalEmbed().metadata.url,
430
+
title: externalEmbed().metadata.title,
431
+
description: externalEmbed().metadata.description,
432
+
thumb: response.data.blob
433
+
}
434
+
} as $Typed<AppBskyEmbedExternal.Main>)
435
+
}, error: err => reject(err)
436
+
})
437
+
}
438
+
});
439
+
}
440
+
}
+29
src/app/services/storage.service.ts
+29
src/app/services/storage.service.ts
···
1
+
import {inject, Injectable, PLATFORM_ID} from '@angular/core';
2
+
import {isPlatformBrowser} from '@angular/common';
3
+
4
+
@Injectable({
5
+
providedIn: 'root'
6
+
})
7
+
export class StorageService {
8
+
private readonly platformId = inject(PLATFORM_ID);
9
+
10
+
set(id: string, item: string) {
11
+
if (isPlatformBrowser(this.platformId)) {
12
+
localStorage.setItem(id, item);
13
+
}
14
+
}
15
+
16
+
get(id: string): string | null {
17
+
if (isPlatformBrowser(this.platformId)) {
18
+
return localStorage.getItem(id);
19
+
} else {
20
+
return null;
21
+
}
22
+
}
23
+
24
+
remove(id: string) {
25
+
if (isPlatformBrowser(this.platformId)) {
26
+
localStorage.removeItem(id);
27
+
}
28
+
}
29
+
}
+33
src/app/views/dashboard/dashboard.component.html
+33
src/app/views/dashboard/dashboard.component.html
···
1
+
<div
2
+
class="flex h-full w-full p-4 overflow-hidden"
3
+
>
4
+
<sidebar/>
5
+
<!-- <sidebar-->
6
+
<!-- (onProfile)="dialogService.openAuthor($event)"-->
7
+
<!-- (onSearch)="dialogService.openSearch()"-->
8
+
<!-- (onFeeds)="dialogService.openLikedFeeds()"-->
9
+
<!-- (onPost)="postService.createPost()"-->
10
+
<!-- />-->
11
+
12
+
<!-- <div-->
13
+
<!-- class="relative flex-1 overflow-hidden"-->
14
+
<!-- >-->
15
+
<!-- <deck-->
16
+
<!-- class="relative flex w-full h-full max-h-full min-h-0 p-4 overflow-x-auto"-->
17
+
<!-- >-->
18
+
<!-- </deck>-->
19
+
<!-- </div>-->
20
+
<div
21
+
class="flex flex-col flex-1 min-w-0"
22
+
>
23
+
<deck
24
+
class="flex-1 min-h-0 min-w-0 border-b border-primary"
25
+
/>
26
+
@if (postService.postCompose()) {
27
+
<post-composer
28
+
class="shrink-0"
29
+
/>
30
+
}
31
+
</div>
32
+
<auxbar/>
33
+
</div>
+26
src/app/views/dashboard/dashboard.component.ts
+26
src/app/views/dashboard/dashboard.component.ts
···
1
+
import {ChangeDetectionStrategy, Component} from '@angular/core';
2
+
import {SidebarComponent} from '@components/navigation/sidebar/sidebar.component';
3
+
import {DeckComponent} from '@components/navigation/deck/deck.component';
4
+
import {PostComposerComponent} from '@components/navigation/post-composer/post-composer.component';
5
+
import {PostService} from '@services/post.service';
6
+
import {AuxbarComponent} from '@components/navigation/auxbar/auxbar.component';
7
+
// import {MskyDialogService} from '@services/msky-dialog.service';
8
+
// import {PostService} from '@services/post.service';
9
+
10
+
@Component({
11
+
selector: 'app-dashboard',
12
+
imports: [
13
+
SidebarComponent,
14
+
DeckComponent,
15
+
PostComposerComponent,
16
+
AuxbarComponent
17
+
],
18
+
templateUrl: './dashboard.component.html',
19
+
changeDetection: ChangeDetectionStrategy.OnPush
20
+
})
21
+
export class DashboardComponent {
22
+
constructor(
23
+
// protected dialogService: MskyDialogService,
24
+
protected postService: PostService
25
+
) {}
26
+
}
+46
src/app/views/login/login.component.html
+46
src/app/views/login/login.component.html
···
1
+
<div
2
+
class="h-full w-full flex flex-col items-center justify-center"
3
+
>
4
+
<div
5
+
class="w-80 border flex-col"
6
+
>
7
+
<div
8
+
class="h-12 bg-primary flex items-center justify-center"
9
+
>
10
+
<span
11
+
class="text-bg font-bold text-2xl"
12
+
>//consolesky.</span>
13
+
</div>
14
+
15
+
<div
16
+
class="p-2 flex flex-col font-mono"
17
+
>
18
+
<span
19
+
class="text-sm"
20
+
>handle</span>
21
+
<input
22
+
[(ngModel)]="credentials().identifier"
23
+
(keydown.enter)="loginBtn.click()"
24
+
name="handle"
25
+
class="border outline-none px-1"
26
+
/>
27
+
28
+
<span
29
+
class="text-sm mt-2"
30
+
>password</span>
31
+
<input
32
+
[(ngModel)]="credentials().password"
33
+
(keydown.enter)="loginBtn.click()"
34
+
name="password"
35
+
type="password"
36
+
class="border outline-none px-1"
37
+
/>
38
+
39
+
<button
40
+
#loginBtn
41
+
(click)="onLogin()"
42
+
class="btn-primary mt-2 ml-auto px-2 py-0.5"
43
+
>Login</button>
44
+
</div>
45
+
</div>
46
+
</div>
+35
src/app/views/login/login.component.ts
+35
src/app/views/login/login.component.ts
···
1
+
import {Component, signal} from '@angular/core';
2
+
import {AuthService} from '@core/auth/auth.service';
3
+
// import {MskyMessageService} from '@services/msky-message.service';
4
+
import {FormsModule} from '@angular/forms';
5
+
6
+
@Component({
7
+
selector: 'app-login',
8
+
imports: [
9
+
FormsModule
10
+
],
11
+
templateUrl: './login.component.html'
12
+
})
13
+
export class LoginComponent {
14
+
credentials = signal({
15
+
identifier: '',
16
+
password: ''
17
+
});
18
+
19
+
constructor(
20
+
private authService: AuthService,
21
+
// private messageService: MskyMessageService
22
+
) {}
23
+
24
+
onLogin() {
25
+
this.credentials().identifier = this.credentials().identifier.trim();
26
+
27
+
if (
28
+
this.credentials().identifier.length
29
+
) {
30
+
this.authService.login(this.credentials());
31
+
} else {
32
+
// this.messageService.warn('Please check your credentials.', 'Oops!');
33
+
}
34
+
}
35
+
}
+6
src/index.html
+6
src/index.html
···
6
6
<base href="/">
7
7
<meta name="viewport" content="width=device-width, initial-scale=1">
8
8
<link rel="icon" type="image/x-icon" href="favicon.ico">
9
+
<link rel="preconnect" href="https://fonts.googleapis.com">
10
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&display=swap">
12
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap">
13
+
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
14
+
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined">
9
15
</head>
10
16
<body>
11
17
<app-root></app-root>
+3
-3
src/main.ts
+3
-3
src/main.ts
···
1
-
import { bootstrapApplication } from '@angular/platform-browser';
2
-
import { appConfig } from './app/app.config';
3
-
import { AppComponent } from './app/app.component';
1
+
import {bootstrapApplication} from '@angular/platform-browser';
2
+
import {appConfig} from './app/app.config';
3
+
import {AppComponent} from './app/app.component';
4
4
5
5
bootstrapApplication(AppComponent, appConfig)
6
6
.catch((err) => console.error(err));
+244
-1
src/styles.css
+244
-1
src/styles.css
···
1
-
/* You can add global styles to this file, and also import other style files */
1
+
@import "tailwindcss";
2
+
@import '@angular/cdk/overlay-prebuilt.css';
3
+
@custom-variant theme-dark (&:where([data-theme="dark"] *));
4
+
@custom-variant theme-hacker (&:where([data-theme="hacker"] *));
5
+
6
+
html, body, .app-container {
7
+
font-size: 14px;
8
+
height: 100%;
9
+
text-align: left;
10
+
line-height: 1.3;
11
+
letter-spacing: -0.025em;
12
+
13
+
background-color: var(--color-bg);
14
+
color: var(--color-primary);
15
+
}
16
+
17
+
* {
18
+
box-sizing: border-box;
19
+
scrollbar-width: thin;
20
+
scrollbar-gutter: stable;
21
+
}
22
+
23
+
.material-icons {
24
+
font-family: 'Material Icons';
25
+
font-weight: normal;
26
+
font-style: normal;
27
+
font-size: 16px;
28
+
display: inline-block;
29
+
line-height: 1;
30
+
text-transform: none;
31
+
letter-spacing: normal;
32
+
word-wrap: normal;
33
+
white-space: nowrap;
34
+
direction: ltr;
35
+
36
+
/* Support for all WebKit browsers. */
37
+
-webkit-font-smoothing: antialiased;
38
+
/* Support for Safari and Chrome. */
39
+
text-rendering: optimizeLegibility;
40
+
41
+
/* Support for Firefox. */
42
+
-moz-osx-font-smoothing: grayscale;
43
+
44
+
/* Support for IE. */
45
+
font-feature-settings: 'liga';
46
+
}
47
+
48
+
.material-icons-outlined {
49
+
font-family: 'Material Icons Outlined';
50
+
font-weight: normal;
51
+
font-style: normal;
52
+
font-size: 16px;
53
+
display: inline-block;
54
+
line-height: 1;
55
+
text-transform: none;
56
+
letter-spacing: normal;
57
+
word-wrap: normal;
58
+
white-space: nowrap;
59
+
direction: ltr;
60
+
61
+
/* Support for all WebKit browsers. */
62
+
-webkit-font-smoothing: antialiased;
63
+
/* Support for Safari and Chrome. */
64
+
text-rendering: optimizeLegibility;
65
+
66
+
/* Support for Firefox. */
67
+
-moz-osx-font-smoothing: grayscale;
68
+
69
+
/* Support for IE. */
70
+
font-feature-settings: 'liga';
71
+
}
72
+
73
+
/* VIDEO.JS */
74
+
75
+
::ng-deep .video-js .vjs-control-bar {
76
+
background: linear-gradient(to top, rgba(43, 51, 63, 0.7), transparent);
77
+
}
78
+
::ng-deep .video-js > .vjs-remaining-time {
79
+
height: 0;
80
+
position: absolute;
81
+
bottom: 0;
82
+
right: 0;
83
+
font-size: 0.75rem;
84
+
font-family: 'Inter', sans-serif;
85
+
opacity: 0;
86
+
}
87
+
::ng-deep .video-js.vjs-user-inactive .vjs-remaining-time {
88
+
height: 2rem;
89
+
opacity: 1;
90
+
transition: 1.5s opacity ease;
91
+
}
92
+
93
+
@theme {
94
+
--font-mono: "Source Code Pro", monospace;
95
+
--font-sans: "Source Code Pro", sans-serif;
96
+
97
+
--text-sm: 0.9285rem;
98
+
99
+
--color-bg: #FFF;
100
+
--color-primary: #000;
101
+
--color-secondary: #E1E1E1;
102
+
--color-repost: #00aa00;
103
+
--color-like: #F00000;
104
+
--color-selection-bg: rgba(0, 0, 0, 0.99);
105
+
--color-selection-text: #FFF;
106
+
--color-background: #FFF;
107
+
--color-text: var(--color-base);
108
+
--color-placeholder: var(--color-base);
109
+
--color-link: var(--color-base);
110
+
--color-code-1: #aaaaaa;
111
+
--color-code-2: #ffffcc;
112
+
--color-code-3: #F00000;
113
+
--color-code-4: #F0A0A0;
114
+
--color-code-5: #0000aa;
115
+
--color-code-6: #4c8317;
116
+
--color-code-7: #aa0000;
117
+
--color-code-8: #000080;
118
+
--color-code-9: #00aa00;
119
+
--color-code-10: #888888;
120
+
--color-code-11: #555555;
121
+
--color-code-12: #800080;
122
+
--color-code-13: #00aaaa;
123
+
--color-code-14: #009999;
124
+
--color-code-15: #aa5500;
125
+
--color-code-16: #1e90ff;
126
+
--color-code-17: #800000;
127
+
--color-code-18: #bbbbbb;
128
+
}
129
+
130
+
@custom-variant midnight {
131
+
&:where([data-theme="midnight"] *) {
132
+
--color-base: #DBDBDB;
133
+
--border: dashed 1px rgba(219, 219, 219, 0.9);
134
+
--color-selection-bg: rgba(219, 219, 219, 0.99);
135
+
--color-selection-text: #000;
136
+
--color-background: #000;
137
+
--color-text: var(--color-base);
138
+
--color-placeholder: var(--color-base);
139
+
--color-link: var(--color-base);
140
+
--color-code-1: #aaaaaa;
141
+
--color-code-2: #ffffcc;
142
+
--color-code-3: #F00000;
143
+
--color-code-4: #F0A0A0;
144
+
--color-code-5: #b38aff;
145
+
--color-code-6: #5ba711;
146
+
--color-code-7: #e4e477;
147
+
--color-code-8: #000080;
148
+
--color-code-9: #05ca05;
149
+
--color-code-10: #888888;
150
+
--color-code-11: #555555;
151
+
--color-code-12: #800080;
152
+
--color-code-13: #00d4d4;
153
+
--color-code-14: #00c1c1;
154
+
--color-code-15: #ed9d13;
155
+
--color-code-16: #1e90ff;
156
+
--color-code-17: #800000;
157
+
--color-code-18: #bbbbbb;
158
+
}
159
+
}
160
+
161
+
@custom-variant hacker {
162
+
&:where([data-theme="hacker"] *) {
163
+
--color-base: #00ff00;
164
+
--border: dashed 1px rgba(0, 255, 0, 0.9);
165
+
--color-selection-bg: rgba(0, 255, 0, 0.99);
166
+
--color-selection-text: #000;
167
+
--color-background: #000;
168
+
--color-text: var(--color-base);
169
+
--color-placeholder: var(--color-base);
170
+
--color-link: var(--color-base);
171
+
--color-code-1: #aaaaaa;
172
+
--color-code-2: #ffffcc;
173
+
--color-code-3: #F00000;
174
+
--color-code-4: #F0A0A0;
175
+
--color-code-5: #b38aff;
176
+
--color-code-6: #5ba711;
177
+
--color-code-7: #e4e477;
178
+
--color-code-8: #000080;
179
+
--color-code-9: #05ca05;
180
+
--color-code-10: #888888;
181
+
--color-code-11: #555555;
182
+
--color-code-12: #800080;
183
+
--color-code-13: #00d4d4;
184
+
--color-code-14: #00c1c1;
185
+
--color-code-15: #ed9d13;
186
+
--color-code-16: #1e90ff;
187
+
--color-code-17: #800000;
188
+
--color-code-18: #bbbbbb;
189
+
}
190
+
}
191
+
192
+
@layer components {
193
+
.btn-primary {
194
+
box-sizing: border-box;
195
+
border: 1px solid var(--color-primary);
196
+
background-color: var(--color-primary);
197
+
color: var(--color-bg);
198
+
width: fit-content;
199
+
text-box: trim-both cap alphabetic;
200
+
padding: 0.5em 0.75em;
201
+
cursor: pointer;
202
+
min-height: 2em;
203
+
204
+
&:hover {
205
+
background-color: var(--color-bg);
206
+
color: var(--color-primary);
207
+
}
208
+
}
209
+
210
+
.btn-secondary {
211
+
box-sizing: border-box;
212
+
border: 1px solid var(--color-primary);
213
+
background-color: var(--color-bg);
214
+
color: var(--color-primary);
215
+
width: fit-content;
216
+
text-box: trim-both cap alphabetic;
217
+
padding: 0.5em 0.75em;
218
+
cursor: pointer;
219
+
min-height: 2em;
220
+
221
+
&:hover {
222
+
background-color: color-mix(in oklab, var(--color-primary) /* #000 = #000000 */ 15%, transparent);
223
+
color: var(--color-primary);
224
+
}
225
+
}
226
+
227
+
.btn-dropdown {
228
+
text-box: trim-both cap alphabetic;
229
+
box-sizing: border-box;
230
+
background-color: var(--color-bg);
231
+
color: var(--color-primary);
232
+
width: 100%;
233
+
text-align: left;
234
+
font-family: var(--font-mono);
235
+
padding: 0.5em 0.75em;
236
+
cursor: pointer;
237
+
238
+
&:hover {
239
+
background-color: var(--color-primary);
240
+
color: var(--color-bg);
241
+
}
242
+
}
243
+
}
244
+
+15
-2
tsconfig.json
+15
-2
tsconfig.json
···
3
3
{
4
4
"compileOnSave": false,
5
5
"compilerOptions": {
6
+
"baseUrl": "./src",
6
7
"outDir": "./dist/out-tsc",
7
8
"strict": true,
8
9
"noImplicitOverride": true,
···
15
16
"experimentalDecorators": true,
16
17
"moduleResolution": "bundler",
17
18
"importHelpers": true,
18
-
"target": "ES2022",
19
-
"module": "ES2022"
19
+
"target": "ES2023",
20
+
"module": "ESNext",
21
+
"paths": {
22
+
"@components/*": ["app/components/*"],
23
+
"@core/*": ["app/core/*"],
24
+
"@models/*": ["app/models/*"],
25
+
"@services/*": ["app/services/*"],
26
+
"@shared/*": ["app/shared/*"],
27
+
"@views/*": ["app/views/*"]
28
+
},
29
+
"strictPropertyInitialization": false,
30
+
"verbatimModuleSyntax": false,
31
+
"strictNullChecks": false,
32
+
"noImplicitAny": false
20
33
},
21
34
"angularCompilerOptions": {
22
35
"enableI18nLegacyMessageIdFormat": false,