├── .gitattributes ├── .github └── workflows │ └── azure-static-web-apps-kind-bush-0e83b041e.yml ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── Api ├── .gitignore ├── .vscode │ └── extensions.json ├── Api.csproj ├── ApiTests.http ├── ClientPrincipalUtility.cs ├── CosmosSystemTextJsonSerializer.cs ├── Functions │ ├── CreateLinkBundle.cs │ ├── DeleteLinkBundle.cs │ ├── GetLinkBundle.cs │ ├── GetLinkBundlesForUser.cs │ ├── GetOpenGraphInfo.cs │ ├── GetQrCode.cs │ └── UpdateLinkBundle.cs ├── Hasher.cs ├── HttpRequestDataExtensions.cs ├── Program.cs └── host.json ├── BlazorStaticWebApps.sln ├── Client ├── .editorconfig ├── .prettierrc ├── App.razor ├── Client.csproj ├── Pages │ ├── Edit.razor │ ├── Index.razor │ ├── Index.razor.css │ ├── Migrate.razor │ ├── MyLists.razor │ ├── MyLists.razor.css │ ├── New.razor │ ├── Privacy.razor │ ├── Public.razor │ └── Terms.razor ├── Program.cs ├── Properties │ └── launchSettings.json ├── Shared │ ├── LinkBundleDetails.razor │ ├── LinkBundleDetails.razor.css │ ├── LinkBundleItem.razor │ ├── LinkBundleItem.razor.css │ ├── LinkBundleItemEditable.razor │ ├── LinkBundleItemEditable.razor.css │ ├── LinkBundleItems.razor │ ├── MainLayout.razor │ ├── MainLayout.razor.css │ ├── ModalConfirm.razor │ ├── ModalImport.razor │ ├── ModalLogin.razor │ ├── ModalLogin.razor.css │ ├── ModalWindow.razor │ ├── ModalWindow.razor.css │ ├── NavBar.razor │ ├── NavBar.razor.css │ ├── NewLink.razor │ ├── ProgressBar.razor │ ├── ProgressBar.razor.css │ ├── QrCodeItem.razor │ ├── QrCodeItem.razor.css │ ├── SortableList.razor │ ├── SortableList.razor.css │ ├── SortableList.razor.js │ ├── ThemeSwitcher.razor │ └── ThemeSwitcher.razor.css ├── StateContainer.cs ├── Utils │ └── Debouncer.cs ├── _Imports.razor ├── staticwebapp.config.json └── wwwroot │ ├── appsettings.Development.json │ ├── css │ ├── app.css │ └── bulma │ │ ├── bulma.css │ │ ├── bulma.css.map │ │ └── bulma.min.css │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── images │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── banner-logo-large.png │ ├── banner-logo-large.svg │ ├── bg.png │ ├── bg@2x.png │ ├── bg@3x.png │ ├── burger.svg │ ├── close.png │ ├── close.svg │ ├── close@2x.png │ ├── close@3x.png │ ├── logo-beta.svg │ ├── logo-dark.svg │ ├── logo.svg │ ├── no-image.png │ ├── no-image.svg │ └── urly.png │ ├── index.html │ └── js │ └── sortable.min.js ├── LICENSE ├── README.md ├── Shared ├── Claim.cs ├── ClientPrincipal.cs ├── ClientPrincipalWrapper.cs ├── Link.cs ├── LinkBundle.cs ├── Shared.csproj └── User.cs └── swa-cli.config.json /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.github/workflows/azure-static-web-apps-kind-bush-0e83b041e.yml: -------------------------------------------------------------------------------- 1 | name: Azure Static Web Apps CI/CD 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [opened, synchronize, reopened, closed] 9 | branches: 10 | - main 11 | 12 | jobs: 13 | build_and_deploy_job: 14 | if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') 15 | runs-on: ubuntu-latest 16 | name: Build and Deploy Job 17 | steps: 18 | - uses: actions/checkout@v3 19 | with: 20 | submodules: true 21 | lfs: false 22 | - name: Build And Deploy 23 | id: builddeploy 24 | uses: Azure/static-web-apps-deploy@v1 25 | with: 26 | azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_KIND_BUSH_0E83B041E }} 27 | repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments) 28 | action: "upload" 29 | ###### Repository/Build Configurations - These values can be configured to match your app requirements. ###### 30 | # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig 31 | app_location: "./Client" # App source code path 32 | api_location: "./Api" # Api source code path - optional 33 | output_location: "wwwroot" # Built app content directory - optional 34 | ###### End of Repository/Build Configurations ###### 35 | 36 | close_pull_request_job: 37 | if: github.event_name == 'pull_request' && github.event.action == 'closed' 38 | runs-on: ubuntu-latest 39 | name: Close Pull Request Job 40 | steps: 41 | - name: Close Pull Request 42 | id: closepullrequest 43 | uses: Azure/static-web-apps-deploy@v1 44 | with: 45 | azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_KIND_BUSH_0E83B041E }} 46 | action: "close" 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Build results 17 | [Dd]ebug/ 18 | [Dd]ebugPublic/ 19 | [Rr]elease/ 20 | [Rr]eleases/ 21 | x64/ 22 | x86/ 23 | [Aa][Rr][Mm]/ 24 | [Aa][Rr][Mm]64/ 25 | bld/ 26 | [Bb]in/ 27 | [Oo]bj/ 28 | [Ll]og/ 29 | 30 | # Visual Studio 2015/2017 cache/options directory 31 | .vs/ 32 | # Uncomment if you have tasks that create the project's static files in wwwroot 33 | #wwwroot/ 34 | 35 | # Visual Studio 2017 auto generated files 36 | Generated\ Files/ 37 | 38 | # MSTest test Results 39 | [Tt]est[Rr]esult*/ 40 | [Bb]uild[Ll]og.* 41 | 42 | # NUNIT 43 | *.VisualState.xml 44 | TestResult.xml 45 | 46 | # Build Results of an ATL Project 47 | [Dd]ebugPS/ 48 | [Rr]eleasePS/ 49 | dlldata.c 50 | 51 | # Benchmark Results 52 | BenchmarkDotNet.Artifacts/ 53 | 54 | # .NET Core 55 | project.lock.json 56 | project.fragment.lock.json 57 | artifacts/ 58 | 59 | # StyleCop 60 | StyleCopReport.xml 61 | 62 | # Files built by Visual Studio 63 | obj 64 | bin 65 | *_i.c 66 | *_p.c 67 | *_h.h 68 | *.ilk 69 | *.meta 70 | *.obj 71 | *.iobj 72 | *.pch 73 | *.pdb 74 | *.ipdb 75 | *.pgc 76 | *.pgd 77 | *.rsp 78 | *.sbr 79 | *.tlb 80 | *.tli 81 | *.tlh 82 | *.tmp 83 | *.tmp_proj 84 | *_wpftmp.csproj 85 | *.log 86 | *.vspscc 87 | *.vssscc 88 | .builds 89 | *.pidb 90 | *.svclog 91 | *.scc 92 | 93 | # Chutzpah Test files 94 | _Chutzpah* 95 | 96 | # Visual C++ cache files 97 | ipch/ 98 | *.aps 99 | *.ncb 100 | *.opendb 101 | *.opensdf 102 | *.sdf 103 | *.cachefile 104 | *.VC.db 105 | *.VC.VC.opendb 106 | 107 | # Visual Studio profiler 108 | *.psess 109 | *.vsp 110 | *.vspx 111 | *.sap 112 | 113 | # Visual Studio Trace Files 114 | *.e2e 115 | 116 | # TFS 2012 Local Workspace 117 | $tf/ 118 | 119 | # Guidance Automation Toolkit 120 | *.gpState 121 | 122 | # ReSharper is a .NET coding add-in 123 | _ReSharper*/ 124 | *.[Rr]e[Ss]harper 125 | *.DotSettings.user 126 | 127 | # JustCode is a .NET coding add-in 128 | .JustCode 129 | 130 | # TeamCity is a build add-in 131 | _TeamCity* 132 | 133 | # DotCover is a Code Coverage Tool 134 | *.dotCover 135 | 136 | # AxoCover is a Code Coverage Tool 137 | .axoCover/* 138 | !.axoCover/settings.json 139 | 140 | # Visual Studio code coverage results 141 | *.coverage 142 | *.coveragexml 143 | 144 | # NCrunch 145 | _NCrunch_* 146 | .*crunch*.local.xml 147 | nCrunchTemp_* 148 | 149 | # MightyMoose 150 | *.mm.* 151 | AutoTest.Net/ 152 | 153 | # Web workbench (sass) 154 | .sass-cache/ 155 | 156 | # Installshield output folder 157 | [Ee]xpress/ 158 | 159 | # DocProject is a documentation generator add-in 160 | DocProject/buildhelp/ 161 | DocProject/Help/*.HxT 162 | DocProject/Help/*.HxC 163 | DocProject/Help/*.hhc 164 | DocProject/Help/*.hhk 165 | DocProject/Help/*.hhp 166 | DocProject/Help/Html2 167 | DocProject/Help/html 168 | 169 | # Click-Once directory 170 | publish/ 171 | 172 | # Publish Web Output 173 | *.[Pp]ublish.xml 174 | *.azurePubxml 175 | # Note: Comment the next line if you want to checkin your web deploy settings, 176 | # but database connection strings (with potential passwords) will be unencrypted 177 | *.pubxml 178 | *.publishproj 179 | 180 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 181 | # checkin your Azure Web App publish settings, but sensitive information contained 182 | # in these scripts will be unencrypted 183 | PublishScripts/ 184 | 185 | # NuGet Packages 186 | *.nupkg 187 | # The packages folder can be ignored because of Package Restore 188 | **/[Pp]ackages/* 189 | # except build/, which is used as an MSBuild target. 190 | !**/[Pp]ackages/build/ 191 | # Uncomment if necessary however generally it will be regenerated when needed 192 | #!**/[Pp]ackages/repositories.config 193 | # NuGet v3's project.json files produces more ignorable files 194 | *.nuget.props 195 | *.nuget.targets 196 | 197 | # Microsoft Azure Build Output 198 | csx/ 199 | *.build.csdef 200 | 201 | # Microsoft Azure Emulator 202 | ecf/ 203 | rcf/ 204 | 205 | # Windows Store app package directories and files 206 | AppPackages/ 207 | BundleArtifacts/ 208 | Package.StoreAssociation.xml 209 | _pkginfo.txt 210 | *.appx 211 | 212 | # Visual Studio cache files 213 | # files ending in .cache can be ignored 214 | *.[Cc]ache 215 | # but keep track of directories ending in .cache 216 | !?*.[Cc]ache/ 217 | 218 | # Others 219 | ClientBin/ 220 | ~$* 221 | *~ 222 | *.dbmdl 223 | *.dbproj.schemaview 224 | *.jfm 225 | *.pfx 226 | *.publishsettings 227 | orleans.codegen.cs 228 | 229 | # Including strong name files can present a security risk 230 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 231 | #*.snk 232 | 233 | # Since there are multiple workflows, uncomment next line to ignore bower_components 234 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 235 | #bower_components/ 236 | 237 | # RIA/Silverlight projects 238 | Generated_Code/ 239 | 240 | # Backup & report files from converting an old project file 241 | # to a newer Visual Studio version. Backup files are not needed, 242 | # because we have git ;-) 243 | _UpgradeReport_Files/ 244 | Backup*/ 245 | UpgradeLog*.XML 246 | UpgradeLog*.htm 247 | ServiceFabricBackup/ 248 | *.rptproj.bak 249 | 250 | # SQL Server files 251 | *.mdf 252 | *.ldf 253 | *.ndf 254 | 255 | # Business Intelligence projects 256 | *.rdl.data 257 | *.bim.layout 258 | *.bim_*.settings 259 | *.rptproj.rsuser 260 | *- Backup*.rdl 261 | 262 | # Microsoft Fakes 263 | FakesAssemblies/ 264 | 265 | # GhostDoc plugin setting file 266 | *.GhostDoc.xml 267 | 268 | # Node.js Tools for Visual Studio 269 | .ntvs_analysis.dat 270 | node_modules/ 271 | 272 | # Visual Studio 6 build log 273 | *.plg 274 | 275 | # Visual Studio 6 workspace options file 276 | *.opt 277 | 278 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 279 | *.vbw 280 | 281 | # Visual Studio LightSwitch build output 282 | **/*.HTMLClient/GeneratedArtifacts 283 | **/*.DesktopClient/GeneratedArtifacts 284 | **/*.DesktopClient/ModelManifest.xml 285 | **/*.Server/GeneratedArtifacts 286 | **/*.Server/ModelManifest.xml 287 | _Pvt_Extensions 288 | 289 | # Paket dependency manager 290 | .paket/paket.exe 291 | paket-files/ 292 | 293 | # FAKE - F# Make 294 | .fake/ 295 | 296 | # JetBrains Rider 297 | .idea/ 298 | *.sln.iml 299 | 300 | # CodeRush personal settings 301 | .cr/personal 302 | 303 | # Python Tools for Visual Studio (PTVS) 304 | __pycache__/ 305 | *.pyc 306 | 307 | # Cake - Uncomment if you are using it 308 | # tools/** 309 | # !tools/packages.config 310 | 311 | # Tabs Studio 312 | *.tss 313 | 314 | # Telerik's JustMock configuration file 315 | *.jmconfig 316 | 317 | # BizTalk build output 318 | *.btp.cs 319 | *.btm.cs 320 | *.odx.cs 321 | *.xsd.cs 322 | 323 | # OpenCover UI analysis results 324 | OpenCover/ 325 | 326 | # Azure Stream Analytics local run output 327 | ASALocalRun/ 328 | 329 | # MSBuild Binary and Structured Log 330 | *.binlog 331 | 332 | # NVidia Nsight GPU debugger configuration file 333 | *.nvuser 334 | 335 | # MFractors (Xamarin productivity tool) working folder 336 | .mfractor/ 337 | 338 | # Local History for Visual Studio 339 | .localhistory/ 340 | 341 | # BeatPulse healthcheck temp database 342 | healthchecksdb 343 | /test-results/ 344 | /playwright-report/ 345 | /playwright/.cache/ 346 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.vscode-azurefunctions", 4 | "ms-dotnettools.csharp" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Attach to Blazor WebAssembly App", 6 | "type": "blazorwasm", 7 | "request": "attach", 8 | "cwd": "${workspaceFolder}/Client" 9 | }, 10 | { 11 | "name": "Attach to .NET Functions", 12 | "type": "coreclr", 13 | "request": "attach", 14 | "processId": "${command:azureFunctions.pickProcess}" 15 | } 16 | ], 17 | "compounds": [ 18 | { 19 | "name": "Client/Server", 20 | "configurations": [ 21 | "Attach to .NET Functions", 22 | "Launch and Debug Standalone Blazor WebAssembly App" 23 | ] 24 | } 25 | ] 26 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "azureFunctions.deploySubpath": "Api/bin/Release/net8.0/publish", 3 | "azureFunctions.projectLanguage": "C#", 4 | "azureFunctions.projectRuntime": "~4", 5 | "debug.internalConsoleOptions": "neverOpen", 6 | "azureFunctions.preDeployTask": "publish", 7 | "dotnet.defaultSolution": "BlazorStaticWebApps.sln", 8 | "editor.tabSize": 2, 9 | "[html]": { 10 | "editor.defaultFormatter": "vscode.html-language-features" 11 | }, 12 | "explorer.fileNesting.enabled": true, 13 | "explorer.fileNesting.patterns": { 14 | "*.razor": "${capture}.razor.*" 15 | } 16 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "clean", 6 | "command": "dotnet", 7 | "args": [ 8 | "clean", 9 | "/property:GenerateFullPaths=true", 10 | "/consoleloggerparameters:NoSummary" 11 | ], 12 | "type": "process", 13 | "problemMatcher": "$msCompile", 14 | "options": { 15 | "cwd": "${workspaceFolder}/Api" 16 | } 17 | }, 18 | { 19 | "label": "build", 20 | "command": "dotnet", 21 | "args": [ 22 | "build", 23 | "/property:GenerateFullPaths=true", 24 | "/consoleloggerparameters:NoSummary" 25 | ], 26 | "type": "process", 27 | "dependsOn": "clean", 28 | "group": { 29 | "kind": "build", 30 | "isDefault": true 31 | }, 32 | "problemMatcher": "$msCompile", 33 | "options": { 34 | "cwd": "${workspaceFolder}/Api" 35 | } 36 | }, 37 | { 38 | "label": "clean release", 39 | "command": "dotnet", 40 | "args": [ 41 | "clean", 42 | "--configuration", 43 | "Release", 44 | "/property:GenerateFullPaths=true", 45 | "/consoleloggerparameters:NoSummary" 46 | ], 47 | "type": "process", 48 | "problemMatcher": "$msCompile", 49 | "options": { 50 | "cwd": "${workspaceFolder}/Api" 51 | } 52 | }, 53 | { 54 | "label": "publish", 55 | "command": "dotnet", 56 | "args": [ 57 | "publish", 58 | "--configuration", 59 | "Release", 60 | "/property:GenerateFullPaths=true", 61 | "/consoleloggerparameters:NoSummary" 62 | ], 63 | "type": "process", 64 | "dependsOn": "clean release", 65 | "problemMatcher": "$msCompile", 66 | "options": { 67 | "cwd": "${workspaceFolder}/Api" 68 | } 69 | }, 70 | { 71 | "type": "func", 72 | "dependsOn": "build", 73 | "options": { 74 | "cwd": "${workspaceFolder}/Api/bin/Debug/net8.0" 75 | }, 76 | "command": "host start", 77 | "isBackground": true, 78 | "problemMatcher": "$func-watch" 79 | } 80 | ] 81 | } -------------------------------------------------------------------------------- /Api/.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # Azure Functions localsettings file 5 | local.settings.json 6 | 7 | # User-specific files 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Build results 17 | [Dd]ebug/ 18 | [Dd]ebugPublic/ 19 | [Rr]elease/ 20 | [Rr]eleases/ 21 | x64/ 22 | x86/ 23 | bld/ 24 | [Bb]in/ 25 | [Oo]bj/ 26 | [Ll]og/ 27 | 28 | # Visual Studio 2015 cache/options directory 29 | .vs/ 30 | # Uncomment if you have tasks that create the project's static files in wwwroot 31 | #wwwroot/ 32 | 33 | # MSTest test Results 34 | [Tt]est[Rr]esult*/ 35 | [Bb]uild[Ll]og.* 36 | 37 | # NUNIT 38 | *.VisualState.xml 39 | TestResult.xml 40 | 41 | # Build Results of an ATL Project 42 | [Dd]ebugPS/ 43 | [Rr]eleasePS/ 44 | dlldata.c 45 | 46 | # DNX 47 | project.lock.json 48 | project.fragment.lock.json 49 | artifacts/ 50 | 51 | *_i.c 52 | *_p.c 53 | *_i.h 54 | *.ilk 55 | *.meta 56 | *.obj 57 | *.pch 58 | *.pdb 59 | *.pgc 60 | *.pgd 61 | *.rsp 62 | *.sbr 63 | *.tlb 64 | *.tli 65 | *.tlh 66 | *.tmp 67 | *.tmp_proj 68 | *.log 69 | *.vspscc 70 | *.vssscc 71 | .builds 72 | *.pidb 73 | *.svclog 74 | *.scc 75 | 76 | # Chutzpah Test files 77 | _Chutzpah* 78 | 79 | # Visual C++ cache files 80 | ipch/ 81 | *.aps 82 | *.ncb 83 | *.opendb 84 | *.opensdf 85 | *.sdf 86 | *.cachefile 87 | *.VC.db 88 | *.VC.VC.opendb 89 | 90 | # Visual Studio profiler 91 | *.psess 92 | *.vsp 93 | *.vspx 94 | *.sap 95 | 96 | # TFS 2012 Local Workspace 97 | $tf/ 98 | 99 | # Guidance Automation Toolkit 100 | *.gpState 101 | 102 | # ReSharper is a .NET coding add-in 103 | _ReSharper*/ 104 | *.[Rr]e[Ss]harper 105 | *.DotSettings.user 106 | 107 | # JustCode is a .NET coding add-in 108 | .JustCode 109 | 110 | # TeamCity is a build add-in 111 | _TeamCity* 112 | 113 | # DotCover is a Code Coverage Tool 114 | *.dotCover 115 | 116 | # NCrunch 117 | _NCrunch_* 118 | .*crunch*.local.xml 119 | nCrunchTemp_* 120 | 121 | # MightyMoose 122 | *.mm.* 123 | AutoTest.Net/ 124 | 125 | # Web workbench (sass) 126 | .sass-cache/ 127 | 128 | # Installshield output folder 129 | [Ee]xpress/ 130 | 131 | # DocProject is a documentation generator add-in 132 | DocProject/buildhelp/ 133 | DocProject/Help/*.HxT 134 | DocProject/Help/*.HxC 135 | DocProject/Help/*.hhc 136 | DocProject/Help/*.hhk 137 | DocProject/Help/*.hhp 138 | DocProject/Help/Html2 139 | DocProject/Help/html 140 | 141 | # Click-Once directory 142 | publish/ 143 | 144 | # Publish Web Output 145 | *.[Pp]ublish.xml 146 | *.azurePubxml 147 | # TODO: Comment the next line if you want to checkin your web deploy settings 148 | # but database connection strings (with potential passwords) will be unencrypted 149 | #*.pubxml 150 | *.publishproj 151 | 152 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 153 | # checkin your Azure Web App publish settings, but sensitive information contained 154 | # in these scripts will be unencrypted 155 | PublishScripts/ 156 | 157 | # NuGet Packages 158 | *.nupkg 159 | # The packages folder can be ignored because of Package Restore 160 | **/packages/* 161 | # except build/, which is used as an MSBuild target. 162 | !**/packages/build/ 163 | # Uncomment if necessary however generally it will be regenerated when needed 164 | #!**/packages/repositories.config 165 | # NuGet v3's project.json files produces more ignoreable files 166 | *.nuget.props 167 | *.nuget.targets 168 | 169 | # Microsoft Azure Build Output 170 | csx/ 171 | *.build.csdef 172 | 173 | # Microsoft Azure Emulator 174 | ecf/ 175 | rcf/ 176 | 177 | # Windows Store app package directories and files 178 | AppPackages/ 179 | BundleArtifacts/ 180 | Package.StoreAssociation.xml 181 | _pkginfo.txt 182 | 183 | # Visual Studio cache files 184 | # files ending in .cache can be ignored 185 | *.[Cc]ache 186 | # but keep track of directories ending in .cache 187 | !*.[Cc]ache/ 188 | 189 | # Others 190 | ClientBin/ 191 | ~$* 192 | *~ 193 | *.dbmdl 194 | *.dbproj.schemaview 195 | *.jfm 196 | *.pfx 197 | *.publishsettings 198 | node_modules/ 199 | orleans.codegen.cs 200 | 201 | # Since there are multiple workflows, uncomment next line to ignore bower_components 202 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 203 | #bower_components/ 204 | 205 | # RIA/Silverlight projects 206 | Generated_Code/ 207 | 208 | # Backup & report files from converting an old project file 209 | # to a newer Visual Studio version. Backup files are not needed, 210 | # because we have git ;-) 211 | _UpgradeReport_Files/ 212 | Backup*/ 213 | UpgradeLog*.XML 214 | UpgradeLog*.htm 215 | 216 | # SQL Server files 217 | *.mdf 218 | *.ldf 219 | 220 | # Business Intelligence projects 221 | *.rdl.data 222 | *.bim.layout 223 | *.bim_*.settings 224 | 225 | # Microsoft Fakes 226 | FakesAssemblies/ 227 | 228 | # GhostDoc plugin setting file 229 | *.GhostDoc.xml 230 | 231 | # Node.js Tools for Visual Studio 232 | .ntvs_analysis.dat 233 | 234 | # Visual Studio 6 build log 235 | *.plg 236 | 237 | # Visual Studio 6 workspace options file 238 | *.opt 239 | 240 | # Visual Studio LightSwitch build output 241 | **/*.HTMLClient/GeneratedArtifacts 242 | **/*.DesktopClient/GeneratedArtifacts 243 | **/*.DesktopClient/ModelManifest.xml 244 | **/*.Server/GeneratedArtifacts 245 | **/*.Server/ModelManifest.xml 246 | _Pvt_Extensions 247 | 248 | # Paket dependency manager 249 | .paket/paket.exe 250 | paket-files/ 251 | 252 | # FAKE - F# Make 253 | .fake/ 254 | 255 | # JetBrains Rider 256 | .idea/ 257 | *.sln.iml 258 | 259 | # CodeRush 260 | .cr/ 261 | 262 | # Python Tools for Visual Studio (PTVS) 263 | __pycache__/ 264 | *.pyc -------------------------------------------------------------------------------- /Api/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.vscode-azurefunctions" 4 | ] 5 | } -------------------------------------------------------------------------------- /Api/Api.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net8.0 4 | v4 5 | Exe 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | PreserveNewest 20 | 21 | 22 | PreserveNewest 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /Api/ApiTests.http: -------------------------------------------------------------------------------- 1 | @port=7071 2 | @clientPrincipal=eyJ1c2VySWQiOiJ0ZXN0VXNlciIsInVzZXJSb2xlcyI6WyJhbm9ueW1vdXMiLCAiYXV0aGVudGljYXRlZCJdLCJpZGVudGl0eVByb3ZpZGVyIjoidGVzdFByb3ZpZGVyIiwidXNlckRldGFpbHMiOiJ0ZXN0RGV0YWlscyJ9 3 | #original value: {"userId":"testUser","userRoles":["anonymous", "authenticated"],"identityProvider":"testProvider","userDetails":"testDetails"} 4 | 5 | POST http://localhost:{{port}}/api/links 6 | x-ms-client-principal: {{clientPrincipal}} 7 | 8 | { 9 | "links":[ 10 | { 11 | "id":"https://google.com", 12 | "url":"https://google.com", 13 | "title":"Google", 14 | "description":"Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for.", 15 | "image":"" 16 | }, 17 | { 18 | "id":"https://github.com", 19 | "url":"https://github.com", 20 | "title":"GitHub", 21 | "description":"GitHub is where over 65 million developers shape the future of software, together. Contribute to the open source community, manage your Git repositories, review code like a pro, track bugs and features, power your CI/CD and DevOps workflows, and secure code before you commit it.", 22 | "image":"" 23 | }, 24 | { 25 | "id":"https://stackoverflow.com", 26 | "url":"https://stackoverflow.com", 27 | "title":"Stack Overflow", 28 | "description":"Stack Overflow is the largest, most trusted online community for developers to learn, share their programming knowledge, and build their careers.", 29 | "image":"" 30 | }, 31 | { 32 | "id":"https://docs.microsoft.com", 33 | "url":"https://docs.microsoft.com", 34 | "title":"Microsoft Docs", 35 | "description":"Learn how to build and manage powerful applications using Microsoft Azure cloud services. Get documentation, example code, tutorials, and more.", 36 | "image":"" 37 | } 38 | ], 39 | "vanityUrl":"test", 40 | "description":"", 41 | "userId":"", 42 | "id":"ffffffff-ffff-ffff-ffff-ffffffffffff" 43 | } 44 | ### 45 | GET http://localhost:{{port}}/api/links/test 46 | ### 47 | PUT http://localhost:{{port}}/api/links/test 48 | x-ms-client-principal: {{clientPrincipal}} 49 | 50 | { 51 | "links":[ 52 | { 53 | "id":"https://montemagno.com", 54 | "url":"https://montemagno.com", 55 | "title":"Stack Overflow", 56 | "description":"Stack Overflow is the largest, most trusted online community for developers to learn, share their programming knowledge, and build their careers.", 57 | "image":"" 58 | }, 59 | { 60 | "id":"https://github.com", 61 | "url":"https://github.com", 62 | "title":"GitHub", 63 | "description":"GitHub is where over 65 million developers shape the future of software, together. Contribute to the open source community, manage your Git repositories, review code like a pro, track bugs and features, power your CI/CD and DevOps workflows, and secure code before you commit it.", 64 | "image":"" 65 | }, 66 | { 67 | "id":"https://docs.microsoft.com", 68 | "url":"https://docs.microsoft.com", 69 | "title":"Microsoft Docs", 70 | "description":"Learn how to build and manage powerful applications using Microsoft Azure cloud services. Get documentation, example code, tutorials, and more.", 71 | "image":"" 72 | }, 73 | { 74 | "id":"https://bing.com", 75 | "url":"https://bing.com", 76 | "title":"Bing", 77 | "description":"Bing helps you turn information into action, making it faster and easier to go from searching to doing.", 78 | "image":"" 79 | } 80 | ], 81 | "vanityUrl":"test", 82 | "description":"", 83 | "userId":"", 84 | "id":"ffffffff-ffff-ffff-ffff-ffffffffffff" 85 | } 86 | ### 87 | DELETE http://localhost:{{port}}/api/links/test 88 | x-ms-client-principal: {{clientPrincipal}} 89 | 90 | ### 91 | DELETE http://localhost:{{port}}/api/links/jons-links 92 | x-ms-client-principal: {{clientPrincipal}} 93 | 94 | ### 95 | PUT http://localhost:{{port}}/api/links/jons-links 96 | x-ms-client-principal: {{clientPrincipal}} 97 | 98 | { 99 | "links":[ 100 | { 101 | "id":"https://montemagno.com", 102 | "url":"https://montemagno.com", 103 | "title":"Stack Overflow", 104 | "description":"Stack Overflow is the largest, most trusted online community for developers to learn, share their programming knowledge, and build their careers.", 105 | "image":"" 106 | } 107 | ], 108 | "vanityUrl":"test", 109 | "description":"", 110 | "userId":"", 111 | "id":"ffffffff-ffff-ffff-ffff-ffffffffffff" 112 | } -------------------------------------------------------------------------------- /Api/ClientPrincipalUtility.cs: -------------------------------------------------------------------------------- 1 | using BlazorApp.Shared; 2 | using Microsoft.Azure.Functions.Worker.Http; 3 | using System; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Text.Json; 7 | 8 | namespace Api.Utility 9 | { 10 | public static class ClientPrincipalUtility 11 | { 12 | public static ClientPrincipal GetClientPrincipal(HttpRequestData req) 13 | { 14 | ClientPrincipal principal = null; 15 | 16 | if (req.Headers.TryGetValues("x-ms-client-principal", out var header)) 17 | { 18 | var data = header.FirstOrDefault(); 19 | var decoded = Convert.FromBase64String(data); 20 | var json = Encoding.UTF8.GetString(decoded); 21 | principal = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); 22 | } 23 | 24 | return principal; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Api/CosmosSystemTextJsonSerializer.cs: -------------------------------------------------------------------------------- 1 | using Azure.Core.Serialization; 2 | using Microsoft.Azure.Cosmos; 3 | using System.IO; 4 | using System.Text.Json; 5 | 6 | /// 7 | /// Uses which leverages System.Text.Json providing a simple API to interact with on the Azure SDKs. 8 | /// https://github.com/Azure/azure-cosmos-dotnet-v3/blob/master/Microsoft.Azure.Cosmos.Samples/Usage/SystemTextJson/CosmosSystemTextJsonSerializer.cs 9 | /// 10 | // 11 | public class CosmosSystemTextJsonSerializer : CosmosSerializer 12 | { 13 | private readonly JsonObjectSerializer systemTextJsonSerializer; 14 | 15 | public CosmosSystemTextJsonSerializer(JsonSerializerOptions jsonSerializerOptions) 16 | { 17 | this.systemTextJsonSerializer = new JsonObjectSerializer(jsonSerializerOptions); 18 | } 19 | 20 | public override T FromStream(Stream stream) 21 | { 22 | using (stream) 23 | { 24 | if (stream.CanSeek 25 | && stream.Length == 0) 26 | { 27 | return default; 28 | } 29 | 30 | if (typeof(Stream).IsAssignableFrom(typeof(T))) 31 | { 32 | return (T)(object)stream; 33 | } 34 | 35 | return (T)this.systemTextJsonSerializer.Deserialize(stream, typeof(T), default); 36 | } 37 | } 38 | 39 | public override Stream ToStream(T input) 40 | { 41 | MemoryStream streamPayload = new MemoryStream(); 42 | this.systemTextJsonSerializer.Serialize(streamPayload, input, input.GetType(), default); 43 | streamPayload.Position = 0; 44 | return streamPayload; 45 | } 46 | } 47 | // -------------------------------------------------------------------------------- /Api/Functions/CreateLinkBundle.cs: -------------------------------------------------------------------------------- 1 | using Api.Utility; 2 | using BlazorApp.Shared; 3 | using Microsoft.Azure.Cosmos; 4 | using Microsoft.Azure.Functions.Worker; 5 | using Microsoft.Azure.Functions.Worker.Http; 6 | using Microsoft.Extensions.Configuration; 7 | using Microsoft.Extensions.Logging; 8 | using System; 9 | using System.Linq; 10 | using System.Net; 11 | using System.Text.RegularExpressions; 12 | using System.Threading.Tasks; 13 | 14 | namespace Api.Functions 15 | { 16 | public partial class CreateLinkBundle(CosmosClient cosmosClient, Hasher hasher, IConfiguration configuration) 17 | { 18 | protected const string CHARACTERS = "abcdefghijklmnopqrstuvwxyz0123456789"; 19 | protected const string VANITY_REGEX = @"^([\w\d-])+(/([\w\d-])+)*$"; 20 | 21 | [Function(nameof(CreateLinkBundle))] 22 | public async Task Run([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "links")] HttpRequestData req, 23 | FunctionContext executionContext) 24 | { 25 | var logger = executionContext.GetLogger("SaveLinks"); 26 | logger.LogInformation("C# HTTP trigger function processed a request."); 27 | var linkBundle = await req.ReadFromJsonAsync(); 28 | 29 | if (!ValidatePayLoad(linkBundle)) 30 | { 31 | return await req.CreateJsonResponse(HttpStatusCode.BadRequest, "Invalid payload"); 32 | } 33 | 34 | EnsureVanityUrl(linkBundle); 35 | Match match = VanityRegex().Match(linkBundle.VanityUrl); 36 | 37 | if (!match.Success) 38 | { 39 | return await req.CreateJsonResponse(HttpStatusCode.BadRequest, "Invalid vanity url"); 40 | } 41 | 42 | ClientPrincipal clientPrincipal = ClientPrincipalUtility.GetClientPrincipal(req); 43 | 44 | if (clientPrincipal != null) 45 | { 46 | string username = clientPrincipal.UserDetails; 47 | linkBundle.UserId = hasher.HashString(username); 48 | linkBundle.Provider = clientPrincipal.IdentityProvider; 49 | } 50 | else 51 | { 52 | return await req.CreateJsonResponse(HttpStatusCode.BadRequest, "You must be logged in to create lists"); 53 | } 54 | 55 | try 56 | { 57 | var databaseName = configuration["COSMOSDB_DATABASE"]; 58 | var containerName = configuration["COSMOSDB_CONTAINER"]; 59 | 60 | var database = cosmosClient.GetDatabase(databaseName); 61 | var container = database.GetContainer(containerName); 62 | 63 | string vanityUrl = linkBundle.VanityUrl; 64 | var query = new QueryDefinition("SELECT TOP 1 * FROM c WHERE c.vanityUrl = @vanityUrl").WithParameter("@vanityUrl", vanityUrl); 65 | 66 | var result = await container.GetItemQueryIterator(query).ReadNextAsync(); 67 | 68 | var partitionKey = new PartitionKey(linkBundle.VanityUrl); 69 | 70 | var response = await container.CreateItemAsync(linkBundle); 71 | var responseMessage = req.CreateResponse(HttpStatusCode.Created); 72 | responseMessage.Headers.Add("Location", $"/{linkBundle.VanityUrl}"); 73 | var linkDocument = response.Resource; 74 | await responseMessage.WriteAsJsonAsync(linkDocument); 75 | 76 | return responseMessage; 77 | } 78 | catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) 79 | { 80 | return await req.CreateJsonResponse(HttpStatusCode.Conflict, "Vanity url already exists"); 81 | } 82 | catch (Exception ex) 83 | { 84 | return await req.CreateJsonResponse(HttpStatusCode.InternalServerError, ex.Message); 85 | } 86 | 87 | } 88 | 89 | private static void EnsureVanityUrl(LinkBundle linkDocument) 90 | { 91 | if (string.IsNullOrWhiteSpace(linkDocument.VanityUrl)) 92 | { 93 | var random = new Random(); 94 | var code = new string(Enumerable.Repeat(CHARACTERS, 7) 95 | .Select(s => s[random.Next(s.Length)]).ToArray()); 96 | 97 | linkDocument.VanityUrl = code; 98 | } 99 | } 100 | 101 | private static bool ValidatePayLoad(LinkBundle linkDocument) 102 | { 103 | return linkDocument != null && linkDocument.Links.Count > 0; 104 | } 105 | 106 | [GeneratedRegex(VANITY_REGEX, RegexOptions.IgnoreCase, "en-US")] 107 | private static partial Regex VanityRegex(); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Api/Functions/DeleteLinkBundle.cs: -------------------------------------------------------------------------------- 1 | using Api.Utility; 2 | using BlazorApp.Shared; 3 | using Microsoft.Azure.Cosmos; 4 | using Microsoft.Azure.Functions.Worker; 5 | using Microsoft.Azure.Functions.Worker.Http; 6 | using Microsoft.Extensions.Configuration; 7 | using Microsoft.Extensions.Logging; 8 | using System.Linq; 9 | using System.Threading.Tasks; 10 | 11 | namespace Api.Functions 12 | { 13 | public class DeleteLinkBundle(ILoggerFactory loggerFactory, CosmosClient cosmosClient, Hasher hasher, IConfiguration configuration) 14 | { 15 | private readonly ILogger _logger = loggerFactory.CreateLogger(); 16 | 17 | [Function(nameof(DeleteLinkBundle))] 18 | public async Task Run( 19 | [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "links/{vanityUrl}")] HttpRequestData req, 20 | string vanityUrl) 21 | { 22 | ClientPrincipal principal = ClientPrincipalUtility.GetClientPrincipal(req); 23 | 24 | var databaseName = configuration["COSMOSDB_DATABASE"]; 25 | var containerName = configuration["COSMOSDB_CONTAINER"]; 26 | 27 | var database = cosmosClient.GetDatabase(databaseName); 28 | var container = database.GetContainer(containerName); 29 | 30 | // get the document id where vanityUrl == vanityUrl 31 | var query = new QueryDefinition("SELECT TOP 1 * FROM c WHERE c.vanityUrl = @vanityUrl") 32 | .WithParameter("@vanityUrl", vanityUrl); 33 | 34 | var result = await container.GetItemQueryIterator(query).ReadNextAsync(); 35 | 36 | if (result.Count != 0) 37 | { 38 | var hashedUsername = hasher.HashString(principal.UserDetails); 39 | if (hashedUsername != result.First().UserId || principal.IdentityProvider != result.First().Provider) 40 | { 41 | return await req.CreateJsonResponse(System.Net.HttpStatusCode.Unauthorized, message: "Unauthorized"); 42 | } 43 | 44 | var partitionKey = new PartitionKey(vanityUrl); 45 | await container.DeleteItemAsync(result.First().Id, partitionKey); 46 | 47 | return await req.CreateOkResponse("Link deleted"); 48 | } 49 | 50 | return await req.CreateJsonResponse(System.Net.HttpStatusCode.NotFound, message: "Link not found"); 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /Api/Functions/GetLinkBundle.cs: -------------------------------------------------------------------------------- 1 | using BlazorApp.Shared; 2 | using Microsoft.Azure.Cosmos; 3 | using Microsoft.Azure.Functions.Worker; 4 | using Microsoft.Azure.Functions.Worker.Http; 5 | using Microsoft.Extensions.Configuration; 6 | using Microsoft.Extensions.Logging; 7 | using System; 8 | using System.Linq; 9 | using System.Net; 10 | using System.Threading.Tasks; 11 | 12 | namespace Api.Functions 13 | { 14 | public class ReadLinkBundle(ILoggerFactory loggerFactory, CosmosClient cosmosClient, IConfiguration configuration) 15 | { 16 | private readonly ILogger _logger = loggerFactory.CreateLogger(); 17 | 18 | [Function(nameof(ReadLinkBundle))] 19 | public async Task Run( 20 | [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "links/{vanityUrl}")] HttpRequestData req, string vanityUrl) 21 | { 22 | if (string.IsNullOrEmpty(vanityUrl)) 23 | { 24 | return await req.CreateJsonResponse(HttpStatusCode.BadRequest, "vanityUrl is required"); 25 | } 26 | 27 | var databaseName = configuration["COSMOSDB_DATABASE"]; 28 | var containerName = configuration["COSMOSDB_CONTAINER"]; 29 | 30 | var database = cosmosClient.GetDatabase(databaseName); 31 | var container = database.GetContainer(containerName); 32 | 33 | var query = new QueryDefinition("SELECT TOP 1 * FROM c WHERE c.vanityUrl = @vanityUrl") 34 | .WithParameter("@vanityUrl", vanityUrl); 35 | 36 | var result = await container.GetItemQueryIterator(query).ReadNextAsync(); 37 | 38 | if (result.Count == 0) 39 | { 40 | return await req.CreateJsonResponse(HttpStatusCode.NotFound, "No LinkBundle found for this vanity url"); 41 | } 42 | 43 | var response = req.CreateResponse(HttpStatusCode.OK); 44 | await response.WriteAsJsonAsync(result.First()); 45 | return response; 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /Api/Functions/GetLinkBundlesForUser.cs: -------------------------------------------------------------------------------- 1 | using Api.Utility; 2 | using BlazorApp.Shared; 3 | using Microsoft.Azure.Cosmos; 4 | using Microsoft.Azure.Functions.Worker; 5 | using Microsoft.Azure.Functions.Worker.Http; 6 | using Microsoft.Extensions.Configuration; 7 | using System; 8 | using System.Net; 9 | using System.Threading.Tasks; 10 | 11 | namespace Api.Functions 12 | { 13 | public class GetLinkBundlesForUser(CosmosClient cosmosClient, Hasher hasher, IConfiguration configuration) 14 | { 15 | [Function(nameof(GetLinkBundlesForUser))] 16 | public async Task Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "user")] HttpRequestData req) 17 | { 18 | try 19 | { 20 | 21 | var databaseName = configuration["COSMOSDB_DATABASE"]; 22 | var containerName = configuration["COSMOSDB_CONTAINER"]; 23 | 24 | var database = cosmosClient.GetDatabase(databaseName); 25 | var container = database.GetContainer(containerName); 26 | 27 | var res = req.CreateResponse(); 28 | 29 | ClientPrincipal clientPrincipal = ClientPrincipalUtility.GetClientPrincipal(req); 30 | 31 | if (clientPrincipal != null) 32 | { 33 | string userDetails = clientPrincipal.UserDetails; 34 | string username = hasher.HashString(userDetails); 35 | string provider = clientPrincipal.IdentityProvider; 36 | 37 | var query = new QueryDefinition("SELECT c.id, c.vanityUrl, c.description, c.links FROM c WHERE c.userId = @username AND c.provider = @provider") 38 | .WithParameter("@username", username) 39 | .WithParameter("@provider", provider) 40 | .WithParameter("@userDetails", userDetails); 41 | 42 | var response = await container.GetItemQueryIterator(query).ReadNextAsync(); 43 | 44 | if (response.Count == 0) 45 | { 46 | return await req.CreateJsonResponse(HttpStatusCode.NotFound, "No link bundles found for user"); 47 | } 48 | 49 | await res.WriteAsJsonAsync(response); 50 | 51 | return res; 52 | } 53 | 54 | // return 401 if no client principal 55 | await res.WriteAsJsonAsync(new { error = "Unauthorized" }, HttpStatusCode.Unauthorized); 56 | 57 | return res; 58 | 59 | } 60 | catch (Exception ex) 61 | { 62 | var res = req.CreateResponse(); 63 | await res.WriteAsJsonAsync(new { error = ex.Message }, HttpStatusCode.InternalServerError); 64 | 65 | return res; 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Api/Functions/GetOpenGraphInfo.cs: -------------------------------------------------------------------------------- 1 | using BlazorApp.Shared; 2 | using Microsoft.Azure.Functions.Worker; 3 | using Microsoft.Azure.Functions.Worker.Http; 4 | using System.Net; 5 | using System.Net.Http; 6 | using System.Threading.Tasks; 7 | 8 | namespace Api.Functions 9 | { 10 | public class GetOpenGraphInfo 11 | { 12 | [Function(nameof(GetOpenGraphInfo))] 13 | public static async Task Run([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "oginfo")] HttpRequestData req) 14 | { 15 | var link = await req.ReadFromJsonAsync(); 16 | if (!link.Url.StartsWith("http://") && !link.Url.StartsWith("https://")) 17 | { 18 | link.Url = $"https://{link.Url}"; 19 | } 20 | 21 | var httpClient = new HttpClient(new HttpClientHandler { AllowAutoRedirect = true }); 22 | 23 | // add a header to mimic a browser 24 | httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0"); 25 | httpClient.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"); 26 | httpClient.DefaultRequestHeaders.AcceptLanguage.Add(new System.Net.Http.Headers.StringWithQualityHeaderValue("en-US", 0.9)); 27 | httpClient.DefaultRequestHeaders.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue { NoCache = true }; 28 | 29 | var response = await httpClient.GetAsync(link.Url); 30 | 31 | // if response status code is 301 or 302, follow the redirect 32 | if (response.StatusCode == HttpStatusCode.Moved || response.StatusCode == HttpStatusCode.MovedPermanently) 33 | { 34 | link.Url = response.Headers.Location.AbsoluteUri; 35 | response = await httpClient.GetAsync(link.Url); 36 | } 37 | 38 | if (response.StatusCode != HttpStatusCode.OK) 39 | { 40 | return await req.CreateJsonResponse(HttpStatusCode.BadRequest, "Unable to load URL"); 41 | } 42 | 43 | // Get the response URI since it may have changed due to redirects 44 | var baseUri = response.RequestMessage.RequestUri.AbsoluteUri; 45 | 46 | var html = await response.Content.ReadAsStringAsync(); 47 | var parser = new AngleSharp.Html.Parser.HtmlParser(); 48 | var document = parser.ParseDocument(html); 49 | if (document == null) 50 | { 51 | return await req.CreateJsonResponse(HttpStatusCode.BadRequest, "Unable to parse document"); 52 | } 53 | 54 | var title = document.QuerySelector("title")?.TextContent 55 | ?? document.QuerySelector("meta[property='og:title']")?.GetAttribute("content") 56 | ?? document.QuerySelector("meta[property='twitter:title']")?.GetAttribute("content") 57 | ?? document.QuerySelector("h1")?.TextContent 58 | ?? document.QuerySelector("meta[property='og:site_name']")?.GetAttribute("content") 59 | ?? ""; 60 | 61 | var description = document.QuerySelector("meta[property='og:description']")?.GetAttribute("content") 62 | ?? document.QuerySelector("meta[property='twitter:description']")?.GetAttribute("content") 63 | ?? document.QuerySelector("meta[name='description']")?.GetAttribute("content") 64 | ?? ""; 65 | 66 | var image = document.QuerySelector("meta[property='og:image']")?.GetAttribute("content") 67 | ?? document.QuerySelector("meta[property='twitter:image']")?.GetAttribute("content") 68 | ?? document.QuerySelector("link[rel='apple-touch-icon']")?.GetAttribute("href") 69 | ?? document.QuerySelector("link[rel='mask-icon']")?.GetAttribute("href") 70 | ?? document.QuerySelector("link[rel='shortcut icon']")?.GetAttribute("href") 71 | ?? document.QuerySelector("meta[itemprop='image']")?.GetAttribute("content") 72 | ?? ""; 73 | 74 | // If the image is an empty string, try to load favicon.ico from the domain root 75 | if (string.IsNullOrEmpty(image)) 76 | { 77 | var uri = new System.Uri(baseUri); 78 | var faviconUrl = $"{uri.Scheme}://{uri.Host}/favicon.ico"; 79 | var faviconResponse = await httpClient.GetAsync(faviconUrl); 80 | if (faviconResponse.StatusCode == HttpStatusCode.OK) 81 | { 82 | image = faviconUrl; 83 | } 84 | } 85 | 86 | // If the image is a relative URL, make it absolute 87 | if (!string.IsNullOrEmpty(image) && !image.StartsWith("http")) 88 | { 89 | image = new System.Uri(new System.Uri(baseUri), image).AbsoluteUri; 90 | } 91 | 92 | // Update the link with the new information 93 | link.Title = title; 94 | link.Description = description; 95 | link.Image = image; 96 | 97 | // Return the updated link 98 | return await req.CreateOkResponse(link); 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Api/Functions/GetQrCode.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Azure.Functions.Worker; 2 | using Microsoft.Azure.Functions.Worker.Http; 3 | using Net.Codecrete.QrCodeGenerator; 4 | using System; 5 | using System.Net; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | 9 | namespace Api.Functions 10 | { 11 | public class GetQrCode() 12 | { 13 | [Function(nameof(GetQrCode))] 14 | public async Task Run( 15 | [HttpTrigger(AuthorizationLevel.Function, "get", Route = "qrcode/{vanityUrl}")] HttpRequestData req, string vanityUrl) 16 | { 17 | if (string.IsNullOrEmpty(vanityUrl)) 18 | { 19 | return await req.CreateJsonResponse(HttpStatusCode.BadRequest, "vanityUrl is required"); 20 | } 21 | 22 | // Decode the vanityUrl from base64 23 | // We're using base64 encoding to allow for special characters in the vanityUrl 24 | string decodedVanityUrl = Encoding.UTF8.GetString(Convert.FromBase64String(vanityUrl)); 25 | 26 | var qrCode = QrCode.EncodeText(decodedVanityUrl, QrCode.Ecc.Medium); 27 | var response = req.CreateResponse(HttpStatusCode.OK); 28 | await response.WriteStringAsync(qrCode.ToSvgString(4, "#121212", "#F9FAFC")); 29 | 30 | return response; 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /Api/Functions/UpdateLinkBundle.cs: -------------------------------------------------------------------------------- 1 | using Api.Utility; 2 | using BlazorApp.Shared; 3 | using Microsoft.Azure.Cosmos; 4 | using Microsoft.Azure.Functions.Worker; 5 | using Microsoft.Azure.Functions.Worker.Http; 6 | using Microsoft.Extensions.Configuration; 7 | using Microsoft.Extensions.Logging; 8 | using System; 9 | using System.Linq; 10 | using System.Net; 11 | using System.Threading.Tasks; 12 | 13 | namespace Api 14 | { 15 | public class UpdateLinkBundle(CosmosClient cosmosClient, Hasher hasher, IConfiguration configuration) 16 | { 17 | [Function(nameof(UpdateLinkBundle))] 18 | public async Task Run([HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "links/{vanityUrl}")] HttpRequestData req, 19 | string vanityUrl, FunctionContext executionContext) 20 | { 21 | var logger = executionContext.GetLogger("UpdateLinks"); 22 | logger.LogInformation("C# HTTP trigger function processed a request."); 23 | var linkBundle = await req.ReadFromJsonAsync(); 24 | 25 | if (!ValidatePayLoad(linkBundle)) 26 | { 27 | return await req.CreateJsonResponse(HttpStatusCode.BadRequest, "Invalid payload"); 28 | } 29 | 30 | if (string.IsNullOrEmpty(vanityUrl)) 31 | { 32 | return await req.CreateJsonResponse(HttpStatusCode.BadRequest, "Invalid vanity url"); 33 | } 34 | 35 | try 36 | { 37 | ClientPrincipal principal = ClientPrincipalUtility.GetClientPrincipal(req); 38 | 39 | var databaseName = configuration["COSMOSDB_DATABASE"]; 40 | var containerName = configuration["COSMOSDB_CONTAINER"]; 41 | 42 | var database = cosmosClient.GetDatabase(databaseName); 43 | var container = database.GetContainer(containerName); 44 | 45 | // get the document id where vanityUrl == vanityUrl 46 | var query = new QueryDefinition("SELECT TOP 1 * FROM c WHERE c.vanityUrl = @vanityUrl") 47 | .WithParameter("@vanityUrl", vanityUrl); 48 | 49 | var result = await container.GetItemQueryIterator(query).ReadNextAsync(); 50 | 51 | if (result.Count != 0) 52 | { 53 | var hashedUsername = hasher.HashString(principal.UserDetails); 54 | if (hashedUsername != result.First().UserId || principal.IdentityProvider != result.First().Provider) 55 | { 56 | return await req.CreateJsonResponse(System.Net.HttpStatusCode.Unauthorized, message: "Unauthorized"); 57 | } 58 | 59 | linkBundle.UserId = hashedUsername; 60 | linkBundle.Provider = principal.IdentityProvider; 61 | var partitionKey = new PartitionKey(vanityUrl); 62 | var response = await container.UpsertItemAsync(linkBundle, partitionKey); 63 | var responseMessage = req.CreateResponse(HttpStatusCode.OK); 64 | var linkDocument = response.Resource; 65 | await responseMessage.WriteAsJsonAsync(linkDocument); 66 | return responseMessage; 67 | } 68 | 69 | return await req.CreateJsonResponse(System.Net.HttpStatusCode.NotFound, message: "Link not found"); 70 | } 71 | catch (Exception ex) 72 | { 73 | return await req.CreateJsonResponse(HttpStatusCode.InternalServerError, ex.Message); 74 | } 75 | } 76 | 77 | private static bool ValidatePayLoad(LinkBundle linkDocument) 78 | { 79 | return (linkDocument != null) && linkDocument.Links.Count > 0; 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Api/Hasher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Security.Cryptography; 3 | 4 | namespace Api 5 | { 6 | public class Hasher(string key, string salt) 7 | { 8 | protected const string HASHER_KEY = "HASHER_KEY"; 9 | protected const string HASHER_SALT = "HASHER_SALT"; 10 | 11 | public Hasher() 12 | : this(Environment.GetEnvironmentVariable(HASHER_KEY), 13 | Environment.GetEnvironmentVariable(HASHER_SALT)) 14 | { } 15 | 16 | public virtual string HashString(string data) 17 | { 18 | if (string.IsNullOrEmpty(data)) throw new ArgumentException("Data parameter was null or empty", nameof(data)); 19 | 20 | byte[] keyByte = System.Text.Encoding.UTF8.GetBytes(key); 21 | byte[] messageBytes = System.Text.Encoding.UTF8.GetBytes(data + salt); 22 | using var hmacsha256 = new HMACSHA384(keyByte); 23 | byte[] hashmessage = hmacsha256.ComputeHash(messageBytes); 24 | 25 | return Convert.ToBase64String(hashmessage); 26 | } 27 | 28 | public virtual bool Verify(string data, string hashedData) 29 | { 30 | return hashedData == HashString(data); 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /Api/HttpRequestDataExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Azure.Functions.Worker.Http; 2 | using System.Net; 3 | using System.Threading.Tasks; 4 | 5 | namespace Api 6 | { 7 | public static class HttpRequestDataExtensions 8 | { 9 | public static async Task CreateJsonResponse(this HttpRequestData req, HttpStatusCode statusCode, T message) 10 | { 11 | var response = req.CreateResponse(statusCode); 12 | await response.WriteAsJsonAsync(message, statusCode); 13 | return response; 14 | } 15 | 16 | public static async Task CreateOkResponse(this HttpRequestData req, T message) 17 | { 18 | return await req.CreateJsonResponse(HttpStatusCode.OK, message); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Api/Program.cs: -------------------------------------------------------------------------------- 1 | using Api; 2 | using Microsoft.Azure.Cosmos; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Hosting; 5 | using System.Text.Json; 6 | using System.Text.Json.Serialization; 7 | 8 | // Setup custom serializer to use System.Text.Json 9 | JsonSerializerOptions jsonSerializerOptions = new() 10 | { 11 | DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull 12 | }; 13 | CosmosSystemTextJsonSerializer cosmosSystemTextJsonSerializer = new(jsonSerializerOptions); 14 | CosmosClientOptions cosmosClientOptions = new() 15 | { 16 | ApplicationName = "SystemTextJson", 17 | Serializer = cosmosSystemTextJsonSerializer 18 | }; 19 | 20 | var host = new HostBuilder() 21 | .ConfigureServices((context, services) => 22 | { 23 | services.AddSingleton(sp => new CosmosClient( 24 | context.Configuration["COSMOSDB_ENDPOINT"], 25 | context.Configuration["COSMOSDB_KEY"], 26 | cosmosClientOptions)); 27 | services.AddSingleton(services => new Hasher( 28 | context.Configuration["HASHER_KEY"], 29 | context.Configuration["HASHER_SALT"])); 30 | }) 31 | .ConfigureFunctionsWorkerDefaults() 32 | .Build(); 33 | 34 | await host.RunAsync(); -------------------------------------------------------------------------------- /Api/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingSettings": { 6 | "isEnabled": true, 7 | "excludedTypes": "Request" 8 | } 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /BlazorStaticWebApps.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31903.59 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Client", "Client\Client.csproj", "{F4B1375B-C892-424D-9CB2-9FE2B5B47EDF}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Shared", "Shared\Shared.csproj", "{DA2371A3-4788-4B2A-A0F0-0FF8EBB4C948}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Api", "Api\Api.csproj", "{CEF28A6B-A047-4153-A4E0-5A2A921B432E}" 11 | EndProject 12 | Global 13 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 14 | Debug|Any CPU = Debug|Any CPU 15 | Release|Any CPU = Release|Any CPU 16 | EndGlobalSection 17 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 18 | {F4B1375B-C892-424D-9CB2-9FE2B5B47EDF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 19 | {F4B1375B-C892-424D-9CB2-9FE2B5B47EDF}.Debug|Any CPU.Build.0 = Debug|Any CPU 20 | {F4B1375B-C892-424D-9CB2-9FE2B5B47EDF}.Release|Any CPU.ActiveCfg = Release|Any CPU 21 | {F4B1375B-C892-424D-9CB2-9FE2B5B47EDF}.Release|Any CPU.Build.0 = Release|Any CPU 22 | {DA2371A3-4788-4B2A-A0F0-0FF8EBB4C948}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {DA2371A3-4788-4B2A-A0F0-0FF8EBB4C948}.Debug|Any CPU.Build.0 = Debug|Any CPU 24 | {DA2371A3-4788-4B2A-A0F0-0FF8EBB4C948}.Release|Any CPU.ActiveCfg = Release|Any CPU 25 | {DA2371A3-4788-4B2A-A0F0-0FF8EBB4C948}.Release|Any CPU.Build.0 = Release|Any CPU 26 | {CEF28A6B-A047-4153-A4E0-5A2A921B432E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 27 | {CEF28A6B-A047-4153-A4E0-5A2A921B432E}.Debug|Any CPU.Build.0 = Debug|Any CPU 28 | {CEF28A6B-A047-4153-A4E0-5A2A921B432E}.Release|Any CPU.ActiveCfg = Release|Any CPU 29 | {CEF28A6B-A047-4153-A4E0-5A2A921B432E}.Release|Any CPU.Build.0 = Release|Any CPU 30 | EndGlobalSection 31 | GlobalSection(SolutionProperties) = preSolution 32 | HideSolutionNode = FALSE 33 | EndGlobalSection 34 | GlobalSection(ExtensibilityGlobals) = postSolution 35 | SolutionGuid = {CAEC77B5-1FF5-4E89-86CD-620314C521C4} 36 | EndGlobalSection 37 | EndGlobal 38 | -------------------------------------------------------------------------------- /Client/.editorconfig: -------------------------------------------------------------------------------- 1 | # Remove the line below if you want to inherit .editorconfig settings from higher directories 2 | root = true 3 | 4 | # C# files 5 | [*.cs] 6 | 7 | #### Core EditorConfig Options #### 8 | 9 | # Indentation and spacing 10 | indent_size = 4 11 | indent_style = tab 12 | tab_width = 4 13 | 14 | # New line preferences 15 | end_of_line = crlf 16 | insert_final_newline = false 17 | 18 | #### .NET Coding Conventions #### 19 | 20 | # Organize usings 21 | dotnet_separate_import_directive_groups = false 22 | dotnet_sort_system_directives_first = false 23 | file_header_template = unset 24 | 25 | # this. and Me. preferences 26 | dotnet_style_qualification_for_event = false 27 | dotnet_style_qualification_for_field = false 28 | 29 | # Razor files 30 | [*.razor] 31 | 32 | # Indentation and spacing 33 | indent_size = 2 34 | indent_style = space 35 | tab_width = 2 -------------------------------------------------------------------------------- /Client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-cshtml"], 3 | "overrides": [ 4 | { 5 | "files": "*.razor", 6 | "options": { 7 | "parser": "cshtml" 8 | } 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /Client/App.razor: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | Not found 7 | 8 |

Sorry, there's nothing at this address.

9 |
10 |
11 |
12 | 13 | -------------------------------------------------------------------------------- /Client/Client.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | BlazorApp.Client 8 | true 9 | false 10 | true 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | <_ContentIncludedByDefault Remove="Shared/ModalConfirm.razor" /> 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /Client/Pages/Edit.razor: -------------------------------------------------------------------------------- 1 | @page "/s/edit" 2 | @using BlazorApp.Shared 3 | 4 | @inject StateContainer StateContainer 5 | @inject HttpClient Http 6 | @inject NavigationManager NavigationManager 7 | 8 | @implements IDisposable 9 | 10 | 11 |
12 | 13 | 15 | 17 | 18 |

19 | The url 20 | @StateContainer.LinkBundle.VanityUrl will be 21 | released for others to use. 22 |

23 |
24 |
25 |
26 | 27 | @code { 28 | 29 | private ModalConfirm? modalConfirm; 30 | 31 | protected override void OnInitialized() 32 | { 33 | StateContainer.OnChange += StateHasChanged; 34 | } 35 | 36 | private void ShowDeleteModal() 37 | { 38 | modalConfirm?.Show(); 39 | } 40 | 41 | private async Task DeleteLinkBundle() 42 | { 43 | await Http.DeleteAsync($"api/links/{StateContainer.LinkBundle.VanityUrl}"); 44 | StateContainer.LinkBundle = new LinkBundle(); 45 | NavigationManager.NavigateTo("/s/new"); 46 | } 47 | 48 | public void Dispose() 49 | { 50 | StateContainer.OnChange -= StateHasChanged; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Client/Pages/Index.razor: -------------------------------------------------------------------------------- 1 | @page "/" 2 | @inject NavigationManager NavigationManager 3 | 4 | @using BlazorApp.Shared 5 | 6 | @inject NavigationManager NavigationManager 7 | @inject StateContainer StateContainer 8 | 9 | The Urlist 10 | 11 |
12 |
13 |
14 |
15 |
16 |
17 |

18 | Group links, 19 | Save & 20 | Share them with the world 21 |

22 |
23 |

Add links to a list and share it with one simple URL.

24 |
25 |

26 | Create a list anonymously or login to save, manage, and edit your 27 | lists. 28 |

29 |
30 |
31 |
32 | 34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |

43 | Get Started 44 |

45 | 46 |
47 |
48 |
49 |
50 | 51 | @code { 52 | private void NewLinkAdded(Link link) 53 | { 54 | // add the url to the state store 55 | StateContainer.AddLinkToBundle(link); 56 | 57 | // navigate to the /s/edit route 58 | NavigationManager.NavigateTo($"/s/new"); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Client/Pages/Index.razor.css: -------------------------------------------------------------------------------- 1 | .banner-image { 2 | max-width: 500px; 3 | height: 100%; 4 | width: 100%; 5 | } 6 | 7 | .main-background { 8 | background-color: var(--main-background); 9 | } 10 | 11 | #getStarted { 12 | overflow: hidden; 13 | position: relative; 14 | flex: 1; 15 | } 16 | 17 | #bannerText { 18 | padding-top: 20px; 19 | } 20 | 21 | #homeBottom { 22 | padding-top: 50px; 23 | } 24 | 25 | #homeBottomBackground { 26 | position: absolute; 27 | left: 0; 28 | right: -50%; 29 | margin-left: -25%; 30 | height: 100%; 31 | max-width: 150%; 32 | width: 150%; 33 | border-top-left-radius: 50%; 34 | border-top-right-radius: 50%; 35 | background-color: var(--home-bottom-background); 36 | } -------------------------------------------------------------------------------- /Client/Pages/Migrate.razor: -------------------------------------------------------------------------------- 1 | @page "/s/migrate" 2 | 3 | @inject StateContainer StateContainer 4 | 5 |
6 |

Migrate from Twitter

7 |

Twitter auth has been removed due to changes in the X API. We're sorry! The good news is that you can migrate your Twitter Login to one of the other providers listed below. Select one of these providers to access any lists you created under a Twitter login.

8 |
-------------------------------------------------------------------------------- /Client/Pages/MyLists.razor: -------------------------------------------------------------------------------- 1 | @page "/s/mylists" 2 | 3 | @using BlazorApp.Shared 4 | 5 | @inject StateContainer StateContainer 6 | @inject NavigationManager NavigationManager 7 | @inject HttpClient Http 8 | 9 |
10 |
11 |

My Lists

12 |
13 |
14 |
15 |
16 |
17 |
+
18 | Create new list 19 |
20 |
21 |
22 |
23 | @if (LoadingUsersLists) 24 | { 25 | @for (var i = 0; i < 3; i++) 26 | { 27 |
28 |
29 | Item 30 |
31 |
32 | } 33 | } 34 | else 35 | { 36 | @if (StateContainer.User?.LinkBundles != null) 37 | { 38 | @foreach (var linkBundle in StateContainer.User.LinkBundles) 39 | { 40 |
41 | 42 |
43 | 44 | @linkBundle.Links.Count Links 45 | 46 |
47 |
48 |

49 | @linkBundle.VanityUrl 50 |

51 |

@linkBundle.Description

52 |

53 |
54 |
55 |
56 |
57 | } 58 | } 59 | } 60 |
61 |
62 |
63 | 64 | 65 | @code { 66 | 67 | private bool LoadingUsersLists { get; set; } = false; 68 | 69 | private ModalImport? modalImport; 70 | 71 | protected override async Task OnInitializedAsync() 72 | { 73 | var linkBundles = await GetLinkBundlesForUser(); 74 | if (StateContainer.User != null) 75 | { 76 | StateContainer.User.LinkBundles = linkBundles; 77 | } 78 | } 79 | 80 | private async Task> GetLinkBundlesForUser() 81 | { 82 | try 83 | { 84 | LoadingUsersLists = true; 85 | 86 | var response = await Http.GetFromJsonAsync>($"api/user"); 87 | return response ?? new List(); 88 | } 89 | catch (Exception ex) 90 | { 91 | Console.WriteLine(ex.Message); 92 | return new List(); 93 | } 94 | finally 95 | { 96 | LoadingUsersLists = false; 97 | } 98 | } 99 | 100 | private void NewList() 101 | { 102 | StateContainer.LinkBundle = new LinkBundle(); 103 | NavigationManager.NavigateTo("/s/new"); 104 | } 105 | 106 | private void EditList(LinkBundle linkBundle) 107 | { 108 | StateContainer.LinkBundle = linkBundle; 109 | NavigationManager.NavigateTo("/s/edit"); 110 | } 111 | 112 | private void ShowImportModal() 113 | { 114 | modalImport?.Show(); 115 | } 116 | } -------------------------------------------------------------------------------- /Client/Pages/MyLists.razor.css: -------------------------------------------------------------------------------- 1 | #userHeader { 2 | margin: 40px 0px; 3 | } 4 | 5 | .lists { 6 | margin: 0px 20px; 7 | } 8 | 9 | .list .list-dots { 10 | top: -20px; 11 | right: -10px; 12 | } 13 | 14 | .list .list-title { 15 | padding: 10px 0px; 16 | } 17 | 18 | .list .list-count { 19 | position: absolute; 20 | margin-left: 20px; 21 | margin-top: -15px; 22 | z-index: 9999; 23 | } 24 | 25 | .list .list-item { 26 | margin-bottom: 20px; 27 | height: 195px; 28 | cursor: pointer; 29 | } 30 | 31 | .list .list-item .list-item-content { 32 | position: absolute; 33 | width: 100%; 34 | overflow: hidden; 35 | margin-bottom: 20px; 36 | } 37 | 38 | .list .list-placeholder { 39 | display: flex; 40 | height: 100%; 41 | align-items: center; 42 | border-style: dashed; 43 | border-color: #979797; 44 | border-width: 2px; 45 | } -------------------------------------------------------------------------------- /Client/Pages/New.razor: -------------------------------------------------------------------------------- 1 | @page "/s/new" 2 | @using BlazorApp.Shared 3 | 4 | @inject StateContainer StateContainer 5 | @inject HttpClient Http 6 | 7 | @implements IDisposable 8 | 9 | 10 |
11 | 12 |
13 | 14 | @code { 15 | protected override void OnInitialized() 16 | { 17 | StateContainer.OnChange += StateHasChanged; 18 | } 19 | 20 | public void Dispose() 21 | { 22 | StateContainer.OnChange -= StateHasChanged; 23 | } 24 | } -------------------------------------------------------------------------------- /Client/Pages/Privacy.razor: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-urlist/blazor-static-web-apps/50fad39a81d9a6b2bc642634d503f9321ef94552/Client/Pages/Privacy.razor -------------------------------------------------------------------------------- /Client/Pages/Public.razor: -------------------------------------------------------------------------------- 1 | @page "/{vanityUrl}" 2 | 3 | @using BlazorApp.Shared 4 | @using System.Collections.Generic 5 | 6 | @inject HttpClient Http 7 | @inject StateContainer StateContainer 8 | @inject NavigationManager NavigationManager 9 | 10 |
11 | 12 |
13 | @if (LoadingList) 14 | { 15 |
16 |

17 | Loading @linkBundle.VanityUrl 18 |

19 |
20 | @for (var i = 0; i < 5; i++) 21 | { 22 | 30 | } 31 |
32 | } 33 | else 34 | { 35 | @if (linkBundle.Links.Count > 0) 36 | { 37 |
38 |

39 | @linkBundle.Description 40 |

41 |
42 |
43 | 69 |
70 |
71 |
72 | 75 | 78 |
79 |
80 |
81 |
82 | 83 |
84 |
85 |
86 |
87 | @foreach (var link in linkBundle.Links) 88 | { 89 | 90 | } 91 |
92 | Report 93 | this list 94 |
95 |
96 | } 97 | else 98 | { 99 |
100 |
101 | Sad green fuzzy 102 |
103 |

104 | We couldn't find that Urlist 105 |

106 |

107 | But don't be sad! That means @vanityUrl is still available. 108 |

109 |
110 | } 111 | } 112 |
113 |
114 | 115 | @code { 116 | [Parameter] 117 | public string vanityUrl { get; set; } = ""; 118 | 119 | private bool LoadingList { get; set; } = false; 120 | private bool ShowQr { get; set; } = false; 121 | 122 | private LinkBundle linkBundle { get; set; } = new LinkBundle(); 123 | protected override async Task OnInitializedAsync() 124 | { 125 | try 126 | { 127 | LoadingList = true; 128 | 129 | var response = await Http.GetFromJsonAsync($"api/links/{vanityUrl}"); 130 | linkBundle = response ?? new LinkBundle(); 131 | } 132 | catch (Exception ex) 133 | { 134 | // if the error is 404, then the vanity url doesn't exist 135 | 136 | // if the error is something else, log it 137 | Console.WriteLine(ex.Message); 138 | } 139 | finally 140 | { 141 | LoadingList = false; 142 | } 143 | } 144 | 145 | private void NewLinkBundle() 146 | { 147 | StateContainer.LinkBundle = new LinkBundle(); 148 | StateContainer.LinkBundle.VanityUrl = vanityUrl; 149 | NavigationManager.NavigateTo($"/s/new/"); 150 | } 151 | } -------------------------------------------------------------------------------- /Client/Pages/Terms.razor: -------------------------------------------------------------------------------- 1 | @page "/s/terms" 2 | 3 | 52 | 54 |
55 |
56 |
57 |
58 | 59 | 60 |

ACCEPTABLE USE POLICY

61 |
62 | 63 |
64 |
Last updated 65 | September 01, 2019 66 |
67 |

68 |

69 |

70 |
This 71 | Acceptable Use Policy 72 | ("Policy") is part of our __________ ("Legal Terms") 75 | and 76 | should therefore be read alongside our main Legal Terms: 77 | __________ 78 | . If you do not agree with these Legal Terms, please refrain from 79 | using 80 | our Services. Your continued use of our Services implies acceptance of these Legal Terms. 81 |
82 |

83 |
Please carefully review this Policy which applies to 84 | any 85 | and all:
86 |

87 |
(a) uses of our Services (as 88 | defined 89 | in "Legal Terms")
90 |
(b) forms, materials, consent 91 | tools, 92 | comments, post, and all other content available on the Services ( 93 | "Content") 94 |
95 |
(c) material which you contribute 96 | to 97 | the Services including any upload, post, review, disclosure, ratings, comments, chat etc. in any forum, chatrooms, reviews, and to any interactive services associated 99 | with 100 | it ( 101 | "Contribution" 102 | ) 103 |
104 |

105 |
106 |

WHO WE ARE

107 |
108 |
We are The Urlist 109 | ("Company," 110 | "we," "us," or "our" 111 | ) a company registered in the 112 | 113 | United States. 114 | 115 | 116 | We operate the website 118 | https://theurlist.com 120 | (the "Site" 121 | ) 122 | 123 | , as well as any other related products and services that refer or link to 124 | this 125 | Policy (collectively, the "Services"). 127 |
128 |

129 |
130 |

USE OF THE SERVICES

131 |
132 |
When you use the Services, you warrant that you will 133 | comply with this Policy and with all applicable laws.
134 |

135 |
You also acknowledge that you may not:
136 |
    137 |
  • Systematically retrieve data or other content from 138 | the 139 | Services to create or compile, directly or indirectly, a collection, compilation, database, or directory 140 | without 141 | written permission from us.
  • 142 |
  • Make any 143 | unauthorized 144 | use of the Services, including collecting usernames and/or 145 | email 146 | addresses of users by electronic or other means for the purpose of sending unsolicited email, or creating user 147 | accounts by automated means or under false pretenses. 149 |
  • 150 |
  • Circumvent, disable, or otherwise interfere with 151 | security-related features of the Services, including features that prevent or restrict the use or copying of 152 | any 153 | Content or enforce limitations on the use of the Services and/or the Content contained therein.
  • 154 |
  • Engage in 155 | unauthorized framing of or linking to the Services.
  • 156 |
  • Trick, defraud, or mislead us and other users, 157 | especially in any attempt to learn sensitive account information such as user passwords.
  • 158 |
  • Make improper use of our Services, including our 159 | support 160 | services or submit false reports of abuse or misconduct.
  • 161 |
  • Engage in any automated use of the Services, such as 162 | using scripts to send comments or messages, or using any data mining, robots, or similar data gathering and 163 | extraction tools.
  • 164 |
  • Interfere with, disrupt, or create an undue burden 165 | on 166 | the Services or the networks or the Services connected.
  • 167 |
  • Attempt to impersonate another user or person or use 168 | the 169 | username of another user.
  • 170 |
  • Use any information obtained from the Services in 171 | order 172 | to harass, abuse, or harm another person.
  • 173 |
  • Use the Services as part of any effort to compete 174 | with 175 | us or otherwise use the Services and/or the Content for any revenue-generating 176 | 177 | endeavor or commercial enterprise.
  • 178 |
  • Decipher, decompile, disassemble, or reverse 179 | engineer 180 | any of the software comprising or in any way making up a part of the Services, except as expressly permitted 181 | by 182 | applicable law.
  • 183 |
  • Attempt to bypass any measures of the Services 184 | designed 185 | to prevent or restrict access to the Services, or any portion of the Services.
  • 186 |
  • Harass, annoy, intimidate, or threaten any of our 187 | employees or agents engaged in providing any portion of the Services to you.
  • 188 |
  • Delete the copyright or other proprietary rights 189 | notice 190 | from any Content.
  • 191 |
  • Copy or adapt the Services’ software, including but 192 | not 193 | limited to Flash, PHP, HTML, JavaScript, or other code.
  • 194 |
  • Upload or transmit (or attempt to upload or to 195 | transmit) 196 | viruses, Trojan horses, or other material, including excessive use of capital letters and spamming (continuous 197 | posting of repetitive text), that interferes with any party’s uninterrupted use and enjoyment of the Services 198 | or 199 | modifies, impairs, disrupts, alters, or interferes with the use, features, functions, operation, or 200 | maintenance 201 | of 202 | the Services.
  • 203 |
  • Upload or transmit (or attempt to upload or to 204 | transmit) 205 | any material that acts as a passive or active information collection or transmission mechanism, including 206 | without 207 | limitation, clear graphics interchange formats ("gifs"), 1×1 pixels, web bugs, cookies, or other similar devices 209 | (sometimes 210 | referred to as "spyware" or "passive collection mechanisms" or "pcms").
  • 212 |
  • Except as may be the result of standard search 213 | engine 214 | or 215 | Internet browser usage, use, launch, develop, or distribute any automated system, including without 216 | limitation, 217 | any spider, robot, cheat utility, scraper, or offline reader that accesses the Services, or using or launching 218 | any 219 | unauthorized script or other 220 | software. 221 |
  • 222 |
  • Disparage, tarnish, or otherwise harm, in our 223 | opinion, 224 | us and/or the Services.
  • 225 |
  • Use the Services in a manner inconsistent with any 226 | applicable laws or regulations. 227 |
  • 228 |
229 |
230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 |
238 |

239 |
240 |

CONTRIBUTIONS

241 |
242 |
In this Policy, the term 244 | "Contributions" means:
245 |
    246 |
  • any data, information, software, text, code, music, 247 | scripts, sound, graphics, photos, videos, tags, messages, interactive features, or other materials that you 248 | post, 249 | share, upload, submit, or otherwise provide in any manner on or through to the Services; or
  • 250 |
  • any other content, materials, or data you provide to 251 | The Urlist or use with the Services. 252 |
  • 253 |
254 |
Some areas of the Services may allow users to upload, 255 | transmit, or post Contributions. We may but are under no obligation to review or moderate the Contributions made 256 | on 257 | the Services, and we expressly exclude our liability for any loss or damage resulting from any of our users' 258 | breach 259 | of this Policy. Please report any Contribution that you believe breaches this Policy; however, we will 260 | determine, 261 | in 262 | our sole discretion, whether a Contribution is indeed in breach of this Policy.
263 |

264 |
You warrant that:
265 |
    266 |
  • you are the creator and owner of or have the 267 | necessary 268 | licenses, rights, consents, 269 | releases, and permissions to use and to authorize us, the Services, and other users of the Services to use your 271 | Contributions in any manner contemplated by the Services and this Policy; 272 |
  • 273 |
  • all your Contributions comply with applicable laws 274 | and 275 | are original and true (if they represent your opinion or facts);
  • 276 |
  • the creation, distribution, transmission, public 277 | display, or performance, and the accessing, downloading, or copying of your Contributions do not and will not 278 | infringe the proprietary rights, including but not limited to the copyright, patent, trademark, trade secret, 279 | or 280 | moral rights of any third party; and
  • 281 |
  • you have the verifiable consent, release, and/or 282 | permission of each and every identifiable individual person in your Contributions to use the name or likeness 283 | of 284 | each and every such identifiable individual person to enable inclusion and use of your Contributions in any 285 | manner 286 | contemplated by the Services and this Policy.
  • 287 |
288 |
You also agree that you will not post, transmit, or 289 | upload any (or any part of a) Contribution that:
290 |
    291 |
  • is in breach of applicable laws, regulation, court 292 | order, contractual obligation, this Policy, our Legal Terms, a legal duty, or that promotes or facilitates 293 | fraud 294 | or illegal activities;
  • 295 |
  • is defamatory, obscene, offensive, hateful, 296 | insulting, 297 | intimidating, bullying, abusive, or threatening, to any person or group;
  • 298 |
  • is false, inaccurate, or misleading;
  • 299 |
  • includes child sexual abuse material, or violates 300 | any 301 | applicable law concerning child pornography or otherwise intended to protect minors;
  • 302 |
  • contains any material that solicits personal 303 | information 304 | from anyone under the age of 18 or exploits people under the age of 18 in a sexual or violent manner;
  • 305 |
  • promotes violence, advocates the violent overthrow 306 | of 307 | any government, or incites, encourages, or threatens physical harm against another;
  • 308 |
  • is obscene, lewd, lascivious, filthy, violent, 309 | harassing, libelous, 310 | slanderous, 311 | contains sexually explicit material, or is otherwise objectionable (as determined by us);
  • 312 |
  • is discriminatory based on race, sex, religion, 313 | nationality, disability, sexual orientation, or age;
  • 314 |
  • bullies, intimidates, humiliates, or insults any 315 | person; 316 |
  • 317 |
  • promotes, facilitates, or assists anyone in 318 | promoting 319 | and facilitating acts of terrorism;
  • 320 |
  • infringes, or assists anyone in infringing, a third 321 | party's intellectual property rights or publicity or privacy rights;
  • 322 |
  • is deceitful, misrepresents your identity or 323 | affiliation 324 | with any person and/or misleads anyone as to your relationship with us or implies that the Contribution was 325 | made 326 | by someone else than you;
  • 327 |
  • contains unsolicited or 329 | unauthorized advertising, promotional materials, pyramid 330 | schemes, chain letters, spam, mass mailings, or other forms of solicitation that has been "paid for," whether with 332 | monetary 333 | compensation or in kind; or
  • 334 |
  • misrepresents your identity or who the Contribution 335 | is 336 | from.
  • 337 |
338 |
339 | 340 |
341 |
342 | 343 | 344 | 345 |
346 |

347 |
348 |

REPORTING A BREACH OF THIS POLICY

349 |
350 |
We may but are under no obligation to review or 351 | moderate 352 | the Contributions made on the Services and we expressly exclude our liability for any loss or damage resulting 353 | from 354 | any of our users' breach of this Policy.
355 |

356 |
If you consider that any Service, Content, or 357 | Contribution:
358 |
    359 |
  • breach this Policy, please 361 | 362 | email us at support@theurlist.com, 364 | or refer to the contact details at the bottom of this document to let us 365 | know 366 | which Service, Content, or Contribution is in breach of this Policy and why 367 |
  • 368 |
  • infringe any third-party intellectual property 369 | rights, 370 | please email us at support@theurlist.com 371 | 372 | 373 |
  • 374 |
375 |
We will reasonably determine whether a Service, 376 | Content, 377 | or Contribution breaches this Policy.
378 |
379 |

380 |
381 |

CONSEQUENCES OF BREACHING THIS POLICY

382 |
383 |
The consequences for violating our Policy will vary 384 | depending on the severity of the breach and the user's history on the Services, by way of example:
385 |

386 |
We may, in some cases, give you a warning and/or remove the infringing Contribution 389 | , however, if your breach is serious or if you continue to breach our Legal Terms and this Policy, we have 390 | the 391 | right to suspend or terminate your access to and use of our Services and, if applicable, disable your account. 392 | We 393 | may also notify law enforcement or issue legal proceedings against you when we believe that there is a genuine 394 | risk 395 | to an individual or a threat to public safety.
396 |

397 |
We exclude our liability for all action we may take 398 | in 399 | response to any of your breaches of this Policy.
400 |
401 | 402 | 403 | 404 |
405 |

406 |
407 |

DISCLAIMER

408 |
409 |
410 | The Urlist is under no obligation to monitor users’ activities, and we disclaim any 411 | responsibility for any user’s misuse of the Services. The Urlist has no 412 | responsibility 413 | for any user or other Content or Contribution created, maintained, stored, transmitted, or accessible on or 414 | through 415 | the Services, and is not obligated to monitor or exercise any editorial control over such material. If The Urlist becomes aware that any such Content or Contribution violates this Policy, 417 | The Urlist may, in addition to removing such Content or Contribution and blocking 418 | your 419 | account, report such breach to the police or appropriate regulatory authority. Unless otherwise stated in this 420 | Policy, The Urlist disclaims any obligation to any person who has not entered into 421 | an 422 | agreement with The Urlist for the use of the Services. 423 |
424 |
425 |

426 |
427 |

HOW CAN YOU CONTACT US ABOUT THIS POLICY?

428 |
429 |
If you have any further questions or comments or wish to report any problematic Content or Contribution, you may contact us by:
432 |

433 |
434 | 435 | 436 | 437 | 438 |
439 |
Email: 440 | support@theurlist.com 441 | 442 | 443 | 444 |
445 | 462 |
463 |
464 |
-------------------------------------------------------------------------------- /Client/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Components.Web; 2 | using Microsoft.AspNetCore.Components.WebAssembly.Hosting; 3 | using BlazorApp.Client; 4 | 5 | var builder = WebAssemblyHostBuilder.CreateDefault(args); 6 | builder.RootComponents.Add("#app"); 7 | builder.RootComponents.Add("head::after"); 8 | 9 | builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.Configuration["API_Prefix"] ?? builder.HostEnvironment.BaseAddress) }); 10 | builder.Services.AddSingleton(); 11 | 12 | await builder.Build().RunAsync(); 13 | -------------------------------------------------------------------------------- /Client/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:46660", 7 | "sslPort": 44350 8 | } 9 | }, 10 | "profiles": { 11 | "BlazorApp.Client": { 12 | "commandName": "Project", 13 | "dotnetRunMessages": true, 14 | "launchBrowser": true, 15 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 16 | "applicationUrl": "http://localhost:5000", 17 | "environmentVariables": { 18 | "ASPNETCORE_ENVIRONMENT": "Development" 19 | } 20 | }, 21 | "IIS Express": { 22 | "commandName": "IISExpress", 23 | "launchBrowser": true, 24 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 25 | "environmentVariables": { 26 | "ASPNETCORE_ENVIRONMENT": "Development" 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Client/Shared/LinkBundleDetails.razor: -------------------------------------------------------------------------------- 1 | @using BlazorApp.Client.Utils 2 | @using BlazorApp.Shared 3 | @using System.ComponentModel.DataAnnotations 4 | 5 | @inject StateContainer StateContainer 6 | @inject HttpClient Http 7 | @inject NavigationManager NavigationManager 8 | 9 | @implements IDisposable 10 | 11 |
12 |
13 |
14 |
15 |
16 | 17 | 22 |

23 | @validationErrorMessage 24 |

25 | 29 |
30 |
31 | 32 | 34 |
35 |
36 | 37 | @if (StateContainer.User != null) { 38 | 42 | } 43 | else { 44 | 48 | } 49 |
50 |
51 |
52 |
53 |
54 | 55 | @code { 56 | 57 | protected override void OnInitialized() 58 | { 59 | StateContainer.OnChange += StateHasChanged; 60 | } 61 | 62 | [Parameter] 63 | public bool IsPublished { get; set; } = false; 64 | 65 | private Debouncer debouncer = new Debouncer(300); 66 | private bool listIsValid = true; 67 | private string validationErrorMessage = ""; 68 | private string validationErrorClass => listIsValid ? "" : "invalid"; 69 | private string validationMessageClass => listIsValid ? "is-invisible" : "is-visible"; 70 | 71 | bool PublishEnabled(bool valid) 72 | { 73 | if (valid && StateContainer.LinkBundle.Links.Count > 0) return true; 74 | return false; 75 | } 76 | 77 | private void ValidateVanityUrl(ChangeEventArgs e) 78 | { 79 | var vanityUrl = e.Value?.ToString(); 80 | StateContainer.LinkBundle.VanityUrl = vanityUrl; 81 | listIsValid = true; 82 | 83 | if (String.IsNullOrEmpty(vanityUrl)) return; 84 | 85 | // Ensure the vanity url contains only letters, numbers, and dashes without using regex 86 | if (vanityUrl.Any(c => !char.IsLetterOrDigit(c) && c != '-')) 87 | { 88 | // if the data annotations validation fails, no need to check if the vanity url is taken 89 | listIsValid = false; 90 | validationErrorMessage = "Vanity URLs can only contain letters, numbers, and dashes."; 91 | return; 92 | } 93 | 94 | debouncer.Debounce(async () => 95 | { 96 | // the characters are valid, so now check to see if the vanity url is already in use 97 | await VanityUrlIsTaken(vanityUrl); 98 | StateHasChanged(); 99 | }); 100 | } 101 | 102 | // calls the API to see if the vanity is stil available for use 103 | private async Task VanityUrlIsTaken(string vanityUrl) 104 | { 105 | 106 | var response = await Http.GetAsync($"api/links/{vanityUrl}"); 107 | 108 | if (response.StatusCode == System.Net.HttpStatusCode.OK) 109 | { 110 | listIsValid = false; 111 | validationErrorMessage = "This vanity URL is already in use. Please choose another."; 112 | return true; 113 | } 114 | 115 | return false; 116 | } 117 | 118 | private async Task PublishLinkBundle() 119 | { 120 | HttpResponseMessage response; 121 | if (IsPublished) 122 | { 123 | response = await Http.PutAsJsonAsync($"api/links/{StateContainer.LinkBundle.VanityUrl}", StateContainer.LinkBundle); 124 | } 125 | else 126 | { 127 | response = await Http.PostAsJsonAsync("api/links", StateContainer.LinkBundle); 128 | } 129 | 130 | var linkBundle = await response.Content.ReadFromJsonAsync(); 131 | 132 | if (linkBundle != null) 133 | { 134 | StateContainer.LinkBundle = linkBundle; 135 | 136 | // navigate to the public page 137 | NavigationManager.NavigateTo($"/{linkBundle.VanityUrl}"); 138 | } 139 | } 140 | 141 | public void Dispose() 142 | { 143 | StateContainer.OnChange -= StateHasChanged; 144 | } 145 | } -------------------------------------------------------------------------------- /Client/Shared/LinkBundleDetails.razor.css: -------------------------------------------------------------------------------- 1 | #listDetails { 2 | padding-top: 40px; 3 | background: var(--main-background); 4 | top: 0; 5 | width: 100%; 6 | padding-bottom: 20px; 7 | min-height: 180px; 8 | box-shadow: var(--drop-shadow); 9 | padding-bottom: 20px; 10 | } 11 | 12 | #description { 13 | font-size: 0.8em; 14 | line-height: 1.5em; 15 | } 16 | 17 | #vanityUrl { 18 | font-size: 2.25em; 19 | font-weight: 500; 20 | } 21 | 22 | #publishButton { 23 | margin-top: 1em; 24 | } 25 | 26 | #liveLink { 27 | margin-top: 10px; 28 | font-size: 12px; 29 | } -------------------------------------------------------------------------------- /Client/Shared/LinkBundleItem.razor: -------------------------------------------------------------------------------- 1 | @using BlazorApp.Shared 2 | @inject NavigationManager NavigationManager 3 | 4 | 45 | 46 | @code { 47 | [Parameter] 48 | public Link link { get; set; } = new Link(); 49 | 50 | [Parameter] 51 | public bool editable { get; set; } = false; 52 | 53 | private string GetUrlWithProtocol(string url) 54 | { 55 | // determine if the url is missing the protocol 56 | if (!url.StartsWith("http://") && !url.StartsWith("https://")) 57 | { 58 | // add a relative protocol 59 | return url = "//" + url; 60 | } 61 | else return url; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Client/Shared/LinkBundleItem.razor.css: -------------------------------------------------------------------------------- 1 | .link-title { 2 | color: var(--bulma-card-color); 3 | } 4 | 5 | .link-description { 6 | color: var(--bulma-card-color); 7 | } -------------------------------------------------------------------------------- /Client/Shared/LinkBundleItemEditable.razor: -------------------------------------------------------------------------------- 1 | @using BlazorApp.Shared 2 | 3 | @inject StateContainer StateContainer 4 | 5 | @implements IDisposable 6 | 7 | 70 | 71 | @code { 72 | 73 | protected override void OnInitialized() 74 | { 75 | StateContainer.OnChange += StateHasChanged; 76 | } 77 | 78 | [Parameter] 79 | public Link newLink { get; set; } = new Link(); 80 | 81 | [Parameter] 82 | public EventCallback OnDeleteLink { get; set; } 83 | 84 | public void Dispose() 85 | { 86 | StateContainer.OnChange -= StateHasChanged; 87 | } 88 | } -------------------------------------------------------------------------------- /Client/Shared/LinkBundleItemEditable.razor.css: -------------------------------------------------------------------------------- 1 | .progress-bar--wrapper { 2 | height: 6px; 3 | } 4 | 5 | .progress-bar--container { 6 | height: 6px; 7 | background-color: #4ade9b; 8 | width: 100%; 9 | display: none; 10 | /* position: absolute; */ 11 | overflow: hidden; 12 | transition: opacity 0.1s ease-out; 13 | opacity: 1; 14 | z-index: 1000; 15 | } 16 | 17 | .progress-bar--container::after { 18 | background-color: #2bad96; 19 | content: ""; 20 | /* position: absolute; */ 21 | width: inherit; 22 | height: inherit; 23 | transform-origin: left; 24 | } 25 | 26 | .progress-bar--container.visible { 27 | display: block; 28 | animation: progress-bar--container_fadeIn 0.2s ease-in; 29 | } 30 | 31 | .progress-bar--container.visible::after { 32 | display: block; 33 | animation: progress-bar_fill 2s linear; 34 | animation-iteration-count: infinite; 35 | } 36 | 37 | .progress-bar--container.visible.fade { 38 | opacity: 0; 39 | } 40 | 41 | @keyframes progress-bar_fill { 42 | 0% { 43 | transform: scaleX(0) translateX(0); 44 | } 45 | 46 | 1% { 47 | transform: scaleX(0) translateX(0); 48 | } 49 | 50 | 33% { 51 | transform: scaleX(0.66) translateX(16.5%); 52 | } 53 | 54 | 75% { 55 | transform: scaleX(1.5) translateX(66%); 56 | } 57 | 58 | 100% { 59 | transform: scaleX(2) translateX(150%); 60 | } 61 | } 62 | 63 | @keyframes progress-bar--container_fadeIn { 64 | from { 65 | opacity: 0; 66 | } 67 | 68 | to { 69 | opacity: 1; 70 | } 71 | } -------------------------------------------------------------------------------- /Client/Shared/LinkBundleItems.razor: -------------------------------------------------------------------------------- 1 | @using BlazorApp.Shared 2 | 3 | @inject StateContainer StateContainer 4 | 5 | @implements IDisposable 6 | 7 | 8 | @if (StateContainer.LinkBundle.Links.Count != 0) 9 | { 10 |
11 |
12 |

13 | Links 14 |

15 |
16 |
17 | Drag links to re-order 18 |
19 |
20 | 21 | 22 | 25 | 26 | 27 | } 28 | 29 | @code { 30 | 31 | protected override void OnInitialized() 32 | { 33 | StateContainer.OnChange += StateHasChanged; 34 | } 35 | 36 | private void NewLinkAdded(Link link) 37 | { 38 | StateContainer.AddLinkToBundle(link); 39 | } 40 | 41 | private void DeleteLink(Link link) 42 | { 43 | StateContainer.DeleteLinkFromBundle(link); 44 | } 45 | 46 | private void SortList((int oldIndex, int newIndex) indices) 47 | { 48 | var (oldIndex, newIndex) = indices; 49 | StateContainer.ReorderLinks(oldIndex, newIndex); 50 | } 51 | 52 | public void Dispose() 53 | { 54 | StateContainer.OnChange -= StateHasChanged; 55 | } 56 | } -------------------------------------------------------------------------------- /Client/Shared/MainLayout.razor: -------------------------------------------------------------------------------- 1 | @inherits LayoutComponentBase 2 | 3 | @using BlazorApp.Shared 4 | 5 | @inject StateContainer StateContainer 6 | @inject HttpClient Http 7 | 8 |
9 | 10 | @Body 11 |
12 | 13 | -------------------------------------------------------------------------------- /Client/Shared/MainLayout.razor.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-urlist/blazor-static-web-apps/50fad39a81d9a6b2bc642634d503f9321ef94552/Client/Shared/MainLayout.razor.css -------------------------------------------------------------------------------- /Client/Shared/ModalConfirm.razor: -------------------------------------------------------------------------------- 1 |  2 | @if (TitleContent != null) 3 | { 4 | @TitleContent 5 | } 6 | else if (!string.IsNullOrEmpty(Title)) 7 | { 8 |

@Title

9 | } 10 |
11 | @if (PromptContent != null) 12 | { 13 | @PromptContent 14 | } 15 | else if (!string.IsNullOrEmpty(Prompt)) 16 | { 17 |

18 | @Prompt 19 |

20 | } 21 |
22 |
23 | 26 | 29 |
30 |
31 | 32 | 33 | @code { 34 | 35 | public enum ModalType 36 | { 37 | primary, 38 | info, 39 | danger 40 | } 41 | 42 | [Parameter] 43 | public ModalType Type { get; set; } = ModalType.primary; 44 | 45 | [Parameter] 46 | public string? Title { get; set; } 47 | 48 | [Parameter] 49 | public string? Prompt { get; set; } 50 | 51 | [Parameter] 52 | public string OKText { get; set; } = "OK"; 53 | 54 | [Parameter] 55 | public string CancelText { get; set; } = "Cancel"; 56 | 57 | [Parameter] 58 | public RenderFragment? TitleContent { get; set; } 59 | 60 | [Parameter] 61 | public RenderFragment? PromptContent { get; set; } 62 | 63 | [Parameter] 64 | public EventCallback OnOK { get; set; } 65 | 66 | [Parameter] 67 | public EventCallback OnCancel { get; set; } 68 | 69 | private ModalWindow? modal; 70 | 71 | public void Show() 72 | { 73 | modal?.ShowModal(); 74 | } 75 | 76 | public async Task OK() 77 | { 78 | await OnOK.InvokeAsync(true); 79 | modal?.DismissModalWithExtremePrejudice(); 80 | } 81 | 82 | public async Task Cancel() 83 | { 84 | await OnCancel.InvokeAsync(true); 85 | modal?.DismissModalWithExtremePrejudice(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Client/Shared/ModalImport.razor: -------------------------------------------------------------------------------- 1 | @using System.Text.Json 2 | 3 | @inject NavigationManager NavigationManager 4 | @inject StateContainer StateContainer 5 | 6 | 7 | 8 |
9 |

Import any lists you created under a Twitter login.

10 | Import 12 | From Twitter 13 |
14 | 15 |
16 | 17 | @code { 18 | ModalWindow? modal; 19 | 20 | private string? userData; 21 | 22 | protected override void OnInitialized() 23 | { 24 | var data = new 25 | { 26 | userDetails = StateContainer.User?.ClientPrincipal.UserDetails, 27 | identityProvider = StateContainer.User?.ClientPrincipal.IdentityProvider 28 | }; 29 | 30 | userData = JsonSerializer.Serialize(data); 31 | } 32 | 33 | public void Show() 34 | { 35 | modal?.ShowModal(); 36 | } 37 | } -------------------------------------------------------------------------------- /Client/Shared/ModalLogin.razor: -------------------------------------------------------------------------------- 1 | @inject NavigationManager NavigationManager 2 | 3 | 4 |
5 |
6 |

7 | Sign in to 8 |

9 |
10 |
11 | 12 |
13 |
14 | 16 | 17 | 18 | with Twitter/X 19 | 20 | 21 |
22 | 24 | 25 | 26 | with GitHub 27 | 28 | 29 |
30 | 32 | 33 | 34 | with Google 35 | 36 | 37 |
38 |
39 | 40 | @code { 41 | ModalWindow? modal; 42 | 43 | private string? currentUrl; 44 | 45 | public void Show() 46 | { 47 | currentUrl = NavigationManager.Uri; 48 | modal?.ShowModal(); 49 | } 50 | } -------------------------------------------------------------------------------- /Client/Shared/ModalLogin.razor.css: -------------------------------------------------------------------------------- 1 | #twitterButton { 2 | background-color: #1cabe9; 3 | } 4 | 5 | #facebookButton { 6 | background-color: #3b5998; 7 | } 8 | 9 | #githubButton { 10 | background-color: #333333; 11 | } 12 | 13 | #googleButton { 14 | background-color: #dd4b39; 15 | } 16 | 17 | .icon { 18 | margin-right: 8px; 19 | } -------------------------------------------------------------------------------- /Client/Shared/ModalWindow.razor: -------------------------------------------------------------------------------- 1 |  14 | 15 | @code { 16 | private bool isOpen { get; set; } 17 | 18 | [Parameter] 19 | public RenderFragment? ChildContent { get; set; } 20 | 21 | public void ShowModal() 22 | { 23 | isOpen = true; 24 | StateHasChanged(); 25 | } 26 | 27 | public void DismissModalWithExtremePrejudice() 28 | { 29 | isOpen = false; 30 | StateHasChanged(); 31 | } 32 | } -------------------------------------------------------------------------------- /Client/Shared/ModalWindow.razor.css: -------------------------------------------------------------------------------- 1 | .modal { 2 | padding: 20px; 3 | } 4 | 5 | .modal-content { 6 | min-width: 300px; 7 | max-width: 500px; 8 | background-color: white; 9 | box-shadow: 0 20px 60px -2px rgba(27, 33, 58, 0.4); 10 | } 11 | 12 | .modal-content-inner { 13 | padding: 20px; 14 | text-align: center; 15 | } 16 | 17 | .modal-background { 18 | opacity: 0.3; 19 | } -------------------------------------------------------------------------------- /Client/Shared/NavBar.razor: -------------------------------------------------------------------------------- 1 | @using BlazorApp.Shared 2 | 3 | @inject StateContainer StateContainer 4 | @inject NavigationManager NavigationManager 5 | @inject HttpClient Http 6 | 7 | 8 | 97 | 98 | @code { 99 | private bool loggedIn = false; 100 | private bool showMenu = false; 101 | private ModalLogin? modalLogin; 102 | private ModalConfirm? modalConfirm; 103 | 104 | private void NewList() 105 | { 106 | @* if the current page is /s/edit and the linbundle has link items *@ 107 | if (NavigationManager.Uri.Contains("/s/new") && StateContainer.LinkBundle?.Links?.Count > 0) 108 | { 109 | modalConfirm?.Show(); 110 | } 111 | else 112 | { 113 | ResetLinkBundle(); 114 | NavigationManager.NavigateTo("/s/new"); 115 | } 116 | } 117 | private void ToggleMenu() 118 | { 119 | showMenu = !showMenu; 120 | } 121 | 122 | private void ToggleModal() 123 | { 124 | modalLogin?.Show(); 125 | } 126 | 127 | private void ResetLinkBundle() 128 | { 129 | StateContainer.LinkBundle = new LinkBundle(); 130 | NavigationManager.NavigateTo("/s/new"); 131 | } 132 | protected override async Task OnInitializedAsync() 133 | { 134 | await GetClientPrincipalAsync(); 135 | 136 | // try to load the last list they were on from localstorage 137 | await StateContainer.LoadLinkBundleFromLocalStorage(); 138 | } 139 | 140 | private async Task GetClientPrincipalAsync() 141 | { 142 | try 143 | { 144 | var result = await Http.GetFromJsonAsync(".auth/me"); 145 | 146 | // if the client principal is null, then they are not logged in 147 | if (result?.ClientPrincipal == null) 148 | { 149 | return; 150 | } 151 | 152 | StateContainer.User = new User(result.ClientPrincipal); 153 | 154 | loggedIn = true; 155 | 156 | StateHasChanged(); 157 | } 158 | catch (Exception ex) 159 | { 160 | Console.WriteLine(ex.Message); 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /Client/Shared/NavBar.razor.css: -------------------------------------------------------------------------------- 1 | .beta-bump { 2 | margin-top: 20px; 3 | } 4 | 5 | #navbar { 6 | top: 0; 7 | width: 100%; 8 | background: var(--main-background); 9 | box-shadow: var(--drop-shadow); 10 | padding-bottom: 20px; 11 | } 12 | 13 | #navbar a.navbar-item { 14 | color: #71777e; 15 | } 16 | 17 | #navbar a.navbar-item img { 18 | max-height: inherit; 19 | } 20 | 21 | #navbar a.navbar-item:hover, 22 | #navbar a.navbar-item:focus, 23 | #navbar a.navbar-item:focus-within { 24 | background-color: inherit; 25 | color: inherit; 26 | } 27 | 28 | #profileImage { 29 | margin-left: 5px; 30 | margin-right: 10px; 31 | > img { 32 | width: 28px; 33 | height: 28px; 34 | } 35 | } -------------------------------------------------------------------------------- /Client/Shared/NewLink.razor: -------------------------------------------------------------------------------- 1 | @using BlazorApp.Shared 2 | @inject HttpClient Http 3 | @inject StateContainer StateContainer 4 | 5 |
6 |

Enter a link and press enter

7 | 8 |
That doesn't look like a valid URL
9 |
10 | 11 | @code { 12 | private string? linkurl; 13 | private bool invalid = false; 14 | private ElementReference newLinkInput; 15 | 16 | [Parameter] 17 | public EventCallback OnNewLinkAdded { get; set; } 18 | 19 | private async Task OnSubmit() 20 | { 21 | if (!ValidateUrl(linkurl)) 22 | { 23 | invalid = true; 24 | StateHasChanged(); 25 | return; 26 | } 27 | 28 | invalid = false; 29 | 30 | var link = new Link 31 | { 32 | Url = linkurl 33 | }; 34 | 35 | await OnNewLinkAdded.InvokeAsync(link); 36 | linkurl = null; 37 | await newLinkInput.FocusAsync(); 38 | await GetOpenGraphInfoForLink(link); 39 | } 40 | 41 | private bool ValidateUrl(string? url) 42 | { 43 | if (string.IsNullOrWhiteSpace(url)) 44 | { 45 | return false; 46 | } 47 | 48 | if (!url.StartsWith("http://") && !url.StartsWith("https://")) 49 | { 50 | url = "http://" + url; 51 | } 52 | 53 | return Uri.TryCreate(url, UriKind.Absolute, out Uri? uriResult) 54 | && (uriResult.Scheme == Uri.UriSchemeHttp 55 | || uriResult.Scheme == Uri.UriSchemeHttps) && uriResult.Host.Replace("www.", "").Split('.').Count() > 1 && uriResult.HostNameType == UriHostNameType.Dns && uriResult.Host.Length > uriResult.Host.LastIndexOf(".") + 1; 56 | } 57 | 58 | private async Task GetOpenGraphInfoForLink(Link link) 59 | { 60 | try 61 | { 62 | StateContainer.AddLinkToUpdatePool(link); 63 | 64 | var cts = new CancellationTokenSource(); 65 | cts.CancelAfter(TimeSpan.FromSeconds(20)); 66 | 67 | var response = await Http.PostAsJsonAsync("api/oginfo", link, cts.Token); 68 | var updatedLink = await response.Content.ReadFromJsonAsync(); 69 | 70 | if (updatedLink != null) 71 | { 72 | // call statecontainer updatelink 73 | StateContainer.UpdateLinkInBundle(link, updatedLink); 74 | } 75 | } 76 | catch (Exception e) 77 | { 78 | // log the error 79 | Console.WriteLine($"Unable to get OpenGraph info for {link}.\n{e.Message}"); 80 | 81 | StateContainer.RemoveLinkFromUpdatePool(link); 82 | } 83 | finally 84 | { 85 | StateContainer.RemoveLinkFromUpdatePool(link); 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /Client/Shared/ProgressBar.razor: -------------------------------------------------------------------------------- 1 | @inject StateContainer StateContainer 2 | 3 | @implements IDisposable 4 | 5 |
6 |
7 |
8 | 9 | @code { 10 | protected override void OnInitialized() 11 | { 12 | StateContainer.OnChange += StateHasChanged; 13 | } 14 | 15 | public void Dispose() 16 | { 17 | StateContainer.OnChange -= StateHasChanged; 18 | } 19 | } -------------------------------------------------------------------------------- /Client/Shared/ProgressBar.razor.css: -------------------------------------------------------------------------------- 1 | .progress-bar--wrapper { 2 | height: 6px; 3 | } 4 | 5 | .progress-bar--container { 6 | height: 6px; 7 | background-color: #4ade9b; 8 | width: 100%; 9 | display: none; 10 | /* position: absolute; */ 11 | overflow: hidden; 12 | transition: opacity 0.1s ease-out; 13 | opacity: 1; 14 | z-index: 1000; 15 | } 16 | 17 | .progress-bar--container::after { 18 | background-color: #2bad96; 19 | content: ""; 20 | /* position: absolute; */ 21 | width: inherit; 22 | height: inherit; 23 | transform-origin: left; 24 | } 25 | 26 | .progress-bar--container.visible { 27 | display: block; 28 | animation: progress-bar--container_fadeIn 0.2s ease-in; 29 | } 30 | 31 | .progress-bar--container.visible::after { 32 | display: block; 33 | animation: progress-bar_fill 2s linear; 34 | animation-iteration-count: infinite; 35 | } 36 | 37 | .progress-bar--container.visible.fade { 38 | opacity: 0; 39 | } 40 | 41 | @keyframes progress-bar_fill { 42 | 0% { 43 | transform: scaleX(0) translateX(0); 44 | } 45 | 46 | 1% { 47 | transform: scaleX(0) translateX(0); 48 | } 49 | 50 | 33% { 51 | transform: scaleX(0.66) translateX(16.5%); 52 | } 53 | 54 | 75% { 55 | transform: scaleX(1.5) translateX(66%); 56 | } 57 | 58 | 100% { 59 | transform: scaleX(2) translateX(150%); 60 | } 61 | } 62 | 63 | @keyframes progress-bar--container_fadeIn { 64 | from { 65 | opacity: 0; 66 | } 67 | 68 | to { 69 | opacity: 1; 70 | } 71 | } -------------------------------------------------------------------------------- /Client/Shared/QrCodeItem.razor: -------------------------------------------------------------------------------- 1 | @using System.Net 2 | @using System.Text 3 | @inject HttpClient Http 4 | 5 |
6 | @((MarkupString)svgMarkup) 7 |
8 | 9 | @code { 10 | [Parameter] 11 | public string Url { get; set; } = "https://theurlist.com"; 12 | 13 | private string svgMarkup = ""; 14 | 15 | protected override async Task OnInitializedAsync() 16 | { 17 | if (!string.IsNullOrEmpty(Url)) 18 | { 19 | // Base64 encode the URL because URL encoding doesn't work with static web app routing 20 | var encodedUrl = Convert.ToBase64String(Encoding.UTF8.GetBytes(Url)); 21 | 22 | var response = await Http.GetAsync($"api/qrcode/{encodedUrl}"); 23 | if (response.IsSuccessStatusCode) 24 | { 25 | svgMarkup = await response.Content.ReadAsStringAsync(); 26 | } 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /Client/Shared/QrCodeItem.razor.css: -------------------------------------------------------------------------------- 1 | .qrcode { 2 | height: calc(100vh - 200px); 3 | } 4 | -------------------------------------------------------------------------------- /Client/Shared/SortableList.razor: -------------------------------------------------------------------------------- 1 | @using System.Collections.Generic 2 | @using System.Diagnostics.CodeAnalysis 3 | 4 | @inject IJSRuntime JS 5 | @inject StateContainer StateContainer 6 | 7 | @implements IDisposable 8 | 9 | @typeparam T 10 | 11 |
12 | @foreach (var item in Items) 13 | { 14 | @if (SortableItemTemplate is not null) 15 | { 16 | @SortableItemTemplate(item) 17 | } 18 | } 19 |
20 | 21 | @code { 22 | 23 | [Parameter] 24 | public RenderFragment? SortableItemTemplate { get; set; } 25 | 26 | [Parameter, EditorRequired] 27 | public List Items { get; set; } = []; 28 | 29 | [Parameter] 30 | public EventCallback<(int oldIndex, int newIndex)> OnUpdate { get; set; } 31 | 32 | private ElementReference sortableEl; 33 | 34 | private DotNetObjectReference>? selfReference; 35 | 36 | protected override void OnInitialized() 37 | { 38 | StateContainer.OnChange += StateHasChanged; 39 | } 40 | 41 | protected override async Task OnAfterRenderAsync(bool firstRender) 42 | { 43 | if (firstRender) 44 | { 45 | selfReference = DotNetObjectReference.Create(this); 46 | 47 | // import the sortablejs library 48 | await JS.InvokeVoidAsync("import", "./js/sortable.min.js"); 49 | 50 | // import the sortablelist component JavaScript 51 | var module = await JS.InvokeAsync("import", "./Shared/SortableList.razor.js"); 52 | 53 | await module.InvokeAsync("init", sortableEl, selfReference); 54 | } 55 | } 56 | 57 | [JSInvokable] 58 | public void Drop(int oldIndex, int newIndex) 59 | { 60 | // invoke the OnUpdate event passing in the oldIndex and the newIndex 61 | OnUpdate.InvokeAsync((oldIndex, newIndex)); 62 | } 63 | 64 | public void Dispose() => selfReference?.Dispose(); 65 | } 66 | -------------------------------------------------------------------------------- /Client/Shared/SortableList.razor.css: -------------------------------------------------------------------------------- 1 | ::deep .sortable-ghost { 2 | visibility: hidden; 3 | } 4 | 5 | ::deep .sortable-fallback { opacity: 1 !important } -------------------------------------------------------------------------------- /Client/Shared/SortableList.razor.js: -------------------------------------------------------------------------------- 1 | export function init(list, component) { 2 | new Sortable(list, { 3 | animation: 200, 4 | handle: '.drag-handle', 5 | onUpdate: (event) => { 6 | // Revert the DOM to match the .NET state 7 | event.item.remove(); 8 | event.to.insertBefore(event.item, event.to.childNodes[event.oldIndex]); 9 | 10 | // Notify .NET to update its model and re-render 11 | component.invokeMethodAsync('Drop', event.oldDraggableIndex, event.newDraggableIndex); 12 | } 13 | }); 14 | } -------------------------------------------------------------------------------- /Client/Shared/ThemeSwitcher.razor: -------------------------------------------------------------------------------- 1 | @using BlazorApp.Shared 2 | 3 | @inject StateContainer StateContainer 4 | @inject IJSRuntime jsRuntime 5 | 6 | 88 | 89 | @code { 90 | 91 | private string preferredTheme = "system"; 92 | 93 | protected override async Task OnInitializedAsync() 94 | { 95 | // the preferred theme is also loaded from localStorage in the 96 | // index.html file via JavaScript to prevent a flash of the default theme 97 | 98 | // get the preferred theme from local storage 99 | preferredTheme = await jsRuntime.InvokeAsync("localStorage.getItem", "preferredTheme"); 100 | 101 | // if the preferred theme is not set, default to system 102 | if (string.IsNullOrWhiteSpace(preferredTheme)) 103 | { 104 | preferredTheme = "system"; 105 | } 106 | 107 | // set the value of the data-theme property on the html element 108 | await jsRuntime.InvokeVoidAsync("document.documentElement.setAttribute", "data-theme", preferredTheme); 109 | } 110 | 111 | public void SetTheme(string theme) 112 | { 113 | preferredTheme = theme; 114 | 115 | // set the value of the data-theme property on the html element 116 | jsRuntime.InvokeVoidAsync("document.documentElement.setAttribute", "data-theme", theme); 117 | 118 | // save the preferred theme to local storage 119 | jsRuntime.InvokeVoidAsync("localStorage.setItem", "preferredTheme", theme); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Client/Shared/ThemeSwitcher.razor.css: -------------------------------------------------------------------------------- 1 | @media screen and (min-width: 1024px) { 2 | .navbar-dropdown a.navbar-item { 3 | padding-inline-end: 1rem; 4 | width: 150px; 5 | } 6 | } -------------------------------------------------------------------------------- /Client/StateContainer.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Immutable; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.Text.Json; 4 | using BlazorApp.Shared; 5 | using Microsoft.JSInterop; 6 | 7 | public class StateContainer 8 | { 9 | private readonly IJSRuntime jsRuntime; 10 | 11 | public StateContainer(IJSRuntime jsRuntime) 12 | { 13 | this.jsRuntime = jsRuntime; 14 | } 15 | 16 | private LinkBundle? linkBundle; 17 | public LinkBundle LinkBundle 18 | { 19 | get => linkBundle ??= new LinkBundle(); 20 | set 21 | { 22 | linkBundle = value; 23 | 24 | SaveLinkBundleToLocalStorage(); 25 | NotifyStateChanged(); 26 | } 27 | } 28 | 29 | private readonly List _linksPendingUpdate = []; 30 | 31 | public void AddLinkToUpdatePool(Link link) 32 | { 33 | _linksPendingUpdate.Add(link); 34 | NotifyStateChanged(); 35 | } 36 | 37 | public void RemoveLinkFromUpdatePool(Link link) 38 | { 39 | _linksPendingUpdate.Remove(link); 40 | NotifyStateChanged(); 41 | } 42 | 43 | public bool IsLinkInUpdatePool(Link link) 44 | { 45 | return _linksPendingUpdate.Contains(link); 46 | } 47 | 48 | [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "Shared assembly hasn't opted into trimming")] 49 | public async Task LoadLinkBundleFromLocalStorage() 50 | { 51 | var json = await jsRuntime.InvokeAsync("localStorage.getItem", "linkBundle"); 52 | if (!string.IsNullOrEmpty(json)) 53 | { 54 | LinkBundle = JsonSerializer.Deserialize(json) ?? new LinkBundle(); 55 | } 56 | } 57 | 58 | [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "Shared assembly hasn't opted into trimming")] 59 | public void SaveLinkBundleToLocalStorage() 60 | { 61 | var json = JsonSerializer.Serialize(LinkBundle); 62 | jsRuntime.InvokeVoidAsync("localStorage.setItem", "linkBundle", json).AsTask(); 63 | } 64 | 65 | private User? user; 66 | public User? User 67 | { 68 | get => user; 69 | set 70 | { 71 | user = value; 72 | NotifyStateChanged(); 73 | } 74 | } 75 | 76 | public void DeleteLinkFromBundle(Link link) 77 | { 78 | LinkBundle.Links.Remove(link); 79 | 80 | LinkBundleHasChanged(); 81 | } 82 | 83 | public void AddLinkToBundle(Link link) 84 | { 85 | LinkBundle.Links.Add(link); 86 | LinkBundleHasChanged(); 87 | } 88 | 89 | public void UpdateLinkInBundle(Link link, Link? updatedLink) 90 | { 91 | if (updatedLink == null) 92 | { 93 | return; 94 | } 95 | 96 | link.Title = updatedLink.Title; 97 | link.Description = updatedLink.Description; 98 | link.Image = updatedLink.Image; 99 | 100 | LinkBundleHasChanged(); 101 | } 102 | 103 | public void ReorderLinks(int moveFromIndex, int moveToIndex) 104 | { 105 | var links = LinkBundle.Links; 106 | var itemToMove = links[moveFromIndex]; 107 | links.RemoveAt(moveFromIndex); 108 | 109 | links.Insert(moveToIndex < links.Count ? moveToIndex : links.Count, itemToMove); 110 | 111 | LinkBundleHasChanged(); 112 | } 113 | 114 | private void LinkBundleHasChanged() 115 | { 116 | SaveLinkBundleToLocalStorage(); 117 | NotifyStateChanged(); 118 | } 119 | 120 | public event Action? OnChange; 121 | 122 | private void NotifyStateChanged() 123 | { 124 | OnChange?.Invoke(); 125 | } 126 | } -------------------------------------------------------------------------------- /Client/Utils/Debouncer.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Components; 2 | 3 | namespace BlazorApp.Client.Utils; 4 | 5 | public class Debouncer 6 | { 7 | private Timer? timer; 8 | private readonly int delay; 9 | 10 | public Debouncer(int delay) 11 | { 12 | this.delay = delay; 13 | } 14 | 15 | public void Debounce(Action action) 16 | { 17 | timer?.Dispose(); 18 | timer = new Timer(_ => action(), null, delay, Timeout.Infinite); 19 | } 20 | } -------------------------------------------------------------------------------- /Client/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using System.Net.Http 2 | @using System.Net.Http.Json 3 | @using Microsoft.AspNetCore.Components.Forms 4 | @using Microsoft.AspNetCore.Components.Routing 5 | @using Microsoft.AspNetCore.Components.Web 6 | @using Microsoft.AspNetCore.Components.Web.Virtualization 7 | @using Microsoft.AspNetCore.Components.WebAssembly.Http 8 | @using Microsoft.JSInterop 9 | @using BlazorApp.Client 10 | @using BlazorApp.Client.Shared 11 | -------------------------------------------------------------------------------- /Client/staticwebapp.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "navigationFallback": { 3 | "rewrite": "index.html", 4 | "exclude": [ 5 | "/images/*.{png,jpg,gif}", 6 | "/css/*" 7 | ] 8 | }, 9 | "auth": { 10 | "identityProviders": { 11 | "google": { 12 | "registration": { 13 | "clientIdSettingName": "GOOGLE_CLIENT_ID", 14 | "clientSecretSettingName": "GOOGLE_CLIENT_SECRET" 15 | } 16 | }, 17 | "github": { 18 | "registration": { 19 | "clientIdSettingName": "GITHUB_CLIENT_ID", 20 | "clientSecretSettingName": "GITHUB_CLIENT_SECRET" 21 | } 22 | }, 23 | "twitter": { 24 | "registration": { 25 | "consumerKeySettingName": "TWITTER_CONSUMER_KEY", 26 | "consumerSecretSettingName": "TWITTER_CONSUMER_SECRET" 27 | } 28 | } 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /Client/wwwroot/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "API_Prefix": "http://localhost:4280" 3 | } -------------------------------------------------------------------------------- /Client/wwwroot/css/app.css: -------------------------------------------------------------------------------- 1 | @import url("bulma/bulma.css"); 2 | @import url("https://use.fontawesome.com/releases/v5.6.3/css/all.css"); 3 | 4 | 5 | :root { 6 | --primary: #20ae96; 7 | --danger: #d9255e; 8 | --text-color: #222c38; 9 | --home-bottom-background: #f9fafc; 10 | --main-background: #fff; 11 | --drop-shadow: 0 50px 40px -40px #e8e8e8; 12 | } 13 | 14 | @media (prefers-color-scheme: dark) { 15 | :root { 16 | --text-color: #fff; 17 | --home-bottom-background: #353638; 18 | --main-background: #14161a; 19 | --drop-shadow: 0 50px 40px -40px #292727; 20 | } 21 | 22 | #logo-img { 23 | content: url("/images/logo-dark.svg"); 24 | } 25 | } 26 | 27 | /* @media (prefers-color-scheme: light) { 28 | :root { 29 | --text-color: #222c38; 30 | --home-bottom-background: #f9fafc; 31 | --main-background: #fff; 32 | --drop-shadow: 0 50px 40px -40px #e8e8e8; 33 | } 34 | 35 | #logo-img { 36 | content: url("/images/logo.svg"); 37 | } 38 | } */ 39 | 40 | [data-theme=light], 41 | .theme-dark { 42 | --text-color: #222c38; 43 | --home-bottom-background: #f9fafc; 44 | --main-background: #fff; 45 | --drop-shadow: 0 50px 40px -40px #e8e8e8; 46 | } 47 | 48 | [data-theme=light] #logo-img, 49 | .theme-dark #logo-img { 50 | content: url("/images/logo.svg"); 51 | } 52 | 53 | [data-theme=dark], 54 | .theme-dark { 55 | --text-color: #fff; 56 | --home-bottom-background: #353638; 57 | --main-background: #14161a; 58 | --drop-shadow: 0 5px 40px 1px #292727; 59 | } 60 | 61 | [data-theme=dark] #logo-img, 62 | .theme-dark #logo-img { 63 | content: url("/images/logo-dark.svg"); 64 | } 65 | 66 | .beta-bump { 67 | margin-top: 10px; 68 | } 69 | 70 | html, 71 | body { 72 | text-rendering: auto; 73 | text-size-adjust: 100%; 74 | color: var(--bulma-card-color); 75 | } 76 | 77 | body { 78 | padding: 0; 79 | margin: 0; 80 | font-family: "Roboto", sans-serif; 81 | background-color: var(--home-bottom-background); 82 | color: var(--text-color); 83 | font-size: 18px; 84 | } 85 | 86 | #themeSelector button { 87 | text-align: left; 88 | } 89 | 90 | .is-fullwidth { 91 | width: 100%; 92 | } 93 | 94 | .input, 95 | .textarea { 96 | outline: none; 97 | padding: 0px 10px; 98 | height: 3.5rem; 99 | border-radius: 4px; 100 | border: 1px solid transparent; 101 | border-color: #979797; 102 | line-height: 1.5; 103 | width: 100%; 104 | -webkit-box-sizing: border-box; 105 | box-sizing: border-box; 106 | } 107 | 108 | .input.invalid, 109 | .textarea.invalid { 110 | visibility: visible; 111 | border-width: 4px; 112 | border-color: var(--danger); 113 | -webkit-animation-name: shakeError; 114 | animation-name: shakeError; 115 | -webkit-animation-fill-mode: forward; 116 | animation-fill-mode: forward; 117 | -webkit-animation-duration: 0.6s; 118 | animation-duration: 0.6s; 119 | -webkit-animation-timing-function: ease-in-out; 120 | animation-timing-function: ease-in-out; 121 | } 122 | 123 | .textarea[rows] { 124 | height: 3.5rem; 125 | min-height: auto; 126 | } 127 | 128 | .textarea:not([rows]) { 129 | height: auto; 130 | } 131 | 132 | a { 133 | color: var(--primary); 134 | } 135 | 136 | .navbar-burger { 137 | width: inherit; 138 | height: inherit; 139 | } 140 | 141 | .tag:not(body) { 142 | border-radius: 20px; 143 | } 144 | 145 | .main { 146 | max-width: 960px; 147 | padding: 0px 10px; 148 | } 149 | 150 | .section { 151 | padding-top: 0px; 152 | } 153 | 154 | .card { 155 | border-radius: 4px; 156 | box-shadow: var(--drop-shadow); 157 | } 158 | 159 | .content { 160 | margin-top: 100px; 161 | } 162 | 163 | .x { 164 | text-decoration: none; 165 | color: inherit; 166 | } 167 | 168 | .is-heading { 169 | margin: 40px 0; 170 | } 171 | 172 | .link-outer { 173 | height: 140px; 174 | } 175 | 176 | .link-editable { 177 | margin: 10px -40px auto -40px; 178 | } 179 | 180 | .link-wrapper { 181 | transition: margin 400ms linear; 182 | margin-top: 10px; 183 | margin-bottom: auto; 184 | } 185 | 186 | .link { 187 | width: 100%; 188 | height: 120px; 189 | cursor: pointer; 190 | margin-bottom: 20px; 191 | } 192 | 193 | .link .textarea, 194 | .link .input { 195 | padding: 0px 4px 0px 4px; 196 | line-height: auto; 197 | height: auto; 198 | min-height: auto; 199 | border: none; 200 | box-shadow: none; 201 | overflow: hidden; 202 | text-overflow: ellipsis; 203 | } 204 | 205 | .link .textarea:hover, 206 | .link .input:hover { 207 | box-shadow: inset 1px 0px 2px #20ae96; 208 | } 209 | 210 | .link .link-image { 211 | padding-right: -14px; 212 | } 213 | 214 | .link .link-image.link-image-little { 215 | margin-left: 4px; 216 | margin-right: 4px; 217 | } 218 | 219 | .link .link-details { 220 | height: 110px; 221 | margin-right: 10px; 222 | } 223 | 224 | .link .link-description { 225 | font-size: 1em; 226 | height: 40px; 227 | width: 100%; 228 | overflow: hidden; 229 | text-overflow: ellipsis; 230 | } 231 | 232 | .link .link-url { 233 | margin-left: 4px; 234 | font-size: 12px; 235 | border: none; 236 | width: 100%; 237 | overflow: hidden; 238 | text-overflow: ellipsis; 239 | background-color: var(--main-background); 240 | color: var(--text-color); 241 | } 242 | 243 | .link .link-url:disabled { 244 | color: black; 245 | } 246 | 247 | .link .link-delete { 248 | cursor: pointer; 249 | margin-top: -20px; 250 | margin-left: 20px; 251 | transition: margin 400ms linear; 252 | } 253 | 254 | .link .link-overlay { 255 | height: 110px; 256 | width: 100%; 257 | cursor: pointer; 258 | position: absolute; 259 | margin-top: -110px; 260 | } 261 | 262 | .drag-handle { 263 | cursor: move; 264 | } 265 | 266 | .beta-bump { 267 | margin-top: 20px; 268 | } 269 | 270 | @media only screen and (max-width: 1090px) { 271 | .link-wrapper { 272 | margin-left: 0; 273 | margin-right: 0; 274 | } 275 | } 276 | 277 | @keyframes shakeError { 278 | 0% { 279 | transform: translateX(0); 280 | } 281 | 282 | 15% { 283 | transform: translateX(0.375rem); 284 | } 285 | 286 | 30% { 287 | transform: translateX(-0.375rem); 288 | } 289 | 290 | 45% { 291 | transform: translateX(0.375rem); 292 | } 293 | 294 | 60% { 295 | transform: translateX(-0.375rem); 296 | } 297 | 298 | 75% { 299 | transform: translateX(0.375rem); 300 | } 301 | 302 | 90% { 303 | transform: translateX(-0.375rem); 304 | } 305 | 306 | 100% { 307 | transform: translateX(0); 308 | } 309 | } 310 | 311 | .errorMessage { 312 | height: 10px; 313 | margin-top: 5px; 314 | } 315 | 316 | @keyframes typing { 317 | 0% { 318 | opacity: 0; 319 | } 320 | 321 | 50% { 322 | opacity: 1; 323 | } 324 | 325 | 100% { 326 | opacity: 0; 327 | } 328 | } 329 | 330 | .dot { 331 | display: inline-block; 332 | height: 10px; 333 | width: 10px; 334 | border-radius: 50%; 335 | margin: 0 2px; 336 | animation: typing 1.5s infinite; 337 | } 338 | 339 | .dot:nth-child(2) { 340 | animation-delay: 0.5s; 341 | } 342 | 343 | .dot:nth-child(3) { 344 | animation-delay: 1s; 345 | } 346 | 347 | 348 | #blazor-error-ui { 349 | background: lightyellow; 350 | bottom: 0; 351 | box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); 352 | display: none; 353 | left: 0; 354 | padding: 0.6rem 1.25rem 0.7rem 1.25rem; 355 | position: fixed; 356 | width: 100%; 357 | z-index: 1000; 358 | } 359 | 360 | #blazor-error-ui .dismiss { 361 | cursor: pointer; 362 | position: absolute; 363 | right: 0.75rem; 364 | top: 0.5rem; 365 | } 366 | 367 | .blazor-error-boundary { 368 | background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; 369 | padding: 1rem 1rem 1rem 3.7rem; 370 | color: white; 371 | } 372 | 373 | .blazor-error-boundary::after { 374 | content: "An error has occurred." 375 | } 376 | 377 | .loading-progress { 378 | position: relative; 379 | display: block; 380 | width: 8rem; 381 | height: 8rem; 382 | margin: 20vh auto 1rem auto; 383 | } 384 | 385 | .loading-progress circle { 386 | fill: none; 387 | stroke: #e0e0e0; 388 | stroke-width: 0.6rem; 389 | transform-origin: 50% 50%; 390 | transform: rotate(-90deg); 391 | } 392 | 393 | .loading-progress circle:last-child { 394 | stroke: #1b6ec2; 395 | stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%; 396 | transition: stroke-dasharray 0.05s ease-in-out; 397 | } 398 | 399 | .loading-progress-text { 400 | position: absolute; 401 | text-align: center; 402 | font-weight: bold; 403 | inset: calc(20vh + 3.25rem) 0 auto 0.2rem; 404 | } 405 | 406 | .loading-progress-text:after { 407 | content: var(--blazor-load-percentage-text, "Loading"); 408 | } 409 | 410 | code { 411 | color: #c02d76; 412 | } 413 | 414 | svg { 415 | width: 100%; 416 | height: 100%; 417 | } -------------------------------------------------------------------------------- /Client/wwwroot/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-urlist/blazor-static-web-apps/50fad39a81d9a6b2bc642634d503f9321ef94552/Client/wwwroot/favicon-16x16.png -------------------------------------------------------------------------------- /Client/wwwroot/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-urlist/blazor-static-web-apps/50fad39a81d9a6b2bc642634d503f9321ef94552/Client/wwwroot/favicon-32x32.png -------------------------------------------------------------------------------- /Client/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-urlist/blazor-static-web-apps/50fad39a81d9a6b2bc642634d503f9321ef94552/Client/wwwroot/favicon.ico -------------------------------------------------------------------------------- /Client/wwwroot/images/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-urlist/blazor-static-web-apps/50fad39a81d9a6b2bc642634d503f9321ef94552/Client/wwwroot/images/android-chrome-192x192.png -------------------------------------------------------------------------------- /Client/wwwroot/images/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-urlist/blazor-static-web-apps/50fad39a81d9a6b2bc642634d503f9321ef94552/Client/wwwroot/images/android-chrome-512x512.png -------------------------------------------------------------------------------- /Client/wwwroot/images/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-urlist/blazor-static-web-apps/50fad39a81d9a6b2bc642634d503f9321ef94552/Client/wwwroot/images/apple-touch-icon.png -------------------------------------------------------------------------------- /Client/wwwroot/images/banner-logo-large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-urlist/blazor-static-web-apps/50fad39a81d9a6b2bc642634d503f9321ef94552/Client/wwwroot/images/banner-logo-large.png -------------------------------------------------------------------------------- /Client/wwwroot/images/banner-logo-large.svg: -------------------------------------------------------------------------------- 1 | New Tabtheurlist.comNew Tabtheurlist.comNew Tabtheurlist.com -------------------------------------------------------------------------------- /Client/wwwroot/images/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-urlist/blazor-static-web-apps/50fad39a81d9a6b2bc642634d503f9321ef94552/Client/wwwroot/images/bg.png -------------------------------------------------------------------------------- /Client/wwwroot/images/bg@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-urlist/blazor-static-web-apps/50fad39a81d9a6b2bc642634d503f9321ef94552/Client/wwwroot/images/bg@2x.png -------------------------------------------------------------------------------- /Client/wwwroot/images/bg@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-urlist/blazor-static-web-apps/50fad39a81d9a6b2bc642634d503f9321ef94552/Client/wwwroot/images/bg@3x.png -------------------------------------------------------------------------------- /Client/wwwroot/images/burger.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Medium Pub Banner 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /Client/wwwroot/images/close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-urlist/blazor-static-web-apps/50fad39a81d9a6b2bc642634d503f9321ef94552/Client/wwwroot/images/close.png -------------------------------------------------------------------------------- /Client/wwwroot/images/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /Client/wwwroot/images/close@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-urlist/blazor-static-web-apps/50fad39a81d9a6b2bc642634d503f9321ef94552/Client/wwwroot/images/close@2x.png -------------------------------------------------------------------------------- /Client/wwwroot/images/close@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-urlist/blazor-static-web-apps/50fad39a81d9a6b2bc642634d503f9321ef94552/Client/wwwroot/images/close@3x.png -------------------------------------------------------------------------------- /Client/wwwroot/images/logo-beta.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Untitled 7 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 26 | 27 | -------------------------------------------------------------------------------- /Client/wwwroot/images/logo-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 35 | 36 | Logo 38 | Created with Sketch. 40 | 42 | 48 | 52 | 56 | 57 | 58 | 82 | 84 | 85 | 87 | Logo 88 | 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /Client/wwwroot/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Logo 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 17 | -------------------------------------------------------------------------------- /Client/wwwroot/images/no-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-urlist/blazor-static-web-apps/50fad39a81d9a6b2bc642634d503f9321ef94552/Client/wwwroot/images/no-image.png -------------------------------------------------------------------------------- /Client/wwwroot/images/no-image.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 54 | -------------------------------------------------------------------------------- /Client/wwwroot/images/urly.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-urlist/blazor-static-web-apps/50fad39a81d9a6b2bc642634d503f9321ef94552/Client/wwwroot/images/urly.png -------------------------------------------------------------------------------- /Client/wwwroot/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | The Urlist - Share the internet 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |
17 | 18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | 27 |
28 | An unhandled error has occurred. 29 | Reload 30 | X 31 |
32 | 33 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 The Urlist 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The Urlist - Blazor Static Web App rewrite 2 | 3 | [![BuiltWithDot.Net shield](https://builtwithdot.net/project/391/the-urlist-blazor/badge)](https://builtwithdot.net/project/391/the-urlist-blazor) 4 | 5 | The Urlist is an application that lets you create lists of url’s that you can share with others. Get it? A list of URL’s? The Urlist? Listen, naming things is hard and all the good domains are already taken. 6 | 7 | The original version of this site was [built in 2019 using Azure Storage, Azure Functions, Azure Front Door, and Vue](https://dev.to/azure/the-urlist-an-application-study-in-serverless-and-azure-2jk1). We originally decided to try a rewrite of this using modern Static Web Apps and Blazor when Twitter authentication became unreliable, trying this in Blazor because it's a new fun challenge. You can [watch us](https://aka.ms/burke-learns-blazor) live stream the effort on Fridays. 8 | 9 | See the work in progress here 👉 [https://victorious-forest-0ccd7d90f.3.azurestaticapps.net](https://victorious-forest-0ccd7d90f.3.azurestaticapps.net) 10 | 11 | ## Project planning 12 | 13 | We're tracking our work on [this GitHub project](https://github.com/orgs/the-urlist/projects/2). 14 | 15 | ## We take pull requests! 16 | 17 | We'd love pull requests! Please file an issue for anything new, and communicate in advance before doing any major work. We'd rather not duplicate effort or have you work on something we can't use. 18 | 19 | ### Visual Studio 2022 setup 20 | 21 | Once you clone the project, open the solution in the latest release of [Visual Studio 2022](https://visualstudio.microsoft.com/vs/) with the Azure workload installed., and follow these steps: 22 | 23 | 1. Right-click on the solution and select **Set Startup Projects...**. 24 | 25 | 1. Select **Multiple startup projects** and set the following actions for each project: 26 | - *Api* - **Start** 27 | - *Client* - **Start** 28 | - *Shared* - None 29 | 30 | 1. Press **F5** to launch both the client application and the Functions API app. 31 | 32 | ### Visual Studio Code with Azure Static Web Apps CLI for a better development experience (Optional) 33 | 34 | 1. Install the [Azure Static Web Apps CLI](https://www.npmjs.com/package/@azure/static-web-apps-cli) and [Azure Functions Core Tools CLI](https://www.npmjs.com/package/azure-functions-core-tools). 35 | 36 | 1. Open the folder in Visual Studio Code. 37 | 38 | 1. In the VS Code terminal, run the following command to start the Static Web Apps CLI, along with the Blazor WebAssembly client application and the Functions API app: 39 | 40 | ```bash 41 | swa start 42 | ``` 43 | 44 | The Static Web Apps CLI (`swa`) starts a proxy on port 4280 that will forward static site requests to the Blazor server on port 5000 and requests to the `/api` endpoint to the Functions server. 45 | 46 | 1. Open a browser and navigate to the Static Web Apps CLI's address at `http://localhost:4280`. You'll be able to access both the client application and the Functions API app in this single address. When you navigate to the "Fetch Data" page, you'll see the data returned by the Functions API app. 47 | 48 | 1. Enter Ctrl-C to stop the Static Web Apps CLI. 49 | 50 | ## Template Structure 51 | 52 | - **Client**: The Blazor WebAssembly sample application 53 | - **Api**: A C# Azure Functions API, which the Blazor application will call 54 | - **Shared**: A C# class library with a shared data model between the Blazor and Functions application 55 | 56 | ## Deploy to Azure Static Web Apps 57 | 58 | This application can be deployed to [Azure Static Web Apps](https://docs.microsoft.com/azure/static-web-apps), to learn how, check out [our quickstart guide](https://aka.ms/blazor-swa/quickstart). 59 | -------------------------------------------------------------------------------- /Shared/Claim.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | public class Claim 4 | { 5 | [JsonPropertyName("typ")] 6 | public string Type { get; set; } 7 | [JsonPropertyName("val")] 8 | public string Value { get; set; } 9 | } -------------------------------------------------------------------------------- /Shared/ClientPrincipal.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text.Json.Serialization; 4 | 5 | namespace BlazorApp.Shared; 6 | 7 | public class ClientPrincipal 8 | { 9 | [JsonPropertyName("userId")] 10 | public string UserId { get; set; } 11 | 12 | [JsonPropertyName("userRoles")] 13 | public IEnumerable UserRoles { get; set; } 14 | 15 | [JsonPropertyName("identityProvider")] 16 | public string IdentityProvider { get; set; } 17 | 18 | [JsonPropertyName("userDetails")] 19 | public string UserDetails { get; set; } 20 | 21 | public List Claims { get; set; } = new List(); 22 | } 23 | 24 | -------------------------------------------------------------------------------- /Shared/ClientPrincipalWrapper.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace BlazorApp.Shared; 4 | 5 | public class ClientPrincipalWrapper 6 | { 7 | [JsonPropertyName("clientPrincipal")] 8 | public ClientPrincipal ClientPrincipal { get; set; } = new ClientPrincipal(); 9 | } 10 | -------------------------------------------------------------------------------- /Shared/Link.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace BlazorApp.Shared 5 | { 6 | public class Link 7 | { 8 | [JsonPropertyName("id")] 9 | public string Id { get; set; } = Guid.NewGuid().ToString(); 10 | [JsonPropertyName("url")] 11 | public string Url { get; set; } 12 | [JsonPropertyName("title")] 13 | public string Title { get; set; } 14 | [JsonPropertyName("description")] 15 | public string Description { get; set; } 16 | [JsonPropertyName("image")] 17 | public string Image { get; set; } 18 | } 19 | } -------------------------------------------------------------------------------- /Shared/LinkBundle.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text.Json.Serialization; 3 | using System.ComponentModel.DataAnnotations; 4 | using System; 5 | 6 | namespace BlazorApp.Shared 7 | { 8 | public class LinkBundle 9 | { 10 | [JsonPropertyName("id")] 11 | public string Id { get; set; } = Guid.NewGuid().ToString(); 12 | [JsonPropertyName("vanityUrl")] 13 | public string VanityUrl { get; set; } 14 | [JsonPropertyName("description")] 15 | public string Description { get; set; } 16 | [JsonPropertyName("userId")] 17 | public string UserId { get; set; } 18 | [JsonPropertyName("provider")] 19 | public string Provider { get; set; } 20 | [JsonPropertyName("links")] 21 | public List Links { get; set; } = new List(); 22 | } 23 | } -------------------------------------------------------------------------------- /Shared/Shared.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | BlazorApp.Shared 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Shared/User.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Security.Claims; 4 | 5 | namespace BlazorApp.Shared; 6 | 7 | public class User 8 | { 9 | public User(ClientPrincipal clientPrincipal) 10 | { 11 | ClientPrincipal = clientPrincipal; 12 | 13 | // depending on the provider, the username and image may be in a different place in the 14 | var identity = new ClaimsIdentity(clientPrincipal.IdentityProvider); 15 | 16 | // google 17 | if (clientPrincipal.IdentityProvider == "google") 18 | { 19 | Console.WriteLine(clientPrincipal.Claims.Count); 20 | UserName = clientPrincipal.Claims.Find(c => c.Type == "name")?.Value; 21 | UserImage = clientPrincipal.Claims.Find(c => c.Type == "picture")?.Value; 22 | } 23 | 24 | // github 25 | if (clientPrincipal.IdentityProvider == "github") 26 | { 27 | Console.WriteLine(clientPrincipal.Claims.Count); 28 | UserName = clientPrincipal.Claims.Find(c => c.Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name")?.Value; 29 | UserImage = clientPrincipal.Claims.Find(c => c.Type == "urn:github:avatar_url")?.Value; 30 | } 31 | 32 | // github 33 | if (clientPrincipal.IdentityProvider == "twitter") 34 | { 35 | Console.WriteLine(clientPrincipal.Claims.Count); 36 | UserName = clientPrincipal.Claims.Find(c => c.Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name")?.Value; 37 | UserImage = clientPrincipal.Claims.Find(c => c.Type == "urn:twitter:profile_image_url_https")?.Value; 38 | } 39 | } 40 | 41 | public string UserName { get; set; } 42 | public string UserImage { get; set; } 43 | 44 | public bool IsLoggedIn => ClientPrincipal != null && !string.IsNullOrEmpty(ClientPrincipal.UserId); 45 | public ClientPrincipal ClientPrincipal { get; set; } = new ClientPrincipal(); 46 | 47 | public List LinkBundles { get; set; } = new List(); 48 | } 49 | -------------------------------------------------------------------------------- /swa-cli.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://aka.ms/azure/static-web-apps-cli/schema", 3 | "configurations": { 4 | "app": { 5 | "appDevserverUrl": "http://localhost:5000", 6 | "appLocation": "Client", 7 | "apiLocation": "Api", 8 | "run": "dotnet watch --no-launch-profile" 9 | } 10 | } 11 | } --------------------------------------------------------------------------------