├── src ├── assets │ ├── .gitkeep │ └── favicon.png ├── app │ ├── pages │ │ ├── package-detail │ │ │ ├── package-detail.component.scss │ │ │ ├── package-detail.component.ts │ │ │ └── package-detail.component.html │ │ └── search-results │ │ │ ├── search-results.component.scss │ │ │ ├── search-results.component.html │ │ │ └── search-results.component.ts │ ├── components │ │ ├── error │ │ │ ├── error.component.html │ │ │ ├── error.component.ts │ │ │ └── error.component.scss │ │ └── loader │ │ │ ├── loader.component.html │ │ │ ├── loader.component.ts │ │ │ └── loader.component.scss │ ├── app-routing.ts │ ├── pipes │ │ ├── format-number.pipe.ts │ │ ├── format-datetime.pipe.ts │ │ └── pseudo-tags.pipe.ts │ ├── app.component.scss │ ├── services │ │ ├── cache.service.ts │ │ ├── database.service.ts │ │ ├── version-comparator.service.ts │ │ └── package-manager.service.ts │ ├── app.component.html │ └── app.component.ts ├── environments │ ├── environment.development.ts │ └── environment.ts ├── styles.scss ├── index.html ├── main.ts └── scss │ └── _nix-search.scss ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── tsconfig.app.json ├── tsconfig.spec.json ├── .editorconfig ├── shell.nix ├── .gitignore ├── tsconfig.json ├── README.md ├── package.json ├── angular.json └── serverless.yml /src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/pages/package-detail/package-detail.component.scss: -------------------------------------------------------------------------------- 1 | p { 2 | font-size: 17px; 3 | } 4 | -------------------------------------------------------------------------------- /src/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RikudouSage/NixPackageHistoryFrontend/HEAD/src/assets/favicon.png -------------------------------------------------------------------------------- /src/environments/environment.development.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | apiUrl: 'https://127.0.0.1:8000', 3 | }; 4 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | apiUrl: 'https://api.history.nix-packages.com', 3 | }; 4 | -------------------------------------------------------------------------------- /src/app/pages/search-results/search-results.component.scss: -------------------------------------------------------------------------------- 1 | .search-results > div > :nth-child(2) > li.package:hover { 2 | padding-bottom: 2em; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/components/error/error.component.html: -------------------------------------------------------------------------------- 1 |
2 | {{message()}} 3 |
4 |
5 | 6 |
7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": ["angular.ng-template"] 4 | } 5 | -------------------------------------------------------------------------------- /src/app/components/loader/loader.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {{message()}} 9 |
10 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | // from https://github.com/NixOS/nixos-search/blob/0d663f27fa55a225a4c71f0b4be7c64f26214fff/frontend/src/index.scss 2 | 3 | @import "bootstrap-2.3.2/css/bootstrap.css"; 4 | @import "bootstrap-2.3.2/css/bootstrap-responsive.css"; 5 | @import "scss/nix-search"; 6 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "include": [ 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import {} }: 2 | pkgs.mkShell { 3 | nativeBuildInputs = with pkgs.buildPackages; [ 4 | nodejs_18 5 | nodePackages."@angular/cli" 6 | nodePackages.serverless 7 | yarn 8 | awscli 9 | jq 10 | ]; 11 | shellHook = '' 12 | source <(ng completion script) 13 | ''; 14 | } 15 | -------------------------------------------------------------------------------- /src/app/components/error/error.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, input, Input} from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-error', 5 | templateUrl: './error.component.html', 6 | styleUrls: ['./error.component.scss'], 7 | standalone: true 8 | }) 9 | export class ErrorComponent { 10 | public message = input.required(); 11 | } 12 | -------------------------------------------------------------------------------- /src/app/components/loader/loader.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, input, Input} from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-loader', 5 | templateUrl: './loader.component.html', 6 | styleUrls: ['./loader.component.scss'], 7 | standalone: true 8 | }) 9 | export class LoaderComponent { 10 | public message = input.required(); 11 | } 12 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Nix old package search 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/app/app-routing.ts: -------------------------------------------------------------------------------- 1 | import {Routes} from '@angular/router'; 2 | 3 | export const appRoutes: Routes = [ 4 | { 5 | path: 'search', 6 | loadComponent: () => import('./pages/search-results/search-results.component').then(c => c.SearchResultsComponent), 7 | }, 8 | { 9 | path: 'package/:packageName/:packageVersion', 10 | loadComponent: () => import('./pages/package-detail/package-detail.component').then(c => c.PackageDetailComponent), 11 | } 12 | ]; 13 | -------------------------------------------------------------------------------- /src/app/components/error/error.component.scss: -------------------------------------------------------------------------------- 1 | $loaderSize: 80px; 2 | 3 | :host { 4 | width: 100vw; 5 | height: 100vh; 6 | background: white; 7 | display: block; 8 | position: fixed; 9 | left: 0; 10 | top: 0; 11 | } 12 | 13 | .message { 14 | text-align: center; 15 | font-size: 1.6rem; 16 | position: relative; 17 | top: calc(50% - 0.8rem); 18 | color: darkred; 19 | } 20 | 21 | .content { 22 | text-align: center; 23 | font-size: 1.2rem; 24 | position: relative; 25 | top: calc(50% + 1.5rem); 26 | } 27 | -------------------------------------------------------------------------------- /src/app/pipes/format-number.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | @Pipe({ 4 | name: 'formatNumber', 5 | standalone: true 6 | }) 7 | export class FormatNumberPipe implements PipeTransform { 8 | 9 | transform(value: string | number, digits: number | null = null): string { 10 | return new Intl.NumberFormat(undefined, { 11 | minimumFractionDigits: digits ?? undefined, 12 | maximumFractionDigits: digits ?? undefined, 13 | }).format(Number(value)); 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/app/pipes/format-datetime.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | @Pipe({ 4 | name: 'formatDatetime', 5 | standalone: true 6 | }) 7 | export class FormatDatetimePipe implements PipeTransform { 8 | 9 | transform(value: string | Date): string { 10 | if (typeof value !== 'string') { 11 | value = value.toISOString(); 12 | } 13 | 14 | return new Intl.DateTimeFormat(undefined, { 15 | dateStyle: 'medium', 16 | timeStyle: 'short' 17 | }).format(new Date(value)); 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "ng serve", 7 | "type": "chrome", 8 | "request": "launch", 9 | "preLaunchTask": "npm: start", 10 | "url": "http://localhost:4200/" 11 | }, 12 | { 13 | "name": "ng test", 14 | "type": "chrome", 15 | "request": "launch", 16 | "preLaunchTask": "npm: test", 17 | "url": "http://localhost:9876/debug.html" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/app/pipes/pseudo-tags.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | @Pipe({ 4 | name: 'pseudoTags', 5 | standalone: true 6 | }) 7 | export class PseudoTagsPipe implements PipeTransform { 8 | 9 | private readonly map: {[key: string]: string} = { 10 | '[s]': '', 11 | '[/s]': '' 12 | }; 13 | 14 | transform(value: string): string { 15 | for (const pseudoTag of Object.keys(this.map)) { 16 | const htmlTag = this.map[pseudoTag]; 17 | value = value.replaceAll(pseudoTag, htmlTag); 18 | } 19 | 20 | return value; 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import {importProvidersFrom} from '@angular/core'; 2 | import {AppComponent} from './app/app.component'; 3 | import {ReactiveFormsModule} from '@angular/forms'; 4 | import {provideHttpClient, withInterceptorsFromDi} from '@angular/common/http'; 5 | import {appRoutes} from './app/app-routing'; 6 | import {bootstrapApplication, BrowserModule} from '@angular/platform-browser'; 7 | import {provideRouter} from "@angular/router"; 8 | 9 | 10 | bootstrapApplication(AppComponent, { 11 | providers: [ 12 | importProvidersFrom(BrowserModule, ReactiveFormsModule), 13 | provideHttpClient(withInterceptorsFromDi()), 14 | provideRouter(appRoutes), 15 | ] 16 | }) 17 | .catch(err => console.error(err)); 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | /.serverless 3 | 4 | # Compiled output 5 | /dist 6 | /tmp 7 | /out-tsc 8 | /bazel-out 9 | 10 | # Node 11 | /node_modules 12 | npm-debug.log 13 | yarn-error.log 14 | 15 | # IDEs and editors 16 | .idea/ 17 | .project 18 | .classpath 19 | .c9/ 20 | *.launch 21 | .settings/ 22 | *.sublime-workspace 23 | 24 | # Visual Studio Code 25 | .vscode/* 26 | !.vscode/settings.json 27 | !.vscode/tasks.json 28 | !.vscode/launch.json 29 | !.vscode/extensions.json 30 | .history/* 31 | 32 | # Miscellaneous 33 | /.angular/cache 34 | .sass-cache/ 35 | /connect.lock 36 | /coverage 37 | /libpeerconnection.log 38 | testem.log 39 | /typings 40 | 41 | # System files 42 | .DS_Store 43 | Thumbs.db 44 | -------------------------------------------------------------------------------- /src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | .no-hover { 2 | &:hover, &:focus { 3 | color: #777777 !important; 4 | cursor: default; 5 | pointer-events: none; 6 | } 7 | } 8 | 9 | .position-relative { 10 | position: relative; 11 | } 12 | 13 | .autocomplete-results { 14 | display: none; 15 | max-height: 66vh; 16 | overflow: scroll; 17 | 18 | margin-left: 0; 19 | li { 20 | list-style-type: none; 21 | height: 40px; 22 | line-height: 40px; 23 | cursor: pointer; 24 | background: #fff; 25 | padding-left: 1vw; 26 | overflow: hidden; 27 | 28 | &:hover { 29 | background: #eee; 30 | } 31 | } 32 | } 33 | 34 | input:focus, input:not(:placeholder-shown) { 35 | & + .autocomplete-results { 36 | display: block; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "noImplicitOverride": true, 10 | "noPropertyAccessFromIndexSignature": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "sourceMap": true, 14 | "declaration": false, 15 | "downlevelIteration": true, 16 | "experimentalDecorators": true, 17 | "moduleResolution": "node", 18 | "importHelpers": true, 19 | "target": "ES2022", 20 | "module": "ES2022", 21 | "useDefineForClassFields": false, 22 | "lib": [ 23 | "ES2022", 24 | "dom" 25 | ] 26 | }, 27 | "angularCompilerOptions": { 28 | "enableI18nLegacyMessageIdFormat": false, 29 | "strictInjectionParameters": true, 30 | "strictInputAccessModifiers": true, 31 | "strictTemplates": true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "isBackground": true, 27 | "problemMatcher": { 28 | "owner": "typescript", 29 | "pattern": "$tsc", 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": { 33 | "regexp": "(.*?)" 34 | }, 35 | "endsPattern": { 36 | "regexp": "bundle generation complete" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nix package version search 2 | 3 | > See the backend for this project at [RikudouSage/NixPackageHistoryBackend](https://github.com/RikudouSage/NixPackageHistoryBackend). 4 | 5 | This is a frontend for the above linked backend that allows searching historical versions of Nix packages. 6 | 7 | > Tip: If you're using nix, you can get all dependencies using `nix-shell` with the `shell.nix` from this repo 8 | 9 | Before you can build the app, you need to install dependencies using `yarn install` (or `npm install` or whatever you use). 10 | 11 | ## Running locally 12 | 13 | Just run the command `yarn start` and open http://localhost:4200. You can also change the api url in [environment.development.ts](src/environments/environment.development.ts). 14 | 15 | ## Running in production 16 | 17 | Change the url of the api in [environment.ts](src/environments/environment.ts) if you wish to. 18 | 19 | Run `yarn build` to compile the production version. Afterwards, copy the contents of `dist/nix-package-history-frontend` to any webserver capable of serving html. 20 | 21 | > Note: I've pretty much copied the design from https://search.nixos.org because I'm no designer. If you wish to create a custom modern design, feel free to! 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nix-os-version-search-frontend", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "test": "ng test" 10 | }, 11 | "private": true, 12 | "dependencies": { 13 | "@angular/animations": "^17.0.2", 14 | "@angular/common": "^17.0.2", 15 | "@angular/compiler": "^17.0.2", 16 | "@angular/core": "^17.0.2", 17 | "@angular/forms": "^17.0.2", 18 | "@angular/platform-browser": "^17.0.2", 19 | "@angular/platform-browser-dynamic": "^17.0.2", 20 | "@angular/router": "^17.0.2", 21 | "bootstrap-2.3.2": "^1.0.0", 22 | "rxjs": "~7.8.0", 23 | "tslib": "^2.3.0", 24 | "zone.js": "^0.14.0" 25 | }, 26 | "devDependencies": { 27 | "@angular-devkit/build-angular": "^17.0.0", 28 | "@angular/cli": "~17.0.0", 29 | "@angular/compiler-cli": "^17.0.2", 30 | "@types/jasmine": "~4.3.0", 31 | "jasmine-core": "~4.6.0", 32 | "karma": "~6.4.0", 33 | "karma-chrome-launcher": "~3.2.0", 34 | "karma-coverage": "~2.2.0", 35 | "karma-jasmine": "~5.1.0", 36 | "karma-jasmine-html-reporter": "~2.0.0", 37 | "typescript": "^5.0.2" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/app/components/loader/loader.component.scss: -------------------------------------------------------------------------------- 1 | $loaderSize: 80px; 2 | 3 | :host { 4 | width: 100vw; 5 | height: 100vh; 6 | background: white; 7 | display: block; 8 | position: fixed; 9 | left: 0; 10 | top: 0; 11 | } 12 | 13 | @keyframes lds-ring { 14 | 0% { 15 | transform: rotate(0deg); 16 | } 17 | 100% { 18 | transform: rotate(360deg); 19 | } 20 | } 21 | 22 | .lds-ring { 23 | display: block; 24 | position: absolute; 25 | width: $loaderSize; 26 | height: $loaderSize; 27 | 28 | left: 0; 29 | right: 0; 30 | top: 0; 31 | bottom: 0; 32 | margin: auto; 33 | 34 | div { 35 | box-sizing: border-box; 36 | display: block; 37 | position: absolute; 38 | width: 64px; 39 | height: 64px; 40 | margin: 8px; 41 | border: 8px solid #fff; 42 | border-radius: 50%; 43 | animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; 44 | border-color: #000 transparent transparent transparent; 45 | 46 | &:nth-child(1) { 47 | animation-delay: -0.45s; 48 | } 49 | 50 | &:nth-child(2) { 51 | animation-delay: -0.3s; 52 | } 53 | 54 | &:nth-child(3) { 55 | animation-delay: -0.15s; 56 | } 57 | } 58 | } 59 | 60 | .message { 61 | text-align: center; 62 | font-size: 1.2rem; 63 | position: relative; 64 | top: calc(50% + $loaderSize); 65 | margin: 0 2vw; 66 | } 67 | -------------------------------------------------------------------------------- /src/app/pages/package-detail/package-detail.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, OnInit, signal} from '@angular/core'; 2 | import {Package, PackageManagerService} from "../../services/package-manager.service"; 3 | import {ActivatedRoute} from "@angular/router"; 4 | import {lastValueFrom} from "rxjs"; 5 | import {LoaderComponent} from '../../components/loader/loader.component'; 6 | import {FormatDatetimePipe} from "../../pipes/format-datetime.pipe"; 7 | 8 | @Component({ 9 | selector: 'app-package-detail', 10 | templateUrl: './package-detail.component.html', 11 | styleUrls: ['./package-detail.component.scss'], 12 | standalone: true, 13 | imports: [LoaderComponent, FormatDatetimePipe] 14 | }) 15 | export class PackageDetailComponent implements OnInit { 16 | public loaded = signal(false); 17 | public packageDetail = signal(null); 18 | 19 | constructor( 20 | private readonly activatedRoute: ActivatedRoute, 21 | private readonly packageManager: PackageManagerService, 22 | ) { 23 | } 24 | 25 | public async ngOnInit(): Promise { 26 | this.activatedRoute.params.subscribe(async params => { 27 | this.loaded.set(false); 28 | 29 | const packageName: string = params['packageName']; 30 | const packageVersion: string = params['packageVersion']; 31 | 32 | this.packageDetail.set(await lastValueFrom(this.packageManager.getPackageVersion(packageName, packageVersion))); 33 | 34 | this.loaded.set(true); 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/services/cache.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import {DatabaseService} from "./database.service"; 3 | 4 | interface PartiallyDeserializedCacheItem { 5 | key: string; 6 | validUntil: string | undefined; 7 | value: T | undefined; 8 | } 9 | 10 | export interface CacheItem { 11 | key: string; 12 | validUntil: Date | undefined; 13 | value: T | undefined; 14 | } 15 | 16 | @Injectable({ 17 | providedIn: 'root' 18 | }) 19 | export class CacheService { 20 | constructor( 21 | private readonly database: DatabaseService, 22 | ) { } 23 | 24 | public async storeCache(key: string, validUntil: Date | undefined, value: T): Promise> { 25 | const item: CacheItem = { 26 | key: key, 27 | validUntil: validUntil, 28 | value: value, 29 | }; 30 | 31 | await this.database.setItem(`cache.${key}`, JSON.stringify(item)); 32 | 33 | return item; 34 | } 35 | 36 | public async getItem(key: string, validOnly: boolean): Promise> { 37 | const result = await this.database.getItem(`cache.${key}`); 38 | if (result === null) { 39 | return { 40 | key: key, 41 | validUntil: undefined, 42 | value: undefined, 43 | }; 44 | } 45 | 46 | const deserialized: PartiallyDeserializedCacheItem = JSON.parse(result); 47 | const item: CacheItem = { 48 | key: deserialized.key, 49 | validUntil: deserialized.validUntil ? new Date(deserialized.validUntil) : undefined, 50 | value: deserialized.value, 51 | }; 52 | if (item.validUntil && item.validUntil.getTime() < new Date().getTime() && validOnly) { 53 | return { 54 | key: key, 55 | validUntil: undefined, 56 | value: undefined, 57 | }; 58 | } 59 | 60 | return item; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/app/services/database.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | 3 | @Injectable({ 4 | providedIn: 'root' 5 | }) 6 | export class DatabaseService { 7 | private db: IDBDatabase | null = null; 8 | 9 | public async getItem(key: string): Promise { 10 | const db = await this.open(); 11 | return new Promise(resolve => { 12 | const transaction = db.transaction('cache', 'readonly'); 13 | const store = transaction.objectStore('cache'); 14 | const request = store.get(key); 15 | request.onsuccess = () => { 16 | if (request.result === undefined) { 17 | resolve(null); 18 | return; 19 | } 20 | resolve(request.result.value); 21 | }; 22 | }); 23 | } 24 | 25 | public async setItem(key: string, value: string): Promise { 26 | const db = await this.open(); 27 | return new Promise(resolve => { 28 | const transaction = db.transaction('cache', 'readwrite'); 29 | const store = transaction.objectStore('cache'); 30 | const request = store.put({ 31 | key: key, 32 | value: value, 33 | }); 34 | request.onsuccess = () => resolve(); 35 | }); 36 | } 37 | 38 | private async open(): Promise { 39 | if (this.db === null) { 40 | const database = new Promise(resolve => { 41 | const request = window.indexedDB.open('nix_os_search', 1); 42 | request.onupgradeneeded = event => { 43 | const db = request.result; 44 | for (let version = event.oldVersion; version <= (event.newVersion ?? 0); ++version) { 45 | switch (version) { 46 | case 0: 47 | db.createObjectStore('cache', { 48 | keyPath: 'key', 49 | autoIncrement: false, 50 | }); 51 | break; 52 | } 53 | } 54 | }; 55 | request.onsuccess = () => { 56 | resolve(request.result); 57 | }; 58 | }); 59 | this.db = await database; 60 | } 61 | 62 | return this.db; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/app/services/version-comparator.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | @Injectable({ 4 | providedIn: 'root' 5 | }) 6 | export class VersionComparatorService { 7 | private readonly patchExtractRegex = /([0-9]+)([^0-9-]+)/; 8 | 9 | constructor() { } 10 | 11 | public compare(a: string, b: string): -1|0|1 { 12 | const [majorA, minorA, patchA, prereleaseA] = this.getParts(a); 13 | const [majorB, minorB, patchB, prereleaseB] = this.getParts(b); 14 | 15 | if (majorA !== majorB) { 16 | return Number(majorA) > Number(majorB) ? -1 : 1; 17 | } 18 | if (minorA !== minorB) { 19 | return Number(minorA) > Number(minorB) ? -1 : 1; 20 | } 21 | if (patchA !== patchB) { 22 | return Number(patchA) > Number(patchB) ? -1 : 1; 23 | } 24 | if (prereleaseA === null && prereleaseB !== null) { 25 | return -1; 26 | } 27 | if (prereleaseA !== null && prereleaseB === null) { 28 | return 1; 29 | } 30 | if (prereleaseA !== null && prereleaseB !== null) { 31 | const prereleaseANum = this.prereleaseToNumber(prereleaseA); 32 | const prereleaseBNum = this.prereleaseToNumber(prereleaseB); 33 | 34 | return prereleaseANum > prereleaseBNum ? -1 : 1; 35 | } 36 | 37 | return 0; 38 | } 39 | 40 | private getParts(version: string): [string, string, string, string|null] { 41 | const parts = version.split(".", 3); 42 | if (!parts.length) { 43 | return ['0', '0', '0', null]; 44 | } 45 | 46 | if (parts.length === 1) { 47 | return [parts[0], '0', '0', null]; 48 | } 49 | if (parts.length === 2) { 50 | return [parts[0], parts[1], '0', null]; 51 | } 52 | if (parts.length > 3) { 53 | parts[2] = parts.slice(2, parts.length - 1).join('.'); 54 | } 55 | if (this.patchExtractRegex.test(parts[2])) { 56 | const matches = parts[2].match(this.patchExtractRegex)!; 57 | parts[2] = `${matches.at(1)}-${matches.at(2)}`; 58 | } 59 | const subparts = parts[2].split("-"); 60 | 61 | return [parts[0], parts[1], subparts[0], subparts[1] ?? null]; 62 | } 63 | 64 | private prereleaseToNumber(prerelease: string): number { 65 | if (prerelease.toLowerCase().startsWith('alpha')) { 66 | return 1; 67 | } 68 | if (prerelease.toLowerCase().startsWith('beta')) { 69 | return 2; 70 | } 71 | if (prerelease.toLowerCase().startsWith('rc')) { 72 | return 3; 73 | } 74 | 75 | return 0; 76 | } 77 | } 78 | 79 | -------------------------------------------------------------------------------- /src/app/pages/package-detail/package-detail.component.html: -------------------------------------------------------------------------------- 1 | @if (!loaded()) { 2 | 3 | } @else { 4 |
5 |
6 |
7 |
8 |

Package {{packageDetail()!.name}}, version {{packageDetail()!.version}}

9 | 10 |

This version is found in revision {{packageDetail()!.revision}} (created at {{packageDetail()!.datetime | formatDatetime}}).

11 | 12 |
13 | 14 |

Install using nix-shell

15 | 16 |

To install using nix-shell, run this command:

17 |

nix-shell -p {{packageDetail()!.name}} -I nixpkgs=https://github.com/NixOS/nixpkgs/archive/{{packageDetail()!.revision}}.tar.gz

18 | 19 |

Install using shell.nix file

20 |

Put this into a shell.nix file:

21 |
22 | { pkgs ? import <nixpkgs> {} }:
23 | pkgs.mkShell {
24 |     nativeBuildInputs = with pkgs.buildPackages;
25 |     let
26 |       custom = import (builtins.fetchTarball https://github.com/nixos/nixpkgs/tarball/{{packageDetail()!.revision}}) {};
27 |     in
28 |     [
29 |       custom.{{packageDetail()!.name}}
30 |     ];
31 | }
32 |

Afterwards, just run nix-shell.

33 | 34 |

Install using configuration.nix file (NixOS)

35 |

Import the custom revision at the top of your configuration.nix:

36 |
37 | { config, pkgs, ... }:
38 | let
39 |   custom = import (builtins.fetchTarball https://github.com/nixos/nixpkgs/tarball/{{packageDetail()!.revision}}) {
40 |     config = config.nixpkgs.config;
41 |   };
42 | in
43 | {
44 |

Then, in your list of packages, add custom.{{packageDetail()!.name}}:

45 |
46 | environment.systemPackages = [
47 |   custom.{{packageDetail()!.name}}
48 |   pkgs.your-other-packages
49 | ];
50 |

Install using nix-env

51 |

52 | Unless you really know what you're doing, you should not use nix-env to install an old version of a package. 53 | If you're not sure why this might be a bad idea, you probably shouldn't be doing it and you should use nix-shell instead. 54 |

55 |

56 | To install using nix-env, run the following command: 57 |

58 | nix-env -iA {{packageDetail()!.name}} -f https://github.com/NixOS/nixpkgs/archive/{{packageDetail()!.revision}}.tar.gz 59 |
60 |
61 |
62 | } 63 | -------------------------------------------------------------------------------- /src/app/pages/search-results/search-results.component.html: -------------------------------------------------------------------------------- 1 | @if (!loaded()) { 2 | 3 | } @else { 4 |
5 |
6 |
7 |
8 | @if (search()) { 9 |

10 | Showing results {{ (start() + 1) | formatNumber }}-{{ end() | formatNumber }} of 11 | {{ packageNames().length | formatNumber }} packages. 12 |

13 | } @else if (singlePackage()) { 14 |

15 | Showing versions for package {{ singlePackage() }} 16 |

17 | } 18 |
19 |
    20 | @for (packageName of currentPageResults(); track packageName) { 21 |
  • 22 | 23 | 24 | {{ packageName }} 25 | 26 | 27 | @if (openedPackage() === packageName) { 28 | ({{ versions().length }} versions) 29 | } 30 |
    31 |
    32 |
    33 | @if (openedPackage() === packageName) { 34 |
    35 |
    36 | 37 | 38 | 39 | 40 | 41 | 42 | @for (version of versions(); track version) { 43 | 44 | 47 | 48 | 49 | 50 | } 51 |
    VersionRevisionDate & time
    45 | {{ version.version }} 46 | {{ version.revision }}{{ version.datetime | formatDatetime }}
    52 |
    53 | } 54 |
  • 55 | } 56 |
57 |
58 | @if (search()) { 59 |
    60 |
  • 61 | 62 |
  • 63 |
  • 64 | 65 |
  • 66 |
  • 67 | 68 |
  • 69 |
  • 70 | 71 |
  • 72 |
73 | } 74 |
75 |
76 |
77 | } 78 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | @if (error()) { 2 | 3 | You can file an issue on GitHub. 4 | 5 | } @else if (!(packageNames().length && tags().length)) { 6 | 7 | } @else { 8 |
9 |
10 | 22 |
23 |
24 |
25 |
26 |

27 | Search through {{stats().packages | formatNumber}} packages 28 | @if (stats().versions) { 29 | and {{stats().versions! | formatNumber}} versions 30 | } 31 |

32 |
33 |
34 |
35 | 38 | @if (!childDisplayed()) { 39 |
    40 | @for (option of autocompleteHints(); track option.displayName) { 41 |
  • 42 | } 43 |
44 | } 45 |
46 | 47 |
48 |
49 | 50 |
51 |
52 |
53 |
54 | Report issues on 55 | GitHub 56 | . 57 | @if (latestRevision?.datetime && latestRevision?.revision) { 58 | 59 | Latest revision: {{latestRevision!.revision}} ({{latestRevision!.datetime! | formatDatetime}}) 60 | 61 | } 62 |
63 |
64 |
65 |
66 | } 67 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "NixOsVersionSearchFrontend": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular-devkit/build-angular:browser", 19 | "options": { 20 | "outputPath": "dist/nix-package-history-frontend", 21 | "index": "src/index.html", 22 | "main": "src/main.ts", 23 | "polyfills": [ 24 | "zone.js" 25 | ], 26 | "tsConfig": "tsconfig.app.json", 27 | "inlineStyleLanguage": "scss", 28 | "assets": [ 29 | "src/favicon.ico", 30 | "src/assets" 31 | ], 32 | "styles": [ 33 | "src/styles.scss" 34 | ], 35 | "scripts": [] 36 | }, 37 | "configurations": { 38 | "production": { 39 | "budgets": [ 40 | { 41 | "type": "initial", 42 | "maximumWarning": "500kb", 43 | "maximumError": "1mb" 44 | }, 45 | { 46 | "type": "anyComponentStyle", 47 | "maximumWarning": "2kb", 48 | "maximumError": "4kb" 49 | } 50 | ], 51 | "outputHashing": "all" 52 | }, 53 | "development": { 54 | "buildOptimizer": false, 55 | "optimization": false, 56 | "vendorChunk": true, 57 | "extractLicenses": false, 58 | "sourceMap": true, 59 | "namedChunks": true, 60 | "fileReplacements": [ 61 | { 62 | "replace": "src/environments/environment.ts", 63 | "with": "src/environments/environment.development.ts" 64 | } 65 | ] 66 | } 67 | }, 68 | "defaultConfiguration": "production" 69 | }, 70 | "serve": { 71 | "builder": "@angular-devkit/build-angular:dev-server", 72 | "configurations": { 73 | "production": { 74 | "buildTarget": "NixOsVersionSearchFrontend:build:production" 75 | }, 76 | "development": { 77 | "buildTarget": "NixOsVersionSearchFrontend:build:development" 78 | } 79 | }, 80 | "defaultConfiguration": "development" 81 | }, 82 | "extract-i18n": { 83 | "builder": "@angular-devkit/build-angular:extract-i18n", 84 | "options": { 85 | "buildTarget": "NixOsVersionSearchFrontend:build" 86 | } 87 | }, 88 | "test": { 89 | "builder": "@angular-devkit/build-angular:karma", 90 | "options": { 91 | "polyfills": [ 92 | "zone.js", 93 | "zone.js/testing" 94 | ], 95 | "tsConfig": "tsconfig.spec.json", 96 | "inlineStyleLanguage": "scss", 97 | "assets": [ 98 | "src/favicon.ico", 99 | "src/assets" 100 | ], 101 | "styles": [ 102 | "src/styles.scss" 103 | ], 104 | "scripts": [] 105 | } 106 | } 107 | } 108 | } 109 | }, 110 | "cli": { 111 | "analytics": false 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/app/services/package-manager.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | import {HttpClient} from "@angular/common/http"; 3 | import {from, map, Observable, of, switchMap, tap} from "rxjs"; 4 | import {environment} from "../../environments/environment"; 5 | import {CacheService} from "./cache.service"; 6 | 7 | export interface Tag { 8 | tag: string; 9 | packages: string[]; 10 | } 11 | 12 | 13 | export interface Package { 14 | name: string; 15 | revision: string; 16 | version: string; 17 | datetime: string; 18 | } 19 | 20 | export interface LatestRevision { 21 | datetime: string | null; 22 | revision: string | null; 23 | } 24 | 25 | export interface Stats { 26 | packages: number; 27 | versions: number; 28 | } 29 | 30 | @Injectable({ 31 | providedIn: 'root' 32 | }) 33 | export class PackageManagerService { 34 | constructor( 35 | private readonly httpClient: HttpClient, 36 | private readonly cache: CacheService, 37 | ) {} 38 | 39 | public getPackageNames(): Observable { 40 | return from(this.cache.getItem('package_names', false)) 41 | .pipe( 42 | tap(() => { 43 | this.cache.getItem('latest_revision', true).then(storedRevision => { 44 | this.getLatestRevision().subscribe(latestRevision => { 45 | if ( 46 | storedRevision.value === undefined 47 | || storedRevision.value.datetime === null 48 | || latestRevision.datetime === null 49 | || new Date(storedRevision.value.datetime).getTime() < new Date(latestRevision.datetime).getTime() 50 | ) { 51 | this.getFreshPackageNames().subscribe(names => { 52 | this.cache.storeCache('package_names', undefined, names).then(() => { 53 | this.cache.storeCache('latest_revision', undefined, latestRevision); 54 | }); 55 | }); 56 | } 57 | }); 58 | }); 59 | }), 60 | switchMap(cacheItem => { 61 | if (cacheItem.value === undefined) { 62 | return this.getFreshPackageNames().pipe( 63 | tap(names => { 64 | this.cache.storeCache('package_names', undefined, names); 65 | }), 66 | tap(() => { 67 | this.getLatestRevision().subscribe(revision => { 68 | this.cache.storeCache('latest_revision', undefined, revision); 69 | }) 70 | }), 71 | ); 72 | } 73 | 74 | return of(cacheItem.value); 75 | }), 76 | ); 77 | 78 | } 79 | 80 | public getPackage(name: string): Observable { 81 | if (!name) { 82 | return of([]); 83 | } 84 | 85 | return this.cachedOrFresh( 86 | `package.${name}`, 87 | 60 * 60 * 1_000, 88 | () => this.httpClient.get(`${environment.apiUrl}/packages/${name}`), 89 | ); 90 | } 91 | 92 | public getPackageVersion(name: string, version: string): Observable { 93 | return this.cachedOrFresh( 94 | `package.${name}.${version}`, 95 | 60 * 60 * 1_000, 96 | () => this.httpClient.get(`${environment.apiUrl}/packages/${name}/${version}`), 97 | ); 98 | } 99 | 100 | private getFreshPackageNames(): Observable { 101 | return this.httpClient.get(`${environment.apiUrl}/package-names`); 102 | } 103 | 104 | public getLatestRevision(): Observable { 105 | return this.httpClient.get(`${environment.apiUrl}/latest-revision`); 106 | } 107 | 108 | public getStats(): Observable { 109 | return this.cachedOrFresh( 110 | `stats`, 111 | 60 * 60 * 1_000, 112 | () => this.httpClient.get(`${environment.apiUrl}/stats`), 113 | ); 114 | } 115 | 116 | public getTags(): Observable { 117 | return this.cachedOrFresh( 118 | `tags`, 119 | 60 * 60 * 1_000, 120 | () => this.httpClient.get(`${environment.apiUrl}/tags`), 121 | ); 122 | } 123 | 124 | public getTag(tag: string): Observable { 125 | return this.cachedOrFresh( 126 | `tags.${tag}`, 127 | 60 * 60 * 1_000, 128 | () => this.httpClient.get(`${environment.apiUrl}/tags/${tag}`).pipe( 129 | map(result => { 130 | if (Object.keys(result).length === 0) { 131 | return null; 132 | } 133 | 134 | return result; 135 | }), 136 | ), 137 | ); 138 | } 139 | 140 | private cachedOrFresh(cacheKey: string, cacheValidity: number, freshCallback: () => Observable): Observable { 141 | return from(this.cache.getItem(cacheKey, true)) 142 | .pipe( 143 | switchMap(cacheItem => { 144 | if (cacheItem.value !== undefined) { 145 | return of(cacheItem.value); 146 | } 147 | 148 | return freshCallback().pipe( 149 | tap (result => { 150 | const validUntil = new Date(new Date().getTime() + cacheValidity); 151 | this.cache.storeCache(cacheKey, validUntil, result); 152 | }), 153 | ); 154 | }), 155 | ); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/app/pages/search-results/search-results.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, computed, OnInit, signal} from '@angular/core'; 2 | import {ActivatedRoute, Router, RouterLink} from "@angular/router"; 3 | import {Package, PackageManagerService} from "../../services/package-manager.service"; 4 | import {lastValueFrom} from "rxjs"; 5 | import {VersionComparatorService} from "../../services/version-comparator.service"; 6 | import {FormatNumberPipe} from '../../pipes/format-number.pipe'; 7 | import {LoaderComponent} from '../../components/loader/loader.component'; 8 | import {FormatDatetimePipe} from "../../pipes/format-datetime.pipe"; 9 | 10 | @Component({ 11 | selector: 'app-search-results', 12 | templateUrl: './search-results.component.html', 13 | styleUrls: ['./search-results.component.scss'], 14 | standalone: true, 15 | imports: [LoaderComponent, RouterLink, FormatNumberPipe, FormatDatetimePipe] 16 | }) 17 | export class SearchResultsComponent implements OnInit { 18 | private readonly perPage = 50; 19 | 20 | public isTag = signal(false); 21 | public singlePackage = signal(null); 22 | public search = signal(null); 23 | public packageNames = signal([]); 24 | public currentPage = signal(1); 25 | public currentPageResults = computed(() => { 26 | if (this.singlePackage()) { 27 | return [this.singlePackage()!]; 28 | } 29 | return this.packageNames().slice(this.start(), this.end()); 30 | }); 31 | public start = signal(1); 32 | public end = signal(this.perPage); 33 | public loaded = signal(false); 34 | public maxPage = signal(1); 35 | 36 | public versions = signal([]); 37 | 38 | public openedPackage = signal(null); 39 | 40 | constructor( 41 | private readonly activatedRoute: ActivatedRoute, 42 | private readonly packageManager: PackageManagerService, 43 | private readonly router: Router, 44 | private readonly versionComparator: VersionComparatorService, 45 | ) { 46 | } 47 | 48 | public async ngOnInit(): Promise { 49 | this.activatedRoute.queryParams.subscribe(async queryParams => { 50 | this.loaded.set(false); 51 | this.singlePackage.set(queryParams['packageName'] ?? null); 52 | this.search.set(queryParams['search'] ?? null); 53 | this.currentPage.set(queryParams['page'] ? Number(queryParams['page']) : 1); 54 | 55 | if (this.singlePackage()) { 56 | if (this.singlePackage()!.endsWith('/tag')) { 57 | this.singlePackage.set(this.singlePackage()!.substring(0, this.singlePackage()!.length - 4)); 58 | this.isTag.set(true); 59 | } else { 60 | this.isTag.set(false); 61 | } 62 | 63 | let detail: any[]; 64 | if (this.isTag()) { 65 | detail = [await lastValueFrom(this.packageManager.getTag(this.singlePackage()!))].filter(item => item !== null); 66 | } else { 67 | detail = await lastValueFrom(this.packageManager.getPackage(this.singlePackage()!)); 68 | } 69 | if (!detail.length) { 70 | this.search.set(this.singlePackage()!); 71 | this.singlePackage.set(null); 72 | } else { 73 | await this.openPackage(this.singlePackage()!); 74 | } 75 | } 76 | 77 | if (this.search()) { 78 | this.packageNames.set((await lastValueFrom(this.packageManager.getPackageNames())) 79 | .filter(packageName => packageName.toLowerCase().includes(this.search()!.toLowerCase())) 80 | .sort((a, b) => { 81 | if (a.toLowerCase().startsWith(this.search()!.toLowerCase()) && b.toLowerCase().startsWith(this.search()!.toLowerCase())) { 82 | return 0; 83 | } 84 | 85 | return a.toLowerCase().startsWith(this.search()!.toLowerCase()) ? -1 : 1; 86 | }) 87 | ); 88 | this.start.set(this.currentPage() * this.perPage - this.perPage); 89 | this.end.set(Math.min(this.currentPage() * this.perPage, this.packageNames().length)); 90 | this.maxPage.set(Math.ceil(this.packageNames().length / this.perPage)); 91 | } 92 | 93 | this.loaded.set(true); 94 | }); 95 | } 96 | 97 | public async goToPage(page: number): Promise { 98 | await this.router.navigate([], { 99 | relativeTo: this.activatedRoute, 100 | queryParams: {page: page}, 101 | queryParamsHandling: 'merge', 102 | }); 103 | } 104 | 105 | public async openPackage(packageName: string): Promise { 106 | this.loaded.set(false); 107 | if (this.isTag()) { 108 | const tagDetail = (await lastValueFrom(this.packageManager.getTag(this.singlePackage()!)))!; 109 | const packageNames = tagDetail.packages; 110 | const promises: Promise[] = []; 111 | for (const name of packageNames) { 112 | promises.push(lastValueFrom(this.packageManager.getPackage(name))) 113 | } 114 | this.versions.set((await Promise.all(promises)).flat()); 115 | } else { 116 | this.versions.set((await lastValueFrom(this.packageManager.getPackage(packageName)))); 117 | } 118 | this.versions.update(versions => versions.sort((a, b) => this.versionComparator.compare(a.version, b.version))) 119 | this.openedPackage.set(packageName); 120 | this.loaded.set(true); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: NixOsPackageSearchFrontend 2 | 3 | custom: 4 | CloudfrontHostedZone: Z2FDTNDATAQYW2 5 | Domain: ${env:DOMAIN_NAME} 6 | DomainZone: ${env:DOMAIN_ZONE} 7 | ServiceToken: !Join [':', ['arn:aws:lambda', !Ref AWS::Region, !Ref AWS::AccountId, 'function:AcmCustomResources-prod-customResources']] 8 | 9 | provider: 10 | name: aws 11 | stage: ${opt:stage, 'prod'} 12 | region: ${opt:region, 'eu-central-1'} 13 | runtime: provided 14 | lambdaHashingVersion: '20201221' 15 | stackTags: 16 | BillingProject: NixOsPackageSearch 17 | BillingSubproject: NixOsPackageSearchFrontend 18 | 19 | package: 20 | exclude: 21 | - ./** 22 | 23 | resources: 24 | Resources: 25 | # Certificates 26 | Certificate: 27 | Type: Custom::Certificate 28 | Properties: 29 | DomainName: ${self:custom.Domain} 30 | ValidationMethod: DNS 31 | Region: us-east-1 32 | ServiceToken: ${self:custom.ServiceToken} 33 | CertificateBlocker: 34 | Type: Custom::IssuedCertificate 35 | DependsOn: 36 | - DnsRecordsCertificateValidation 37 | Properties: 38 | CertificateArn: !Ref Certificate 39 | ServiceToken: ${self:custom.ServiceToken} 40 | CertificateDnsRecord: 41 | Type: Custom::CertificateDNSRecord 42 | Properties: 43 | CertificateArn: !Ref Certificate 44 | DomainName: ${self:custom.Domain} 45 | ServiceToken: ${self:custom.ServiceToken} 46 | DnsRecordsCertificateValidation: 47 | Type: AWS::Route53::RecordSetGroup 48 | Properties: 49 | HostedZoneId: ${self:custom.DomainZone} 50 | RecordSets: 51 | - Name: !GetAtt CertificateDnsRecord.Name 52 | Type: !GetAtt CertificateDnsRecord.Type 53 | TTL: 60 54 | Weight: 1 55 | SetIdentifier: !Ref Certificate 56 | ResourceRecords: 57 | - !GetAtt CertificateDnsRecord.Value 58 | 59 | # DNS 60 | DnsRecords: 61 | Type: AWS::Route53::RecordSetGroup 62 | Properties: 63 | HostedZoneId: ${self:custom.DomainZone} 64 | RecordSets: 65 | - AliasTarget: 66 | DNSName: !GetAtt WebsiteCDN.DomainName 67 | HostedZoneId: ${self:custom.CloudfrontHostedZone} 68 | Name: ${self:custom.Domain} 69 | Type: A 70 | 71 | # Hosting 72 | Website: 73 | Type: AWS::S3::Bucket 74 | Properties: 75 | CorsConfiguration: 76 | CorsRules: 77 | - AllowedHeaders: ["*"] 78 | AllowedMethods: [GET] 79 | AllowedOrigins: ["*"] 80 | PublicAccessBlockConfiguration: 81 | BlockPublicAcls: false 82 | BlockPublicPolicy: false 83 | IgnorePublicAcls: false 84 | RestrictPublicBuckets: false 85 | WebsiteBucketPolicy: 86 | Type: AWS::S3::BucketPolicy 87 | Properties: 88 | Bucket: !Ref Website 89 | PolicyDocument: 90 | Statement: 91 | - Effect: Allow 92 | Principal: '*' # everyone 93 | Action: 's3:GetObject' # to read 94 | Resource: !Join ['/', [!GetAtt Website.Arn, '*']] # things in the bucket 95 | 96 | # Webserver 97 | WebsiteCDN: 98 | Type: AWS::CloudFront::Distribution 99 | DependsOn: 100 | - CertificateBlocker 101 | Properties: 102 | DistributionConfig: 103 | Aliases: 104 | - ${self:custom.Domain} 105 | Enabled: true 106 | PriceClass: PriceClass_100 107 | HttpVersion: http2 108 | DefaultRootObject: index.html 109 | Origins: 110 | - Id: Website 111 | DomainName: !GetAtt Website.RegionalDomainName 112 | S3OriginConfig: {} # this key is required to tell CloudFront that this is an S3 origin, even though nothing is configured 113 | DefaultCacheBehavior: 114 | TargetOriginId: Website 115 | AllowedMethods: [GET, HEAD] 116 | ForwardedValues: 117 | QueryString: 'false' 118 | Cookies: 119 | Forward: none 120 | ViewerProtocolPolicy: redirect-to-https 121 | Compress: true 122 | CustomErrorResponses: 123 | - ErrorCode: 500 124 | ErrorCachingMinTTL: 0 125 | - ErrorCode: 504 126 | ErrorCachingMinTTL: 0 127 | - ErrorCode: 404 128 | ResponsePagePath: /index.html 129 | ErrorCachingMinTTL: 0 130 | ResponseCode: 200 131 | - ErrorCode: 403 132 | ResponsePagePath: /index.html 133 | ErrorCachingMinTTL: 0 134 | ResponseCode: 200 135 | ViewerCertificate: 136 | AcmCertificateArn: !Ref Certificate 137 | MinimumProtocolVersion: TLSv1.2_2019 138 | SslSupportMethod: sni-only 139 | 140 | Outputs: 141 | Bucket: 142 | Description: The name of the website bucket 143 | Value: !Ref Website 144 | Cdn: 145 | Description: The id of the CDN for website 146 | Value: !Ref WebsiteCDN 147 | Url: 148 | Description: The url of the deployed app 149 | Value: https://${self:custom.Domain} 150 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, computed, OnInit, signal} from '@angular/core'; 2 | import {LatestRevision, PackageManagerService, Stats, Tag} from "./services/package-manager.service"; 3 | import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms"; 4 | import {Router, RouterLink, RouterOutlet} from "@angular/router"; 5 | import {HttpErrorResponse} from "@angular/common/http"; 6 | import {FormatNumberPipe} from './pipes/format-number.pipe'; 7 | import {ErrorComponent} from './components/error/error.component'; 8 | import {LoaderComponent} from './components/loader/loader.component'; 9 | import {toSignal} from "@angular/core/rxjs-interop"; 10 | import {FormatDatetimePipe} from "./pipes/format-datetime.pipe"; 11 | import {PseudoTagsPipe} from "./pipes/pseudo-tags.pipe"; 12 | 13 | interface AutocompleteHint { 14 | displayName: string; 15 | package: string; 16 | } 17 | 18 | interface PartialStats extends Partial { 19 | packages: number; 20 | } 21 | 22 | @Component({ 23 | selector: 'app-root', 24 | templateUrl: './app.component.html', 25 | styleUrls: ['./app.component.scss'], 26 | standalone: true, 27 | imports: [LoaderComponent, RouterLink, ReactiveFormsModule, RouterOutlet, ErrorComponent, FormatNumberPipe, FormatDatetimePipe, PseudoTagsPipe] 28 | }) 29 | export class AppComponent implements OnInit { 30 | public form = new FormGroup({ 31 | packageName: new FormControl('', [Validators.required]), 32 | }); 33 | public latestRevision: LatestRevision | null = null; 34 | 35 | private currentPackageName = toSignal(this.form.controls.packageName.valueChanges, {initialValue: ''}); 36 | private fullStats = toSignal(this.packageManager.getStats()); 37 | 38 | // todo use toSignal once I find out how to handle the error 39 | public packageNames = signal([]); 40 | public tags = signal([]); 41 | public autocompleteHints = computed(() => { 42 | const currentSearch = this.currentPackageName()!.toLowerCase(); 43 | 44 | const tagsStartsWith: AutocompleteHint[] = this.tags() 45 | .filter(tag => tag.tag.toLowerCase().startsWith(currentSearch)) 46 | .map(tag => ({displayName: `[s]${tag.tag}[/s] (${tag.packages.join(', ')})`, package: `${tag.tag}/tag`})) 47 | ; 48 | const tagsContains: AutocompleteHint[] = this.tags() 49 | .filter(tag => tag.tag.toLowerCase().includes(currentSearch)) 50 | .map(tag => ({displayName: `[s]${tag.tag}[/s] (${tag.packages.join(', ')})`, package: `${tag.tag}/tag`})) 51 | ; 52 | 53 | const packagesStartsWith: AutocompleteHint[] = this.packageNames() 54 | .filter(item => item.toLowerCase().startsWith(currentSearch)) 55 | .map(item => ({displayName: item, package: item})) 56 | ; 57 | const packagesContains: AutocompleteHint[] = this.packageNames() 58 | .filter(item => item.toLowerCase().includes(currentSearch)) 59 | .map(item => ({displayName: item, package: item})) 60 | ; 61 | 62 | return [...tagsStartsWith, ...packagesStartsWith, ...tagsContains, ...packagesContains].slice(0, 100).sort((a, b) => { 63 | const packageA = a.package.endsWith('/tag') ? a.package.substring(0, a.package.length - 4) : a.package; 64 | const packageB = b.package.endsWith('/tag') ? b.package.substring(0, b.package.length - 4) : b.package; 65 | if (packageA === packageB) { 66 | if (a.package.endsWith('/tag')) { 67 | return -1; 68 | } else if (b.package.endsWith('/tag')) { 69 | return 1; 70 | } 71 | if ((a.package.endsWith('/tag') && b.package.endsWith('/tag')) || !(a.package.endsWith('/tag') && b.package.endsWith('/tag'))) { 72 | return 0; 73 | } 74 | 75 | return a.package.endsWith('/tag') ? -1 : 1; 76 | } 77 | 78 | return packageA < packageB ? -1 : 1; 79 | }); 80 | }); 81 | public childDisplayed = signal(false); 82 | public error = signal(''); 83 | public stats = computed(() => { 84 | if (this.fullStats()) { 85 | return this.fullStats()!; 86 | } 87 | 88 | return { 89 | packages: this.packageNames().length, 90 | }; 91 | }); 92 | 93 | constructor( 94 | private readonly packageManager: PackageManagerService, 95 | private readonly router: Router, 96 | ) { 97 | } 98 | 99 | public ngOnInit(): void { 100 | this.packageManager.getPackageNames().subscribe({ 101 | next: packageNames => this.packageNames.set(packageNames), 102 | error: (error: HttpErrorResponse) => { 103 | if (error.status === 429) { 104 | const date = error.headers.get('Retry-After'); 105 | this.error.set('You have requested the list of packages too many times and have been rate limited.'); 106 | if (date) { 107 | const dateFormatted = new Intl.DateTimeFormat('en-US', { 108 | dateStyle: 'medium', 109 | timeStyle: 'short' 110 | }).format(new Date(date)); 111 | this.error.set(`${this.error()} Please try again after ${dateFormatted}.`); 112 | } 113 | return; 114 | } 115 | this.error.set('There was an error while trying to fetch the list of packages.'); 116 | }, 117 | }); 118 | this.packageManager.getTags().subscribe({ 119 | next: tags => this.tags.set(tags), 120 | error: (error: HttpErrorResponse) => { 121 | if (error.status === 429) { 122 | const date = error.headers.get('Retry-After'); 123 | this.error.set('You have requested the list of tags too many times and have been rate limited.'); 124 | if (date) { 125 | const dateFormatted = new Intl.DateTimeFormat('en-US', { 126 | dateStyle: 'medium', 127 | timeStyle: 'short' 128 | }).format(new Date(date)); 129 | this.error.set(`${this.error()} Please try again after ${dateFormatted}.`); 130 | } 131 | return; 132 | } 133 | this.error.set('There was an error while trying to fetch the list of tags.'); 134 | } 135 | }) 136 | this.packageManager.getLatestRevision().subscribe(revision => this.latestRevision = revision); 137 | } 138 | 139 | public async goToPackage(packageName: string) { 140 | this.form.patchValue({packageName: packageName}); 141 | await this.router.navigateByUrl(this.router.createUrlTree(['/search'], { 142 | queryParams: { 143 | packageName: packageName, 144 | } 145 | })); 146 | } 147 | 148 | public async search(): Promise { 149 | await this.router.navigateByUrl(this.router.createUrlTree(['/search'], { 150 | queryParams: { 151 | search: this.form.controls.packageName.value!, 152 | } 153 | })); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/scss/_nix-search.scss: -------------------------------------------------------------------------------- 1 | /* ------------------------------------------------------------------------- */ 2 | /* -- Utils ---------------------------------------------------------------- */ 3 | /* ------------------------------------------------------------------------- */ 4 | 5 | @mixin terminal() { 6 | background: #333; 7 | color: #fff; 8 | margin: 0; 9 | } 10 | 11 | 12 | @mixin search-result-item() { 13 | .result-item-show-more-wrapper { 14 | text-align: center; 15 | } 16 | 17 | // show longer details link 18 | .result-item-show-more { 19 | margin: 0 auto; 20 | display: none; 21 | text-align: center; 22 | text-decoration: none; 23 | line-height: 1.5em; 24 | color: #666; 25 | background: #FFF; 26 | padding: 0 1em; 27 | position: relative; 28 | top: 0.75em; 29 | outline: none; 30 | } 31 | &.opened, 32 | &:hover { 33 | padding-bottom: 0; 34 | 35 | .result-item-show-more { 36 | display: inline-block; 37 | padding-top: 0.5em; 38 | } 39 | } 40 | } 41 | 42 | .package-name { 43 | user-select: all; 44 | } 45 | 46 | /* ------------------------------------------------------------------------- */ 47 | /* -- Layout --------------------------------------------------------------- */ 48 | /* ------------------------------------------------------------------------- */ 49 | 50 | body { 51 | position: relative; 52 | min-height: 100vh; 53 | overflow-y: auto; 54 | 55 | & > div:first-child { 56 | position: relative; 57 | min-height: 100vh; 58 | } 59 | } 60 | 61 | .code-block { 62 | display: block; 63 | cursor: text; 64 | } 65 | 66 | .shell-command:before { 67 | content: "$ "; 68 | } 69 | 70 | #content { 71 | padding-bottom: 4rem; 72 | } 73 | 74 | footer { 75 | position: absolute; 76 | bottom: 0; 77 | width: 100%; 78 | height: 4rem; 79 | } 80 | 81 | header .navbar.navbar-static-top { 82 | .brand { 83 | padding-bottom: 0; 84 | } 85 | img.logo { 86 | margin-top: -5px; 87 | padding-right: 5px; 88 | line-height: 25px; 89 | height: 25px; 90 | } 91 | ul.nav > li { 92 | line-height: 20px; 93 | sup { 94 | margin-left: 0.5em; 95 | } 96 | } 97 | } 98 | 99 | // Search seatch 100 | .search-page { 101 | 102 | &.not-asked { 103 | 104 | & > h1 { 105 | margin-top: 2.5em; 106 | margin-bottom: 0.8em; 107 | } 108 | } 109 | 110 | // Search section title title 111 | & > h1 { 112 | font-weight: normal; 113 | font-size: 2.3em; 114 | 115 | &:before { 116 | content: "\2315"; 117 | display: inline-block; 118 | font-size: 1.5em; 119 | margin-right: 0.2em; 120 | -moz-transform: scale(-1, 1); 121 | -webkit-transform: scale(-1, 1); 122 | -o-transform: scale(-1, 1); 123 | -ms-transform: scale(-1, 1); 124 | transform: scale(-1, 1); 125 | } 126 | } 127 | 128 | // Search input section 129 | & > .search-input { 130 | 131 | // Search Input and Button 132 | & > div:nth-child(1) { 133 | display: grid; 134 | grid-template-columns: auto 8em; 135 | 136 | & > div > input { 137 | font-size: 18px; 138 | height: 40px; 139 | width: 100%; 140 | } 141 | 142 | & > button { 143 | font-size: 24px; 144 | height: 50px; 145 | min-width: 4em; 146 | } 147 | } 148 | 149 | // List of channels 150 | & > div:nth-child(2) { 151 | margin-bottom: 0.5em; 152 | 153 | // "Channels: " label 154 | & > div > h2 { 155 | display: inline; 156 | vertical-align: middle; 157 | font-size: 1.2em; 158 | margin-left: 0.2em; 159 | line-height: 20px 160 | } 161 | } 162 | } 163 | 164 | // Loader during loading the search results 165 | & > .loader-wrapper > h2 { 166 | position: absolute; 167 | top: 3em; 168 | width: 100%; 169 | text-align: center; 170 | } 171 | 172 | & > .search-no-results { 173 | padding: 2em 1em; 174 | text-align: center; 175 | margin-bottom: 2em; 176 | 177 | & > h2 { 178 | margin-top: 0; 179 | } 180 | } 181 | 182 | 183 | .search-result-button { 184 | list-style: none; 185 | margin: 0; 186 | padding: 0; 187 | 188 | & > li { 189 | display: inline-block; 190 | 191 | &:first-child:not(:last-child):after { 192 | content: "→"; 193 | margin: 0 0.2em; 194 | } 195 | 196 | & > a:hover { 197 | text-decoration: underline; 198 | } 199 | } 200 | } 201 | 202 | // Buckets 203 | ul.search-sidebar { 204 | width: 25em; 205 | 206 | list-style: none; 207 | margin: 0 1em 0 0; 208 | 209 | & > li { 210 | margin-bottom: 1em; 211 | border: 1px solid #ccc; 212 | padding: 1em; 213 | border-radius: 4px; 214 | 215 | & > ul { 216 | list-style: none; 217 | margin: 0; 218 | 219 | & > li { 220 | margin-bottom: 0.2em; 221 | 222 | &.header { 223 | font-size: 1.2em; 224 | font-weight: bold; 225 | margin-bottom: 0.5em; 226 | } 227 | 228 | & > a { 229 | display: grid; 230 | grid-template-columns: auto max-content; 231 | color: #333; 232 | padding: 0.5em 0.5em 0.5em 1em; 233 | text-decoration: none; 234 | 235 | &:hover { 236 | text-decoration: none; 237 | background: #eee; 238 | border-radius: 4px; 239 | } 240 | 241 | & > span:first-child { 242 | overflow: hidden; 243 | } 244 | & > span:last-child { 245 | text-align: right; 246 | margin-left: 0.3em; 247 | } 248 | 249 | &.selected { 250 | background: #0081c2; 251 | color: #FFF; 252 | border-radius: 4px; 253 | position: relative; 254 | & > span:last-child { 255 | display: none; 256 | 257 | } 258 | } 259 | 260 | & .close { 261 | opacity: 1; 262 | text-shadow: none; 263 | color: inherit; 264 | font-size: inherit; 265 | padding-left: .5em; 266 | padding-right: .5em; 267 | } 268 | } 269 | } 270 | } 271 | } 272 | } 273 | 274 | 275 | & .search-results { 276 | display: flex; 277 | flex-direction: row; 278 | 279 | // Results section 280 | & > div { 281 | width: 100%; 282 | 283 | // Search results header 284 | & > :nth-child(1) { 285 | 286 | // Dropdown to show sorting options 287 | & > div:nth-child(1) { 288 | 289 | & > button { 290 | & > .selected { 291 | margin-right: 0.5em; 292 | } 293 | } 294 | 295 | & > ul > li { 296 | 297 | & > a { 298 | padding: 3px 10px; 299 | } 300 | 301 | & > a:before { 302 | display: inline-block; 303 | content: " "; 304 | width: 24.5px; 305 | } 306 | 307 | &.selected > a:before { 308 | content: "\2714"; 309 | } 310 | } 311 | 312 | & > ul > li.header { 313 | font-weight: bold; 314 | padding: 3px 10px 0 10px; 315 | } 316 | 317 | & > ul > li.header:before, 318 | & > ul > li.divider:before { 319 | display: none; 320 | } 321 | } 322 | 323 | // Text that displays number of results 324 | & > h2:nth-child(2) { 325 | font-size: 1.7em; 326 | font-weight: normal; 327 | line-height: 1.3em; 328 | 329 | & > p { 330 | font-size: 0.7em; 331 | } 332 | } 333 | } 334 | 335 | // Search results list 336 | & > :nth-child(2) { 337 | list-style: none; 338 | margin: 2em 0 0 0; 339 | 340 | // Result item 341 | & > li { 342 | border-bottom: 1px solid #ccc; 343 | padding-bottom: 2em; 344 | margin-bottom: 2em; 345 | 346 | &:last-child { 347 | border-bottom: 0; 348 | } 349 | 350 | // Attribute name or option name 351 | & > :nth-child(1) { 352 | background: inherit; 353 | border: 0; 354 | padding: 0; 355 | color: #08c; 356 | font-size: 1.5em; 357 | margin-bottom: 0.5em; 358 | text-align: left; 359 | display: block; 360 | } 361 | 362 | &.package { 363 | @include search-result-item; 364 | 365 | // Description 366 | & > :nth-child(2) { 367 | font-size: 1.2em; 368 | margin-bottom: 0.5em; 369 | text-align: left; 370 | } 371 | 372 | // short details of a pacakge 373 | & > :nth-child(3) { 374 | color: #666; 375 | list-style: none; 376 | text-align: left; 377 | margin: 0; 378 | 379 | & > li { 380 | display: inline-block; 381 | margin-right: 1em; 382 | } 383 | & > li:last-child { 384 | margin-right: 0; 385 | } 386 | } 387 | 388 | // longer details of a pacakge 389 | & > :nth-child(5) { 390 | margin: 2em 0 1em 1em; 391 | text-align: left; 392 | 393 | // long description of a package 394 | & > :nth-child(1) { 395 | margin-top: 1em; 396 | } 397 | 398 | // how to install a package 399 | & > :nth-child(2) { 400 | 401 | h4 { 402 | font-size: 1.2em; 403 | line-height: 1em; 404 | float: left; 405 | } 406 | 407 | ul.nav-tabs { 408 | margin: 0; 409 | 410 | & > li > a { 411 | margin-right: 0; 412 | } 413 | } 414 | 415 | div.tab-content { 416 | padding: 1em; 417 | border: 1px solid #ddd; 418 | border-top: 0; 419 | } 420 | 421 | pre { 422 | @include terminal; 423 | } 424 | 425 | } 426 | 427 | // programs 428 | & > :nth-child(3) { 429 | } 430 | 431 | // maintainers and platforms 432 | & > :nth-child(4) { 433 | margin-top: 1em; 434 | display: grid; 435 | grid-template-columns: auto auto; 436 | } 437 | } 438 | } 439 | 440 | &.option { 441 | margin: 0; 442 | padding: 0; 443 | 444 | & > :nth-child(1) { 445 | padding: 0.5em 0; 446 | } 447 | 448 | // short details of a pacakge 449 | & > :nth-child(2) { 450 | margin: 2em 0 1em 1em; 451 | display: grid; 452 | grid-template-columns: 100px 1fr; 453 | column-gap: 1em; 454 | row-gap: 0.5em; 455 | 456 | & > div:nth-child(2n+1) { 457 | font-weight: bold; 458 | text-align: right; 459 | } 460 | 461 | & > div:nth-child(2n) { 462 | pre { 463 | background: transparent; 464 | margin: 0; 465 | padding: 0; 466 | border: 0; 467 | vertical-align: inherit; 468 | display: inline; 469 | } 470 | 471 | pre code { 472 | background: #333; 473 | color: #fff; 474 | padding: 0.5em 475 | } 476 | } 477 | 478 | } 479 | } 480 | } 481 | 482 | } 483 | 484 | // Search results footer 485 | & > :nth-child(3) { 486 | margin-top: 1em; 487 | 488 | & > ul > li > a { 489 | cursor: pointer; 490 | margin: 0 2px; 491 | } 492 | } 493 | } 494 | } 495 | } 496 | 497 | /* ------------------------------------------------------------------------- */ 498 | /* -- Loader --------------------------------------------------------------- */ 499 | /* ------------------------------------------------------------------------- */ 500 | 501 | .loader-wrapper { 502 | height: 200px; 503 | overflow: hidden; 504 | position: relative; 505 | } 506 | .loader, 507 | .loader:before, 508 | .loader:after { 509 | background: transparent; 510 | -webkit-animation: load1 1s infinite ease-in-out; 511 | animation: load1 1s infinite ease-in-out; 512 | width: 1em; 513 | height: 4em; 514 | } 515 | .loader { 516 | color: #000000; 517 | text-indent: -9999em; 518 | margin: 88px auto; 519 | position: relative; 520 | font-size: 11px; 521 | -webkit-transform: translateZ(0); 522 | -ms-transform: translateZ(0); 523 | transform: translateZ(0); 524 | -webkit-animation-delay: -0.16s; 525 | animation-delay: -0.16s; 526 | } 527 | .loader:before, 528 | .loader:after { 529 | position: absolute; 530 | top: 0; 531 | content: ''; 532 | } 533 | .loader:before { 534 | left: -1.5em; 535 | -webkit-animation-delay: -0.32s; 536 | animation-delay: -0.32s; 537 | } 538 | .loader:after { 539 | left: 1.5em; 540 | } 541 | @keyframes load1 { 542 | 0%, 543 | 80%, 544 | 100% { 545 | box-shadow: 0 0; 546 | height: 4em; 547 | } 548 | 40% { 549 | box-shadow: 0 -2em; 550 | height: 5em; 551 | } 552 | } 553 | 554 | /* ------------------------------------------------------------------------- */ 555 | /* -- Accessibility overrides ---------------------------------------------- */ 556 | /* ------------------------------------------------------------------------- */ 557 | .label-info { 558 | background: #007dbb; 559 | } 560 | 561 | a { 562 | color: #007dbb; 563 | } 564 | 565 | .pager { 566 | display: flex; 567 | justify-content: space-between; 568 | align-items: center; 569 | max-width: 20em; 570 | margin: 0 auto; 571 | padding: 20px 0; 572 | } 573 | 574 | .badge { 575 | background-color: #757575 576 | } 577 | --------------------------------------------------------------------------------