website

#astro#js#html#css

git clone https://git.pyrossh.dev/website

木 Personal website of pyrossh. Built with astrojs, shiki, vite.


0cc3607pyrossh 2026-07-07T11:37:51+05:30
feat: add ui components package with typed-htmx JSX
packages/shared/ui/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@pyrossh/ui",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "dependencies": {
9
+ "@pyrossh/config": "workspace:*",
10
+ "@pyrossh/types": "workspace:*",
11
+ "typed-htmx": "^0.3.1"
12
+ }
13
+ }
packages/shared/ui/src/commit-entry.tsx ADDED
@@ -0,0 +1,33 @@
1
+ interface CommitEntryProps {
2
+ repoId: string;
3
+ commit: {
4
+ hash: string;
5
+ author?: string;
6
+ author_name?: string;
7
+ date?: string;
8
+ message: string;
9
+ body?: string;
10
+ };
11
+ }
12
+
13
+ export function CommitEntry({ repoId, commit }: CommitEntryProps) {
14
+ const author = commit.author_name || commit.author || "";
15
+ return (
16
+ <div class="commit-entry">
17
+ <div>
18
+ <a href={`/repos/${repoId}/commits/${commit.hash}`} title={commit.hash} rel="nofollow">
19
+ {commit.hash.substring(0, 8)}
20
+ </a>
21
+ —<strong>{author}</strong>
22
+ <small class="pull-right">
23
+ {commit.date && (
24
+ <a href={`/repos/${repoId}/logs`} rel="nofollow">
25
+ <time-ago data-date={commit.date} />
26
+ </a>
27
+ )}
28
+ </small>
29
+ </div>
30
+ <pre class="commit">{commit.message}</pre>
31
+ </div>
32
+ );
33
+ }
packages/shared/ui/src/css.ts ADDED
@@ -0,0 +1,1025 @@
1
+ export const globalCSS = `
2
+ :root {
3
+ --color-black: light-dark(hsl(0, 0%, 0%), hsl(0, 0%, 0%));
4
+ --color-white: light-dark(hsl(0, 0%, 100%), hsl(0, 0%, 100%));
5
+ --color-accent: hsl(57.25deg 100% 70%);
6
+ --color-body-bg: light-dark(hsl(0, 0%, 100%), hsl(210, 13%, 15%));
7
+ --color-box-bg: light-dark(hsl(0, 0%, 95%), hsl(210, 13%, 9%));
8
+ --color-text: light-dark(hsl(0, 0%, 13%), hsl(210, 17%, 98%));
9
+ --color-link: light-dark(hsl(211, 100%, 45%), hsl(211, 100%, 60%));
10
+ --color-post-link: light-dark(hsl(0, 0%, 0%), hsl(211, 100%, 60%));
11
+ --color-highlight-bg: light-dark(hsl(183, 80%, 88%), hsl(183, 80%, 18%));
12
+ --color-code-fg: light-dark(hsl(348, 86%, 43%), hsl(348, 86%, 80%));
13
+ --color-code-bg: light-dark(hsl(0, 0%, 95%), hsl(0, 0%, 0%));
14
+ --color-pre-bg: light-dark(hsl(0, 0%, 95%), hsl(0, 0%, 0%));
15
+ --color-tag: light-dark(hsl(152, 96%, 35%), hsl(152, 96%, 44%));
16
+ --btn-light: none;
17
+ --btn-dark: block;
18
+ }
19
+
20
+ [data-theme="dark"] {
21
+ --btn-light: block;
22
+ --btn-dark: none;
23
+ }
24
+
25
+ .astro-code,
26
+ .astro-code span {
27
+ background: var(--color-pre-bg) !important;
28
+ }
29
+
30
+ [data-theme="dark"] .expressive-code .frame pre {
31
+ background: var(--color-pre-bg) !important;
32
+ }
33
+
34
+ [data-tooltip] {
35
+ position: relative;
36
+ cursor: help;
37
+ }
38
+
39
+ [data-tooltip]::after {
40
+ position: absolute;
41
+ opacity: 0;
42
+ pointer-events: none;
43
+ content: attr(data-tooltip);
44
+ left: 0;
45
+ top: calc(100% + 10px);
46
+ border-radius: 3px;
47
+ box-shadow: 0 0 1px 1px var(--color-text);
48
+ background-color: var(--color-box-bg);
49
+ z-index: 10;
50
+ padding: 8px;
51
+ width: 300px;
52
+ transform: translateY(-20px);
53
+ transition: all 150ms cubic-bezier(0.25, 0.8, 0.25, 1);
54
+ }
55
+
56
+ [data-tooltip]:hover::after {
57
+ opacity: 1;
58
+ transform: translateY(0);
59
+ transition-duration: 300ms;
60
+ }
61
+
62
+ body {
63
+ display: flex;
64
+ flex-direction: column;
65
+ flex: 1;
66
+ background-color: var(--color-body-bg);
67
+ color: var(--color-text);
68
+ font-family: system-ui;
69
+ text-rendering: optimizeLegibility;
70
+ font-variant-ligatures: common-ligatures;
71
+ font-kerning: normal;
72
+ font-size: 1.25rem;
73
+ line-height: 1.5;
74
+ min-height: 100vh;
75
+ }
76
+
77
+ @media (max-width: 720px) {
78
+ body {
79
+ font-size: 1.1rem;
80
+ }
81
+ }
82
+
83
+ main {
84
+ margin-top: 1rem;
85
+ margin-bottom: 1rem;
86
+ padding-bottom: 1em;
87
+ }
88
+
89
+ a {
90
+ color: var(--color-link);
91
+ }
92
+
93
+ /* Header */
94
+ header {
95
+ background: #131618;
96
+ }
97
+
98
+ .safe-area {
99
+ background: #131618;
100
+ position: sticky;
101
+ top: 0;
102
+ padding-top: env(safe-area-inset-top);
103
+ }
104
+
105
+ /* Footer */
106
+ footer {
107
+ background: #131618;
108
+ }
109
+
110
+ footer .container {
111
+ display: flex;
112
+ flex-direction: row;
113
+ flex: 1;
114
+ padding: 16px 0px;
115
+ }
116
+
117
+ footer .spacer {
118
+ flex: 1;
119
+ }
120
+
121
+ footer .copyright-container {
122
+ display: flex;
123
+ align-items: center;
124
+ justify-content: center;
125
+ margin-left: 0.5rem;
126
+ margin-right: 0.5rem;
127
+ color: var(--color-white);
128
+ font-size: 1rem;
129
+ }
130
+
131
+ footer .link {
132
+ font-size: 1rem;
133
+ line-height: 1.5rem;
134
+ margin-left: 0.5rem;
135
+ color: var(--color-white);
136
+ font-weight: 500;
137
+ text-underline-offset: 2px;
138
+ text-decoration-thickness: 2px;
139
+ text-decoration-line: underline;
140
+ text-decoration-color: white;
141
+ }
142
+
143
+ footer .link:hover {
144
+ color: var(--color-gray-200);
145
+ text-decoration-color: var(--color-gray-200);
146
+ }
147
+
148
+ footer + .safe-area {
149
+ background: #131618;
150
+ position: sticky;
151
+ bottom: 0;
152
+ padding-bottom: env(safe-area-inset-bottom);
153
+ }
154
+
155
+ /* Repo layout */
156
+ .repo {
157
+ text-overflow: ellipsis;
158
+ overflow: hidden;
159
+ padding: 0.5rem;
160
+ background: var(--color-box-bg);
161
+ color: var(--color-text);
162
+ font-size: 0.95rem;
163
+ }
164
+
165
+ .repo a {
166
+ color: var(--color-link);
167
+ }
168
+
169
+ .repo .header {
170
+ display: flex;
171
+ flex-direction: row;
172
+ align-items: baseline;
173
+ }
174
+
175
+ @media (max-width: 720px) {
176
+ .repo .header {
177
+ flex-direction: column;
178
+ align-items: start;
179
+ }
180
+ }
181
+
182
+ .repo h4 {
183
+ font-size: 1.05rem;
184
+ font-weight: 500;
185
+ line-height: 1.2;
186
+ border-bottom: 1px solid #495057;
187
+ text-overflow: ellipsis;
188
+ white-space: nowrap;
189
+ overflow: hidden;
190
+ margin-bottom: 0;
191
+ padding: 0;
192
+ }
193
+
194
+ .repo .tags {
195
+ margin-top: 0.5rem;
196
+ display: flex;
197
+ flex-wrap: wrap;
198
+ gap: 0.25rem;
199
+ }
200
+
201
+ .repo .tag {
202
+ color: var(--color-tag);
203
+ padding: 0.15rem 0.5rem 0.5rem 0rem;
204
+ border-radius: 0.25rem;
205
+ font-size: 0.95rem;
206
+ font-weight: 500;
207
+ }
208
+
209
+ .repo h1 {
210
+ font-size: 1.6rem;
211
+ font-weight: 500;
212
+ line-height: 1;
213
+ margin-bottom: 0.1rem;
214
+ flex: 1;
215
+ }
216
+
217
+ .repo h1 a {
218
+ color: var(--color-link);
219
+ }
220
+
221
+ .repo h2 {
222
+ display: flex;
223
+ flex: 1;
224
+ padding-top: 0.3rem;
225
+ font-size: 0.95rem;
226
+ }
227
+
228
+ .repo h3 {
229
+ font-size: 1rem;
230
+ font-weight: 500;
231
+ }
232
+
233
+ .repo h3 span {
234
+ font-size: 0.8rem;
235
+ }
236
+
237
+ .repo hr {
238
+ margin-top: 0.5rem;
239
+ border-color: var(--color-text);
240
+ }
241
+
242
+ .repo .nav {
243
+ display: flex;
244
+ align-items: center;
245
+ margin-top: 0.5rem;
246
+ }
247
+
248
+ @media (max-width: 720px) {
249
+ .repo .nav {
250
+ margin-top: 0.5rem;
251
+ }
252
+ }
253
+
254
+ .repo .nav div {
255
+ padding-right: 0.5rem;
256
+ }
257
+
258
+ .repo .nav a {
259
+ font-size: 1rem;
260
+ font-weight: 500;
261
+ padding-right: 0.5rem;
262
+ cursor: pointer;
263
+ color: var(--color-link);
264
+ }
265
+
266
+ .repo .nav a[aria-current="true"] {
267
+ text-underline-offset: 4px;
268
+ text-decoration: underline;
269
+ }
270
+
271
+ /* File layout */
272
+ .file-layout .nav {
273
+ display: flex;
274
+ align-items: center;
275
+ margin-bottom: 0.5rem;
276
+ }
277
+
278
+ @media (max-width: 720px) {
279
+ .file-layout .nav {
280
+ margin-top: 0.5rem;
281
+ }
282
+ }
283
+
284
+ .file-layout .nav div {
285
+ padding-right: 0.5rem;
286
+ }
287
+
288
+ .file-layout .nav a {
289
+ font-size: 1rem;
290
+ font-weight: 500;
291
+ padding-right: 0.5rem;
292
+ cursor: pointer;
293
+ color: var(--color-link);
294
+ }
295
+
296
+ .file-layout .nav a[aria-current="true"] {
297
+ text-underline-offset: 4px;
298
+ text-decoration: underline;
299
+ }
300
+
301
+ .file-layout .title {
302
+ display: flex;
303
+ align-items: center;
304
+ margin-top: 0.75rem;
305
+ margin-bottom: 0.5rem;
306
+ font-size: 1.1rem;
307
+ font-weight: 500;
308
+ }
309
+
310
+ /* Index page */
311
+ .description {
312
+ font-size: 1.1rem;
313
+ }
314
+
315
+ @media (max-width: 720px) {
316
+ .description {
317
+ font-size: 1rem;
318
+ }
319
+ }
320
+
321
+ .page-section h2 {
322
+ font-size: 1.4rem;
323
+ font-weight: 600;
324
+ }
325
+
326
+ @media (max-width: 720px) {
327
+ .page-section h2 {
328
+ font-size: 1.15rem;
329
+ }
330
+ }
331
+
332
+ .page-section section {
333
+ display: flex;
334
+ flex-direction: column;
335
+ margin-top: 1.5rem;
336
+ margin-bottom: 1rem;
337
+ }
338
+
339
+ @media (max-width: 720px) {
340
+ .page-section section {
341
+ margin-top: 1.5rem;
342
+ margin-bottom: 0rem;
343
+ }
344
+ }
345
+
346
+ .rounded-md {
347
+ border-radius: 0.375rem;
348
+ }
349
+
350
+ .repos-grid {
351
+ display: grid;
352
+ grid-template-columns: auto auto;
353
+ gap: 1rem;
354
+ margin-top: 0.5rem;
355
+ }
356
+
357
+ @media (max-width: 720px) {
358
+ .repos-grid {
359
+ gap: 1rem;
360
+ grid-template-columns: auto;
361
+ }
362
+ }
363
+
364
+ .pageContainer {
365
+ margin-left: auto;
366
+ margin-right: auto;
367
+ margin-bottom: 2.5rem;
368
+ }
369
+
370
+ .page-title {
371
+ font-size: 1.875rem;
372
+ line-height: 2.25rem;
373
+ font-weight: 700;
374
+ margin-bottom: 1rem;
375
+ }
376
+
377
+ @media (max-width: 720px) {
378
+ .page-title {
379
+ font-size: 1.6rem;
380
+ }
381
+ }
382
+
383
+ .linkUnderline {
384
+ text-decoration-line: underline;
385
+ }
386
+
387
+ .gridContainer {
388
+ display: grid;
389
+ grid-template-columns: repeat(1, minmax(0, 1fr));
390
+ gap: 1rem;
391
+ }
392
+
393
+ @media (max-width: 720px) {
394
+ .gridContainer {
395
+ gap: 0rem;
396
+ }
397
+ }
398
+
399
+ @media (min-width: 640px) {
400
+ .gridContainer {
401
+ grid-template-columns: repeat(2, minmax(0, 1fr));
402
+ gap: 2rem;
403
+ }
404
+ }
405
+
406
+ .repo-card {
407
+ text-overflow: ellipsis;
408
+ overflow: hidden;
409
+ padding: 0.5rem;
410
+ background: var(--color-box-bg);
411
+ color: var(--color-text);
412
+ font-size: 0.95rem;
413
+ }
414
+
415
+ .repo-card a {
416
+ color: var(--color-link);
417
+ }
418
+
419
+ .repo-card h4 {
420
+ font-size: 1.05rem;
421
+ font-weight: 500;
422
+ line-height: 1.2;
423
+ border-bottom: 1px solid #495057;
424
+ text-overflow: ellipsis;
425
+ white-space: nowrap;
426
+ overflow: hidden;
427
+ margin-bottom: 0;
428
+ padding: 0;
429
+ }
430
+
431
+ .repo-card .tags {
432
+ margin-top: 0.5rem;
433
+ display: flex;
434
+ flex-wrap: wrap;
435
+ gap: 0.25rem;
436
+ }
437
+
438
+ .repo-card .tag {
439
+ color: var(--color-tag);
440
+ padding: 0.15rem 0.5rem 0.5rem 0rem;
441
+ border-radius: 0.25rem;
442
+ font-size: 0.95rem;
443
+ font-weight: 500;
444
+ }
445
+
446
+ .interests ul {
447
+ display: grid;
448
+ gap: 0.5rem;
449
+ grid-template-columns: auto auto auto;
450
+ text-align: center;
451
+ margin-top: 0.5rem;
452
+ font-size: 1.1rem;
453
+ }
454
+
455
+ .interests ul li {
456
+ background-color: var(--color-box-bg);
457
+ padding: 0.25rem;
458
+ }
459
+
460
+ .contact ul {
461
+ display: grid;
462
+ gap: 0.5rem;
463
+ grid-template-columns: auto;
464
+ margin-top: 0.5rem;
465
+ }
466
+
467
+ .contact ul li {
468
+ display: flex;
469
+ flex-direction: row;
470
+ align-items: baseline;
471
+ padding: 0.25rem;
472
+ background-color: var(--color-box-bg);
473
+ font-size: 1.1rem;
474
+ }
475
+
476
+ @media (max-width: 720px) {
477
+ .contact ul li {
478
+ font-size: 1rem;
479
+ flex-direction: column;
480
+ }
481
+ }
482
+
483
+ .contact ul a {
484
+ color: var(--color-link);
485
+ }
486
+
487
+ .contact ul strong {
488
+ font-weight: 500;
489
+ margin-left: 0.25rem;
490
+ margin-right: 0.5rem;
491
+ }
492
+
493
+ .tools img {
494
+ width: 36px;
495
+ height: 36px;
496
+ }
497
+
498
+ .tools ul {
499
+ display: grid;
500
+ gap: 0.5rem;
501
+ grid-template-columns: auto auto auto auto;
502
+ text-align: center;
503
+ margin-top: 0.5rem;
504
+ }
505
+
506
+ @media (max-width: 720px) {
507
+ .tools ul {
508
+ grid-template-columns: auto auto auto;
509
+ }
510
+ }
511
+
512
+ .tools ul li {
513
+ display: flex;
514
+ align-items: center;
515
+ background-color: var(--color-box-bg);
516
+ padding: 0.25rem;
517
+ font-size: 1rem;
518
+ }
519
+
520
+ .tools ul li a {
521
+ display: flex;
522
+ flex: 1;
523
+ flex-direction: row;
524
+ align-items: center;
525
+ text-align: left;
526
+ margin-left: 0.25rem;
527
+ margin-right: 0.25rem;
528
+ }
529
+
530
+ .tools ul li a div {
531
+ flex: 1;
532
+ }
533
+
534
+ /* Post content */
535
+ .post-content h1 {
536
+ text-align: left;
537
+ font-size: 1.8rem;
538
+ font-weight: bold;
539
+ line-height: 1;
540
+ margin-top: 0.5rem;
541
+ margin-bottom: 0.1rem;
542
+ }
543
+
544
+ .post-content time {
545
+ font-size: 1.1rem;
546
+ text-align: left;
547
+ color: light-dark(
548
+ hsl(from var(--color-text) h s 40%),
549
+ hsl(from var(--color-text) h s 80%)
550
+ );
551
+ }
552
+
553
+ .post-content hr {
554
+ margin: 0.5rem 0;
555
+ border-color: hsl(var(--color-text));
556
+ }
557
+
558
+ .post-content h2 {
559
+ font-size: 1.1rem;
560
+ font-weight: 500;
561
+ }
562
+
563
+ .post-content h3 {
564
+ font-size: 1.25rem;
565
+ font-weight: 700;
566
+ margin-top: 0.5rem;
567
+ margin-bottom: 0.1rem;
568
+ }
569
+
570
+ .post-content p {
571
+ font-size: 1.1rem;
572
+ padding-bottom: 1rem;
573
+ }
574
+
575
+ .post-content p code,
576
+ .post-content li code {
577
+ color: var(--color-code-fg) !important;
578
+ background: var(--color-code-bg) !important;
579
+ word-wrap: break-word;
580
+ box-decoration-break: clone;
581
+ padding: 2px 4px;
582
+ border-radius: 0.2rem;
583
+ font-weight: 400;
584
+ font-size: 0.8rem;
585
+ }
586
+
587
+ .post-content blockquote {
588
+ font-size: 1.2rem;
589
+ font-weight: 600;
590
+ background: var(--color-highlight-bg);
591
+ padding: 0.4rem;
592
+ margin: 16px 0px;
593
+ }
594
+
595
+ .post-content blockquote p {
596
+ padding: 0;
597
+ }
598
+
599
+ /* Posts list */
600
+ .posts-list h1 {
601
+ display: flex;
602
+ align-items: flex-start;
603
+ font-weight: 800;
604
+ font-size: 2rem;
605
+ }
606
+
607
+ .posts-list .container {
608
+ display: flex;
609
+ float: left;
610
+ }
611
+
612
+ .posts-list ul {
613
+ display: flex;
614
+ flex-direction: column;
615
+ }
616
+
617
+ .posts-list ul li {
618
+ display: grid;
619
+ grid-template-columns: repeat(1, minmax(0, 1fr));
620
+ gap: 0.4rem;
621
+ justify-items: start;
622
+ align-items: end;
623
+ margin-top: 1.25rem;
624
+ line-height: 1.5rem;
625
+ }
626
+
627
+ @media (min-width: 640px) {
628
+ .posts-list ul li {
629
+ grid-template-columns: repeat(2, minmax(0, 1fr));
630
+ gap: 0.75rem;
631
+ }
632
+ }
633
+
634
+ .posts-list ul li a {
635
+ font-size: 1.1rem;
636
+ color: var(--color-post-link);
637
+ text-decoration-line: underline;
638
+ text-underline-offset: 4px;
639
+ }
640
+
641
+ .posts-list ul li time {
642
+ font-size: 1.1rem;
643
+ }
644
+
645
+ .posts-list ul li .content {
646
+ display: flex;
647
+ flex-direction: column;
648
+ }
649
+
650
+ .posts-list ul li .description {
651
+ font-size: 13pt;
652
+ }
653
+
654
+ .posts-list ul li .description:hover {
655
+ cursor: default;
656
+ }
657
+
658
+ .posts-list ul li .date-wrapper {
659
+ display: flex;
660
+ flex: 1;
661
+ justify-content: flex-end;
662
+ font-size: 1rem;
663
+ line-height: 1.5rem;
664
+ }
665
+
666
+ @media (min-width: 640px) {
667
+ .posts-list ul li .date-wrapper {
668
+ margin-left: 1rem;
669
+ }
670
+ }
671
+
672
+ /* Repo readme content (scoped to tab-content) */
673
+ #tab-content article {
674
+ font-size: 1.1rem;
675
+ margin-top: 0.25rem;
676
+ }
677
+
678
+ #tab-content article h1,
679
+ #tab-content article h2 {
680
+ font-size: 1.5rem;
681
+ font-weight: 700;
682
+ margin-bottom: 0.5rem;
683
+ }
684
+
685
+ #tab-content article h3 {
686
+ font-size: 1.15rem;
687
+ font-weight: 700;
688
+ margin-top: 0.5rem;
689
+ margin-bottom: 0.5rem;
690
+ }
691
+
692
+ #tab-content article p {
693
+ font-size: 1.1rem;
694
+ padding-bottom: 1rem;
695
+ }
696
+
697
+ #tab-content article p code,
698
+ #tab-content article li code {
699
+ color: var(--color-code-fg) !important;
700
+ background: var(--color-code-bg) !important;
701
+ word-wrap: break-word;
702
+ box-decoration-break: clone;
703
+ padding: 2px 4px;
704
+ border-radius: 0.2rem;
705
+ font-weight: 400;
706
+ font-size: 0.8rem;
707
+ }
708
+
709
+ #tab-content article ol {
710
+ list-style: decimal;
711
+ padding-left: 1.5rem;
712
+ margin-bottom: 1rem;
713
+ margin-left: 1rem;
714
+ }
715
+
716
+ #tab-content article ol a {
717
+ color: var(--color-link);
718
+ text-decoration: underline;
719
+ }
720
+
721
+ #tab-content article blockquote {
722
+ padding-left: 0.5rem;
723
+ border-left: 3px solid #aaa;
724
+ }
725
+
726
+ #tab-content article table {
727
+ width: 100%;
728
+ max-width: 100%;
729
+ margin-bottom: 1rem;
730
+ border-collapse: collapse;
731
+ text-indent: initial;
732
+ unicode-bidi: isolate;
733
+ border-spacing: 2px;
734
+ font-size: 1rem;
735
+ }
736
+
737
+ #tab-content article table thead th {
738
+ color: var(--color-text);
739
+ padding: 0.1rem 0.75rem;
740
+ border: 1px solid var(--color-text);
741
+ background-color: var(--color-box-bg);
742
+ }
743
+
744
+ #tab-content article table tbody td {
745
+ position: relative;
746
+ background-clip: padding-box;
747
+ vertical-align: middle;
748
+ padding: 0.1rem 0.75rem;
749
+ border: 1px solid var(--color-text);
750
+ background-color: var(--color-box-bg);
751
+ }
752
+
753
+ #tab-content article table tbody td label {
754
+ margin-bottom: 0;
755
+ }
756
+
757
+ /* Issues page */
758
+ .issues-section {
759
+ margin-top: 0.5rem;
760
+ }
761
+
762
+ .issues-section .summary {
763
+ display: flex;
764
+ flex-wrap: wrap;
765
+ gap: 0.5rem;
766
+ align-items: baseline;
767
+ padding: 0.5rem;
768
+ background: var(--color-box-bg);
769
+ color: var(--color-text);
770
+ font-size: 0.95rem;
771
+ }
772
+
773
+ .issues-section .empty {
774
+ margin-top: 0.5rem;
775
+ padding: 0.5rem;
776
+ background: var(--color-box-bg);
777
+ color: var(--color-text);
778
+ }
779
+
780
+ .issues-section .new-issue {
781
+ display: grid;
782
+ gap: 0.5rem;
783
+ margin: 0.5rem 0;
784
+ padding: 0.5rem;
785
+ background: var(--color-box-bg);
786
+ }
787
+
788
+ .issues-section .new-issue input,
789
+ .issues-section .new-issue textarea {
790
+ width: 100%;
791
+ border: 1px solid var(--color-text);
792
+ background: var(--color-body-bg);
793
+ color: var(--color-text);
794
+ padding: 0.35rem 0.5rem;
795
+ font: inherit;
796
+ }
797
+
798
+ .issues-section .new-issue textarea {
799
+ min-height: 6rem;
800
+ resize: vertical;
801
+ }
802
+
803
+ .issues-section .new-issue button {
804
+ border: 1px solid var(--color-text);
805
+ background: var(--color-box-bg);
806
+ color: var(--color-link);
807
+ padding: 0.35rem 0.75rem;
808
+ font: inherit;
809
+ cursor: pointer;
810
+ }
811
+
812
+ .issues-section .form-row {
813
+ display: grid;
814
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
815
+ gap: 0.5rem;
816
+ }
817
+
818
+ @media (max-width: 720px) {
819
+ .issues-section .form-row {
820
+ grid-template-columns: 1fr;
821
+ }
822
+ }
823
+
824
+ /* Single issue page */
825
+ .issue-detail .state-form,
826
+ .issue-detail .comment-form {
827
+ margin-top: 0.5rem;
828
+ }
829
+
830
+ .issue-detail button {
831
+ border: 1px solid var(--color-text);
832
+ background: var(--color-box-bg);
833
+ color: var(--color-link);
834
+ padding: 0.35rem 0.75rem;
835
+ font: inherit;
836
+ cursor: pointer;
837
+ }
838
+
839
+ .issue-detail .body,
840
+ .issue-detail .comments {
841
+ margin-top: 0.5rem;
842
+ }
843
+
844
+ .issue-detail .body,
845
+ .issue-detail .comment {
846
+ background-color: var(--color-box-bg);
847
+ color: var(--color-text);
848
+ padding: 0.5rem;
849
+ }
850
+
851
+ .issue-detail h3 {
852
+ margin: 0.5rem 0;
853
+ font-size: 1.05rem;
854
+ font-weight: 500;
855
+ }
856
+
857
+ .issue-detail .comment {
858
+ margin: 0.5rem 0;
859
+ }
860
+
861
+ .issue-detail .comment-meta {
862
+ display: flex;
863
+ flex-wrap: wrap;
864
+ gap: 0.5rem;
865
+ margin-bottom: 0.35rem;
866
+ font-size: 0.85rem;
867
+ }
868
+
869
+ .issue-detail pre {
870
+ white-space: pre-wrap;
871
+ overflow-x: auto;
872
+ margin: 0;
873
+ font-family: inherit;
874
+ }
875
+
876
+ .issue-detail .comment-form {
877
+ display: grid;
878
+ gap: 0.5rem;
879
+ padding: 0.5rem;
880
+ background: var(--color-box-bg);
881
+ }
882
+
883
+ .issue-detail .comment-form textarea,
884
+ .issue-detail .comment-form input {
885
+ width: 100%;
886
+ border: 1px solid var(--color-text);
887
+ background: var(--color-body-bg);
888
+ color: var(--color-text);
889
+ padding: 0.35rem 0.5rem;
890
+ font: inherit;
891
+ }
892
+
893
+ .issue-detail .comment-form textarea {
894
+ min-height: 6rem;
895
+ resize: vertical;
896
+ }
897
+
898
+ .issue-detail .comment-actions {
899
+ display: grid;
900
+ grid-template-columns: minmax(0, 1fr) auto;
901
+ gap: 0.5rem;
902
+ }
903
+
904
+ @media (max-width: 720px) {
905
+ .issue-detail .comment-actions {
906
+ grid-template-columns: 1fr;
907
+ }
908
+ }
909
+
910
+ /* Commit entry */
911
+ .commit-entry {
912
+ background-color: var(--color-box-bg);
913
+ color: var(--color-text);
914
+ text-overflow: ellipsis;
915
+ overflow: hidden;
916
+ padding: 0.5rem;
917
+ margin: 0.5rem 0;
918
+ font-size: 11pt;
919
+ }
920
+
921
+ .commit-entry a {
922
+ color: var(--color-link);
923
+ }
924
+
925
+ .commit-entry strong {
926
+ font-weight: 500;
927
+ }
928
+
929
+ .commit-entry small {
930
+ font-size: 85%;
931
+ font-weight: 400;
932
+ float: right;
933
+ }
934
+
935
+ /* Issue card */
936
+ .issue-card {
937
+ background-color: var(--color-box-bg);
938
+ color: var(--color-text);
939
+ padding: 0.5rem;
940
+ margin: 0.5rem 0;
941
+ font-size: 11pt;
942
+ }
943
+
944
+ .issue-card a {
945
+ color: var(--color-link);
946
+ }
947
+
948
+ .issue-card .issue-title {
949
+ display: flex;
950
+ align-items: baseline;
951
+ gap: 0.5rem;
952
+ font-size: 1.05rem;
953
+ font-weight: 500;
954
+ }
955
+
956
+ .issue-card .state {
957
+ border: 1px solid var(--color-text);
958
+ border-radius: 0.2rem;
959
+ padding: 0.05rem 0.35rem;
960
+ font-size: 0.75rem;
961
+ text-transform: uppercase;
962
+ }
963
+
964
+ .issue-card .open {
965
+ color: var(--color-tag);
966
+ }
967
+
968
+ .issue-card .closed {
969
+ opacity: 0.8;
970
+ }
971
+
972
+ .issue-card .meta {
973
+ display: flex;
974
+ flex-wrap: wrap;
975
+ gap: 0.5rem;
976
+ margin-top: 0.25rem;
977
+ font-size: 0.85rem;
978
+ }
979
+
980
+ .issue-card .labels {
981
+ display: flex;
982
+ flex-wrap: wrap;
983
+ gap: 0.35rem;
984
+ margin-top: 0.35rem;
985
+ }
986
+
987
+ .issue-card .label {
988
+ color: var(--color-tag);
989
+ font-size: 0.85rem;
990
+ font-weight: 500;
991
+ }
992
+
993
+ /* TimeAgo */
994
+ time-ago {
995
+ display: inline;
996
+ }
997
+
998
+ /* File tree */
999
+ .file-tree {
1000
+ font-family: monospace;
1001
+ font-size: 0.9rem;
1002
+ line-height: 1.8;
1003
+ }
1004
+
1005
+ .file-tree ul {
1006
+ list-style: none;
1007
+ padding-left: 1.2rem;
1008
+ margin: 0;
1009
+ }
1010
+
1011
+ .file-tree li {
1012
+ display: flex;
1013
+ align-items: center;
1014
+ gap: 0.35rem;
1015
+ }
1016
+
1017
+ .file-tree a {
1018
+ color: var(--color-link);
1019
+ text-decoration: none;
1020
+ }
1021
+
1022
+ .file-tree a:hover {
1023
+ text-decoration: underline;
1024
+ }
1025
+ `;
packages/shared/ui/src/elements.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ import "typed-htmx";
2
+
3
+ declare global {
4
+ namespace JSX {
5
+ interface HtmlMetaTag {
6
+ property?: string;
7
+ }
8
+
9
+ interface IntrinsicElements {
10
+ summary: HtmlTag;
11
+ "time-ago": HtmlTag & { "data-date"?: string };
12
+ "current-year": HtmlTag;
13
+ }
14
+ }
15
+ }
packages/shared/ui/src/file-layout.tsx ADDED
@@ -0,0 +1,62 @@
1
+ import { RepoLayout } from "./repo-layout";
2
+ import type { RuntimeRepo } from "@pyrossh/types";
3
+ import type { FileNode } from "@pyrossh/types";
4
+
5
+ interface FileLayoutProps {
6
+ repo: RuntimeRepo;
7
+ file: FileNode;
8
+ request: Request;
9
+ children: any;
10
+ }
11
+
12
+ export function FileLayout({ repo, file, request, children }: FileLayoutProps) {
13
+ const url = new URL(request.url);
14
+ const pathname = url.pathname;
15
+ const currentTab = pathname.includes("/files/") ? "Files" : "Readme";
16
+
17
+ return (
18
+ <RepoLayout repo={repo} request={request} currentTab={currentTab}>
19
+ <div class="file-layout">
20
+ <hr />
21
+ <div class="title">
22
+ <h3>{file.name}</h3>
23
+ </div>
24
+ <hr />
25
+ <div class="nav">
26
+ <div>
27
+ <a
28
+ aria-current={pathname === `/repos/${repo.id}/files/${file.name}` ? "true" : "false"}
29
+ href={`/repos/${repo.id}/files/${file.name}`}
30
+ >
31
+ Contents
32
+ </a>
33
+ </div>
34
+ <div>|</div>
35
+ <div>
36
+ <a
37
+ aria-current={
38
+ pathname === `/repos/${repo.id}/files/${file.name}/history` ? "true" : "false"
39
+ }
40
+ href={`/repos/${repo.id}/files/${file.name}/history`}
41
+ >
42
+ History
43
+ </a>
44
+ </div>
45
+ <div>|</div>
46
+ <div>
47
+ <a
48
+ aria-current={
49
+ pathname === `/repos/${repo.id}/files/${file.name}/blame` ? "true" : "false"
50
+ }
51
+ href={`/repos/${repo.id}/files/${file.name}/blame`}
52
+ >
53
+ Blame
54
+ </a>
55
+ </div>
56
+ </div>
57
+ <hr />
58
+ {children}
59
+ </div>
60
+ </RepoLayout>
61
+ );
62
+ }
packages/shared/ui/src/file-tree.tsx ADDED
@@ -0,0 +1,60 @@
1
+ import type { FileNode } from "@pyrossh/types";
2
+
3
+ interface FileTreeProps {
4
+ nodes: FileNode[];
5
+ repoId: string;
6
+ basePath?: string;
7
+ }
8
+
9
+ function FileTreeItem({
10
+ node,
11
+ repoId,
12
+ basePath,
13
+ }: {
14
+ node: FileNode;
15
+ repoId: string;
16
+ basePath: string;
17
+ }) {
18
+ const href = node.isDirectory
19
+ ? `/repos/${repoId}/files/${node.path}`
20
+ : `/repos/${repoId}/files/${node.path}`;
21
+
22
+ if (node.isDirectory && node.children) {
23
+ return (
24
+ <li>
25
+ <details open="true">
26
+ <summary>
27
+ <a href={href} hx-get={href} hx-target="#tab-content" hx-push-url="true">
28
+ {node.name}/
29
+ </a>
30
+ </summary>
31
+ <ul>
32
+ {node.children.map((child) => (
33
+ <FileTreeItem node={child} repoId={repoId} basePath={basePath} />
34
+ ))}
35
+ </ul>
36
+ </details>
37
+ </li>
38
+ );
39
+ }
40
+
41
+ return (
42
+ <li>
43
+ <a href={href} hx-get={href} hx-target="#tab-content" hx-push-url="true">
44
+ {node.name}
45
+ </a>
46
+ </li>
47
+ );
48
+ }
49
+
50
+ export function FileTree({ nodes, repoId, basePath = "" }: FileTreeProps) {
51
+ return (
52
+ <div class="file-tree">
53
+ <ul>
54
+ {nodes.map((node) => (
55
+ <FileTreeItem node={node} repoId={repoId} basePath={basePath} />
56
+ ))}
57
+ </ul>
58
+ </div>
59
+ );
60
+ }
packages/shared/ui/src/footer.tsx ADDED
@@ -0,0 +1,20 @@
1
+ export function Footer() {
2
+ return (
3
+ <>
4
+ <footer style="margin-top:auto">
5
+ <div style="max-width:min(70ch,100%-4rem);margin:0 auto">
6
+ <div class="container">
7
+ <div class="spacer" />
8
+ <p class="copyright-container">
9
+ Copyright © <current-year>{new Date().getFullYear()}</current-year>
10
+ <a class="link" href="https://pyrossh.dev">
11
+ pyrossh
12
+ </a>
13
+ </p>
14
+ </div>
15
+ </div>
16
+ </footer>
17
+ <div class="safe-area" />
18
+ </>
19
+ );
20
+ }
packages/shared/ui/src/formatted-date.tsx ADDED
@@ -0,0 +1,11 @@
1
+ interface FormattedDateProps {
2
+ date: Date;
3
+ }
4
+
5
+ export function FormattedDate({ date }: FormattedDateProps) {
6
+ return (
7
+ <time datetime={date.toISOString()}>
8
+ {new Intl.DateTimeFormat("en-GB", { dateStyle: "long" }).format(new Date(date))}
9
+ </time>
10
+ );
11
+ }
packages/shared/ui/src/header.tsx ADDED
@@ -0,0 +1,48 @@
1
+ import { NAV_ITEMS } from "@pyrossh/config";
2
+
3
+ interface HeaderProps {
4
+ currentPath?: string;
5
+ }
6
+
7
+ export function Header({ currentPath }: HeaderProps) {
8
+ const isActive = (href: string) => (currentPath?.startsWith(href) ? "true" : "false");
9
+ return (
10
+ <>
11
+ <div class="safe-area" />
12
+ <header>
13
+ <nav style="max-width:min(70ch,100%-4rem);margin:0 auto;display:flex;align-items:center;justify-content:space-between;line-height:56px">
14
+ <a
15
+ href="/"
16
+ style="display:flex;font-size:22px;font-weight:400;color:var(--color-accent);text-decoration:none"
17
+ >
18
+ 木 pyrossh
19
+ </a>
20
+ <div style="display:flex;align-items:center;gap:0;font-size:22px;color:white">
21
+ <button
22
+ id="theme-toggle"
23
+ type="button"
24
+ style="background:none;border:none;cursor:pointer;font-size:1.3rem;padding-right:12px;color:white;display:flex;align-items:center"
25
+ onclick="document.documentElement.toggleAttribute('data-theme')"
26
+ >
27
+ <span style="display:var(--btn-light)">☀️</span>
28
+ <span style="display:var(--btn-dark)">🌙</span>
29
+ </button>
30
+ <span>|</span>
31
+ {NAV_ITEMS.map((item) => (
32
+ <>
33
+ <a
34
+ href={item.href}
35
+ aria-current={isActive(item.href)}
36
+ style={`display:inline-block;text-decoration:none;font-size:22px;line-height:56px;color:white;font-weight:400;padding:0 12px;${currentPath?.startsWith(item.href) ? "text-decoration:underline;text-decoration-color:var(--color-accent);text-underline-offset:8px" : ""}`}
37
+ >
38
+ {item.label}
39
+ </a>
40
+ <span>|</span>
41
+ </>
42
+ ))}
43
+ </div>
44
+ </nav>
45
+ </header>
46
+ </>
47
+ );
48
+ }
packages/shared/ui/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { globalCSS } from "./css";
2
+ export { Layout } from "./layout";
3
+ export { Header } from "./header";
4
+ export { Footer } from "./footer";
5
+ export { RepoLayout } from "./repo-layout";
6
+ export { FileLayout } from "./file-layout";
7
+ export { CommitEntry } from "./commit-entry";
8
+ export { IssueCard } from "./issue-card";
9
+ export { FileTree } from "./file-tree";
10
+ export { FormattedDate } from "./formatted-date";
packages/shared/ui/src/issue-card.tsx ADDED
@@ -0,0 +1,35 @@
1
+ import type { GitBugIssue } from "@pyrossh/types";
2
+
3
+ interface IssueCardProps {
4
+ repoId: string;
5
+ issue: GitBugIssue;
6
+ }
7
+
8
+ export function IssueCard({ repoId, issue }: IssueCardProps) {
9
+ const date = issue.updatedAt ?? issue.createdAt;
10
+ return (
11
+ <article class="issue-card">
12
+ <div class="issue-title">
13
+ <a href={`/repos/${repoId}/issues/${issue.id}`} title={issue.id}>
14
+ {issue.title}
15
+ </a>
16
+ <span class={`state ${issue.state}`}>{issue.state}</span>
17
+ </div>
18
+ <div class="meta">
19
+ <a href={`/repos/${repoId}/issues/${issue.id}`} title={issue.id}>
20
+ {issue.id.substring(0, 8)}
21
+ </a>
22
+ {issue.author && <span>by {issue.author}</span>}
23
+ {date && <time-ago data-date={date} />}
24
+ {issue.comments.length > 0 && <span>{issue.comments.length} comments</span>}
25
+ </div>
26
+ {issue.labels.length > 0 && (
27
+ <div class="labels">
28
+ {issue.labels.map((label) => (
29
+ <span class="label">#{label}</span>
30
+ ))}
31
+ </div>
32
+ )}
33
+ </article>
34
+ );
35
+ }
packages/shared/ui/src/layout.tsx ADDED
@@ -0,0 +1,52 @@
1
+ import { globalCSS } from "./css";
2
+ import { Header } from "./header";
3
+ import { Footer } from "./footer";
4
+ import { SITE_TITLE, SITE_DESCRIPTION, SITE_URL } from "@pyrossh/config";
5
+
6
+ interface LayoutProps {
7
+ title: string;
8
+ description?: string;
9
+ request?: Request;
10
+ children: any;
11
+ }
12
+
13
+ export function Layout({ title, description, request, children }: LayoutProps) {
14
+ const url = request ? new URL(request.url) : undefined;
15
+ const canonicalURL = url ? `${SITE_URL}${url.pathname}` : SITE_URL;
16
+ return (
17
+ <html lang="en">
18
+ <head>
19
+ <meta charset="utf-8" />
20
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
21
+ <meta name="theme-color" content="#131618" />
22
+ <link rel="icon" type="image/svg+xml" href="/assets/icons/icon.svg" />
23
+ <link rel="alternate" type="application/rss+xml" title={title} href="/rss.xml" />
24
+ <link rel="canonical" href={canonicalURL} />
25
+ <title>{title}</title>
26
+ <meta name="title" content={title} />
27
+ <meta name="description" content={description || SITE_DESCRIPTION} />
28
+ <meta name="author" content="pyrossh" />
29
+ <meta name="keywords" content="pyrossh.dev,pyrossh,website" />
30
+ <meta property="og:site_name" content="pyrossh.dev" />
31
+ <meta property="og:type" content="website" />
32
+ <meta property="og:url" content={url?.href || SITE_URL} />
33
+ <meta property="og:title" content={title} />
34
+ <meta property="og:description" content={description || SITE_DESCRIPTION} />
35
+ <meta property="og:image" content="/assets/icons/icon.svg" />
36
+ <meta property="twitter:card" content="summary_large_image" />
37
+ <meta property="twitter:url" content={url?.href || SITE_URL} />
38
+ <meta property="twitter:title" content={title} />
39
+ <meta property="twitter:description" content={description || SITE_DESCRIPTION} />
40
+ <meta property="twitter:image" content="/assets/icons/icon.svg" />
41
+ <style>{globalCSS}</style>
42
+ <script src="https://unpkg.com/[email protected]"></script>
43
+ <script src="/assets/js/timeago.js"></script>
44
+ </head>
45
+ <body>
46
+ <Header currentPath={url?.pathname} />
47
+ <main>{children}</main>
48
+ <Footer />
49
+ </body>
50
+ </html>
51
+ );
52
+ }
packages/shared/ui/src/repo-layout.tsx ADDED
@@ -0,0 +1,63 @@
1
+ import { Layout } from "./layout";
2
+ import type { RuntimeRepo } from "@pyrossh/types";
3
+
4
+ interface RepoLayoutProps {
5
+ repo: RuntimeRepo;
6
+ request: Request;
7
+ children: any;
8
+ currentTab: string;
9
+ }
10
+
11
+ export function RepoLayout({ repo, request, children, currentTab }: RepoLayoutProps) {
12
+ const { title, description, tags } = repo.data;
13
+ const url = new URL(request.url);
14
+ const pathname = url.pathname;
15
+
16
+ return (
17
+ <Layout title={`${title} — pyrossh`} description={description} request={request}>
18
+ <div class="repo">
19
+ <div class="header">
20
+ <h1>
21
+ <a href="/">~repos</a>/{title}
22
+ </h1>
23
+ <div class="tags">
24
+ {tags.map((tag) => (
25
+ <span class="tag">#{tag}</span>
26
+ ))}
27
+ </div>
28
+ </div>
29
+ <h3>
30
+ GIT_CONFIG_PARAMETERS="'http.version=HTTP/1.1'" git clone{" "}
31
+ <a>https://git.pyrossh.dev/{title}/.git</a> {title}
32
+ </h3>
33
+ <div class="headerExtension">
34
+ <h2>{description}</h2>
35
+ </div>
36
+ <hr />
37
+ <div class="nav">
38
+ {["Readme", "Commits", "Files"].map((tab) => {
39
+ const href =
40
+ tab === "Readme" ? `/repos/${title}` : `/repos/${title}/${tab.toLowerCase()}`;
41
+ const active = currentTab === tab;
42
+ return (
43
+ <div>
44
+ <a
45
+ href={href}
46
+ aria-current={active ? "true" : "false"}
47
+ hx-get={href}
48
+ hx-target="#tab-content"
49
+ hx-push-url="true"
50
+ style={`${active ? "text-underline-offset:4px;text-decoration:underline" : ""}`}
51
+ >
52
+ {tab}
53
+ </a>
54
+ <span>|</span>
55
+ </div>
56
+ );
57
+ })}
58
+ </div>
59
+ </div>
60
+ <div id="tab-content">{children}</div>
61
+ </Layout>
62
+ );
63
+ }
packages/shared/ui/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "include": ["src"]
4
+ }
pnpm-lock.yaml CHANGED
@@ -363,6 +363,18 @@ importers:
363
363
 
364
364
  packages/shared/types: {}
365
365
 
366
+ packages/shared/ui:
367
+ dependencies:
368
+ '@pyrossh/config':
369
+ specifier: workspace:*
370
+ version: link:../config
371
+ '@pyrossh/types':
372
+ specifier: workspace:*
373
+ version: link:../types
374
+ typed-htmx:
375
+ specifier: ^0.3.1
376
+ version: 0.3.1
377
+
366
378
  packages:
367
379
 
368
380
  '@antfu/[email protected]':