diff options
| author | ivarlovlie <git@ivarlovlie.no> | 2022-06-01 22:10:32 +0200 |
|---|---|---|
| committer | ivarlovlie <git@ivarlovlie.no> | 2022-06-01 22:10:32 +0200 |
| commit | a640703f2da8815dc26ad1600a6f206be1624379 (patch) | |
| tree | dbda195fb5783d16487e557e06471cf848b75427 | |
| download | greatoffice-a640703f2da8815dc26ad1600a6f206be1624379.tar.xz greatoffice-a640703f2da8815dc26ad1600a6f206be1624379.zip | |
feat: Initial after clean slate
328 files changed, 27739 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e301ba6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,479 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*[.json, .xml, .info] + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# JetBrains Rider +.idea/ +*.sln.iml + +## +## Visual Studio Code +## +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.build +build +web_modules +server-secrets.* +src/server/wwwroot +src/server/AppData +/.svelte-kit +/package +**/dist +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +.svelte +bin +obj +AppData @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + <program> Copyright (C) <year> <name of author> + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +<https://www.gnu.org/licenses/>. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +<https://www.gnu.org/licenses/why-not-lgpl.html>. diff --git a/README.md b/README.md new file mode 100644 index 0000000..05e6ce1 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# Great Office + +[Changelog](CHANGELOG.md) + +## server + +Contains an ASP.NET Core Web API project using the [ApiEndpoints](https://github.com/ardalis/ApiEndpoints) paradigm. + +It handles all data operation and administration for the platform. + +To run it you need .NET 6 and a PostgreSQL instance. + +### Environment + +The server is configured through environments variables, +in development [user-secret](https://docs.microsoft.com/en-us/aspnet/core/security/app-secrets) is a nifty tool. + +All environment variables the server needs to function properly is specified +in [src/server/Data/Static/AppEnvironmentVariables.cs](./server/src/Data/Static/AppEnvironmentVariables.cs), +at a bare minimum these variables needs to be set: + +``` +DB_HOST=<host> +DB_USER=<user> +DB_PASSWORD=<password> +DB_NAME=<schema> +``` + +### Building/Developing + +To run the server in development mode use `dotnet run` (`dotnet watch` for hot-reloading). + +To build the server locally use `dotnet build` or `dotnet build -c Release` for production builds. + +## tests + +Contains integration tests for the web-app, written in .NET and xunit with Playwright for browser mocking. + +It automatically starts the server and expects the server to host the web-app at /index.html. + +Use `dotnet run` to run the tests. + +## apps/web-app + +A svelte project built with vite. + +Run the dev task in package.json to open a dev server at localhost:3xxx (usually :3000). Append `--host` as a parameter to this task if you need to expose the app on your local +network. + +Contains the public sites (in `apps/web-app/_public`) which include login, signup and password resetting etc. and the meat of the application as a nested application (meaning it is +not loaded unless you are logged in) +in `apps/web-app/app`. + +## apps/web-shared + +A source lib containing models, shared styles and shared components for all of great office's js clients. diff --git a/apps/accounts/.version b/apps/accounts/.version new file mode 100644 index 0000000..722aa6d --- /dev/null +++ b/apps/accounts/.version @@ -0,0 +1 @@ +v1-accounts diff --git a/apps/accounts/.version-dev b/apps/accounts/.version-dev new file mode 100644 index 0000000..6eeb9c2 --- /dev/null +++ b/apps/accounts/.version-dev @@ -0,0 +1 @@ +v12-accounts-dev diff --git a/apps/accounts/CHANGELOG.md b/apps/accounts/CHANGELOG.md new file mode 100644 index 0000000..4d4ed65 --- /dev/null +++ b/apps/accounts/CHANGELOG.md @@ -0,0 +1,53 @@ +# Changelog + +## [unreleased] + +### Miscellaneous Tasks + +- Bump version +- Update CHANGELOG.md for v4-projects-dev +- Bump version +- Update CHANGELOG.md for v3-projects-dev +- Bump version +- Bump version +- Update CHANGELOG.md for v12-accounts-dev + +### Refactor + +- Name path basers based on the app it bases + +## [unreleased] + +### Miscellaneous Tasks + +- Bump version +- Update CHANGELOG.md for v11-accounts-dev + +## [unreleased] + +### Miscellaneous Tasks + +- Bump version +- Update CHANGELOG.md for v14-web-app-dev +- Bump version +- Update CHANGELOG.md for v9-web-app-dev + +## [unreleased] + +### Bug Fixes + +- . + +### Miscellaneous Tasks + +- Bump version +- Update CHANGELOG.md for v8-web-app-dev + +## [unreleased] + +### Miscellaneous Tasks + +- Bump version +- Bump version +- Update CHANGELOG.md for v7-web-app-dev + diff --git a/apps/accounts/build_and_push.sh b/apps/accounts/build_and_push.sh new file mode 100755 index 0000000..bd349ff --- /dev/null +++ b/apps/accounts/build_and_push.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash + +set -Eueo pipefail + +APP_NAME="accounts"; +CURRENT_DEV_VERSION=$(cat .version-dev) +CURRENT_DEV_VERSION_INT=${CURRENT_DEV_VERSION//[!0-9]/} +CURRENT_VERSION=$(cat .version) +CURRENT_VERSION_INT=${CURRENT_VERSION//[!0-9]/} +if [ ${1-prod} == "dev" ]; then + NEW_VERSION="v$((CURRENT_DEV_VERSION_INT+1))-$APP_NAME-dev" + OLD_VERSION=$CURRENT_DEV_VERSION +else + NEW_VERSION="v$((CURRENT_VERSION_INT+1))-$APP_NAME" + OLD_VERSION=$CURRENT_VERSION +fi +# Check for uncommited changes and optionally commit them +if [ "$(git status --untracked-files=no --porcelain)" ]; then + echo "Unclean git tree! press CTRL+C to exit or press ENTER to commit and push to the default branch" + read -n 1 + + read -p "Enter commit message: " COMMIT_MESSAGE + git add ../../ + git commit --quiet -m "$COMMIT_MESSAGE" +fi + +if [ ${1-prod} == "dev" ]; then + echo $NEW_VERSION >| .version-dev + git add .version-dev +else + echo $NEW_VERSION >| .version + git add .version +fi + +echo "Starting build of $APP_NAME@$NEW_VERSION at $(date -u)..." +echo + +git commit --quiet -m "chore(release): Bump version"; + +read -p "Do you want to tag this build? (y/n) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]] +then + read -p "Enter tag message (can be empty): " TAG_MESSAGE + commit_msg="chore(release): Update CHANGELOG.md for $NEW_VERSION" + git cliff -r ../../ $OLD_VERSION..HEAD --with-commit "$commit_msg" --prepend CHANGELOG.md + git add CHANGELOG.md + git commit --quiet -m "$commit_msg"; + git tag -am "$TAG_MESSAGE" $NEW_VERSION +fi + +read -p "Do you want to push the latest commits and tags to origin? (y/n) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]] +then + echo "Pushing latest changes to remotes..." + echo + git push --quiet --follow-tags +fi + +pushd src +pnpm run build + +cd build +echo "$NEW_VERSION" >version.txt + + +if [ ${1-prod} == "dev" ]; then + scp -r * contabo-fast-1:services/public/a.dev.greatoffice.life/www +else + echo "Pushing to production in 10 sec, press CTRL+C to cancel" + sleep 10 + scp -r * contabo-fast-1:services/public/a.greatoffice.life/www +fi + +popd diff --git a/apps/accounts/cliff.toml b/apps/accounts/cliff.toml new file mode 100644 index 0000000..955a72b --- /dev/null +++ b/apps/accounts/cliff.toml @@ -0,0 +1,62 @@ +# configuration file for git-cliff (0.1.0) + +[changelog] +# changelog header +header = """ +# Changelog\n +""" +# template for the changelog body +# https://tera.netlify.app/docs/#introduction +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | upper_first }} + {% for commit in commits %} + - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\ + {% endfor %} +{% endfor %}\n +""" +# remove the leading and trailing whitespace from the template +trim = true +# changelog footer +footer = """ +<!-- generated by git-cliff --> +""" + +[git] +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# regex for preprocessing the commit messages +commit_preprocessors = [ + { pattern = "([ \\n])(([a-f0-9]{7})[a-f0-9]*)", replace = "${1}commit # [${3}](https://git.ivarlovlie.no/time-tracker/commit/${2})" }, + { pattern = "https://git.ivarlovlie.no/time-tracker/commit/([a-f0-9]{7})[a-f0-9]*", replace = "commit # [${1}](${0})" }, +] +# regex for parsing and grouping commits +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^doc", group = "Documentation" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactor" }, + { message = "^style", group = "Styling" }, + { message = "^test", group = "Testing" }, + { message = "^chore", group = "Miscellaneous Tasks" }, +] +# filter out the commits that are not matched by commit parsers +filter_commits = true +# glob pattern for matching git tags +tag_pattern = "v.*" +# regex for skipping tags +skip_tags = "v0.1.0-beta.1" +# regex for ignoring tags +ignore_tags = "" +# sort the tags chronologically +date_order = true +# sort the commits inside sections by oldest/newest order +sort_commits = "newest" diff --git a/apps/accounts/src/_assets/pre.css b/apps/accounts/src/_assets/pre.css new file mode 100644 index 0000000..9c9446e --- /dev/null +++ b/apps/accounts/src/_assets/pre.css @@ -0,0 +1,128 @@ +:root { + --loader-primary: hsl(250, 84%, 54%); + --loader-accent: hsl(342, 89%, 48%); + --loader-contrast: hsl(180, 1%, 84%); + --loader-easing: cubic-bezier(0.645, 0.045, 0.355, 1); +} + +[data-theme="dark"] :root { + --loader-primary: hsl(250, 93%, 65%); + --loader-accent: hsl(342, 92%, 54%); + --loader-contrast: hsl(208, 12%, 24%); + --loader-easing: cubic-bezier(0.645, 0.045, 0.355, 1); +} + +[data-theme="dark"] { + background-color: hsl(203, 24%, 13%); +} + +.fill-loader { + position: relative; + overflow: hidden; + display: inline-block; + margin: 3rem; +} + +.fill-loader__fill { + position: absolute; +} + +@supports (-webkit-animation-name: this) or (animation-name: this) { + .fill-loader__label { + position: absolute; + clip: rect(1px, 1px, 1px, 1px); + -webkit-clip-path: inset(50%); + clip-path: inset(50%); + } +} + +@supports (-webkit-animation-name: this) or (animation-name: this) { + .fill-loader--v4 { + width: 90%; + max-width: 300px; + } + + .fill-loader--v4 .fill-loader__base { + height: 4px; + background-color: var(--loader-contrast); + } + + .fill-loader--v4 .fill-loader__fill { + top: 0; + left: 0; + right: 0; + height: 100%; + background-color: var(--loader-primary); + -webkit-animation: fill-loader-4 1.6s infinite var(--loader-easing); + animation: fill-loader-4 1.6s infinite var(--loader-easing); + will-change: left, right; + } +} + +@-webkit-keyframes fill-loader-4 { + 0% { + left: 0; + right: 100%; + background-color: var(--loader-primary); + } + + 10%, + 60% { + left: 0; + } + + 40%, + 90% { + right: 0; + } + + 50% { + left: 100%; + background-color: var(--loader-primary); + } + + 51% { + left: 0; + right: 100%; + background-color: var(--loader-accent); + } + + 100% { + left: 100%; + background-color: var(--loader-accent); + } +} + +@keyframes fill-loader-4 { + 0% { + left: 0; + right: 100%; + background-color: var(--loader-primary); + } + + 10%, + 60% { + left: 0; + } + + 40%, + 90% { + right: 0; + } + + 50% { + left: 100%; + background-color: var(--loader-primary); + } + + 51% { + left: 0; + right: 100%; + background-color: var(--loader-accent); + } + + 100% { + left: 100%; + background-color: var(--loader-accent); + } +} diff --git a/apps/accounts/src/_assets/pwa/android-chrome-192x192.png b/apps/accounts/src/_assets/pwa/android-chrome-192x192.png Binary files differnew file mode 100644 index 0000000..5c098bc --- /dev/null +++ b/apps/accounts/src/_assets/pwa/android-chrome-192x192.png diff --git a/apps/accounts/src/_assets/pwa/android-chrome-512x512.png b/apps/accounts/src/_assets/pwa/android-chrome-512x512.png Binary files differnew file mode 100644 index 0000000..973a1c3 --- /dev/null +++ b/apps/accounts/src/_assets/pwa/android-chrome-512x512.png diff --git a/apps/accounts/src/_assets/pwa/apple-touch-icon.png b/apps/accounts/src/_assets/pwa/apple-touch-icon.png Binary files differnew file mode 100644 index 0000000..b4d9773 --- /dev/null +++ b/apps/accounts/src/_assets/pwa/apple-touch-icon.png diff --git a/apps/accounts/src/_assets/pwa/browserconfig.xml b/apps/accounts/src/_assets/pwa/browserconfig.xml new file mode 100644 index 0000000..b3930d0 --- /dev/null +++ b/apps/accounts/src/_assets/pwa/browserconfig.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<browserconfig> + <msapplication> + <tile> + <square150x150logo src="/mstile-150x150.png"/> + <TileColor>#da532c</TileColor> + </tile> + </msapplication> +</browserconfig> diff --git a/apps/accounts/src/_assets/pwa/favicon-16x16.png b/apps/accounts/src/_assets/pwa/favicon-16x16.png Binary files differnew file mode 100644 index 0000000..5dde9f9 --- /dev/null +++ b/apps/accounts/src/_assets/pwa/favicon-16x16.png diff --git a/apps/accounts/src/_assets/pwa/favicon-32x32.png b/apps/accounts/src/_assets/pwa/favicon-32x32.png Binary files differnew file mode 100644 index 0000000..9cef4c4 --- /dev/null +++ b/apps/accounts/src/_assets/pwa/favicon-32x32.png diff --git a/apps/accounts/src/_assets/pwa/favicon.ico b/apps/accounts/src/_assets/pwa/favicon.ico Binary files differnew file mode 100644 index 0000000..89c7542 --- /dev/null +++ b/apps/accounts/src/_assets/pwa/favicon.ico diff --git a/apps/accounts/src/_assets/pwa/favicon.svg b/apps/accounts/src/_assets/pwa/favicon.svg new file mode 100644 index 0000000..964dbb8 --- /dev/null +++ b/apps/accounts/src/_assets/pwa/favicon.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-stopwatch" viewBox="0 0 16 16"> + <path d="M8.5 5.6a.5.5 0 1 0-1 0v2.9h-3a.5.5 0 0 0 0 1H8a.5.5 0 0 0 .5-.5V5.6z"/> + <path d="M6.5 1A.5.5 0 0 1 7 .5h2a.5.5 0 0 1 0 1v.57c1.36.196 2.594.78 3.584 1.64a.715.715 0 0 1 .012-.013l.354-.354-.354-.353a.5.5 0 0 1 .707-.708l1.414 1.415a.5.5 0 1 1-.707.707l-.353-.354-.354.354a.512.512 0 0 1-.013.012A7 7 0 1 1 7 2.071V1.5a.5.5 0 0 1-.5-.5zM8 3a6 6 0 1 0 .001 12A6 6 0 0 0 8 3z"/> +</svg>
\ No newline at end of file diff --git a/apps/accounts/src/_assets/pwa/manifest.json b/apps/accounts/src/_assets/pwa/manifest.json new file mode 100644 index 0000000..4c550fe --- /dev/null +++ b/apps/accounts/src/_assets/pwa/manifest.json @@ -0,0 +1,28 @@ +{ + "manifest_version": 2, + "version": "0.1", + "name": "Time Tracker", + "short_name": "Time Tracker", + "display": "standalone", + "background_color": "#fff", + "theme_color": "#4D3DF7", + "start_url": ".", + "orientation": "portrait", + "icons": [ + { + "src": "/favicon.svg", + "purpose": "maskable any", + "sizes": "any" + }, + { + "src": "/pwa/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/pwa/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/apps/accounts/src/_assets/pwa/mstile-144x144.png b/apps/accounts/src/_assets/pwa/mstile-144x144.png Binary files differnew file mode 100644 index 0000000..84d94cb --- /dev/null +++ b/apps/accounts/src/_assets/pwa/mstile-144x144.png diff --git a/apps/accounts/src/_assets/pwa/mstile-150x150.png b/apps/accounts/src/_assets/pwa/mstile-150x150.png Binary files differnew file mode 100644 index 0000000..b1398ae --- /dev/null +++ b/apps/accounts/src/_assets/pwa/mstile-150x150.png diff --git a/apps/accounts/src/_assets/pwa/mstile-310x150.png b/apps/accounts/src/_assets/pwa/mstile-310x150.png Binary files differnew file mode 100644 index 0000000..76b16a0 --- /dev/null +++ b/apps/accounts/src/_assets/pwa/mstile-310x150.png diff --git a/apps/accounts/src/_assets/pwa/mstile-310x310.png b/apps/accounts/src/_assets/pwa/mstile-310x310.png Binary files differnew file mode 100644 index 0000000..d8e4097 --- /dev/null +++ b/apps/accounts/src/_assets/pwa/mstile-310x310.png diff --git a/apps/accounts/src/_assets/pwa/mstile-70x70.png b/apps/accounts/src/_assets/pwa/mstile-70x70.png Binary files differnew file mode 100644 index 0000000..0df1e8c --- /dev/null +++ b/apps/accounts/src/_assets/pwa/mstile-70x70.png diff --git a/apps/accounts/src/_assets/pwa/safari-pinned-tab.svg b/apps/accounts/src/_assets/pwa/safari-pinned-tab.svg new file mode 100644 index 0000000..ba2220c --- /dev/null +++ b/apps/accounts/src/_assets/pwa/safari-pinned-tab.svg @@ -0,0 +1,50 @@ +<?xml version="1.0" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" + "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"> +<svg version="1.0" xmlns="http://www.w3.org/2000/svg" + width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000" + preserveAspectRatio="xMidYMid meet"> +<metadata> +Created by potrace 1.14, written by Peter Selinger 2001-2017 +</metadata> +<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)" +fill="#000000" stroke="none"> +<path d="M3195 6780 c-116 -3 -211 -10 -226 -17 -39 -17 -105 -98 -116 -142 +-19 -72 -2 -146 45 -202 26 -31 96 -69 131 -72 25 -2 31 -6 32 -27 1 -27 1 +-198 0 -216 -1 -6 -47 -19 -103 -28 -160 -28 -451 -107 -533 -146 -11 -5 -51 +-21 -90 -36 -60 -23 -246 -112 -325 -155 -431 -236 -834 -619 -1101 -1045 +-207 -328 -364 -733 -423 -1089 -51 -307 -61 -583 -31 -875 26 -261 119 -615 +225 -861 185 -430 432 -773 800 -1108 75 -69 387 -301 405 -301 1 0 33 -18 70 +-40 209 -128 602 -288 796 -325 12 -2 29 -7 39 -10 72 -23 273 -56 435 -73 +144 -14 601 -5 658 13 7 2 37 7 67 10 273 33 616 141 904 283 725 357 1275 +982 1542 1754 55 159 113 395 129 523 4 28 8 57 10 65 2 8 7 47 10 85 3 39 8 +93 10 120 6 66 6 327 0 390 -2 28 -7 82 -10 120 -3 39 -11 99 -16 135 -6 36 +-13 79 -16 95 -15 98 -61 279 -103 405 -121 372 -298 694 -542 993 -27 32 -48 +61 -48 65 0 4 35 41 78 84 l77 76 90 -90 c53 -54 108 -99 134 -110 62 -28 130 +-25 191 8 95 52 135 151 103 257 -13 46 -44 79 -362 397 -322 323 -351 349 +-398 363 -148 44 -287 -61 -285 -215 1 -62 35 -118 126 -208 47 -47 86 -87 86 +-90 0 -6 -91 -101 -132 -138 l-25 -23 -46 38 c-264 223 -584 405 -924 528 -92 +34 -320 100 -376 109 -15 3 -35 7 -45 10 -9 3 -34 7 -54 10 -86 13 -113 18 +-117 22 -2 2 -4 56 -4 121 l0 118 29 9 c66 19 114 47 139 80 72 95 65 215 -18 +296 -58 56 -83 60 -402 63 -159 1 -380 0 -490 -3z m560 -1104 c224 -24 547 +-99 670 -156 11 -5 56 -24 100 -41 90 -37 282 -134 306 -155 8 -8 19 -14 23 +-14 13 0 192 -124 286 -199 97 -77 297 -270 364 -351 237 -288 405 -598 509 +-941 30 -98 44 -157 72 -299 3 -14 8 -47 11 -75 3 -27 7 -56 10 -63 22 -69 21 +-519 -1 -642 -1 -8 -6 -40 -10 -70 -4 -30 -9 -64 -11 -75 -2 -11 -7 -33 -10 +-50 -3 -16 -14 -66 -26 -110 -11 -44 -21 -84 -22 -90 -18 -79 -93 -275 -154 +-408 -39 -83 -158 -296 -171 -307 -3 -3 -26 -34 -50 -70 -116 -169 -312 -384 +-466 -508 -38 -32 -78 -65 -89 -74 -25 -22 -229 -160 -281 -189 -177 -99 -405 +-197 -570 -244 -126 -36 -305 -74 -375 -81 -19 -2 -48 -5 -65 -8 -121 -22 +-509 -22 -618 0 -12 2 -42 6 -67 10 -369 45 -795 215 -1125 448 -192 135 -399 +326 -517 476 -23 30 -48 61 -55 67 -57 60 -227 336 -291 473 -64 135 -150 365 +-167 444 -2 12 -6 30 -9 41 -28 120 -36 156 -41 193 -3 24 -7 53 -10 65 -32 +148 -38 552 -10 707 2 14 7 45 10 70 33 274 160 643 313 910 60 106 201 312 +232 340 3 3 23 28 45 55 22 28 85 96 140 151 347 352 768 590 1252 709 56 14 +118 27 137 30 20 2 61 9 93 14 32 6 92 13 133 17 41 3 76 7 77 8 6 5 368 -2 +428 -8z"/> +<path d="M3423 4754 c-45 -17 -95 -61 -121 -109 -15 -27 -17 -93 -19 -650 -1 +-341 -2 -641 -3 -666 l0 -47 -679 0 -679 -1 -49 -24 c-59 -30 -76 -49 -104 +-112 -54 -122 23 -270 154 -293 23 -5 400 -8 837 -7 l795 1 42 22 c52 27 98 +82 112 136 8 29 10 273 9 826 -3 854 1 796 -59 867 -53 63 -153 87 -236 57z"/> +</g> +</svg> diff --git a/apps/accounts/src/app/index.d.ts b/apps/accounts/src/app/index.d.ts new file mode 100644 index 0000000..c044583 --- /dev/null +++ b/apps/accounts/src/app/index.d.ts @@ -0,0 +1,48 @@ +/* Use this file to declare any custom file extensions for importing */ +/* Use this folder to also add/extend a package d.ts file, if needed. */ + +/* CSS MODULES */ +declare module "*.module.css" { + const classes: { [key: string]: string }; + export default classes; +} +declare module "*.module.scss" { + const classes: { [key: string]: string }; + export default classes; +} + +/* CSS */ +declare module "*.css"; +declare module "*.scss"; + +/* IMAGES */ +declare module "*.svg" { + const ref: string; + export default ref; +} +declare module "*.bmp" { + const ref: string; + export default ref; +} +declare module "*.gif" { + const ref: string; + export default ref; +} +declare module "*.jpg" { + const ref: string; + export default ref; +} +declare module "*.jpeg" { + const ref: string; + export default ref; +} +declare module "*.png" { + const ref: string; + export default ref; +} + +/* CUSTOM: ADD YOUR OWN HERE */ +declare module "*.svelte" { + const value: any; + export default value; +} diff --git a/apps/accounts/src/app/index.scss b/apps/accounts/src/app/index.scss new file mode 100644 index 0000000..56ac1c0 --- /dev/null +++ b/apps/accounts/src/app/index.scss @@ -0,0 +1,21 @@ +@use '../../web-shared/src/styles/base'as * with ($breakpoints: ('xs': "768px", + 'sm': "768px", + 'md': "1200px", + 'lg': "1200px", + 'xl': "1600px", + ), + $grid-columns: 12); + +@use '../../web-shared/src/styles/custom-style/colors'; +@use '../../web-shared/src/styles/custom-style/spacing'; +@use '../../web-shared/src/styles/custom-style/shared-styles'; +@use '../../web-shared/src/styles/custom-style/typography'; +@use '../../web-shared/src/styles/custom-style/icons'; +@use '../../web-shared/src/styles/custom-style/buttons'; +@use '../../web-shared/src/styles/custom-style/forms'; +@use '../../web-shared/src/styles/custom-style/util'; + +@use '../../web-shared/src/styles/components/radios-checkboxes'; +@use '../../web-shared/src/styles/components/btn-states'; +@use '../../web-shared/src/styles/components/alert'; +@use '../../web-shared/src/styles/components/details'; diff --git a/apps/accounts/src/app/index.svelte b/apps/accounts/src/app/index.svelte new file mode 100644 index 0000000..40fe6ae --- /dev/null +++ b/apps/accounts/src/app/index.svelte @@ -0,0 +1,61 @@ +<svelte:options immutable={true}/> +<svelte:window bind:online={online}/> + +<script> + import {projects_base} from "$shared/lib/configuration"; + import Router from "svelte-spa-router"; + import {wrap} from "svelte-spa-router/wrap"; + import {is_active} from "$shared/lib/session"; + import NotFound from "$app/pages/not-found.svelte"; + import SignUp from "$app/pages/sign-up.svelte"; + import Login from "$app/pages/login.svelte"; + import Forgot from "$app/pages/forgot.svelte"; + import Reset from "$app/pages/reset-password.svelte"; + import PreHeader from "$shared/components/pre-header.svelte"; + + let online = true; + + async function user_is_logged_in() { + if (await is_active()) { + location.replace(projects_base("#/home")); + } + return true; + } + + const routes = { + "/login": wrap({ + component: Login, + conditions: [user_is_logged_in], + }), + "/": wrap({ + component: Login, + conditions: [user_is_logged_in], + }), + "/signup": wrap({ + component: SignUp, + conditions: [user_is_logged_in], + }), + "/reset-password": wrap({ + component: Reset, + conditions: [user_is_logged_in], + }), + "/forgot": wrap({ + component: Forgot, + conditions: [user_is_logged_in], + }), + "*": NotFound, + }; +</script> + +<PreHeader show="{!online}">You seem to be offline, please check your internet connection.</PreHeader> + +<Router + {routes} + restoreScrollState={true} + on:routeLoading={() => { + document.getElementById("loader").style.display = "inline-block"; + }} + on:routeLoaded={() => { + document.getElementById("loader").style.display = "none"; + }} +/> diff --git a/apps/accounts/src/app/index.ts b/apps/accounts/src/app/index.ts new file mode 100644 index 0000000..0bfb30d --- /dev/null +++ b/apps/accounts/src/app/index.ts @@ -0,0 +1,14 @@ +import App from "./index.svelte"; +import "./index.scss"; +import {is_debug, is_development} from "$shared/lib/configuration"; +import {noop} from "$shared/lib/helpers"; + +if (!is_development() && !is_debug()) { + console.log("%c Production; Suppressing logs", "background-color:yellow;color:black;font-size:18px;"); + console.log = noop; +} + +// @ts-ignore +export default new App({ + target: document.getElementById("root"), +}); diff --git a/apps/accounts/src/app/pages/_layout.svelte b/apps/accounts/src/app/pages/_layout.svelte new file mode 100644 index 0000000..8c2e4a8 --- /dev/null +++ b/apps/accounts/src/app/pages/_layout.svelte @@ -0,0 +1,142 @@ +<script> + import Details from "$shared/components/details.svelte"; + import Button from "$shared/components/button.svelte"; + import {switch_theme} from "$shared/lib/helpers"; +</script> + +<style> + #decoration { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + width: 100%; + height: 100%; + overflow: hidden; + } + + #decoration svg { + position: absolute; + top: 0; + left: 50%; + -webkit-transform: translateX(-50%); + transform: translateX(-50%); + width: 134%; + min-width: 1280px; + max-width: 1920px; + height: auto; + } +</style> + +<main class="container-fluid padding-x-xs padding-x-xxl@xs padding-y-md padding-y-lg@md max-width-sm"> + <slot/> + + <Details summary="About"> + <p>Time Tracker is a tool to keep track of time spent.</p> + <p>Use demo@demo.demo 123456 to demo the app.</p> + <a href="https://git.ivarlovlie.no/time-tracker">Source</a> + <a href="https://git.ivarlovlie.no/time-tracker/tree/LICENSE">License</a> + <a href="/assets/third-party-licenses.txt">License notices</a> + </Details> + + <Details summary="Pricing"/> + + <Details summary="Privacy policy"> + <h3>Information we collect</h3> + <p>We collect information you the user provide, explicitly this means:</p> + <ul> + <li>Username</li> + <li>Password</li> + <li>Entries generated by you</li> + <li>Labels generated by you</li> + <li>Categories generated by you</li> + <li>Your IP address when making requests to our API (using the service)</li> + </ul> + + <h3>How we use your information</h3> + <p>We use your information to provide the time-tracker service.</p> + + <h3>How we share your information</h3> + <p> + We do not share your information with anyone nor any entity. All information is handled by us the provider and you the user + exclusively. + </p> + + <h3>Right to delete</h3> + <p> + You can at any time delete any data related to your personal information by navigating to your profile page inside of the + service. + </p> + + <h3>Right to inspect</h3> + <p>You can at any time download all of your generated data by navigating to your profile page inside of the service.</p> + + <h3>Contact</h3> + <p>Please direct any inquires about your personal data to time-tracker@ivarlovlie.no.</p> + </Details> + + <Details summary="Terms of service"/> + + <Button on:click={() => switch_theme()} + text="Switch theme" + variant="secondary"/> + + <figure id="decoration" + aria-hidden="true"> + <svg class="color-contrast-higher opacity-10%" + viewBox="0 0 1920 450" + fill="none"> + <g stroke="currentColor" + stroke-width="2"> + <rect x="1286" + y="64" + width="128" + height="128"/> + <circle cx="1350" + cy="128" + r="64"/> + <path d="M1286 64L1414 192"/> + <circle cx="1478" + cy="128" + r="64"/> + <rect x="1414" + y="192" + width="128" + height="128"/> + <circle cx="1478" + cy="256" + r="64"/> + <path d="M1414 192L1542 320"/> + <circle cx="1606" + cy="256" + r="64"/> + <rect x="1542" + y="320" + width="128" + height="128"/> + <circle cx="1606" + cy="384" + r="64"/> + <path d="M1542 320L1670 448"/> + <rect x="1690" + y="192" + width="128" + height="128"/> + <circle cx="1754" + cy="256" + r="64"/> + <path d="M1690 192L1818 320"/> + <rect x="1542" + y="64" + width="128" + height="128"/> + <circle cx="1606" + cy="128" + r="64"/> + <path d="M1542 64L1670 192"/> + <circle cx="1478" + r="64"/> + </g> + </svg> + </figure> +</main> diff --git a/apps/accounts/src/app/pages/forgot.svelte b/apps/accounts/src/app/pages/forgot.svelte new file mode 100644 index 0000000..f22d664 --- /dev/null +++ b/apps/accounts/src/app/pages/forgot.svelte @@ -0,0 +1,99 @@ +<script> + import {onMount} from "svelte"; + import {link} from "svelte-spa-router"; + import {create_forgot_password_request} from "$shared/lib/api/user"; + import {is_email} from "$shared/lib/helpers"; + import Alert from "$shared/components/alert.svelte"; + import Button from "$shared/components/button.svelte"; + import Layout from "./_layout.svelte"; + + let isLoading = false; + let username; + + const alert = { + title: "", + type: "", + message: "", + isVisible: false, + show(type, obj) { + alert.title = obj.title; + alert.message = obj.text; + alert.type = type; + alert.isVisible = true; + isLoading = false; + }, + hide() { + alert.isVisible = false; + alert.title = ""; + alert.message = ""; + alert.type = ""; + isLoading = false; + }, + }; + + function is_valid() { + return is_email(username); + } + + async function submit_form() { + if (isLoading) { + return; + } + if (is_valid()) { + isLoading = true; + const response = await create_forgot_password_request(username); + if (response.ok) { + alert.show("success", { + title: "Request is sent", + text: "If we find an account associated with this email address, you will receive an email with a reset link very soon.", + }); + } else { + console.error(response.data); + alert.show("error", { + title: response.data?.title ?? "An error occured", + text: response.data?.text ?? "Please try again soon", + }); + } + } + } + + onMount(() => { + document.addEventListener("DOMContentLoaded", () => { + document.getElementById("email-address").focus(); + }); + }); +</script> + +<Layout> + <form on:submit|preventDefault={submit_form} + class="margin-bottom-md max-width-xxs"> + <fieldset> + <legend class="form-legend"> + <span class="margin-bottom-xs">Send reset link</span> <br/> + <span class="text-sm">... or <a href="/login" + use:link>log in</a></span> + </legend> + <div class="margin-bottom-xs"> + <p>Provide your email address, and we'll send you a link to set your new password.</p> + </div> + <div class="margin-bottom-xxs max-width-xxs"> + <Alert visible={alert.isVisible} + title={alert.title} + message={alert.message} + type={alert.type}/> + </div> + <div class="margin-bottom-xs"> + <input type="email" + id="email-address" + placeholder="Email address" + class="form-control width-100%" + bind:value={username}/> + </div> + <div class="flex justify-end"> + <Button text="Send reset link" + type="primary" + loading={isLoading}/> + </div> + </fieldset> + </form> +</Layout> diff --git a/apps/accounts/src/app/pages/login.svelte b/apps/accounts/src/app/pages/login.svelte new file mode 100644 index 0000000..3324056 --- /dev/null +++ b/apps/accounts/src/app/pages/login.svelte @@ -0,0 +1,145 @@ +<script> + import {onMount} from "svelte"; + import {link, querystring} from "svelte-spa-router"; + import {api_base, projects_base, IconNames} from "$shared/lib/configuration"; + import Button from "$shared/components/button.svelte"; + import Alert from "$shared/components/alert.svelte"; + import {login} from "$shared/lib/api/user"; + import {is_email} from "$shared/lib/helpers"; + import Layout from "./_layout.svelte"; + + const loginForm = { + loading: false, + values: { + username: "", + password: "", + }, + alert: { + title: "", + type: "", + message: "", + isVisible: false, + show(type, obj) { + loginForm.alert.title = obj.title; + loginForm.alert.message = obj.text; + loginForm.alert.type = type; + loginForm.alert.isVisible = true; + loginForm.loading = false; + }, + hide() { + loginForm.alert.isVisible = false; + loginForm.alert.title = ""; + loginForm.alert.message = ""; + loginForm.alert.type = ""; + }, + }, + is_valid() { + return is_email(loginForm.values.username) && loginForm.values.password.length > 0; + }, + async submit_form() { + if (loginForm.loading) { + return; + } + if (loginForm.is_valid()) { + loginForm.alert.hide(); + loginForm.loading = true; + try { + const response = await login(loginForm.values); + if (response.ok) { + location.replace(projects_base("#/home")); + } else { + if (response.data.title || response.data.text) { + loginForm.alert.show("error", { + title: response.data.title ?? "", + text: response.data.text ?? "", + }); + } else { + loginForm.alert.show("error", { + title: "An unknown error occured", + text: "Try again soon", + }); + } + } + } catch (e) { + console.error(e); + loginForm.alert.show("error", { + title: "An error occured", + text: "Could not connect to server, please check your internet connection", + }); + } + } else { + loginForm.alert.show("error", { + title: "Invalid form", + }); + } + }, + }; + + onMount(() => { + if ($querystring === "deleted") { + loginForm.alert.show("info", { + title: "Account deleted", + text: "Your account and all its data was successfully deleted.", + }); + } + if ($querystring === "expired") { + loginForm.alert.show("info", { + title: "Session expired", + text: "Your session has expired, feel free to log in again.", + }); + } + }); +</script> + +<Layout> + <form on:submit|preventDefault={loginForm.submit_form} + class="margin-bottom-md max-width-xxs"> + <fieldset> + <legend class="form-legend"> + <span class="margin-bottom-xs">Log into your account</span> + <br/> + <span class="text-sm">... or <a href="/signup" + use:link>create a new one</a></span> + </legend> + <div class="margin-bottom-xxs max-width-xxs"> + <Alert visible={loginForm.alert.isVisible} + title={loginForm.alert.title} + message={loginForm.alert.message} + type={loginForm.alert.type}/> + </div> + <div class="margin-bottom-xxs"> + <input type="email" + placeholder="Email address" + class="form-control width-100%" + id="username" + bind:value={loginForm.values.username}/> + </div> + <div class="margin-bottom-xxs"> + <input type="password" + placeholder="Password" + id="password" + class="form-control width-100%" + bind:value={loginForm.values.password}/> + <div class="flex justify-end"> + <a tabindex="-1" + class="text-sm" + href="/forgot" + use:link>Reset password</a> + </div> + </div> + <div class="flex justify-between"> + <Button text="Login with Github" + variant="secondary" + icon="{IconNames.github}" + icon_right_aligned="true" + href={api_base("_/account/create-github-session")} + loading={loginForm.loading} + /> + <Button text="Login" + type="submit" + variant="primary" + loading={loginForm.loading}/> + </div> + </fieldset> + </form> +</Layout> diff --git a/apps/accounts/src/app/pages/not-found.svelte b/apps/accounts/src/app/pages/not-found.svelte new file mode 100644 index 0000000..34568ba --- /dev/null +++ b/apps/accounts/src/app/pages/not-found.svelte @@ -0,0 +1,23 @@ +<script> + import {link} from "svelte-spa-router"; +</script> +<style> + header { + font-size: 12rem; + } + + main { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + text-align: center; + } +</style> + +<main> + <header>404</header> + <p>Page not found!</p> + <a use:link + href="/">Go to front</a> +</main> diff --git a/apps/accounts/src/app/pages/reset-password.svelte b/apps/accounts/src/app/pages/reset-password.svelte new file mode 100644 index 0000000..56c4f62 --- /dev/null +++ b/apps/accounts/src/app/pages/reset-password.svelte @@ -0,0 +1,135 @@ +<script> + import {querystring, link} from "svelte-spa-router"; + import {check_forgot_password_request, fulfill_forgot_password_request} from "$shared/lib/api/user"; + import Alert from "$shared/components/alert.svelte"; + import Button from "$shared/components/button.svelte"; + import Layout from "./_layout.svelte"; + + const requestId = new URLSearchParams($querystring).get("id"); + let isLoading = false; + let newPassword; + let newPasswordError; + let alert = { + title: "", + type: "", + message: "", + isVisible: false, + show(type, obj) { + alert.title = obj.title; + alert.message = obj.text; + alert.type = type; + alert.isVisible = true; + isLoading = false; + }, + hide() { + alert.isVisible = false; + alert.title = ""; + alert.message = ""; + alert.type = ""; + isLoading = false; + }, + }; + + function is_valid() { + let isValid = true; + if (!newPassword.length > 5) { + newPasswordError = "The new password must be at least 5 characters"; + isValid = false; + } + return isValid; + } + + async function submit() { + if (isLoading) { + return; + } + if (is_valid()) { + isLoading = true; + const response = await fulfill_forgot_password_request(requestId, newPassword); + if (response.ok) { + alert.show("success", { + title: "Your new password is set", + text: "<a href='/#/login'>Click here to log in</a>", + }); + } else { + console.error(response.data); + alert.show("error", { + title: response.data?.title ?? "An error occured", + text: response.data?.text ?? "Please try again soon", + }); + } + } + } + + async function is_valid_password_reset_request() { + const response = await check_forgot_password_request(requestId); + if (response.ok) { + return response.data === true; + } + return false; + } +</script> + +<Layout> + <form on:submit|preventDefault={submit} + class="margin-bottom-md max-width-xxs {isLoading ? 'c-disabled loading' : ''}"> + {#if requestId} + {#await is_valid_password_reset_request()} + <p>Checking your request...</p> + <a href="/login" + use:link>cancel</a> + {:then isActive} + {#if isActive === true} + <fieldset> + <legend class="form-legend"> + <span class="margin-bottom-xs">Set your new password</span> <br/> + <span class="text-sm"> + ... or + <a href="/login" + use:link> log in </a> + </span> + </legend> + <div class="margin-bottom-xxs max-width-xxs"> + <Alert visible={alert.isVisible} + title={alert.title} + message={alert.message} + type={alert.type}/> + </div> + <div class="margin-bottom-xs"> + <input + type="password" + id="new-password" + placeholder="New password" + class="form-control width-100%" + bind:value={newPassword} + /> + {#if newPasswordError} + <small class="color-danger">{newPasswordError}</small> + {/if} + </div> + <div class="flex justify-end"> + <Button text="Set new password" + type="primary" + loading={isLoading} + on:click={submit}/> + </div> + </fieldset> + {:else} + <Alert title="This request is expired" + message="Please submit the forgot password form again" + type="warning"/> + <div class="flex justify-between width-100% margin-y-sm"> + <a href="/forgot" + use:link>Go to forgot form</a> + <a href="/login" + use:link>Go to login form</a> + </div> + {/if} + {:catch _} + <Alert title="An error occured" + message="Please try again soon" + type="error"/> + {/await} + {/if} + </form> +</Layout> diff --git a/apps/accounts/src/app/pages/sign-up.svelte b/apps/accounts/src/app/pages/sign-up.svelte new file mode 100644 index 0000000..80780e0 --- /dev/null +++ b/apps/accounts/src/app/pages/sign-up.svelte @@ -0,0 +1,128 @@ +<script> + import {create_account} from "$shared/lib/api/user"; + import {is_email} from "$shared/lib/helpers"; + import Alert from "$shared/components/alert.svelte"; + import Button from "$shared/components/button.svelte"; + import {link} from "svelte-spa-router"; + import Layout from "./_layout.svelte"; + + const signupForm = { + loading: false, + values: { + username: "", + password: "", + }, + alert: { + title: "", + type: "", + message: "", + isVisible: false, + show(type, obj) { + signupForm.alert.title = obj.title; + signupForm.alert.message = obj.text; + signupForm.alert.type = type; + signupForm.alert.isVisible = true; + signupForm.loading = false; + }, + hide() { + signupForm.alert.isVisible = false; + signupForm.alert.title = ""; + signupForm.alert.message = ""; + signupForm.alert.type = ""; + }, + }, + is_valid() { + return ( + is_email(signupForm.values.username) && + signupForm.values.password.length > 0 + ); + }, + async submit_form() { + if (signupForm.loading) { + return; + } + if (signupForm.is_valid()) { + signupForm.alert.hide(); + signupForm.loading = true; + try { + const response = await create_account(signupForm.values); + if (response.ok) { + location.reload(); + } else { + if (response.data.title || response.data.text) { + signupForm.alert.show("error", { + title: response.data.title ?? "", + text: response.data.text ?? "", + }); + } else { + signupForm.alert.show("error", { + title: "An unknown error occured", + text: "Try again soon", + }); + } + } + } catch (e) { + console.error(e); + signupForm.alert.show("error", { + title: "An error occured", + text: "Could not connect to server, please check your internet connection", + }); + } + } else { + signupForm.alert.show("error", { + title: "Invalid form", + }); + } + }, + }; +</script> + +<Layout> + <form + on:submit|preventDefault={signupForm.submit_form} + class="margin-bottom-md max-width-xxs" + > + <fieldset> + <legend class="form-legend"> + <span class="margin-bottom-xs">Create your account</span> <br/> + <span class="text-sm" + >... or <a href="/login" + use:link>log in</a></span + > + </legend> + <div class="margin-bottom-xxs max-width-xxs"> + <Alert + visible={signupForm.alert.isVisible} + title={signupForm.alert.title} + message={signupForm.alert.message} + type={signupForm.alert.type} + /> + </div> + <div class="margin-bottom-xxs"> + <input + type="email" + placeholder="Email address" + class="form-control width-100%" + id="email-address" + bind:value={signupForm.values.username} + /> + </div> + <div class="margin-bottom-xxs"> + <input + type="password" + placeholder="Password" + class="form-control width-100%" + bind:value={signupForm.values.password} + /> + </div> + <div class="flex justify-end"> + <Button + class="margin-bottom-xs" + text="Submit" + type="primary" + loading={signupForm.loading} + /> + </div> + </fieldset> + </form> +</Layout> diff --git a/apps/accounts/src/index.html b/apps/accounts/src/index.html new file mode 100644 index 0000000..985b62b --- /dev/null +++ b/apps/accounts/src/index.html @@ -0,0 +1,63 @@ +<!doctype html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" + content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> + <link rel="apple-touch-icon" + sizes="180x180" + href="./_assets/pwa/apple-touch-icon.png"> + <link rel="icon" + type="image/png" + sizes="32x32" + href="./_assets/pwa/favicon-32x32.png"> + <link rel="icon" + type="image/png" + sizes="16x16" + href="./_assets/pwa/favicon-16x16.png"> + <link rel="manifest" + href="./_assets/pwa/manifest.json"> + <link rel="mask-icon" + href="./_assets/pwa/safari-pinned-tab.svg" + color="#5bbad5"> + <meta name="msapplication-TileColor" + content="#da532c"> + <link rel="icon" + href="./_assets/pwa/favicon.svg"> + <script> + const currentTheme = localStorage.getItem("theme"); + if (currentTheme === "light") { + document.querySelector("html").dataset.theme = "light"; + } else { + document.querySelector("html").dataset.theme = "dark"; + } + </script> + <link rel="stylesheet" + href="./_assets/pre.css"> + <title>Time Tracker</title> +</head> + +<body> + +<noscript> + This page is built with javascript. Allow it and try again. +</noscript> + +<div class="fill-loader fill-loader--v4" + id="loader" + role="alert"> + <p class="fill-loader__label">Loading Time Tracker...</p> + <div aria-hidden="true"> + <div class="fill-loader__base"></div> + <div class="fill-loader__fill"></div> + </div> +</div> + +<div id="root"></div> + +<script type="module" + src="./app/index.ts"></script> +</body> + +</html> diff --git a/apps/accounts/src/package.json b/apps/accounts/src/package.json new file mode 100644 index 0000000..8ff516d --- /dev/null +++ b/apps/accounts/src/package.json @@ -0,0 +1,22 @@ +{ + "name": "time-tracker-public", + "version": "0.0.1", + "private": "true", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "1.0.0-next.43", + "sass": "^1.51.0", + "svelte": "^3.48.0", + "svelte-preprocess": "^4.10.6", + "svelte-spa-router": "^3.2.0", + "typescript": "4.6.4", + "vite": "^2.9.8" + }, + "dependencies": { + "@js-temporal/polyfill": "^0.4.1", + "fuzzysort": "^1.9.0" + } +} diff --git a/apps/accounts/src/pnpm-lock.yaml b/apps/accounts/src/pnpm-lock.yaml new file mode 100644 index 0000000..3b56115 --- /dev/null +++ b/apps/accounts/src/pnpm-lock.yaml @@ -0,0 +1,769 @@ +lockfileVersion: 5.4 + +specifiers: + '@js-temporal/polyfill': ^0.4.1 + '@sveltejs/vite-plugin-svelte': 1.0.0-next.43 + fuzzysort: ^1.9.0 + sass: ^1.51.0 + svelte: ^3.48.0 + svelte-preprocess: ^4.10.6 + svelte-spa-router: ^3.2.0 + typescript: 4.6.4 + vite: ^2.9.8 + +dependencies: + '@js-temporal/polyfill': 0.4.1 + fuzzysort: 1.9.0 + +devDependencies: + '@sveltejs/vite-plugin-svelte': 1.0.0-next.43_svelte@3.48.0+vite@2.9.8 + sass: 1.51.0 + svelte: 3.48.0 + svelte-preprocess: 4.10.6_24ezlekk4ocevlsjgs2qnqmjum + svelte-spa-router: 3.2.0 + typescript: 4.6.4 + vite: 2.9.8_sass@1.51.0 + +packages: + + /@js-temporal/polyfill/0.4.1: + resolution: {integrity: sha512-q45ecIocpa2TLem2jNOsCrDwP/sgKZdSkt+C1Rx07OkdKsdbvVfHcD1iDiK9scxBZrBQ38uJ8VQISXBS70ql1w==} + engines: {node: '>=12'} + dependencies: + jsbi: 4.3.0 + tslib: 2.4.0 + dev: false + + /@rollup/pluginutils/4.2.1: + resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==} + engines: {node: '>= 8.0.0'} + dependencies: + estree-walker: 2.0.2 + picomatch: 2.3.1 + dev: true + + /@sveltejs/vite-plugin-svelte/1.0.0-next.43_svelte@3.48.0+vite@2.9.8: + resolution: {integrity: sha512-MzeczqGrnDmbAldw/LfXV/dhpLC2bdUzuMhcx0C2j79V2uNzQERHDinxXnG2AVTCTjSpbQxzQwMMmYflnI7W1g==} + engines: {node: ^14.13.1 || >= 16} + peerDependencies: + diff-match-patch: ^1.0.5 + svelte: ^3.44.0 + vite: ^2.9.0 + peerDependenciesMeta: + diff-match-patch: + optional: true + dependencies: + '@rollup/pluginutils': 4.2.1 + debug: 4.3.4 + deepmerge: 4.2.2 + kleur: 4.1.4 + magic-string: 0.26.1 + svelte: 3.48.0 + svelte-hmr: 0.14.11_svelte@3.48.0 + vite: 2.9.8_sass@1.51.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@types/node/17.0.31: + resolution: {integrity: sha512-AR0x5HbXGqkEx9CadRH3EBYx/VkiUgZIhP4wvPn/+5KIsgpNoyFaRlVe0Zlx9gRtg8fA06a9tskE2MSN7TcG4Q==} + dev: true + + /@types/pug/2.0.6: + resolution: {integrity: sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg==} + dev: true + + /@types/sass/1.43.1: + resolution: {integrity: sha512-BPdoIt1lfJ6B7rw35ncdwBZrAssjcwzI5LByIrYs+tpXlj/CAkuVdRsgZDdP4lq5EjyWzwxZCqAoFyHKFwp32g==} + dependencies: + '@types/node': 17.0.31 + dev: true + + /anymatch/3.1.2: + resolution: {integrity: sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==} + engines: {node: '>= 8'} + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + dev: true + + /balanced-match/1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + dev: true + + /binary-extensions/2.2.0: + resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} + engines: {node: '>=8'} + dev: true + + /brace-expansion/1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + dev: true + + /braces/3.0.2: + resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} + engines: {node: '>=8'} + dependencies: + fill-range: 7.0.1 + dev: true + + /buffer-crc32/0.2.13: + resolution: {integrity: sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=} + dev: true + + /chokidar/3.5.3: + resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.2 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /concat-map/0.0.1: + resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} + dev: true + + /debug/4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + dev: true + + /deepmerge/4.2.2: + resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==} + engines: {node: '>=0.10.0'} + dev: true + + /detect-indent/6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + dev: true + + /es6-promise/3.3.1: + resolution: {integrity: sha1-oIzd6EzNvzTQJ6FFG8kdS80ophM=} + dev: true + + /esbuild-android-64/0.14.38: + resolution: {integrity: sha512-aRFxR3scRKkbmNuGAK+Gee3+yFxkTJO/cx83Dkyzo4CnQl/2zVSurtG6+G86EQIZ+w+VYngVyK7P3HyTBKu3nw==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /esbuild-android-arm64/0.14.38: + resolution: {integrity: sha512-L2NgQRWuHFI89IIZIlpAcINy9FvBk6xFVZ7xGdOwIm8VyhX1vNCEqUJO3DPSSy945Gzdg98cxtNt8Grv1CsyhA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /esbuild-darwin-64/0.14.38: + resolution: {integrity: sha512-5JJvgXkX87Pd1Og0u/NJuO7TSqAikAcQQ74gyJ87bqWRVeouky84ICoV4sN6VV53aTW+NE87qLdGY4QA2S7KNA==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /esbuild-darwin-arm64/0.14.38: + resolution: {integrity: sha512-eqF+OejMI3mC5Dlo9Kdq/Ilbki9sQBw3QlHW3wjLmsLh+quNfHmGMp3Ly1eWm981iGBMdbtSS9+LRvR2T8B3eQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /esbuild-freebsd-64/0.14.38: + resolution: {integrity: sha512-epnPbhZUt93xV5cgeY36ZxPXDsQeO55DppzsIgWM8vgiG/Rz+qYDLmh5ts3e+Ln1wA9dQ+nZmVHw+RjaW3I5Ig==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-freebsd-arm64/0.14.38: + resolution: {integrity: sha512-/9icXUYJWherhk+y5fjPI5yNUdFPtXHQlwP7/K/zg8t8lQdHVj20SqU9/udQmeUo5pDFHMYzcEFfJqgOVeKNNQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-32/0.14.38: + resolution: {integrity: sha512-QfgfeNHRFvr2XeHFzP8kOZVnal3QvST3A0cgq32ZrHjSMFTdgXhMhmWdKzRXP/PKcfv3e2OW9tT9PpcjNvaq6g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-64/0.14.38: + resolution: {integrity: sha512-uuZHNmqcs+Bj1qiW9k/HZU3FtIHmYiuxZ/6Aa+/KHb/pFKr7R3aVqvxlAudYI9Fw3St0VCPfv7QBpUITSmBR1Q==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-arm/0.14.38: + resolution: {integrity: sha512-FiFvQe8J3VKTDXG01JbvoVRXQ0x6UZwyrU4IaLBZeq39Bsbatd94Fuc3F1RGqPF5RbIWW7RvkVQjn79ejzysnA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-arm64/0.14.38: + resolution: {integrity: sha512-HlMGZTEsBrXrivr64eZ/EO0NQM8H8DuSENRok9d+Jtvq8hOLzrxfsAT9U94K3KOGk2XgCmkaI2KD8hX7F97lvA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-mips64le/0.14.38: + resolution: {integrity: sha512-qd1dLf2v7QBiI5wwfil9j0HG/5YMFBAmMVmdeokbNAMbcg49p25t6IlJFXAeLzogv1AvgaXRXvgFNhScYEUXGQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-ppc64le/0.14.38: + resolution: {integrity: sha512-mnbEm7o69gTl60jSuK+nn+pRsRHGtDPfzhrqEUXyCl7CTOCLtWN2bhK8bgsdp6J/2NyS/wHBjs1x8aBWwP2X9Q==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-riscv64/0.14.38: + resolution: {integrity: sha512-+p6YKYbuV72uikChRk14FSyNJZ4WfYkffj6Af0/Tw63/6TJX6TnIKE+6D3xtEc7DeDth1fjUOEqm+ApKFXbbVQ==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-s390x/0.14.38: + resolution: {integrity: sha512-0zUsiDkGJiMHxBQ7JDU8jbaanUY975CdOW1YDrurjrM0vWHfjv9tLQsW9GSyEb/heSK1L5gaweRjzfUVBFoybQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-netbsd-64/0.14.38: + resolution: {integrity: sha512-cljBAApVwkpnJZfnRVThpRBGzCi+a+V9Ofb1fVkKhtrPLDYlHLrSYGtmnoTVWDQdU516qYI8+wOgcGZ4XIZh0Q==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-openbsd-64/0.14.38: + resolution: {integrity: sha512-CDswYr2PWPGEPpLDUO50mL3WO/07EMjnZDNKpmaxUPsrW+kVM3LoAqr/CE8UbzugpEiflYqJsGPLirThRB18IQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-sunos-64/0.14.38: + resolution: {integrity: sha512-2mfIoYW58gKcC3bck0j7lD3RZkqYA7MmujFYmSn9l6TiIcAMpuEvqksO+ntBgbLep/eyjpgdplF7b+4T9VJGOA==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-32/0.14.38: + resolution: {integrity: sha512-L2BmEeFZATAvU+FJzJiRLFUP+d9RHN+QXpgaOrs2klshoAm1AE6Us4X6fS9k33Uy5SzScn2TpcgecbqJza1Hjw==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-64/0.14.38: + resolution: {integrity: sha512-Khy4wVmebnzue8aeSXLC+6clo/hRYeNIm0DyikoEqX+3w3rcvrhzpoix0S+MF9vzh6JFskkIGD7Zx47ODJNyCw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-arm64/0.14.38: + resolution: {integrity: sha512-k3FGCNmHBkqdJXuJszdWciAH77PukEyDsdIryEHn9cKLQFxzhT39dSumeTuggaQcXY57UlmLGIkklWZo2qzHpw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild/0.14.38: + resolution: {integrity: sha512-12fzJ0fsm7gVZX1YQ1InkOE5f9Tl7cgf6JPYXRJtPIoE0zkWAbHdPHVPPaLi9tYAcEBqheGzqLn/3RdTOyBfcA==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + esbuild-android-64: 0.14.38 + esbuild-android-arm64: 0.14.38 + esbuild-darwin-64: 0.14.38 + esbuild-darwin-arm64: 0.14.38 + esbuild-freebsd-64: 0.14.38 + esbuild-freebsd-arm64: 0.14.38 + esbuild-linux-32: 0.14.38 + esbuild-linux-64: 0.14.38 + esbuild-linux-arm: 0.14.38 + esbuild-linux-arm64: 0.14.38 + esbuild-linux-mips64le: 0.14.38 + esbuild-linux-ppc64le: 0.14.38 + esbuild-linux-riscv64: 0.14.38 + esbuild-linux-s390x: 0.14.38 + esbuild-netbsd-64: 0.14.38 + esbuild-openbsd-64: 0.14.38 + esbuild-sunos-64: 0.14.38 + esbuild-windows-32: 0.14.38 + esbuild-windows-64: 0.14.38 + esbuild-windows-arm64: 0.14.38 + dev: true + + /estree-walker/2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + dev: true + + /fill-range/7.0.1: + resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + engines: {node: '>=8'} + dependencies: + to-regex-range: 5.0.1 + dev: true + + /fs.realpath/1.0.0: + resolution: {integrity: sha1-FQStJSMVjKpA20onh8sBQRmU6k8=} + dev: true + + /fsevents/2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /function-bind/1.1.1: + resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} + dev: true + + /fuzzysort/1.9.0: + resolution: {integrity: sha512-MOxCT0qLTwLqmEwc7UtU045RKef7mc8Qz8eR4r2bLNEq9dy/c3ZKMEFp6IEst69otkQdFZ4FfgH2dmZD+ddX1g==} + dev: false + + /glob-parent/5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + dependencies: + is-glob: 4.0.3 + dev: true + + /glob/7.2.0: + resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + dev: true + + /graceful-fs/4.2.10: + resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} + dev: true + + /has/1.0.3: + resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} + engines: {node: '>= 0.4.0'} + dependencies: + function-bind: 1.1.1 + dev: true + + /immutable/4.0.0: + resolution: {integrity: sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==} + dev: true + + /inflight/1.0.6: + resolution: {integrity: sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=} + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + dev: true + + /inherits/2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + dev: true + + /is-binary-path/2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + dependencies: + binary-extensions: 2.2.0 + dev: true + + /is-core-module/2.9.0: + resolution: {integrity: sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==} + dependencies: + has: 1.0.3 + dev: true + + /is-extglob/2.1.1: + resolution: {integrity: sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=} + engines: {node: '>=0.10.0'} + dev: true + + /is-glob/4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + dependencies: + is-extglob: 2.1.1 + dev: true + + /is-number/7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + dev: true + + /jsbi/4.3.0: + resolution: {integrity: sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g==} + dev: false + + /kleur/4.1.4: + resolution: {integrity: sha512-8QADVssbrFjivHWQU7KkMgptGTl6WAcSdlbBPY4uNF+mWr6DGcKrvY2w4FQJoXch7+fKMjj0dRrL75vk3k23OA==} + engines: {node: '>=6'} + dev: true + + /magic-string/0.25.9: + resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} + dependencies: + sourcemap-codec: 1.4.8 + dev: true + + /magic-string/0.26.1: + resolution: {integrity: sha512-ndThHmvgtieXe8J/VGPjG+Apu7v7ItcD5mhEIvOscWjPF/ccOiLxHaSuCAS2G+3x4GKsAbT8u7zdyamupui8Tg==} + engines: {node: '>=12'} + dependencies: + sourcemap-codec: 1.4.8 + dev: true + + /min-indent/1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + dev: true + + /minimatch/3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + dependencies: + brace-expansion: 1.1.11 + dev: true + + /minimist/1.2.6: + resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==} + dev: true + + /mkdirp/0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + dependencies: + minimist: 1.2.6 + dev: true + + /ms/2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + dev: true + + /nanoid/3.3.4: + resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + dev: true + + /normalize-path/3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + dev: true + + /once/1.4.0: + resolution: {integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=} + dependencies: + wrappy: 1.0.2 + dev: true + + /path-is-absolute/1.0.1: + resolution: {integrity: sha1-F0uSaHNVNP+8es5r9TpanhtcX18=} + engines: {node: '>=0.10.0'} + dev: true + + /path-parse/1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + dev: true + + /picocolors/1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + dev: true + + /picomatch/2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + dev: true + + /postcss/8.4.13: + resolution: {integrity: sha512-jtL6eTBrza5MPzy8oJLFuUscHDXTV5KcLlqAWHl5q5WYRfnNRGSmOZmOZ1T6Gy7A99mOZfqungmZMpMmCVJ8ZA==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.4 + picocolors: 1.0.0 + source-map-js: 1.0.2 + dev: true + + /readdirp/3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + dependencies: + picomatch: 2.3.1 + dev: true + + /regexparam/2.0.0: + resolution: {integrity: sha512-gJKwd2MVPWHAIFLsaYDZfyKzHNS4o7E/v8YmNf44vmeV2e4YfVoDToTOKTvE7ab68cRJ++kLuEXJBaEeJVt5ow==} + engines: {node: '>=8'} + dev: true + + /resolve/1.22.0: + resolution: {integrity: sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==} + hasBin: true + dependencies: + is-core-module: 2.9.0 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + dev: true + + /rimraf/2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + hasBin: true + dependencies: + glob: 7.2.0 + dev: true + + /rollup/2.72.1: + resolution: {integrity: sha512-NTc5UGy/NWFGpSqF1lFY8z9Adri6uhyMLI6LvPAXdBKoPRFhIIiBUpt+Qg2awixqO3xvzSijjhnb4+QEZwJmxA==} + engines: {node: '>=10.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /sander/0.5.1: + resolution: {integrity: sha1-dB4kXiMfB8r7b98PEzrfohalAq0=} + dependencies: + es6-promise: 3.3.1 + graceful-fs: 4.2.10 + mkdirp: 0.5.6 + rimraf: 2.7.1 + dev: true + + /sass/1.51.0: + resolution: {integrity: sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==} + engines: {node: '>=12.0.0'} + hasBin: true + dependencies: + chokidar: 3.5.3 + immutable: 4.0.0 + source-map-js: 1.0.2 + dev: true + + /sorcery/0.10.0: + resolution: {integrity: sha1-iukK19fLBfxZ8asMY3hF1cFaUrc=} + hasBin: true + dependencies: + buffer-crc32: 0.2.13 + minimist: 1.2.6 + sander: 0.5.1 + sourcemap-codec: 1.4.8 + dev: true + + /source-map-js/1.0.2: + resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + engines: {node: '>=0.10.0'} + dev: true + + /sourcemap-codec/1.4.8: + resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} + dev: true + + /strip-indent/3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + dependencies: + min-indent: 1.0.1 + dev: true + + /supports-preserve-symlinks-flag/1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + dev: true + + /svelte-hmr/0.14.11_svelte@3.48.0: + resolution: {integrity: sha512-R9CVfX6DXxW1Kn45Jtmx+yUe+sPhrbYSUp7TkzbW0jI5fVPn6lsNG9NEs5dFg5qRhFNAoVdRw5qQDLALNKhwbQ==} + engines: {node: ^12.20 || ^14.13.1 || >= 16} + peerDependencies: + svelte: '>=3.19.0' + dependencies: + svelte: 3.48.0 + dev: true + + /svelte-preprocess/4.10.6_24ezlekk4ocevlsjgs2qnqmjum: + resolution: {integrity: sha512-I2SV1w/AveMvgIQlUF/ZOO3PYVnhxfcpNyGt8pxpUVhPfyfL/CZBkkw/KPfuFix5FJ9TnnNYMhACK3DtSaYVVQ==} + engines: {node: '>= 9.11.2'} + requiresBuild: true + peerDependencies: + '@babel/core': ^7.10.2 + coffeescript: ^2.5.1 + less: ^3.11.3 || ^4.0.0 + node-sass: '*' + postcss: ^7 || ^8 + postcss-load-config: ^2.1.0 || ^3.0.0 + pug: ^3.0.0 + sass: ^1.26.8 + stylus: ^0.55.0 + sugarss: ^2.0.0 + svelte: ^3.23.0 + typescript: ^3.9.5 || ^4.0.0 + peerDependenciesMeta: + '@babel/core': + optional: true + coffeescript: + optional: true + less: + optional: true + node-sass: + optional: true + postcss: + optional: true + postcss-load-config: + optional: true + pug: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + typescript: + optional: true + dependencies: + '@types/pug': 2.0.6 + '@types/sass': 1.43.1 + detect-indent: 6.1.0 + magic-string: 0.25.9 + sass: 1.51.0 + sorcery: 0.10.0 + strip-indent: 3.0.0 + svelte: 3.48.0 + typescript: 4.6.4 + dev: true + + /svelte-spa-router/3.2.0: + resolution: {integrity: sha512-igemo5Vs82TGBBw+DjWt6qKameXYzNs6aDXcTxou5XbEvOjiRcAM6MLkdVRCatn6u8r42dE99bt/br7T4qe/AQ==} + dependencies: + regexparam: 2.0.0 + dev: true + + /svelte/3.48.0: + resolution: {integrity: sha512-fN2YRm/bGumvjUpu6yI3BpvZnpIm9I6A7HR4oUNYd7ggYyIwSA/BX7DJ+UXXffLp6XNcUijyLvttbPVCYa/3xQ==} + engines: {node: '>= 8'} + dev: true + + /to-regex-range/5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + dependencies: + is-number: 7.0.0 + dev: true + + /tslib/2.4.0: + resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==} + dev: false + + /typescript/4.6.4: + resolution: {integrity: sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==} + engines: {node: '>=4.2.0'} + hasBin: true + dev: true + + /vite/2.9.8_sass@1.51.0: + resolution: {integrity: sha512-zsBGwn5UT3YS0NLSJ7hnR54+vUKfgzMUh/Z9CxF1YKEBVIe213+63jrFLmZphgGI5zXwQCSmqIdbPuE8NJywPw==} + engines: {node: '>=12.2.0'} + hasBin: true + peerDependencies: + less: '*' + sass: '*' + stylus: '*' + peerDependenciesMeta: + less: + optional: true + sass: + optional: true + stylus: + optional: true + dependencies: + esbuild: 0.14.38 + postcss: 8.4.13 + resolve: 1.22.0 + rollup: 2.72.1 + sass: 1.51.0 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /wrappy/1.0.2: + resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=} + dev: true diff --git a/apps/accounts/src/tsconfig.json b/apps/accounts/src/tsconfig.json new file mode 100644 index 0000000..e00d638 --- /dev/null +++ b/apps/accounts/src/tsconfig.json @@ -0,0 +1,30 @@ +{ + "include": [ + "./**/*.d.ts", + "./**/*.ts", + "./**/*.js", + "./**/*.svelte" + ], + "exclude": [ + "./node_modules" + ], + "compilerOptions": { + "target": "esnext", + "useDefineForClassFields": true, + "module": "esnext", + "moduleResolution": "node", + "allowJs": true, + "checkJs": false, + "paths": { + "$app/*": [ + "./_public/*" + ], + "$app/*": [ + "./app/*" + ], + "$shared/*": [ + "../../web-shared/src/*" + ] + } + } +} diff --git a/apps/accounts/src/vite.config.ts b/apps/accounts/src/vite.config.ts new file mode 100644 index 0000000..907422e --- /dev/null +++ b/apps/accounts/src/vite.config.ts @@ -0,0 +1,30 @@ +import {defineConfig} from "vite"; +import {svelte} from "@sveltejs/vite-plugin-svelte"; +import sveltePreprocess from "svelte-preprocess"; +// @ts-ignore +import path from "path"; + +// https://vitejs.dev/config/ +export default defineConfig({ + resolve: { + alias: { + "$shared": path.resolve(__dirname, "../../web-shared/src"), + "$app": path.resolve(__dirname, "./app"), + } + }, + build: { + outDir: "build", + emptyOutDir: true, + rollupOptions: { + input: { + main: path.resolve(__dirname, "index.html"), + } + } + }, + + plugins: [ + svelte({ + preprocess: sveltePreprocess() + }) + ], +}); diff --git a/apps/frontpage/.gitignore b/apps/frontpage/.gitignore new file mode 100644 index 0000000..f4401a3 --- /dev/null +++ b/apps/frontpage/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example diff --git a/apps/frontpage/.version b/apps/frontpage/.version new file mode 100644 index 0000000..0b20bfd --- /dev/null +++ b/apps/frontpage/.version @@ -0,0 +1 @@ +v8-frontpage diff --git a/apps/frontpage/.version-dev b/apps/frontpage/.version-dev new file mode 100644 index 0000000..ee11e9e --- /dev/null +++ b/apps/frontpage/.version-dev @@ -0,0 +1 @@ +v1-frontpage-dev diff --git a/apps/frontpage/CHANGELOG.md b/apps/frontpage/CHANGELOG.md new file mode 100644 index 0000000..864930a --- /dev/null +++ b/apps/frontpage/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## [unreleased] + +### Miscellaneous Tasks + +- Bump version +- Update CHANGELOG.md for v8-frontpage + diff --git a/apps/frontpage/README.md b/apps/frontpage/README.md new file mode 100644 index 0000000..42ee3ec --- /dev/null +++ b/apps/frontpage/README.md @@ -0,0 +1,3 @@ +# Frontpage + +This is a sveltekit app that makes up the frontpage on greatoffice.life diff --git a/apps/frontpage/build_and_push.sh b/apps/frontpage/build_and_push.sh new file mode 100755 index 0000000..3e048f5 --- /dev/null +++ b/apps/frontpage/build_and_push.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +set -Eueo pipefail + +APP_NAME="frontpage"; +CURRENT_DEV_VERSION=$(cat .version-dev) +CURRENT_DEV_VERSION_INT=${CURRENT_DEV_VERSION//[!0-9]/} +CURRENT_VERSION=$(cat .version) +CURRENT_VERSION_INT=${CURRENT_VERSION//[!0-9]/} +if [ ${1-prod} == "dev" ]; then + NEW_VERSION="v$((CURRENT_DEV_VERSION_INT+1))-$APP_NAME-dev" + OLD_VERSION=$CURRENT_DEV_VERSION +else + NEW_VERSION="v$((CURRENT_VERSION_INT+1))-$APP_NAME" + OLD_VERSION=$CURRENT_VERSION +fi +# Check for uncommited changes and optionally commit them +if [ "$(git status --untracked-files=no --porcelain)" ]; then + echo "Unclean git tree! press CTRL+C to exit or press ENTER to commit and push to the default branch" + read -n 1 + + read -p "Enter commit message: " COMMIT_MESSAGE + git add ../../ + git commit --quiet -m "$COMMIT_MESSAGE" +fi + +if [ ${1-prod} == "dev" ]; then + echo $NEW_VERSION >| .version-dev + git add .version-dev +else + echo $NEW_VERSION >| .version + git add .version +fi + +echo "Starting build of $APP_NAME@$NEW_VERSION at $(date -u)..." +echo + +git commit --quiet -m "chore(release): Bump version"; + +read -p "Do you want to tag this build? (y/n) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]] +then + read -p "Enter tag message (can be empty): " TAG_MESSAGE + commit_msg="chore(release): Update CHANGELOG.md for $NEW_VERSION" + git cliff -r ../../ $OLD_VERSION..HEAD --with-commit "$commit_msg" --prepend CHANGELOG.md + git add CHANGELOG.md + git commit --quiet -m "$commit_msg"; + git tag -am "$TAG_MESSAGE" $NEW_VERSION +fi + +read -p "Do you want to push the latest commits and tags to origin? (y/n) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]] +then + echo "Pushing latest changes to remotes..." + echo + git push --quiet --follow-tags +fi + +pnpm run build + +cd build +echo "$NEW_VERSION" >version.txt + + +if [ ${1-prod} == "dev" ]; then + scp -r * contabo-fast-1:services/public/dev.greatoffice.life/www +else + echo "Pushing to production in 10 sec, press CTRL+C to cancel" + sleep 10 + scp -r * contabo-fast-1:services/public/greatoffice.life/www +fi diff --git a/apps/frontpage/cliff.toml b/apps/frontpage/cliff.toml new file mode 100644 index 0000000..955a72b --- /dev/null +++ b/apps/frontpage/cliff.toml @@ -0,0 +1,62 @@ +# configuration file for git-cliff (0.1.0) + +[changelog] +# changelog header +header = """ +# Changelog\n +""" +# template for the changelog body +# https://tera.netlify.app/docs/#introduction +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | upper_first }} + {% for commit in commits %} + - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\ + {% endfor %} +{% endfor %}\n +""" +# remove the leading and trailing whitespace from the template +trim = true +# changelog footer +footer = """ +<!-- generated by git-cliff --> +""" + +[git] +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# regex for preprocessing the commit messages +commit_preprocessors = [ + { pattern = "([ \\n])(([a-f0-9]{7})[a-f0-9]*)", replace = "${1}commit # [${3}](https://git.ivarlovlie.no/time-tracker/commit/${2})" }, + { pattern = "https://git.ivarlovlie.no/time-tracker/commit/([a-f0-9]{7})[a-f0-9]*", replace = "commit # [${1}](${0})" }, +] +# regex for parsing and grouping commits +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^doc", group = "Documentation" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactor" }, + { message = "^style", group = "Styling" }, + { message = "^test", group = "Testing" }, + { message = "^chore", group = "Miscellaneous Tasks" }, +] +# filter out the commits that are not matched by commit parsers +filter_commits = true +# glob pattern for matching git tags +tag_pattern = "v.*" +# regex for skipping tags +skip_tags = "v0.1.0-beta.1" +# regex for ignoring tags +ignore_tags = "" +# sort the tags chronologically +date_order = true +# sort the commits inside sections by oldest/newest order +sort_commits = "newest" diff --git a/apps/frontpage/index.html b/apps/frontpage/index.html new file mode 100644 index 0000000..e149a39 --- /dev/null +++ b/apps/frontpage/index.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Title</title> +</head> +<body> + +</body> +</html> diff --git a/apps/frontpage/package.json b/apps/frontpage/package.json new file mode 100644 index 0000000..11f90ae --- /dev/null +++ b/apps/frontpage/package.json @@ -0,0 +1,25 @@ +{ + "name": "frontpage", + "version": "0.0.1", + "scripts": { + "dev": "svelte-kit dev", + "build": "svelte-kit build", + "package": "svelte-kit package", + "preview": "svelte-kit preview", + "prepare": "svelte-kit sync", + "test": "playwright test", + "check": "svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@playwright/test": "^1.21.0", + "@sveltejs/adapter-static": "1.0.0-next.34", + "@sveltejs/kit": "next", + "svelte": "^3.44.0", + "svelte-check": "^2.2.6", + "svelte-preprocess": "^4.10.1", + "tslib": "^2.3.1", + "typescript": "~4.6.2" + }, + "type": "module" +} diff --git a/apps/frontpage/playwright.config.ts b/apps/frontpage/playwright.config.ts new file mode 100644 index 0000000..05dea1f --- /dev/null +++ b/apps/frontpage/playwright.config.ts @@ -0,0 +1,10 @@ +import type {PlaywrightTestConfig} from "@playwright/test"; + +const config: PlaywrightTestConfig = { + webServer: { + command: "pnpm run build && pnpm run preview", + port: 3000 + } +}; + +export default config; diff --git a/apps/frontpage/pnpm-lock.yaml b/apps/frontpage/pnpm-lock.yaml new file mode 100644 index 0000000..9486720 --- /dev/null +++ b/apps/frontpage/pnpm-lock.yaml @@ -0,0 +1,925 @@ +lockfileVersion: 5.4 + +specifiers: + '@playwright/test': ^1.21.0 + '@sveltejs/adapter-static': 1.0.0-next.34 + '@sveltejs/kit': next + svelte: ^3.44.0 + svelte-check: ^2.2.6 + svelte-preprocess: ^4.10.1 + tslib: ^2.3.1 + typescript: ~4.6.2 + +devDependencies: + '@playwright/test': 1.22.2 + '@sveltejs/adapter-static': 1.0.0-next.34 + '@sveltejs/kit': 1.0.0-next.347_svelte@3.48.0 + svelte: 3.48.0 + svelte-check: 2.7.1_svelte@3.48.0 + svelte-preprocess: 4.10.6_wwvk7nlptlrqo2czohjtk6eiqm + tslib: 2.4.0 + typescript: 4.6.4 + +packages: + + /@jridgewell/resolve-uri/3.0.7: + resolution: {integrity: sha512-8cXDaBBHOr2pQ7j77Y6Vp5VDT2sIqWyWQ56TjEq4ih/a4iST3dItRe8Q9fp0rrIl9DoKhWQtUQz/YpOxLkXbNA==} + engines: {node: '>=6.0.0'} + dev: true + + /@jridgewell/sourcemap-codec/1.4.13: + resolution: {integrity: sha512-GryiOJmNcWbovBxTfZSF71V/mXbgcV3MewDe3kIMCLyIh5e7SKAeUZs+rMnJ8jkMolZ/4/VsdBmMrw3l+VdZ3w==} + dev: true + + /@jridgewell/trace-mapping/0.3.13: + resolution: {integrity: sha512-o1xbKhp9qnIAoHJSWd6KlCZfqslL4valSF81H8ImioOAxluWYWOpWkpyktY2vnt4tbrX9XYaxovq6cgowaJp2w==} + dependencies: + '@jridgewell/resolve-uri': 3.0.7 + '@jridgewell/sourcemap-codec': 1.4.13 + dev: true + + /@nodelib/fs.scandir/2.1.5: + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + dev: true + + /@nodelib/fs.stat/2.0.5: + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + dev: true + + /@nodelib/fs.walk/1.2.8: + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.13.0 + dev: true + + /@playwright/test/1.22.2: + resolution: {integrity: sha512-cCl96BEBGPtptFz7C2FOSN3PrTnJ3rPpENe+gYCMx4GNNDlN4tmo2D89y13feGKTMMAIVrXfSQ/UmaQKLy1XLA==} + engines: {node: '>=14'} + hasBin: true + dependencies: + '@types/node': 17.0.38 + playwright-core: 1.22.2 + dev: true + + /@rollup/pluginutils/4.2.1: + resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==} + engines: {node: '>= 8.0.0'} + dependencies: + estree-walker: 2.0.2 + picomatch: 2.3.1 + dev: true + + /@sveltejs/adapter-static/1.0.0-next.34: + resolution: {integrity: sha512-XjuMhemme5z0L/B2nTZpA6k+RJjF+b6L96ts6gIQ6ixiCzJQSbBqJhrrBYBCaeLAKvdUMoGEmX8m862JhKjRFg==} + dependencies: + tiny-glob: 0.2.9 + dev: true + + /@sveltejs/kit/1.0.0-next.347_svelte@3.48.0: + resolution: {integrity: sha512-kxan2F8g9nM/4QzLINsPiZdLZLx6X2Tjg+Ft8KR2QPhHKCEQ3jlosnGTzmznt572PTg89UhiUhQWKK4IDk2nSA==} + engines: {node: '>=16.7'} + hasBin: true + peerDependencies: + svelte: ^3.44.0 + dependencies: + '@sveltejs/vite-plugin-svelte': 1.0.0-next.46_svelte@3.48.0+vite@2.9.9 + chokidar: 3.5.3 + sade: 1.8.1 + svelte: 3.48.0 + vite: 2.9.9 + transitivePeerDependencies: + - diff-match-patch + - less + - sass + - stylus + - supports-color + dev: true + + /@sveltejs/vite-plugin-svelte/1.0.0-next.46_svelte@3.48.0+vite@2.9.9: + resolution: {integrity: sha512-dumtaI5XusnDgXoQ3vxQAdoCaTWf8zKVezJdiTGjuaS/GSsmLIvtHUvMt0NlwEikPQ/hL53eIzMliRQ/j35w9w==} + engines: {node: ^14.13.1 || >= 16} + peerDependencies: + diff-match-patch: ^1.0.5 + svelte: ^3.44.0 + vite: ^2.9.0 + peerDependenciesMeta: + diff-match-patch: + optional: true + dependencies: + '@rollup/pluginutils': 4.2.1 + debug: 4.3.4 + deepmerge: 4.2.2 + kleur: 4.1.4 + magic-string: 0.26.2 + svelte: 3.48.0 + svelte-hmr: 0.14.12_svelte@3.48.0 + vite: 2.9.9 + transitivePeerDependencies: + - supports-color + dev: true + + /@types/node/17.0.38: + resolution: {integrity: sha512-5jY9RhV7c0Z4Jy09G+NIDTsCZ5G0L5n+Z+p+Y7t5VJHM30bgwzSjVtlcBxqAj+6L/swIlvtOSzr8rBk/aNyV2g==} + dev: true + + /@types/pug/2.0.6: + resolution: {integrity: sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg==} + dev: true + + /@types/sass/1.43.1: + resolution: {integrity: sha512-BPdoIt1lfJ6B7rw35ncdwBZrAssjcwzI5LByIrYs+tpXlj/CAkuVdRsgZDdP4lq5EjyWzwxZCqAoFyHKFwp32g==} + dependencies: + '@types/node': 17.0.38 + dev: true + + /anymatch/3.1.2: + resolution: {integrity: sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==} + engines: {node: '>= 8'} + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + dev: true + + /balanced-match/1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + dev: true + + /binary-extensions/2.2.0: + resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} + engines: {node: '>=8'} + dev: true + + /brace-expansion/1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + dev: true + + /braces/3.0.2: + resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} + engines: {node: '>=8'} + dependencies: + fill-range: 7.0.1 + dev: true + + /buffer-crc32/0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + dev: true + + /callsites/3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + dev: true + + /chokidar/3.5.3: + resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.2 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /concat-map/0.0.1: + resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} + dev: true + + /debug/4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + dev: true + + /deepmerge/4.2.2: + resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==} + engines: {node: '>=0.10.0'} + dev: true + + /detect-indent/6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + dev: true + + /es6-promise/3.3.1: + resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} + dev: true + + /esbuild-android-64/0.14.42: + resolution: {integrity: sha512-P4Y36VUtRhK/zivqGVMqhptSrFILAGlYp0Z8r9UQqHJ3iWztRCNWnlBzD9HRx0DbueXikzOiwyOri+ojAFfW6A==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /esbuild-android-arm64/0.14.42: + resolution: {integrity: sha512-0cOqCubq+RWScPqvtQdjXG3Czb3AWI2CaKw3HeXry2eoA2rrPr85HF7IpdU26UWdBXgPYtlTN1LUiuXbboROhg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /esbuild-darwin-64/0.14.42: + resolution: {integrity: sha512-ipiBdCA3ZjYgRfRLdQwP82rTiv/YVMtW36hTvAN5ZKAIfxBOyPXY7Cejp3bMXWgzKD8B6O+zoMzh01GZsCuEIA==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /esbuild-darwin-arm64/0.14.42: + resolution: {integrity: sha512-bU2tHRqTPOaoH/4m0zYHbFWpiYDmaA0gt90/3BMEFaM0PqVK/a6MA2V/ypV5PO0v8QxN6gH5hBPY4YJ2lopXgA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /esbuild-freebsd-64/0.14.42: + resolution: {integrity: sha512-75h1+22Ivy07+QvxHyhVqOdekupiTZVLN1PMwCDonAqyXd8TVNJfIRFrdL8QmSJrOJJ5h8H1I9ETyl2L8LQDaw==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-freebsd-arm64/0.14.42: + resolution: {integrity: sha512-W6Jebeu5TTDQMJUJVarEzRU9LlKpNkPBbjqSu+GUPTHDCly5zZEQq9uHkmHHl7OKm+mQ2zFySN83nmfCeZCyNA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-32/0.14.42: + resolution: {integrity: sha512-Ooy/Bj+mJ1z4jlWcK5Dl6SlPlCgQB9zg1UrTCeY8XagvuWZ4qGPyYEWGkT94HUsRi2hKsXvcs6ThTOjBaJSMfg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-64/0.14.42: + resolution: {integrity: sha512-2L0HbzQfbTuemUWfVqNIjOfaTRt9zsvjnme6lnr7/MO9toz/MJ5tZhjqrG6uDWDxhsaHI2/nsDgrv8uEEN2eoA==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-arm/0.14.42: + resolution: {integrity: sha512-STq69yzCMhdRaWnh29UYrLSr/qaWMm/KqwaRF1pMEK7kDiagaXhSL1zQGXbYv94GuGY/zAwzK98+6idCMUOOCg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-arm64/0.14.42: + resolution: {integrity: sha512-c3Ug3e9JpVr8jAcfbhirtpBauLxzYPpycjWulD71CF6ZSY26tvzmXMJYooQ2YKqDY4e/fPu5K8bm7MiXMnyxuA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-mips64le/0.14.42: + resolution: {integrity: sha512-QuvpHGbYlkyXWf2cGm51LBCHx6eUakjaSrRpUqhPwjh/uvNUYvLmz2LgPTTPwCqaKt0iwL+OGVL0tXA5aDbAbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-ppc64le/0.14.42: + resolution: {integrity: sha512-8ohIVIWDbDT+i7lCx44YCyIRrOW1MYlks9fxTo0ME2LS/fxxdoJBwHWzaDYhjvf8kNpA+MInZvyOEAGoVDrMHg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-riscv64/0.14.42: + resolution: {integrity: sha512-DzDqK3TuoXktPyG1Lwx7vhaF49Onv3eR61KwQyxYo4y5UKTpL3NmuarHSIaSVlTFDDpcIajCDwz5/uwKLLgKiQ==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-s390x/0.14.42: + resolution: {integrity: sha512-YFRhPCxl8nb//Wn6SiS5pmtplBi4z9yC2gLrYoYI/tvwuB1jldir9r7JwAGy1Ck4D7sE7wBN9GFtUUX/DLdcEQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-netbsd-64/0.14.42: + resolution: {integrity: sha512-QYSD2k+oT9dqB/4eEM9c+7KyNYsIPgzYOSrmfNGDIyJrbT1d+CFVKvnKahDKNJLfOYj8N4MgyFaU9/Ytc6w5Vw==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-openbsd-64/0.14.42: + resolution: {integrity: sha512-M2meNVIKWsm2HMY7+TU9AxM7ZVwI9havdsw6m/6EzdXysyCFFSoaTQ/Jg03izjCsK17FsVRHqRe26Llj6x0MNA==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-sunos-64/0.14.42: + resolution: {integrity: sha512-uXV8TAZEw36DkgW8Ak3MpSJs1ofBb3Smkc/6pZ29sCAN1KzCAQzsje4sUwugf+FVicrHvlamCOlFZIXgct+iqQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-32/0.14.42: + resolution: {integrity: sha512-4iw/8qWmRICWi9ZOnJJf9sYt6wmtp3hsN4TdI5NqgjfOkBVMxNdM9Vt3626G1Rda9ya2Q0hjQRD9W1o+m6Lz6g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-64/0.14.42: + resolution: {integrity: sha512-j3cdK+Y3+a5H0wHKmLGTJcq0+/2mMBHPWkItR3vytp/aUGD/ua/t2BLdfBIzbNN9nLCRL9sywCRpOpFMx3CxzA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-arm64/0.14.42: + resolution: {integrity: sha512-+lRAARnF+hf8J0mN27ujO+VbhPbDqJ8rCcJKye4y7YZLV6C4n3pTRThAb388k/zqF5uM0lS5O201u0OqoWSicw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild/0.14.42: + resolution: {integrity: sha512-V0uPZotCEHokJdNqyozH6qsaQXqmZEOiZWrXnds/zaH/0SyrIayRXWRB98CENO73MIZ9T3HBIOsmds5twWtmgw==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + esbuild-android-64: 0.14.42 + esbuild-android-arm64: 0.14.42 + esbuild-darwin-64: 0.14.42 + esbuild-darwin-arm64: 0.14.42 + esbuild-freebsd-64: 0.14.42 + esbuild-freebsd-arm64: 0.14.42 + esbuild-linux-32: 0.14.42 + esbuild-linux-64: 0.14.42 + esbuild-linux-arm: 0.14.42 + esbuild-linux-arm64: 0.14.42 + esbuild-linux-mips64le: 0.14.42 + esbuild-linux-ppc64le: 0.14.42 + esbuild-linux-riscv64: 0.14.42 + esbuild-linux-s390x: 0.14.42 + esbuild-netbsd-64: 0.14.42 + esbuild-openbsd-64: 0.14.42 + esbuild-sunos-64: 0.14.42 + esbuild-windows-32: 0.14.42 + esbuild-windows-64: 0.14.42 + esbuild-windows-arm64: 0.14.42 + dev: true + + /estree-walker/2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + dev: true + + /fast-glob/3.2.11: + resolution: {integrity: sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==} + engines: {node: '>=8.6.0'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.5 + dev: true + + /fastq/1.13.0: + resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==} + dependencies: + reusify: 1.0.4 + dev: true + + /fill-range/7.0.1: + resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + engines: {node: '>=8'} + dependencies: + to-regex-range: 5.0.1 + dev: true + + /fs.realpath/1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + dev: true + + /fsevents/2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /function-bind/1.1.1: + resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} + dev: true + + /glob-parent/5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + dependencies: + is-glob: 4.0.3 + dev: true + + /glob/7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + dev: true + + /globalyzer/0.1.0: + resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==} + dev: true + + /globrex/0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + dev: true + + /graceful-fs/4.2.10: + resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} + dev: true + + /has/1.0.3: + resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} + engines: {node: '>= 0.4.0'} + dependencies: + function-bind: 1.1.1 + dev: true + + /import-fresh/3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + dev: true + + /inflight/1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + dev: true + + /inherits/2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + dev: true + + /is-binary-path/2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + dependencies: + binary-extensions: 2.2.0 + dev: true + + /is-core-module/2.9.0: + resolution: {integrity: sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==} + dependencies: + has: 1.0.3 + dev: true + + /is-extglob/2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + dev: true + + /is-glob/4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + dependencies: + is-extglob: 2.1.1 + dev: true + + /is-number/7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + dev: true + + /kleur/4.1.4: + resolution: {integrity: sha512-8QADVssbrFjivHWQU7KkMgptGTl6WAcSdlbBPY4uNF+mWr6DGcKrvY2w4FQJoXch7+fKMjj0dRrL75vk3k23OA==} + engines: {node: '>=6'} + dev: true + + /magic-string/0.25.9: + resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} + dependencies: + sourcemap-codec: 1.4.8 + dev: true + + /magic-string/0.26.2: + resolution: {integrity: sha512-NzzlXpclt5zAbmo6h6jNc8zl2gNRGHvmsZW4IvZhTC4W7k4OlLP+S5YLussa/r3ixNT66KOQfNORlXHSOy/X4A==} + engines: {node: '>=12'} + dependencies: + sourcemap-codec: 1.4.8 + dev: true + + /merge2/1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + dev: true + + /micromatch/4.0.5: + resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} + engines: {node: '>=8.6'} + dependencies: + braces: 3.0.2 + picomatch: 2.3.1 + dev: true + + /min-indent/1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + dev: true + + /minimatch/3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + dependencies: + brace-expansion: 1.1.11 + dev: true + + /minimist/1.2.6: + resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==} + dev: true + + /mkdirp/0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + dependencies: + minimist: 1.2.6 + dev: true + + /mri/1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + dev: true + + /ms/2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + dev: true + + /nanoid/3.3.4: + resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + dev: true + + /normalize-path/3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + dev: true + + /once/1.4.0: + resolution: {integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=} + dependencies: + wrappy: 1.0.2 + dev: true + + /parent-module/1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + dependencies: + callsites: 3.1.0 + dev: true + + /path-is-absolute/1.0.1: + resolution: {integrity: sha1-F0uSaHNVNP+8es5r9TpanhtcX18=} + engines: {node: '>=0.10.0'} + dev: true + + /path-parse/1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + dev: true + + /picocolors/1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + dev: true + + /picomatch/2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + dev: true + + /playwright-core/1.22.2: + resolution: {integrity: sha512-w/hc/Ld0RM4pmsNeE6aL/fPNWw8BWit2tg+TfqJ3+p59c6s3B6C8mXvXrIPmfQEobkcFDc+4KirNzOQ+uBSP1Q==} + engines: {node: '>=14'} + hasBin: true + dev: true + + /postcss/8.4.14: + resolution: {integrity: sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.4 + picocolors: 1.0.0 + source-map-js: 1.0.2 + dev: true + + /queue-microtask/1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + dev: true + + /readdirp/3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + dependencies: + picomatch: 2.3.1 + dev: true + + /resolve-from/4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + dev: true + + /resolve/1.22.0: + resolution: {integrity: sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==} + hasBin: true + dependencies: + is-core-module: 2.9.0 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + dev: true + + /reusify/1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + dev: true + + /rimraf/2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + hasBin: true + dependencies: + glob: 7.2.3 + dev: true + + /rollup/2.75.4: + resolution: {integrity: sha512-JgZiJMJkKImMZJ8ZY1zU80Z2bA/TvrL/7D9qcBCrfl2bP+HUaIw0QHUroB4E3gBpFl6CRFM1YxGbuYGtdAswbQ==} + engines: {node: '>=10.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /run-parallel/1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + dependencies: + queue-microtask: 1.2.3 + dev: true + + /sade/1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + dependencies: + mri: 1.2.0 + dev: true + + /sander/0.5.1: + resolution: {integrity: sha1-dB4kXiMfB8r7b98PEzrfohalAq0=} + dependencies: + es6-promise: 3.3.1 + graceful-fs: 4.2.10 + mkdirp: 0.5.6 + rimraf: 2.7.1 + dev: true + + /sorcery/0.10.0: + resolution: {integrity: sha1-iukK19fLBfxZ8asMY3hF1cFaUrc=} + hasBin: true + dependencies: + buffer-crc32: 0.2.13 + minimist: 1.2.6 + sander: 0.5.1 + sourcemap-codec: 1.4.8 + dev: true + + /source-map-js/1.0.2: + resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + engines: {node: '>=0.10.0'} + dev: true + + /sourcemap-codec/1.4.8: + resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} + dev: true + + /strip-indent/3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + dependencies: + min-indent: 1.0.1 + dev: true + + /supports-preserve-symlinks-flag/1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + dev: true + + /svelte-check/2.7.1_svelte@3.48.0: + resolution: {integrity: sha512-vHVu2+SQ6ibt77iTQaq2oiOjBgGL48qqcg0ZdEOsP5pPOjgeyR9QbnaEdzdBs9nsVYBc/42haKtzb2uFqS8GVw==} + hasBin: true + peerDependencies: + svelte: ^3.24.0 + dependencies: + '@jridgewell/trace-mapping': 0.3.13 + chokidar: 3.5.3 + fast-glob: 3.2.11 + import-fresh: 3.3.0 + picocolors: 1.0.0 + sade: 1.8.1 + svelte: 3.48.0 + svelte-preprocess: 4.10.6_wwvk7nlptlrqo2czohjtk6eiqm + typescript: 4.6.4 + transitivePeerDependencies: + - '@babel/core' + - coffeescript + - less + - node-sass + - postcss + - postcss-load-config + - pug + - sass + - stylus + - sugarss + dev: true + + /svelte-hmr/0.14.12_svelte@3.48.0: + resolution: {integrity: sha512-4QSW/VvXuqVcFZ+RhxiR8/newmwOCTlbYIezvkeN6302YFRE8cXy0naamHcjz8Y9Ce3ITTZtrHrIL0AGfyo61w==} + engines: {node: ^12.20 || ^14.13.1 || >= 16} + peerDependencies: + svelte: '>=3.19.0' + dependencies: + svelte: 3.48.0 + dev: true + + /svelte-preprocess/4.10.6_wwvk7nlptlrqo2czohjtk6eiqm: + resolution: {integrity: sha512-I2SV1w/AveMvgIQlUF/ZOO3PYVnhxfcpNyGt8pxpUVhPfyfL/CZBkkw/KPfuFix5FJ9TnnNYMhACK3DtSaYVVQ==} + engines: {node: '>= 9.11.2'} + requiresBuild: true + peerDependencies: + '@babel/core': ^7.10.2 + coffeescript: ^2.5.1 + less: ^3.11.3 || ^4.0.0 + node-sass: '*' + postcss: ^7 || ^8 + postcss-load-config: ^2.1.0 || ^3.0.0 + pug: ^3.0.0 + sass: ^1.26.8 + stylus: ^0.55.0 + sugarss: ^2.0.0 + svelte: ^3.23.0 + typescript: ^3.9.5 || ^4.0.0 + peerDependenciesMeta: + '@babel/core': + optional: true + coffeescript: + optional: true + less: + optional: true + node-sass: + optional: true + postcss: + optional: true + postcss-load-config: + optional: true + pug: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + typescript: + optional: true + dependencies: + '@types/pug': 2.0.6 + '@types/sass': 1.43.1 + detect-indent: 6.1.0 + magic-string: 0.25.9 + sorcery: 0.10.0 + strip-indent: 3.0.0 + svelte: 3.48.0 + typescript: 4.6.4 + dev: true + + /svelte/3.48.0: + resolution: {integrity: sha512-fN2YRm/bGumvjUpu6yI3BpvZnpIm9I6A7HR4oUNYd7ggYyIwSA/BX7DJ+UXXffLp6XNcUijyLvttbPVCYa/3xQ==} + engines: {node: '>= 8'} + dev: true + + /tiny-glob/0.2.9: + resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} + dependencies: + globalyzer: 0.1.0 + globrex: 0.1.2 + dev: true + + /to-regex-range/5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + dependencies: + is-number: 7.0.0 + dev: true + + /tslib/2.4.0: + resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==} + dev: true + + /typescript/4.6.4: + resolution: {integrity: sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==} + engines: {node: '>=4.2.0'} + hasBin: true + dev: true + + /vite/2.9.9: + resolution: {integrity: sha512-ffaam+NgHfbEmfw/Vuh6BHKKlI/XIAhxE5QSS7gFLIngxg171mg1P3a4LSRME0z2ZU1ScxoKzphkipcYwSD5Ew==} + engines: {node: '>=12.2.0'} + hasBin: true + peerDependencies: + less: '*' + sass: '*' + stylus: '*' + peerDependenciesMeta: + less: + optional: true + sass: + optional: true + stylus: + optional: true + dependencies: + esbuild: 0.14.42 + postcss: 8.4.14 + resolve: 1.22.0 + rollup: 2.75.4 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /wrappy/1.0.2: + resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=} + dev: true diff --git a/apps/frontpage/src/app.d.ts b/apps/frontpage/src/app.d.ts new file mode 100644 index 0000000..121720c --- /dev/null +++ b/apps/frontpage/src/app.d.ts @@ -0,0 +1,10 @@ +/// <reference types="@sveltejs/kit" /> + +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +declare namespace App { + // interface Locals {} + // interface Platform {} + // interface Session {} + // interface Stuff {} +} diff --git a/apps/frontpage/src/app.html b/apps/frontpage/src/app.html new file mode 100644 index 0000000..3dff376 --- /dev/null +++ b/apps/frontpage/src/app.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <link rel="icon" href="%sveltekit.assets%/favicon.png" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + %sveltekit.head% + </head> + <body> + <main>%sveltekit.body%</main> + </body> +</html> diff --git a/apps/frontpage/src/routes/index.svelte b/apps/frontpage/src/routes/index.svelte new file mode 100644 index 0000000..8038f19 --- /dev/null +++ b/apps/frontpage/src/routes/index.svelte @@ -0,0 +1 @@ +<h1>Welcome to Greatoffice</h1> diff --git a/apps/frontpage/static/favicon.png b/apps/frontpage/static/favicon.png Binary files differnew file mode 100644 index 0000000..825b9e6 --- /dev/null +++ b/apps/frontpage/static/favicon.png diff --git a/apps/frontpage/svelte.config.js b/apps/frontpage/svelte.config.js new file mode 100644 index 0000000..20f856b --- /dev/null +++ b/apps/frontpage/svelte.config.js @@ -0,0 +1,20 @@ +import adapter from "@sveltejs/adapter-static"; +import preprocess from "svelte-preprocess"; + +/** @type {import("@sveltejs/kit").Config} */ +const config = { + // Consult https://github.com/sveltejs/svelte-preprocess + // for more information about preprocessors + preprocess: preprocess(), + + kit: { + adapter: adapter({ + fallback: "index.html", + prerender: { + default: false + } + }) + } +}; + +export default config; diff --git a/apps/frontpage/tests/test.ts b/apps/frontpage/tests/test.ts new file mode 100644 index 0000000..af64ad3 --- /dev/null +++ b/apps/frontpage/tests/test.ts @@ -0,0 +1,6 @@ +import { expect, test } from '@playwright/test'; + +test('index page has expected h1', async ({ page }) => { + await page.goto('/'); + expect(await page.textContent('h1')).toBe("Welcome to Greatoffice"); +}); diff --git a/apps/frontpage/tsconfig.json b/apps/frontpage/tsconfig.json new file mode 100644 index 0000000..0f47472 --- /dev/null +++ b/apps/frontpage/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true + } +} diff --git a/apps/projects-web/.version b/apps/projects-web/.version new file mode 100644 index 0000000..2eb62bb --- /dev/null +++ b/apps/projects-web/.version @@ -0,0 +1 @@ +v1-projects diff --git a/apps/projects-web/.version-dev b/apps/projects-web/.version-dev new file mode 100644 index 0000000..1c69081 --- /dev/null +++ b/apps/projects-web/.version-dev @@ -0,0 +1 @@ +v4-projects-dev diff --git a/apps/projects-web/CHANGELOG.md b/apps/projects-web/CHANGELOG.md new file mode 100644 index 0000000..0aa4f12 --- /dev/null +++ b/apps/projects-web/CHANGELOG.md @@ -0,0 +1,67 @@ +# Changelog + +## [unreleased] + +### Miscellaneous Tasks + +- Bump version +- Update CHANGELOG.md for v4-projects-dev + +### Refactor + +- Name path basers based on the app it bases + +## [unreleased] + +### Miscellaneous Tasks + +- Bump version +- Update CHANGELOG.md for v3-projects-dev + +## [unreleased] + +### Bug Fixes + +- . +- . +- Incorrect paths + +### Miscellaneous Tasks + +- Bump version +- Update CHANGELOG.md for v8-web-app-dev +- Bump version +- Update CHANGELOG.md for v7-web-app-dev +- Bump version +- Bump version +- Bump version +- Bump version +- Bump version +- Bump version +- Update CHANGELOG.md for v14-web-app-dev + +## [unreleased] + +### Miscellaneous Tasks + +- Bump version +- Update CHANGELOG.md for v13-web-app-dev + +## [unreleased] + +### Bug Fixes + +- Inncorrect paths + +### Miscellaneous Tasks + +- Bump version +- Update CHANGELOG.md for v20-web-app + +## [unreleased] + +### Miscellaneous Tasks + +- Bump version +- Update CHANGELOG.md for v19-web-app + diff --git a/apps/projects-web/build_and_push.sh b/apps/projects-web/build_and_push.sh new file mode 100755 index 0000000..abc8ea9 --- /dev/null +++ b/apps/projects-web/build_and_push.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash + +set -Eueo pipefail + +APP_NAME="projects" +CURRENT_DEV_VERSION=$(cat .version-dev) +CURRENT_DEV_VERSION_INT=${CURRENT_DEV_VERSION//[!0-9]/} +CURRENT_VERSION=$(cat .version) +CURRENT_VERSION_INT=${CURRENT_VERSION//[!0-9]/} +if [ ${1-prod} == "dev" ]; then + NEW_VERSION="v$((CURRENT_DEV_VERSION_INT+1))-$APP_NAME-dev" + OLD_VERSION=$CURRENT_DEV_VERSION +else + NEW_VERSION="v$((CURRENT_VERSION_INT+1))-$APP_NAME" + OLD_VERSION=$CURRENT_VERSION +fi +# Check for uncommited changes and optionally commit them +if [ "$(git status --untracked-files=no --porcelain)" ]; then + echo "Unclean git tree! press CTRL+C to exit or press ENTER to commit and push to the default branch" + read -n 1 + + read -p "Enter commit message: " COMMIT_MESSAGE + git add ../.. + git commit --quiet -m "$COMMIT_MESSAGE" +fi + +if [ ${1-prod} == "dev" ]; then + echo $NEW_VERSION >| .version-dev + git add .version-dev +else + echo $NEW_VERSION >| .version + git add .version +fi + +echo "Starting build of $APP_NAME@$NEW_VERSION at $(date -u)..." +echo + +git commit --quiet -m "chore(release): Bump version"; + +read -p "Do you want to tag this build? (y/n) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]] +then + read -p "Enter tag message (can be empty): " TAG_MESSAGE + commit_msg="chore(release): Update CHANGELOG.md for $NEW_VERSION" + git cliff -r ../../ $OLD_VERSION..HEAD --with-commit "$commit_msg" --prepend CHANGELOG.md + git add CHANGELOG.md + git commit --quiet -m "$commit_msg"; + git tag -am "$TAG_MESSAGE" $NEW_VERSION +fi + +read -p "Do you want to push the latest commits and tags to origin? (y/n) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]] +then + echo "Pushing latest changes to remotes..." + echo + git push --quiet --follow-tags +fi + +pushd src +pnpm run build + +cd build +echo "$NEW_VERSION" >version.txt + + +if [ ${1-prod} == "dev" ]; then + scp -r * contabo-fast-1:services/public/projects.dev.greatoffice.life/www +else + echo "Pushing to production in 10 sec, press CTRL+C to cancel" + sleep 10 + scp -r * contabo-fast-1:services/public/projects.greatoffice.life/www +fi + +popd diff --git a/apps/projects-web/cliff.toml b/apps/projects-web/cliff.toml new file mode 100644 index 0000000..955a72b --- /dev/null +++ b/apps/projects-web/cliff.toml @@ -0,0 +1,62 @@ +# configuration file for git-cliff (0.1.0) + +[changelog] +# changelog header +header = """ +# Changelog\n +""" +# template for the changelog body +# https://tera.netlify.app/docs/#introduction +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | upper_first }} + {% for commit in commits %} + - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\ + {% endfor %} +{% endfor %}\n +""" +# remove the leading and trailing whitespace from the template +trim = true +# changelog footer +footer = """ +<!-- generated by git-cliff --> +""" + +[git] +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# regex for preprocessing the commit messages +commit_preprocessors = [ + { pattern = "([ \\n])(([a-f0-9]{7})[a-f0-9]*)", replace = "${1}commit # [${3}](https://git.ivarlovlie.no/time-tracker/commit/${2})" }, + { pattern = "https://git.ivarlovlie.no/time-tracker/commit/([a-f0-9]{7})[a-f0-9]*", replace = "commit # [${1}](${0})" }, +] +# regex for parsing and grouping commits +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^doc", group = "Documentation" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactor" }, + { message = "^style", group = "Styling" }, + { message = "^test", group = "Testing" }, + { message = "^chore", group = "Miscellaneous Tasks" }, +] +# filter out the commits that are not matched by commit parsers +filter_commits = true +# glob pattern for matching git tags +tag_pattern = "v.*" +# regex for skipping tags +skip_tags = "v0.1.0-beta.1" +# regex for ignoring tags +ignore_tags = "" +# sort the tags chronologically +date_order = true +# sort the commits inside sections by oldest/newest order +sort_commits = "newest" diff --git a/apps/projects-web/src/_assets/pre.css b/apps/projects-web/src/_assets/pre.css new file mode 100644 index 0000000..9c9446e --- /dev/null +++ b/apps/projects-web/src/_assets/pre.css @@ -0,0 +1,128 @@ +:root { + --loader-primary: hsl(250, 84%, 54%); + --loader-accent: hsl(342, 89%, 48%); + --loader-contrast: hsl(180, 1%, 84%); + --loader-easing: cubic-bezier(0.645, 0.045, 0.355, 1); +} + +[data-theme="dark"] :root { + --loader-primary: hsl(250, 93%, 65%); + --loader-accent: hsl(342, 92%, 54%); + --loader-contrast: hsl(208, 12%, 24%); + --loader-easing: cubic-bezier(0.645, 0.045, 0.355, 1); +} + +[data-theme="dark"] { + background-color: hsl(203, 24%, 13%); +} + +.fill-loader { + position: relative; + overflow: hidden; + display: inline-block; + margin: 3rem; +} + +.fill-loader__fill { + position: absolute; +} + +@supports (-webkit-animation-name: this) or (animation-name: this) { + .fill-loader__label { + position: absolute; + clip: rect(1px, 1px, 1px, 1px); + -webkit-clip-path: inset(50%); + clip-path: inset(50%); + } +} + +@supports (-webkit-animation-name: this) or (animation-name: this) { + .fill-loader--v4 { + width: 90%; + max-width: 300px; + } + + .fill-loader--v4 .fill-loader__base { + height: 4px; + background-color: var(--loader-contrast); + } + + .fill-loader--v4 .fill-loader__fill { + top: 0; + left: 0; + right: 0; + height: 100%; + background-color: var(--loader-primary); + -webkit-animation: fill-loader-4 1.6s infinite var(--loader-easing); + animation: fill-loader-4 1.6s infinite var(--loader-easing); + will-change: left, right; + } +} + +@-webkit-keyframes fill-loader-4 { + 0% { + left: 0; + right: 100%; + background-color: var(--loader-primary); + } + + 10%, + 60% { + left: 0; + } + + 40%, + 90% { + right: 0; + } + + 50% { + left: 100%; + background-color: var(--loader-primary); + } + + 51% { + left: 0; + right: 100%; + background-color: var(--loader-accent); + } + + 100% { + left: 100%; + background-color: var(--loader-accent); + } +} + +@keyframes fill-loader-4 { + 0% { + left: 0; + right: 100%; + background-color: var(--loader-primary); + } + + 10%, + 60% { + left: 0; + } + + 40%, + 90% { + right: 0; + } + + 50% { + left: 100%; + background-color: var(--loader-primary); + } + + 51% { + left: 0; + right: 100%; + background-color: var(--loader-accent); + } + + 100% { + left: 100%; + background-color: var(--loader-accent); + } +} diff --git a/apps/projects-web/src/_assets/pwa/android-chrome-192x192.png b/apps/projects-web/src/_assets/pwa/android-chrome-192x192.png Binary files differnew file mode 100644 index 0000000..5c098bc --- /dev/null +++ b/apps/projects-web/src/_assets/pwa/android-chrome-192x192.png diff --git a/apps/projects-web/src/_assets/pwa/android-chrome-512x512.png b/apps/projects-web/src/_assets/pwa/android-chrome-512x512.png Binary files differnew file mode 100644 index 0000000..973a1c3 --- /dev/null +++ b/apps/projects-web/src/_assets/pwa/android-chrome-512x512.png diff --git a/apps/projects-web/src/_assets/pwa/apple-touch-icon.png b/apps/projects-web/src/_assets/pwa/apple-touch-icon.png Binary files differnew file mode 100644 index 0000000..b4d9773 --- /dev/null +++ b/apps/projects-web/src/_assets/pwa/apple-touch-icon.png diff --git a/apps/projects-web/src/_assets/pwa/browserconfig.xml b/apps/projects-web/src/_assets/pwa/browserconfig.xml new file mode 100644 index 0000000..b3930d0 --- /dev/null +++ b/apps/projects-web/src/_assets/pwa/browserconfig.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<browserconfig> + <msapplication> + <tile> + <square150x150logo src="/mstile-150x150.png"/> + <TileColor>#da532c</TileColor> + </tile> + </msapplication> +</browserconfig> diff --git a/apps/projects-web/src/_assets/pwa/favicon-16x16.png b/apps/projects-web/src/_assets/pwa/favicon-16x16.png Binary files differnew file mode 100644 index 0000000..5dde9f9 --- /dev/null +++ b/apps/projects-web/src/_assets/pwa/favicon-16x16.png diff --git a/apps/projects-web/src/_assets/pwa/favicon-32x32.png b/apps/projects-web/src/_assets/pwa/favicon-32x32.png Binary files differnew file mode 100644 index 0000000..9cef4c4 --- /dev/null +++ b/apps/projects-web/src/_assets/pwa/favicon-32x32.png diff --git a/apps/projects-web/src/_assets/pwa/favicon.ico b/apps/projects-web/src/_assets/pwa/favicon.ico Binary files differnew file mode 100644 index 0000000..89c7542 --- /dev/null +++ b/apps/projects-web/src/_assets/pwa/favicon.ico diff --git a/apps/projects-web/src/_assets/pwa/favicon.svg b/apps/projects-web/src/_assets/pwa/favicon.svg new file mode 100644 index 0000000..964dbb8 --- /dev/null +++ b/apps/projects-web/src/_assets/pwa/favicon.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-stopwatch" viewBox="0 0 16 16"> + <path d="M8.5 5.6a.5.5 0 1 0-1 0v2.9h-3a.5.5 0 0 0 0 1H8a.5.5 0 0 0 .5-.5V5.6z"/> + <path d="M6.5 1A.5.5 0 0 1 7 .5h2a.5.5 0 0 1 0 1v.57c1.36.196 2.594.78 3.584 1.64a.715.715 0 0 1 .012-.013l.354-.354-.354-.353a.5.5 0 0 1 .707-.708l1.414 1.415a.5.5 0 1 1-.707.707l-.353-.354-.354.354a.512.512 0 0 1-.013.012A7 7 0 1 1 7 2.071V1.5a.5.5 0 0 1-.5-.5zM8 3a6 6 0 1 0 .001 12A6 6 0 0 0 8 3z"/> +</svg>
\ No newline at end of file diff --git a/apps/projects-web/src/_assets/pwa/manifest.json b/apps/projects-web/src/_assets/pwa/manifest.json new file mode 100644 index 0000000..4c550fe --- /dev/null +++ b/apps/projects-web/src/_assets/pwa/manifest.json @@ -0,0 +1,28 @@ +{ + "manifest_version": 2, + "version": "0.1", + "name": "Time Tracker", + "short_name": "Time Tracker", + "display": "standalone", + "background_color": "#fff", + "theme_color": "#4D3DF7", + "start_url": ".", + "orientation": "portrait", + "icons": [ + { + "src": "/favicon.svg", + "purpose": "maskable any", + "sizes": "any" + }, + { + "src": "/pwa/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/pwa/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/apps/projects-web/src/_assets/pwa/mstile-144x144.png b/apps/projects-web/src/_assets/pwa/mstile-144x144.png Binary files differnew file mode 100644 index 0000000..84d94cb --- /dev/null +++ b/apps/projects-web/src/_assets/pwa/mstile-144x144.png diff --git a/apps/projects-web/src/_assets/pwa/mstile-150x150.png b/apps/projects-web/src/_assets/pwa/mstile-150x150.png Binary files differnew file mode 100644 index 0000000..b1398ae --- /dev/null +++ b/apps/projects-web/src/_assets/pwa/mstile-150x150.png diff --git a/apps/projects-web/src/_assets/pwa/mstile-310x150.png b/apps/projects-web/src/_assets/pwa/mstile-310x150.png Binary files differnew file mode 100644 index 0000000..76b16a0 --- /dev/null +++ b/apps/projects-web/src/_assets/pwa/mstile-310x150.png diff --git a/apps/projects-web/src/_assets/pwa/mstile-310x310.png b/apps/projects-web/src/_assets/pwa/mstile-310x310.png Binary files differnew file mode 100644 index 0000000..d8e4097 --- /dev/null +++ b/apps/projects-web/src/_assets/pwa/mstile-310x310.png diff --git a/apps/projects-web/src/_assets/pwa/mstile-70x70.png b/apps/projects-web/src/_assets/pwa/mstile-70x70.png Binary files differnew file mode 100644 index 0000000..0df1e8c --- /dev/null +++ b/apps/projects-web/src/_assets/pwa/mstile-70x70.png diff --git a/apps/projects-web/src/_assets/pwa/safari-pinned-tab.svg b/apps/projects-web/src/_assets/pwa/safari-pinned-tab.svg new file mode 100644 index 0000000..ba2220c --- /dev/null +++ b/apps/projects-web/src/_assets/pwa/safari-pinned-tab.svg @@ -0,0 +1,50 @@ +<?xml version="1.0" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" + "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"> +<svg version="1.0" xmlns="http://www.w3.org/2000/svg" + width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000" + preserveAspectRatio="xMidYMid meet"> +<metadata> +Created by potrace 1.14, written by Peter Selinger 2001-2017 +</metadata> +<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)" +fill="#000000" stroke="none"> +<path d="M3195 6780 c-116 -3 -211 -10 -226 -17 -39 -17 -105 -98 -116 -142 +-19 -72 -2 -146 45 -202 26 -31 96 -69 131 -72 25 -2 31 -6 32 -27 1 -27 1 +-198 0 -216 -1 -6 -47 -19 -103 -28 -160 -28 -451 -107 -533 -146 -11 -5 -51 +-21 -90 -36 -60 -23 -246 -112 -325 -155 -431 -236 -834 -619 -1101 -1045 +-207 -328 -364 -733 -423 -1089 -51 -307 -61 -583 -31 -875 26 -261 119 -615 +225 -861 185 -430 432 -773 800 -1108 75 -69 387 -301 405 -301 1 0 33 -18 70 +-40 209 -128 602 -288 796 -325 12 -2 29 -7 39 -10 72 -23 273 -56 435 -73 +144 -14 601 -5 658 13 7 2 37 7 67 10 273 33 616 141 904 283 725 357 1275 +982 1542 1754 55 159 113 395 129 523 4 28 8 57 10 65 2 8 7 47 10 85 3 39 8 +93 10 120 6 66 6 327 0 390 -2 28 -7 82 -10 120 -3 39 -11 99 -16 135 -6 36 +-13 79 -16 95 -15 98 -61 279 -103 405 -121 372 -298 694 -542 993 -27 32 -48 +61 -48 65 0 4 35 41 78 84 l77 76 90 -90 c53 -54 108 -99 134 -110 62 -28 130 +-25 191 8 95 52 135 151 103 257 -13 46 -44 79 -362 397 -322 323 -351 349 +-398 363 -148 44 -287 -61 -285 -215 1 -62 35 -118 126 -208 47 -47 86 -87 86 +-90 0 -6 -91 -101 -132 -138 l-25 -23 -46 38 c-264 223 -584 405 -924 528 -92 +34 -320 100 -376 109 -15 3 -35 7 -45 10 -9 3 -34 7 -54 10 -86 13 -113 18 +-117 22 -2 2 -4 56 -4 121 l0 118 29 9 c66 19 114 47 139 80 72 95 65 215 -18 +296 -58 56 -83 60 -402 63 -159 1 -380 0 -490 -3z m560 -1104 c224 -24 547 +-99 670 -156 11 -5 56 -24 100 -41 90 -37 282 -134 306 -155 8 -8 19 -14 23 +-14 13 0 192 -124 286 -199 97 -77 297 -270 364 -351 237 -288 405 -598 509 +-941 30 -98 44 -157 72 -299 3 -14 8 -47 11 -75 3 -27 7 -56 10 -63 22 -69 21 +-519 -1 -642 -1 -8 -6 -40 -10 -70 -4 -30 -9 -64 -11 -75 -2 -11 -7 -33 -10 +-50 -3 -16 -14 -66 -26 -110 -11 -44 -21 -84 -22 -90 -18 -79 -93 -275 -154 +-408 -39 -83 -158 -296 -171 -307 -3 -3 -26 -34 -50 -70 -116 -169 -312 -384 +-466 -508 -38 -32 -78 -65 -89 -74 -25 -22 -229 -160 -281 -189 -177 -99 -405 +-197 -570 -244 -126 -36 -305 -74 -375 -81 -19 -2 -48 -5 -65 -8 -121 -22 +-509 -22 -618 0 -12 2 -42 6 -67 10 -369 45 -795 215 -1125 448 -192 135 -399 +326 -517 476 -23 30 -48 61 -55 67 -57 60 -227 336 -291 473 -64 135 -150 365 +-167 444 -2 12 -6 30 -9 41 -28 120 -36 156 -41 193 -3 24 -7 53 -10 65 -32 +148 -38 552 -10 707 2 14 7 45 10 70 33 274 160 643 313 910 60 106 201 312 +232 340 3 3 23 28 45 55 22 28 85 96 140 151 347 352 768 590 1252 709 56 14 +118 27 137 30 20 2 61 9 93 14 32 6 92 13 133 17 41 3 76 7 77 8 6 5 368 -2 +428 -8z"/> +<path d="M3423 4754 c-45 -17 -95 -61 -121 -109 -15 -27 -17 -93 -19 -650 -1 +-341 -2 -641 -3 -666 l0 -47 -679 0 -679 -1 -49 -24 c-59 -30 -76 -49 -104 +-112 -54 -122 23 -270 154 -293 23 -5 400 -8 837 -7 l795 1 42 22 c52 27 98 +82 112 136 8 29 10 273 9 826 -3 854 1 796 -59 867 -53 63 -153 87 -236 57z"/> +</g> +</svg> diff --git a/apps/projects-web/src/app/index.d.ts b/apps/projects-web/src/app/index.d.ts new file mode 100644 index 0000000..c044583 --- /dev/null +++ b/apps/projects-web/src/app/index.d.ts @@ -0,0 +1,48 @@ +/* Use this file to declare any custom file extensions for importing */ +/* Use this folder to also add/extend a package d.ts file, if needed. */ + +/* CSS MODULES */ +declare module "*.module.css" { + const classes: { [key: string]: string }; + export default classes; +} +declare module "*.module.scss" { + const classes: { [key: string]: string }; + export default classes; +} + +/* CSS */ +declare module "*.css"; +declare module "*.scss"; + +/* IMAGES */ +declare module "*.svg" { + const ref: string; + export default ref; +} +declare module "*.bmp" { + const ref: string; + export default ref; +} +declare module "*.gif" { + const ref: string; + export default ref; +} +declare module "*.jpg" { + const ref: string; + export default ref; +} +declare module "*.jpeg" { + const ref: string; + export default ref; +} +declare module "*.png" { + const ref: string; + export default ref; +} + +/* CUSTOM: ADD YOUR OWN HERE */ +declare module "*.svelte" { + const value: any; + export default value; +} diff --git a/apps/projects-web/src/app/index.html b/apps/projects-web/src/app/index.html new file mode 100644 index 0000000..7e0b0e1 --- /dev/null +++ b/apps/projects-web/src/app/index.html @@ -0,0 +1,63 @@ +<!doctype html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" + content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> + <link rel="apple-touch-icon" + sizes="180x180" + href="../_assets/pwa/apple-touch-icon.png"> + <link rel="icon" + type="image/png" + sizes="32x32" + href="../_assets/pwa/favicon-32x32.png"> + <link rel="icon" + type="image/png" + sizes="16x16" + href="../_assets/pwa/favicon-16x16.png"> + <link rel="manifest" + href="../_assets/pwa/manifest.json"> + <link rel="mask-icon" + href="../_assets/pwa/safari-pinned-tab.svg" + color="#5bbad5"> + <meta name="msapplication-TileColor" + content="#da532c"> + <link rel="icon" + href="../_assets/pwa/favicon.svg"> + <script> + const currentTheme = localStorage.getItem("theme"); + if (currentTheme === "light") { + document.querySelector("html").dataset.theme = "light"; + } else { + document.querySelector("html").dataset.theme = "dark"; + } + </script> + <link rel="stylesheet" + href="../_assets/pre.css"> + <title>Time Tracker</title> +</head> + +<body> + +<noscript> + This page is built with javascript. Allow it and try again. +</noscript> + +<div class="fill-loader fill-loader--v4" + id="loader" + role="alert"> + <p class="fill-loader__label">Loading Time Tracker...</p> + <div aria-hidden="true"> + <div class="fill-loader__base"></div> + <div class="fill-loader__fill"></div> + </div> +</div> + +<div id="root"></div> + +<script type="module" + src="./index.ts"></script> +</body> + +</html> diff --git a/apps/projects-web/src/app/index.scss b/apps/projects-web/src/app/index.scss new file mode 100644 index 0000000..4794787 --- /dev/null +++ b/apps/projects-web/src/app/index.scss @@ -0,0 +1,38 @@ +@use '../../web-shared/src/styles/base'as * with ($breakpoints: ('xs': "768px", + 'sm': "768px", + 'md': "1200px", + 'lg': "1200px", + 'xl': "1600px", + ), + $grid-columns: 12); + +@use '../../web-shared/src/styles/custom-style/colors'; +@use '../../web-shared/src/styles/custom-style/spacing'; +@use '../../web-shared/src/styles/custom-style/shared-styles'; +@use '../../web-shared/src/styles/custom-style/typography'; +@use '../../web-shared/src/styles/custom-style/icons'; +@use '../../web-shared/src/styles/custom-style/buttons'; +@use '../../web-shared/src/styles/custom-style/forms'; +@use '../../web-shared/src/styles/custom-style/util'; + +@use '../../web-shared/src/styles/components/radios-checkboxes'; +@use '../../web-shared/src/styles/components/circle-loader'; +@use '../../web-shared/src/styles/components/list'; +@use '../../web-shared/src/styles/components/form-validator'; +@use '../../web-shared/src/styles/components/btn-states'; +@use '../../web-shared/src/styles/components/alert'; +@use '../../web-shared/src/styles/components/details'; +@use '../../web-shared/src/styles/components/tabbed-navigation'; +@use '../../web-shared/src/styles/components/dropdown'; +@use '../../web-shared/src/styles/components/modal'; +@use '../../web-shared/src/styles/components/chip'; +@use '../../web-shared/src/styles/components/autocomplete'; +@use '../../web-shared/src/styles/components/select-autocomplete'; +@use '../../web-shared/src/styles/components/interactive-table'; +@use '../../web-shared/src/styles/components/pagination'; +@use '../../web-shared/src/styles/components/custom-select'; +@use '../../web-shared/src/styles/components/pre-header'; +@use '../../web-shared/src/styles/components/table'; +@use '../../web-shared/src/styles/components/custom-checkbox'; +@use '../../web-shared/src/styles/components/menu'; +@use '../../web-shared/src/styles/components/user-menu'; diff --git a/apps/projects-web/src/app/index.svelte b/apps/projects-web/src/app/index.svelte new file mode 100644 index 0000000..9dd2bf8 --- /dev/null +++ b/apps/projects-web/src/app/index.svelte @@ -0,0 +1,56 @@ +<svelte:options immutable={true}/> +<svelte:window bind:online={online}/> + +<script> + import {logout_user} from "$app/lib/services/user-service"; + import Router from "svelte-spa-router"; + import {wrap} from "svelte-spa-router/wrap"; + import {is_active} from "$shared/lib/session"; + import UiWorkbench from "$app/pages/ui-workbench.svelte"; + import NotFound from "$app/pages/not-found.svelte"; + import Home from "$app/pages/home.svelte"; + import Settings from "$app/pages/settings.svelte"; + import Data from "$app/pages/data.svelte"; + import PreHeader from "$shared/components/pre-header.svelte"; + + let online = true; + + async function user_is_logged_in() { + if (!await is_active()) { + await logout_user("expired"); + } + return true; + } + + const routes = { + "/home": wrap({ + component: Home, + conditions: [user_is_logged_in], + }), + "/": wrap({ + component: Home, + conditions: [user_is_logged_in], + }), + "/settings": wrap({ + component: Settings, + conditions: [user_is_logged_in], + }), + "/data": wrap({ + component: Data, + conditions: [user_is_logged_in], + }), + "/ui-workbench": UiWorkbench, + "*": NotFound, + }; +</script> +<PreHeader show="{!online}">You seem to be offline, please check your internet connection.</PreHeader> +<Router + {routes} + restoreScrollState={true} + on:routeLoading={() => { + document.getElementById("loader").style.display = "inline-block"; + }} + on:routeLoaded={() => { + document.getElementById("loader").style.display = "none"; + }} +/> diff --git a/apps/projects-web/src/app/index.ts b/apps/projects-web/src/app/index.ts new file mode 100644 index 0000000..febb583 --- /dev/null +++ b/apps/projects-web/src/app/index.ts @@ -0,0 +1,16 @@ +// @ts-ignore +import App from "./index.svelte"; +import "./index.scss"; +import {is_debug, is_development} from "$shared/lib/configuration"; +import {noop} from "$shared/lib/helpers"; + +if (is_development() || is_debug()) { + console.log("%c Debug", "background-color:yellow;color:black;font-size:18px;"); +} else { + console.log("%c Production; Suppressing logs", "background-color:yellow;color:black;font-size:18px;"); + console.log = noop; +} + +export default new App({ + target: document.getElementById("root"), +}); diff --git a/apps/projects-web/src/app/lib/services/user-service.ts b/apps/projects-web/src/app/lib/services/user-service.ts new file mode 100644 index 0000000..7bffa49 --- /dev/null +++ b/apps/projects-web/src/app/lib/services/user-service.ts @@ -0,0 +1,21 @@ +import {delete_account, logout} from "$shared/lib/api/user"; +import {accounts_base} from "$shared/lib/configuration"; +import {clear_session_data} from "$shared/lib/session"; +import {clear_categories} from "$app/lib/stores/categories"; +import {clear_entries} from "$app/lib/stores/entries"; +import {clear_labels} from "$app/lib/stores/labels"; + +export async function logout_user(reason: string = "") { + await logout(); + clear_session_data(); + clear_categories(); + clear_labels(); + clear_entries(); + location.replace(accounts_base("#/login" + (reason ? "?" + reason : ""))); +} + +export async function delete_user() { + await delete_account(); + clear_session_data(); + location.replace(accounts_base("#/login?deleted")); +} diff --git a/apps/projects-web/src/app/lib/stores/categories.ts b/apps/projects-web/src/app/lib/stores/categories.ts new file mode 100644 index 0000000..2a63c42 --- /dev/null +++ b/apps/projects-web/src/app/lib/stores/categories.ts @@ -0,0 +1,44 @@ +import {writable, get} from "svelte/store"; +import {create_time_category, delete_time_category, get_time_categories} from "$shared/lib/api/time-entry"; +import type {TimeCategoryDto} from "$shared/lib/models/TimeCategoryDto"; +import type {IInternalFetchResponse} from "$shared/lib/models/IInternalFetchResponse"; + +const categories = writable<Array<TimeCategoryDto>>([]); + +export async function reload_categories() { + const get_categories_response = await get_time_categories(); + if (!get_categories_response.ok) { + clear_categories(); + return; + } + categories.set(get_categories_response.data ?? []); +} + +export function clear_categories() { + categories.set([]); +} + +export async function create_category_async(request: TimeCategoryDto): Promise<IInternalFetchResponse> { + const create_entry_response = await create_time_category(request); + if (create_entry_response.ok) { + const stored_entries = get(categories); + stored_entries.push(create_entry_response.data); + categories.set(stored_entries); + } + return create_entry_response; +} + +export async function edit_category_async(entry: TimeCategoryDto) { + if (!entry.id) return; +} + +export async function delete_category_async(entry: TimeCategoryDto) { + if (!entry.id) return; + const http_request = await delete_time_category(entry.id); + if (http_request.ok) { + const stored_entries = get(categories); + categories.set(stored_entries.filter(e => e.id !== entry.id)); + } +} + +export default categories; diff --git a/apps/projects-web/src/app/lib/stores/entries.ts b/apps/projects-web/src/app/lib/stores/entries.ts new file mode 100644 index 0000000..e933568 --- /dev/null +++ b/apps/projects-web/src/app/lib/stores/entries.ts @@ -0,0 +1,74 @@ +import {Temporal} from "@js-temporal/polyfill"; +import {writable, get} from "svelte/store"; +import {get_time_entries, create_time_entry, delete_time_entry, update_time_entry} from "$shared/lib/api/time-entry"; +import type {TimeEntryDto} from "$shared/lib/models/TimeEntryDto"; +import type {IInternalFetchResponse} from "$shared/lib/models/IInternalFetchResponse"; +import type {TimeEntryQuery} from "$shared/lib/models/TimeEntryQuery"; + +const entries = writable<Array<TimeEntryDto>>([]); + +export function get_time_entry(id: string): TimeEntryDto { + return get(entries).find(c => c.id === id); +} + +export async function reload_entries(query: TimeEntryQuery): Promise<void> { + const get_entries_response = await get_time_entries(query); + if (!get_entries_response.ok) { + clear_entries(); + return; + } + entries.set(get_default_sorted(get_entries_response.data?.results ?? [])); +} + +export function clear_entries() { + entries.set([]); +} + +function get_default_sorted(unsorted: Array<TimeEntryDto>): Array<TimeEntryDto> { + if (unsorted.length < 1) return unsorted; + const byStart = unsorted.sort((a, b) => { + return Temporal.Instant.compare(Temporal.Instant.from(b.start), Temporal.Instant.from(a.start)); + }); + + return byStart.sort((a, b) => { + return Temporal.Instant.compare(Temporal.Instant.from(b.stop), Temporal.Instant.from(a.stop)); + }); +} + +export async function create_entry_async(request: TimeEntryDto): Promise<IInternalFetchResponse> { + const create_entry_response = await create_time_entry(request); + if (create_entry_response.ok) { + const stored_entries = get(entries) ?? []; + stored_entries.push(create_entry_response.data); + entries.set(get_default_sorted(stored_entries)); + } + return create_entry_response; +} + +export async function edit_entry_async(request: TimeEntryDto): Promise<IInternalFetchResponse> { + if (!request.id) return; + const edit_entry_response = await update_time_entry(request); + if (edit_entry_response.ok) { + const stored_entries = get(entries) ?? []; + const index = stored_entries.findIndex(c => c.id === request.id); + if (index === -1) { + stored_entries.push(edit_entry_response.data); + } else { + stored_entries[index] = edit_entry_response.data; + } + entries.set(get_default_sorted(stored_entries)); + } + return edit_entry_response; +} + +export async function delete_entry_async(entry_id: string): Promise<void> { + if (!entry_id) throw new Error("No id was supplied when deleting query"); + const delete_entry_response = await delete_time_entry(entry_id); + if (delete_entry_response.ok) { + const stored_entries = get(entries) ?? []; + entries.set(get_default_sorted(stored_entries.filter((e) => e.id !== entry_id) ?? [])); + } +} + + +export default entries; diff --git a/apps/projects-web/src/app/lib/stores/labels.ts b/apps/projects-web/src/app/lib/stores/labels.ts new file mode 100644 index 0000000..d5ffaa9 --- /dev/null +++ b/apps/projects-web/src/app/lib/stores/labels.ts @@ -0,0 +1,44 @@ +import {writable, get} from "svelte/store"; +import {create_time_label, delete_time_label, get_time_labels} from "$shared/lib/api/time-entry"; +import type {IInternalFetchResponse} from "$shared/lib/models/IInternalFetchResponse"; +import type {TimeLabelDto} from "$shared/lib/models/TimeLabelDto"; + +const labels = writable<Array<TimeLabelDto>>([]); + +export async function reload_labels() { + const get_labels_response = await get_time_labels(); + if (!get_labels_response.ok) { + clear_labels(); + return; + } + labels.set(get_labels_response.data ?? []); +} + +export function clear_labels() { + labels.set([]); +} + +export async function create_label_async(request: TimeLabelDto): Promise<IInternalFetchResponse> { + const create_label_response = await create_time_label(request); + if (create_label_response.ok) { + const stored_entries = get(labels) ?? []; + stored_entries.push(create_label_response.data); + labels.set(stored_entries); + } + return create_label_response; +} + +export async function edit_label_async(entry: TimeLabelDto) { + if (!entry.id) throw new Error("Label id is required"); +} + +export async function delete_label_async(entry: TimeLabelDto) { + if (!entry.id) return; + const http_request = await delete_time_label(entry.id); + if (http_request.ok) { + const stored_entries = get(labels) ?? []; + labels.set(stored_entries.filter(e => e.id !== entry.id)); + } +} + +export default labels; diff --git a/apps/projects-web/src/app/pages/_layout.svelte b/apps/projects-web/src/app/pages/_layout.svelte new file mode 100644 index 0000000..24a9370 --- /dev/null +++ b/apps/projects-web/src/app/pages/_layout.svelte @@ -0,0 +1,79 @@ +<script> + import {onMount} from "svelte"; + import {location, link} from "svelte-spa-router"; + import {logout_user} from "$app/lib/services/user-service"; + import {random_string, switch_theme} from "$shared/lib/helpers"; + import {get_session_data} from "$shared/lib/session"; + import ProfileModal from "$app/pages/views/profile-modal.svelte"; + import {Menu, MenuItem, MenuItemSeparator} from "$shared/components/menu"; + import Button from "$shared/components/button.svelte"; + import {IconNames} from "$shared/lib/configuration"; + + let ProfileModalFunctions = {}; + let showUserMenu = false; + let userMenuTriggerNode; + const userMenuId = "__menu_" + random_string(3); + const username = get_session_data().profile.username; + + onMount(() => { + userMenuTriggerNode = document.getElementById("open-user-menu"); + }); +</script> + +<ProfileModal bind:functions={ProfileModalFunctions}/> + +<nav class="container max-width-xl@md width-fit-content@md width-100% max-width-none margin-y-xs@md margin-bottom-xs block@md position-relative@md position-absolute bottom-unset@md bottom-0"> + <div class="tabs-nav-v2 justify-between"> + <div class="tab-v2"> + <div class="tab-v2"> + <a href="/home" + use:link + class="tabs-nav-v2__item {$location === '/home' ? 'tabs-nav-v2__item--selected' : ''}">Home</a> + </div> + <div class="tab-v2"> + <a href="/data" + use:link + class="tabs-nav-v2__item {$location === '/data' ? 'tabs-nav-v2__item--selected' : ''}">Data</a> + </div> + <div class="tab-v2"> + <a href="/settings" + use:link + class="tabs-nav-v2__item {$location === '/settings' ? 'tabs-nav-v2__item--selected' : ''}">Settings</a> + </div> + </div> + <div class="tab-v2 padding-x-sm"> + <Button class="user-menu-control" + variant="reset" + id="open-user-menu" + on:click={() => showUserMenu = !showUserMenu} + text={username} + icon={IconNames.chevronDown} + icon_width="2rem" + icon_height="2rem" + icon_right_aligned="true" + title="Toggle user menu" + aria-controls="{userMenuId}" + /> + <Menu bind:show="{showUserMenu}" + trigger={userMenuTriggerNode} + id="{userMenuId}"> + <div slot="options"> + <MenuItem on:click={() => ProfileModalFunctions.open()}> + <span title="Administrate your profile">Profile</span> + </MenuItem> + <MenuItem on:click={() => switch_theme()}> + <span title="Change between a dark and light theme">Switch theme</span> + </MenuItem> + <MenuItemSeparator/> + <MenuItem danger="true" on:click={() => logout_user()}> + <span title="Log out of your profile">Log out</span> + </MenuItem> + </div> + </Menu> + </div> + </div> +</nav> + +<main class="container max-width-xl"> + <slot/> +</main> diff --git a/apps/projects-web/src/app/pages/data.svelte b/apps/projects-web/src/app/pages/data.svelte new file mode 100644 index 0000000..070b98b --- /dev/null +++ b/apps/projects-web/src/app/pages/data.svelte @@ -0,0 +1,392 @@ +<script> + import {IconNames} from "$shared/lib/configuration"; + import {onMount} from "svelte"; + import {Temporal} from "@js-temporal/polyfill"; + import Layout from "./_layout.svelte"; + import Modal from "$shared/components/modal.svelte"; + import Tile from "$shared/components/tile.svelte"; + import Icon from "$shared/components/icon.svelte"; + import EntryForm from "$app/pages/views/entry-form/index.svelte"; + import {Table, THead, TBody, TCell, TRow, TablePaginator} from "$shared/components/table"; + import {TimeEntryQueryDuration} from "$shared/lib/models/TimeEntryQuery"; + import {delete_time_entry, get_time_entries, get_time_entry} from "$shared/lib/api/time-entry"; + import {seconds_to_hour_minute_string, is_guid, move_focus, unwrap_date_time_from_entry} from "$shared/lib/helpers"; + import Button from "$shared/components/button.svelte"; + + let pageCount = 1; + let page = 1; + + const defaultQuery = { + duration: TimeEntryQueryDuration.THIS_YEAR, + categories: [], + labels: [], + page: page, + pageSize: 50, + }; + + let isLoading; + let categories = []; + let labels = []; + let entries = []; + let durationSummary = false; + let EditEntryModal; + let EditEntryForm; + let currentTimespanFilter = TimeEntryQueryDuration.THIS_YEAR; + let currentSpecificDateFilter = Temporal.Now.plainDateTimeISO().subtract({days: 1}).toString().substring(0, 10); + let currentDateRangeFilter = {}; + let currentCategoryFilter = "all"; + let currentLabelFilter = "all"; + let showDateFilterOptions = false; + let secondsLogged = 0; + + function set_duration_summary_string() { + if (entries.length > 0) { + durationSummary = `Showing ${entries.length} ${entries.length === 1 ? "entry" : "entries"}, totalling in ${seconds_to_hour_minute_string(secondsLogged)}`; + } else { + durationSummary = ""; + } + } + + async function load_entries(query = defaultQuery) { + isLoading = true; + const response = await get_time_entries(query); + if (response.status === 200) { + const responseEntries = []; + secondsLogged = 0; + for (const entry of response.data.results) { + const date_time = unwrap_date_time_from_entry(entry); + const seconds = (date_time.duration.hours * 60 * 60) + (date_time.duration.minutes * 60); + responseEntries.push({ + id: entry.id, + date: date_time.start_date, + start: date_time.start_time, + stop: date_time.stop_time, + durationString: date_time.duration.hours + "h" + date_time.duration.minutes + "m", + seconds: seconds, + category: entry.category, + labels: entry.labels, + description: entry.description, + }); + secondsLogged += seconds; + } + entries = responseEntries; + page = response.data.page; + pageCount = response.data.totalPageCount; + } else { + entries = []; + page = 0; + pageCount = 0; + } + isLoading = false; + set_duration_summary_string(); + } + + function load_entries_with_filter(page = 1) { + let query = defaultQuery; + query.duration = currentTimespanFilter; + query.labels = []; + query.categories = []; + query.page = page; + + if (currentTimespanFilter === TimeEntryQueryDuration.SPECIFIC_DATE) { + query.specificDate = currentSpecificDateFilter; + } else { + delete query.specificDate; + } + + if (currentTimespanFilter === TimeEntryQueryDuration.DATE_RANGE) { + query.dateRange = currentDateRangeFilter; + } else { + delete query.dateRange; + } + + if ((currentCategoryFilter !== "all" && currentCategoryFilter?.length > 0) ?? false) { + for (const chosenCategoryId of currentCategoryFilter) { + if (chosenCategoryId === "all") { + continue; + } + query.categories.push({ + id: chosenCategoryId, + }); + } + } + + if ((currentLabelFilter !== "all" && currentLabelFilter?.length > 0) ?? false) { + for (const chosenLabelId of currentLabelFilter) { + if (chosenLabelId === "all") { + continue; + } + query.labels.push({ + id: chosenLabelId, + }); + } + } + + load_entries(query); + } + + async function handle_delete_entry_button_click(e, entryId) { + if (confirm("Are you sure you want to delete this entry?")) { + const response = await delete_time_entry(entryId); + if (response.ok) { + const indexOfEntry = entries.findIndex((c) => c.id === entryId); + if (indexOfEntry !== -1) { + secondsLogged -= entries[indexOfEntry].seconds; + entries.splice(indexOfEntry, 1); + entries = entries; + set_duration_summary_string(); + } + } + } + } + + function handle_edit_entry_form_updated() { + load_entries_with_filter(page); + EditEntryModal.close(); + } + + async function handle_edit_entry_button_click(event, entryId) { + const response = await get_time_entry(entryId); + if (response.status === 200) { + if (is_guid(response.data.id)) { + EditEntryForm.set_values(response.data); + EditEntryModal.open(); + move_focus(document.querySelector("input[id='date']")); + } + } + } + + function close_date_filter_box(event) { + if (!event.target.closest(".date_filter_box_el")) { + showDateFilterOptions = false; + window.removeEventListener("click", close_date_filter_box); + } + } + + function toggle_date_filter_box(event) { + const box = document.getElementById("date_filter_box"); + const rect = event.target.getBoundingClientRect(); + box.style.top = rect.y + "px"; + box.style.left = rect.x - 50 + "px"; + showDateFilterOptions = true; + window.addEventListener("click", close_date_filter_box); + } + + onMount(() => { + isLoading = true; + Promise.all([load_entries()]).then(() => { + isLoading = false; + }); + }); +</script> + +<Modal title="Edit entry" + bind:functions={EditEntryModal} + on:closed={() => EditEntryForm.reset()}> + <EntryForm bind:functions={EditEntryForm} + on:updated={handle_edit_entry_form_updated}/> +</Modal> + +<div id="date_filter_box" + style="margin-top:25px" + class="padding-xs z-index-overlay bg shadow-sm position-absolute date_filter_box_el border {showDateFilterOptions ? '' : 'hide'}"> + <div class="flex items-baseline margin-bottom-xxxxs"> + <label class="text-sm color-contrast-medium margin-right-xs" + for="durationSelect">Timespan:</label> + <div class="select inline-block js-select"> + <select name="durationSelect" + bind:value={currentTimespanFilter} + id="durationSelect"> + <option value={TimeEntryQueryDuration.TODAY} + selected> Today + </option> + <option value={TimeEntryQueryDuration.THIS_WEEK}>This week</option> + <option value={TimeEntryQueryDuration.THIS_MONTH}>This month</option> + <option value={TimeEntryQueryDuration.THIS_YEAR}>This year</option> + <option value={TimeEntryQueryDuration.SPECIFIC_DATE}>Spesific date</option> + <option value={TimeEntryQueryDuration.DATE_RANGE}>Date range</option> + </select> + + <svg class="icon icon--xxxs margin-left-xxs" + viewBox="0 0 8 8"> + <path d="M7.934,1.251A.5.5,0,0,0,7.5,1H.5a.5.5,0,0,0-.432.752l3.5,6a.5.5,0,0,0,.864,0l3.5-6A.5.5,0,0,0,7.934,1.251Z"/> + </svg> + </div> + </div> + + {#if currentTimespanFilter === TimeEntryQueryDuration.SPECIFIC_DATE} + <div class="flex items-baseline margin-bottom-xxxxs justify-between"> + <span class="text-sm color-contrast-medium margin-right-xs">Date:</span> + <span class="text-sm"> + <input type="date" + class="border-none padding-0 color-inherit bg-transparent" + bind:value={currentSpecificDateFilter}/> + </span> + </div> + {/if} + + {#if currentTimespanFilter === TimeEntryQueryDuration.DATE_RANGE} + <div class="flex items-baseline margin-bottom-xxxxs justify-between"> + <span class="text-sm color-contrast-medium margin-right-xs">From:</span> + <span class="text-sm"> + <input type="date" + class="border-none padding-0 color-inherit bg-transparent" + on:change={(e) => (currentDateRangeFilter.from = e.target.value)}/> + </span> + </div> + + <div class="flex items-baseline margin-bottom-xxxxs justify-between"> + <span class="text-sm color-contrast-medium margin-right-xs">To:</span> + <span class="text-sm"> + <input type="date" + class="border-none padding-0 color-inherit bg-transparent" + on:change={(e) => (currentDateRangeFilter.to = e.target.value)}/> + </span> + </div> + {/if} + + <div class="flex items-baseline justify-end"> + <Button variant="subtle" + on:click={() => load_entries_with_filter(page)} + class="text-sm" + text="Save"/> + </div> +</div> + +<Layout> + <Tile class="{isLoading ? 'c-disabled loading' : ''}"> + <nav class="s-tabs text-sm"> + <ul class="s-tabs__list"> + <li><span class="s-tabs__link s-tabs__link--current">All (21)</span></li> + <li><span class="s-tabs__link">Published (19)</span></li> + <li><span class="s-tabs__link">Draft (2)</span></li> + </ul> + </nav> + <div class="max-width-100% overflow-auto" + style="max-height: 82.5vh"> + <Table class="text-sm width-100% int-table--sticky-header"> + <THead> + <TCell type="th" + style="width: 30px;"> + <div class="custom-checkbox int-table__checkbox"> + <input class="custom-checkbox__input" + type="checkbox" + aria-label="Select all rows"/> + <div class="custom-checkbox__control" + aria-hidden="true"></div> + </div> + </TCell> + + <TCell type="th" + style="width: 100px"> + <div class="flex items-center justify-between"> + <span>Date</span> + <div class="date_filter_box_el cursor-pointer" + on:click={toggle_date_filter_box}> + <Icon name="{IconNames.funnel}"/> + </div> + </div> + </TCell> + + <TCell type="th" + style="width: 100px"> + <div class="flex items-center"> + <span>Duration</span> + </div> + </TCell> + + <TCell type="th" + style="width: 100px;"> + <div class="flex items-center"> + <span>Category</span> + </div> + </TCell> + + <TCell type="th" + style="width: 300px;"> + <div class="flex items-center"> + <span>Description</span> + </div> + </TCell> + <TCell type="th" + style="width: 50px"></TCell> + </THead> + <TBody> + {#if entries.length > 0} + {#each entries as entry} + <TRow class="text-nowrap" + data-id={entry.id}> + <TCell type="th" + thScope="row"> + <div class="custom-checkbox int-table__checkbox"> + <input class="custom-checkbox__input" + type="checkbox" + aria-label="Select this row"/> + <div class="custom-checkbox__control" + aria-hidden="true"></div> + </div> + </TCell> + <TCell> + <pre>{entry.date.toLocaleString()}</pre> + </TCell> + <TCell> + <pre class="flex justify-between"> + <div class="flex justify-between"> + <span>{entry.start.toLocaleString(undefined, {timeStyle: "short"})}</span> + <span> - </span> + <span>{entry.stop.toLocaleString(undefined, {timeStyle: "short"})}</span> + </div> + </pre> + </TCell> + <TCell> + <span data-id={entry.category.id}>{entry.category.name}</span> + </TCell> + <TCell class="text-truncate max-width-xxxxs" + title="{entry.description}"> + {entry.description ?? ""} + </TCell> + <TCell class="flex flex-row justify-end items-center"> + <Button icon="{IconNames.pencilSquare}" + variant="reset" + icon_width="1.2rem" + icon_height="1.2rem" + on:click={(e) => handle_edit_entry_button_click(e, entry.id)} + title="Edit entry"/> + <Button icon="{IconNames.trash}" + variant="reset" + icon_width="1.2rem" + icon_height="1.2rem" + on:click={(e) => handle_delete_entry_button_click(e, entry.id)} + title="Delete entry"/> + </TCell> + </TRow> + {/each} + {:else} + <TRow class="text-nowrap"> + <TCell type="th" + thScope="row" + colspan="7"> + {isLoading ? "Loading..." : "No entries"} + </TCell> + </TRow> + {/if} + </TBody> + </Table> + </div> + <div class="flex items-center justify-between"> + <p class="text-sm"> + {#if durationSummary} + <small class={isLoading ? "c-disabled loading" : ""}>{durationSummary}</small> + {:else} + <small class={isLoading ? "c-disabled loading" : ""}>No entries</small> + {/if} + </p> + + <nav class="grid padding-y-sm {isLoading ? 'c-disabled loading' : ''}"> + <TablePaginator {page} + on:value_change={(e) => load_entries_with_filter(e.detail.newValue)} + {pageCount}/> + </nav> + </div> + </Tile> +</Layout> diff --git a/apps/projects-web/src/app/pages/home.svelte b/apps/projects-web/src/app/pages/home.svelte new file mode 100644 index 0000000..c3e7af4 --- /dev/null +++ b/apps/projects-web/src/app/pages/home.svelte @@ -0,0 +1,167 @@ +<script lang="ts"> + import {IconNames} from "$shared/lib/configuration"; + import {TimeEntryDto} from "$shared/lib/models/TimeEntryDto"; + import {Temporal} from "@js-temporal/polyfill"; + import {onMount} from "svelte"; + import Tile from "$shared/components/tile.svelte"; + import Button from "$shared/components/button.svelte"; + import Stopwatch from "$shared/components/stopwatch.svelte"; + import {Table, THead, TBody, TCell, TRow} from "$shared/components/table"; + import Layout from "./_layout.svelte"; + import EntryFrom from "$app/pages/views/entry-form/index.svelte"; + import {seconds_to_hour_minute_string, unwrap_date_time_from_entry} from "$shared/lib/helpers"; + import {TimeEntryQueryDuration} from "$shared/lib/models/TimeEntryQuery"; + import entries, {delete_entry_async, get_time_entry, reload_entries} from "$app/lib/stores/entries"; + + let currentTime = ""; + let isLoading = false; + let EditEntryForm: any; + let timeEntries = [] as Array<TimeEntryDto>; + let timeLoggedTodayString = "0h0m"; + + function set_current_time() { + currentTime = Temporal.Now.plainTimeISO().toLocaleString(undefined, { + timeStyle: "short", + }); + } + + async function on_edit_entry_button_click(event, entryId: string) { + const response = get_time_entry(entryId); + EditEntryForm.set_values(response); + } + + async function on_delete_entry_button_click(event, entryId: string) { + if (confirm("Are you sure you want to delete this entry?")) { + await delete_entry_async(entryId); + } + } + + async function load_todays_entries() { + await reload_entries({ + duration: TimeEntryQueryDuration.TODAY, + page: 1, + pageSize: 100, + }); + } + + function on_create_from_stopwatch(event) { + EditEntryForm.set_time({to: event.detail.to, from: event.detail.from}); + if (event.detail.description) { + EditEntryForm.set_description(event.detail.description); + } + } + + onMount(async () => { + set_current_time(); + setInterval(() => { + set_current_time(); + }, 1e4); + await load_todays_entries(); + entries.subscribe((val) => { + const newEntries = []; + let loggedSecondsToday = 0; + for (const entry of val) { + const date_time = unwrap_date_time_from_entry(entry); + newEntries.push({ + id: entry.id, + start: date_time.start_time, + stop: date_time.stop_time, + category: entry.category, + }); + loggedSecondsToday += (date_time.duration.hours * 60 * 60) + (date_time.duration.minutes * 60); + } + timeLoggedTodayString = seconds_to_hour_minute_string(loggedSecondsToday); + timeEntries = newEntries; + }); + }); +</script> + +<Layout> + <div class="grid gap-md margin-top-xs flex-row@md items-start flex-column-reverse"> + <Tile class="col"> + <h3 class="text-md padding-bottom-xxxs">New entry</h3> + <EntryFrom bind:functions={EditEntryForm}/> + </Tile> + <div class="col grid gap-sm"> + <Tile class="col-6@md col-12"> + <p class="text-xxl">{timeLoggedTodayString}</p> + <p class="text-xs margin-bottom-xxs">Logged time today</p> + <pre class="text-xxl">{currentTime}</pre> + <p class="text-xs">Current time</p> + </Tile> + <Tile class="col-6@md col-12"> + <Stopwatch on:create={on_create_from_stopwatch}> + <h3 slot="header" + class="text-md">Stopwatch</h3> + </Stopwatch> + </Tile> + <Tile class="col-12"> + <h3 class="text-md padding-bottom-xxxs">Today's entries</h3> + <div class="max-width-100% overflow-auto"> + <Table class="width-100% text-sm"> + <THead> + <TCell type="th" + class="text-left"> + <span>Category</span> + </TCell> + <TCell type="th" + class="text-left"> + <span>Timespan</span> + </TCell> + <TCell type="th" + class="text-right"> + <Button icon="{IconNames.refresh}" + variant="reset" + icon_width="1.2rem" + icon_height="1.2rem" + title="Refresh today's entries" + on:click={load_todays_entries}/> + </TCell> + </THead> + <TBody> + {#if timeEntries.length > 0} + {#each timeEntries as entry} + <TRow class="text-nowrap text-left" + data-id={entry.id}> + <TCell> + <span data-id={entry.category?.id}> + {entry.category?.name} + </span> + </TCell> + <TCell> + {entry.start.toLocaleString(undefined, {timeStyle: "short"})} + <span>-</span> + {entry.stop.toLocaleString(undefined, {timeStyle: "short"})} + </TCell> + <TCell class="flex flex-row justify-end items-center"> + <Button icon="{IconNames.pencilSquare}" + variant="reset" + icon_width="1.2rem" + icon_height="1.2rem" + on:click={(e) => on_edit_entry_button_click(e, entry.id)} + title="Edit entry"/> + <Button icon="{IconNames.trash}" + variant="reset" + icon_width="1.2rem" + icon_height="1.2rem" + on:click={(e) => on_delete_entry_button_click(e, entry.id)} + title="Delete entry"/> + </TCell> + </TRow> + {/each} + {:else} + <TRow class="text-nowrap"> + <TCell type="th" + thScope="row" + colspan="7"> + {isLoading ? "Loading..." : "No entries today"} + </TCell> + </TRow> + {/if} + </TBody> + </Table> + </div> + </Tile> + </div> + </div> +</Layout> diff --git a/apps/projects-web/src/app/pages/not-found.svelte b/apps/projects-web/src/app/pages/not-found.svelte new file mode 100644 index 0000000..46d0d1d --- /dev/null +++ b/apps/projects-web/src/app/pages/not-found.svelte @@ -0,0 +1,24 @@ +<script> + import {link} from "svelte-spa-router"; +</script> + +<style> + header { + font-size: 12rem; + } + + main { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + text-align: center; + } +</style> + +<main> + <header>404</header> + <p>Page not found!</p> + <a use:link + href="/">Go to front</a> +</main> diff --git a/apps/projects-web/src/app/pages/settings.svelte b/apps/projects-web/src/app/pages/settings.svelte new file mode 100644 index 0000000..ca9fd47 --- /dev/null +++ b/apps/projects-web/src/app/pages/settings.svelte @@ -0,0 +1,12 @@ +<script> + import Layout from "./_layout.svelte"; + import CategoriesTile from "$app/pages/views/settings-categories-tile.svelte"; + import LabelsTile from "$app/pages/views/settings-labels-tile.svelte"; +</script> + +<Layout> + <section class="grid gap-md"> + <CategoriesTile/> + <LabelsTile/> + </section> +</Layout> diff --git a/apps/projects-web/src/app/pages/ui-workbench.svelte b/apps/projects-web/src/app/pages/ui-workbench.svelte new file mode 100644 index 0000000..5e92c9d --- /dev/null +++ b/apps/projects-web/src/app/pages/ui-workbench.svelte @@ -0,0 +1,48 @@ +<script> + import Dropdown from "$shared/components/dropdown.svelte"; + import {generate_random_hex_color} from "$shared/lib/colors"; + import {uuid_v4} from "$shared/lib/helpers"; + + let entries = []; + + let dropdown; + + for (let i = 1; i < 20; i++) { + entries.push({ + id: uuid_v4(), + name: "Option " + i, + selected: false, + color: generate_random_hex_color(true) + }); + } + + function on_create({detail}) { + const copy = entries; + const entry = {id: uuid_v4(), name: detail.name}; + copy.push(entry); + entries = copy; + console.log("Created", entry); + dropdown.select_entry(entry.id); + } + + function on_select({detail}) { + console.log(detail); + } +</script> + +<main class="grid gap-y-lg padding-md"> + <div class="row"> + <label for="dropdown">Choose an entry</label> + <Dropdown id="dropdown" + name="dropdown" + placeholder="Search or create" + maxlength="50" + creatable="true" + multiple="false" + {entries} + bind:this={dropdown} + on:create={on_create} + on:select={on_select} + /> + </div> +</main> diff --git a/apps/projects-web/src/app/pages/views/category-form/index.svelte b/apps/projects-web/src/app/pages/views/category-form/index.svelte new file mode 100644 index 0000000..e8c0f94 --- /dev/null +++ b/apps/projects-web/src/app/pages/views/category-form/index.svelte @@ -0,0 +1,144 @@ +<script lang="ts"> + import Alert from "$shared/components/alert.svelte"; + import Dropdown from "$shared/components/dropdown.svelte"; + import labels, {reload_labels, create_label_async} from "$app/lib/stores/labels"; + import {generate_random_hex_color} from "$shared/lib/colors"; + import {get} from "svelte/store"; + + let LabelsDropdown; + + const dough = { + error: "", + fields: { + name: { + value: "", + error: "", + validate() { + return false; + } + }, + color: { + value: "", + error: "", + validate() { + return true; + } + }, + labels: { + loading: false, + value: [], + error: "", + validate() { + return true; + }, + async create({name}) { + dough.fields.labels.loading = true; + const response = await create_label_async({ + name: name, + color: generate_random_hex_color(), + }); + dough.fields.labels.loading = false; + if (response.ok) { + // Small pause to allow loading state to update everywhere. + setTimeout(() => LabelsDropdown.select_entry(response.data.id), 50); + } + } + }, + archived: { + value: false, + error: "", + validate() { + return true; + } + } + }, + bake() { + // labels.filter((c) => Object.hasOwn(c, "selected") && c.selected === true); + return { + labels: dough.fields.labels.value, + name: dough.fields.name.value, + color: dough.fields.color.value, + }; + }, + submit(event) { + const bread = dough.bake(); + console.log(bread); + console.log("Submitted"); + } + }; + + const functions = { + set(values) { + functions.set_archived(values.archived); + functions.set_labels(values.labels); + functions.set_color(values.color); + functions.set_name(values.name); + }, + is_valid() { + let isValid = true; + if (!dough.fields.labels.validate()) isValid = false; + if (!dough.fields.color.validate()) isValid = false; + if (!dough.fields.name.validate()) isValid = false; + if (!dough.fields.archived.validate()) isValid = false; + return isValid; + }, + set_archived(value) { + dough.fields.archived.value = value; + }, + set_labels(value) { + dough.fields.labels.value = value; + }, + set_color(value) { + dough.fields.color.value = value; + }, + set_name(value) { + dough.fields.name.value = value; + }, + }; +</script> + +<form on:submit|preventDefault={dough.submit}> + <div class="margin-y-sm"> + <Alert visible={dough.error !== ""} + message={dough.error} + type="error"/> + </div> + <div class="grid gap-x-xs margin-bottom-sm"> + <div class="col-10"> + <label for="name" + class="form-label margin-bottom-xxs">Name</label> + <input type="text" + class="form-control width-100%" + id="name" + bind:value={dough.fields.name.value}/> + {#if dough.fields.name.error} + <small class="color-error">{dough.fields.name.error}</small> + {/if} + </div> + <div class="col-2"> + <label for="color" + class="form-label margin-bottom-xxs">Color</label> + <input type="color" + class="form-control width-100%" + id="color" + style="height: 41px" + bind:value={dough.fields.color.value}/> + {#if dough.fields.color.error} + <small class="color-error">{dough.fields.color.error}</small> + {/if} + </div> + </div> + <div class="margin-bottom-sm"> + <label for="labels" + class="form-label margin-bottom-xxs">Default labels</label> + <Dropdown id="labels" + createable={true} + placeholder="Search or create" + entries={$labels} + multiple={true} + on_create_async={(name) => dough.fields.labels.create({name})}/> + {#if dough.fields.labels.error} + <small class="color-error">{dough.fields.labels.error}</small> + {/if} + </div> +</form> diff --git a/apps/projects-web/src/app/pages/views/data-table-paginator.svelte b/apps/projects-web/src/app/pages/views/data-table-paginator.svelte new file mode 100644 index 0000000..7696ca2 --- /dev/null +++ b/apps/projects-web/src/app/pages/views/data-table-paginator.svelte @@ -0,0 +1,107 @@ +<script> + import {createEventDispatcher, onMount} from "svelte"; + import {restrict_input_to_numbers} from "$shared/lib/helpers"; + + const dispatch = createEventDispatcher(); + export let page = 1; + export let pageCount = 1; + let prevCount = page; + let canIncrement = false; + let canDecrement = false; + $: canIncrement = page < pageCount; + $: canDecrement = page > 1; + + onMount(() => { + restrict_input_to_numbers(document.querySelector("#curr-page")); + }); + + function increment() { + if (canIncrement) { + page++; + } + } + + function decrement() { + if (canDecrement) { + page--; + } + } + + $: if (page) { + handle_change(); + } + + function handle_change() { + if (page === prevCount) { + return; + } + prevCount = page; + if (page > pageCount) { + page = pageCount; + } + dispatch("value_change", { + newValue: page, + }); + } +</script> + +<nav class="pagination" + aria-label="Pagination"> + <ul + class="pagination__list flex flex-wrap gap-xxxs justify-center justify-end@md" + > + <li> + <button + on:click={decrement} + class="reset pagination__item {canDecrement ? '' : 'c-disabled'}" + > + <svg class="icon icon--xs flip-x" + viewBox="0 0 16 16" + ><title>Go to previous page</title> + <polyline + points="6 2 12 8 6 14" + fill="none" + stroke="currentColor" + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2" + /> + </svg> + </button> + </li> + + <li> + <span class="pagination__jumper flex items-center"> + <input + aria-label="Page number" + class="form-control" + id="curr-page" + type="text" + on:change={handle_change} + value={page} + /> + <em>of {pageCount}</em> + </span> + </li> + + <li> + <button + on:click={increment} + class="reset pagination__item {canIncrement ? '' : 'c-disabled'}" + > + <svg class="icon icon--xs" + viewBox="0 0 16 16" + ><title>Go to next page</title> + <polyline + points="6 2 12 8 6 14" + fill="none" + stroke="currentColor" + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2" + /> + </svg> + </button> + </li> + </ul> +</nav> diff --git a/apps/projects-web/src/app/pages/views/entry-form/index.svelte b/apps/projects-web/src/app/pages/views/entry-form/index.svelte new file mode 100644 index 0000000..cb974ed --- /dev/null +++ b/apps/projects-web/src/app/pages/views/entry-form/index.svelte @@ -0,0 +1,196 @@ +<script lang="ts"> + import {TimeEntryDto} from "$shared/lib/models/TimeEntryDto"; + import {Temporal} from "@js-temporal/polyfill"; + import {createEventDispatcher, onMount, onDestroy} from "svelte"; + import DateTimePart from "./sections/date-time.svelte"; + import LabelsPart from "./sections/labels.svelte"; + import CategoryPart from "./sections/category.svelte"; + import Button from "$shared/components/button.svelte"; + import {Textarea} from "$shared/components/form"; + import Alert from "$shared/components/alert.svelte"; + import {is_guid} from "$shared/lib/helpers"; + import {create_entry_async, edit_entry_async} from "$app/lib/stores/entries"; + + const dispatch = createEventDispatcher(); + + let formError = ""; + let formIsLoading = false; + let description = ""; + let descriptionError = ""; + let dateTimePart; + let labelsPart; + let categoryPart; + let entryId; + + onMount(() => { + formIsLoading = true; + + Promise.all([categoryPart.load_categories(), labelsPart.load_labels()]).then(() => { + formIsLoading = false; + }); + + window.addEventListener("keydown", handle_window_keydown); + }); + + onDestroy(() => { + window.removeEventListener("keydown", handle_window_keydown); + }); + + function handle_window_keydown(event) { + if (event.ctrlKey && event.code === "Enter") { + submit_form(); + } + } + + function validate_form() { + return dateTimePart.is_valid() && categoryPart.is_valid() && description_is_valid(); + } + + function description_is_valid() { + if (!description) { + descriptionError = "Description is required"; + } else { + descriptionError = ""; + } + + return description; + } + + function get_payload() { + const response = {} as TimeEntryDto; + const values = get_values(); + if (!is_guid(values.id)) { + delete values.id; + } else { + response.id = values.id; + } + + const currentTimeZone = Temporal.Now.zonedDateTimeISO().offset; + response.start = values.date + "T" + values.fromTimeValue + currentTimeZone.toString(); + response.stop = values.date + "T" + values.toTimeValue + currentTimeZone.toString(); + + response.category = { + id: values.category.id, + }; + + const selectedLabels = values.labels; + if (selectedLabels?.length > 0 ?? false) { + response.labels = selectedLabels; + } + + const descriptionContent = description?.trim(); + if (descriptionContent?.length > 0 ?? false) { + response.description = descriptionContent; + } + + return response; + } + + async function submit_form() { + formError = ""; + if (validate_form()) { + const payload = get_payload() as TimeEntryDto; + formIsLoading = true; + if (is_guid(payload.id)) { + const response = await edit_entry_async(payload); + if (response.ok) { + functions.reset(); + dispatch("updated", response.data); + } else { + formError = "An error occured while updating the entry, try again soon"; + formIsLoading = false; + } + } else { + const response = await create_entry_async(payload); + if (response.ok) { + functions.reset(); + dispatch("created"); + } else { + formError = "An error occured while creating the entry, try again soon"; + formIsLoading = false; + } + } + } + } + + function get_values() { + return { + id: entryId, + toTimeValue: dateTimePart.get_to_time_value(), + fromTimeValue: dateTimePart.get_from_time_value(), + date: dateTimePart.get_date(), + category: categoryPart.get_selected(), + labels: labelsPart.get_selected(), + description: description, + }; + } + + export const functions = { + set_values(values) { + entryId = values.id; + dateTimePart.set_values(values); + labelsPart.select_labels(values?.labels.map((c) => c.id) ?? []); + categoryPart.select_category(values?.category?.id); + description = values.description; + }, + set_time(value: {to: Temporal.PlainTime, from: Temporal.PlainTime}) { + dateTimePart.set_times(value); + }, + set_description(value: string) { + if (description) description = description + "\n\n" + value; + else description = value; + }, + reset() { + formIsLoading = false; + entryId = ""; + labelsPart.reset(); + categoryPart.reset(); + dateTimePart.reset(true); + description = ""; + formError = ""; + }, + }; +</script> + +<form on:submit|preventDefault={submit_form} + on:reset={() => functions.reset()}> + <div class="margin-y-sm"> + <Alert visible={formError !== ""} + message={formError} + type="error"/> + </div> + + <div class="margin-bottom-sm"> + <DateTimePart bind:functions={dateTimePart}/> + </div> + + <div class="margin-bottom-sm"> + <CategoryPart bind:functions={categoryPart}/> + </div> + + <div class="margin-bottom-sm"> + <LabelsPart bind:functions={labelsPart}/> + </div> + + <div class="margin-bottom-sm"> + <Textarea class="width-100%" + id="description" + label="Description" + errorText="{descriptionError}" + bind:value={description}></Textarea> + </div> + + <div class="flex flex-row justify-end gap-x-xs"> + {#if entryId} + <Button text="Reset" + on:click={() => functions.reset()} + variant="subtle" + /> + {/if} + <Button loading={formIsLoading} + type="submit" + variant="primary" + text={entryId ? "Save" : "Create"} + /> + </div> +</form> diff --git a/apps/projects-web/src/app/pages/views/entry-form/sections/category.svelte b/apps/projects-web/src/app/pages/views/entry-form/sections/category.svelte new file mode 100644 index 0000000..f98c045 --- /dev/null +++ b/apps/projects-web/src/app/pages/views/entry-form/sections/category.svelte @@ -0,0 +1,75 @@ +<script> + import {generate_random_hex_color} from "$shared/lib/colors"; + import Dropdown from "$shared/components/dropdown.svelte"; + import {is_guid, move_focus} from "$shared/lib/helpers"; + import categories, {reload_categories, create_category_async} from "$app/lib/stores/categories"; + + let categoriesError = ""; + let loading = false; + + let DropdownExports; + + function reset() { + DropdownExports.reset(); + categoriesError = ""; + console.log("Reset category-part"); + } + + async function on_create({name}) { + loading = true; + const response = await create_category_async({ + name: name, + color: generate_random_hex_color(), + }); + loading = false; + if (response.ok) { + // Small pause to allow loading state to update everywhere. + setTimeout(() => select_category(response.data.id), 50); + } + } + + function get_selected() { + return $categories.find((c) => c.selected === true); + } + + function select_category(id) { + DropdownExports.select(id); + } + + function is_valid() { + let isValid = true; + const category = get_selected(); + if (!is_guid(category?.id)) { + categoriesError = "Category is required"; + isValid = false; + move_focus(document.getElementById("category-dropdown")); + } else { + categoriesError = ""; + } + return isValid; + } + + export const functions = { + get_selected, + reset, + is_valid, + select_category, + load_categories: reload_categories, + }; +</script> + +<Dropdown + entries={$categories} + label="Category" + maxlength="50" + createable={true} + placeholder="Search or create" + id="category-dropdown" + loading={loading} + name="category-dropdown" + on_create_async={on_create} + noResultsText="No categories available (Create a new one by searching for it and pressing enter)" + errorText="{categoriesError}" + bind:this={DropdownExports} +/> + diff --git a/apps/projects-web/src/app/pages/views/entry-form/sections/date-time.svelte b/apps/projects-web/src/app/pages/views/entry-form/sections/date-time.svelte new file mode 100644 index 0000000..c91e014 --- /dev/null +++ b/apps/projects-web/src/app/pages/views/entry-form/sections/date-time.svelte @@ -0,0 +1,165 @@ +<script lang="ts"> + import {Temporal} from "@js-temporal/polyfill"; + + // TIME + let fromTimeValue = ""; + let fromTimeError = ""; + let toTimeValue = ""; + let toTimeError = ""; + + function handle_from_time_changed(e) { + fromTimeValue = e.target.value; + if (fromTimeValue) { + fromTimeError = ""; + } + } + + function handle_to_time_changed(e) { + toTimeValue = e.target.value; + if (toTimeValue) { + toTimeError = ""; + } + } + + // DATE + let date = Temporal.Now.plainDateTimeISO().toString().substring(0, 10); + let dateError = ""; + + function is_valid() { + let isValid = true; + let focusIsSet = false; + if (!date) { + dateError = "Date is required"; + isValid = false; + if (!focusIsSet) { + document.getElementById("date")?.focus(); + focusIsSet = true; + } + } else { + dateError = ""; + } + + if (!fromTimeValue) { + fromTimeError = "From is required"; + isValid = false; + if (!focusIsSet) { + document.getElementById("from")?.focus(); + focusIsSet = true; + } + } else if (toTimeValue && fromTimeValue > toTimeValue) { + fromTimeError = "From can not be after To"; + isValid = false; + if (!focusIsSet) { + document.getElementById("from")?.focus(); + focusIsSet = true; + } + } else if (fromTimeValue === toTimeValue) { + fromTimeError = "From and To can not be equal"; + isValid = false; + if (!focusIsSet) { + document.getElementById("from")?.focus(); + focusIsSet = true; + } + } else { + fromTimeError = ""; + } + + if (!toTimeValue) { + toTimeError = "To is required"; + isValid = false; + if (!focusIsSet) { + document.getElementById("to")?.focus(); + focusIsSet = true; + } + } else if (fromTimeValue && toTimeValue < fromTimeValue) { + toTimeError = "To can not be before From"; + isValid = false; + if (!focusIsSet) { + document.getElementById("to")?.focus(); + focusIsSet = true; + } + } else { + toTimeError = ""; + } + + return isValid; + } + + export const functions = { + get_from_time_value() { + return fromTimeValue; + }, + get_to_time_value() { + return toTimeValue; + }, + get_date() { + return date; + }, + is_valid, + reset(focusDate = false) { + fromTimeValue = ""; + toTimeValue = ""; + if (focusDate) { + document.getElementById("date")?.focus(); + } + }, + set_times(value) { + console.log(value); + fromTimeValue = value.from.toString().substring(0, 5); + toTimeValue = value.to.toString().substring(0, 5); + }, + set_date(new_date: Temporal.PlainDate) { + date = new_date.toString(); + }, + set_values(values) { + const currentTimeZone = Temporal.Now.timeZone().id; + const startDate = Temporal.Instant.from(values.start); + const stopDate = Temporal.Instant.from(values.stop); + fromTimeValue = startDate.toZonedDateTimeISO(currentTimeZone).toPlainTime().toString().substring(0, 5); + toTimeValue = stopDate.toZonedDateTimeISO(currentTimeZone).toPlainTime().toString().substring(0, 5); + date = startDate.toZonedDateTimeISO(currentTimeZone).toPlainDate().toString(); + } + }; +</script> + +<div class="grid gap-xs"> + <div class="col-4"> + <label for="date" + class="form-label margin-bottom-xxs">Date</label> + <input type="date" + id="date" + class="form-control width-100%" + bind:value={date}> + {#if dateError} + <small class="color-error">{dateError}</small> + {/if} + </div> + <div class="col-4"> + <label for="from" + class="form-label margin-bottom-xxs">From</label> + <input id="from" + class="form-control width-100%" + pattern="[0-9][0-9]:[0-9][0-9]" + type="time" + bind:value={fromTimeValue} + on:input={handle_from_time_changed} + /> + {#if fromTimeError} + <small class="color-error">{fromTimeError}</small> + {/if} + </div> + <div class="col-4"> + <label for="to" + class="form-label margin-bottom-xxs">To</label> + <input id="to" + class="form-control width-100%" + pattern="[0-9][0-9]:[0-9][0-9]" + type="time" + bind:value={toTimeValue} + on:input={handle_to_time_changed} + /> + {#if toTimeError} + <small class="color-error">{toTimeError}</small> + {/if} + </div> +</div> diff --git a/apps/projects-web/src/app/pages/views/entry-form/sections/labels.svelte b/apps/projects-web/src/app/pages/views/entry-form/sections/labels.svelte new file mode 100644 index 0000000..06c703d --- /dev/null +++ b/apps/projects-web/src/app/pages/views/entry-form/sections/labels.svelte @@ -0,0 +1,65 @@ +<script> + import {generate_random_hex_color} from "$shared/lib/colors"; + import labels, {reload_labels, create_label_async} from "$app/lib/stores/labels"; + import Dropdown from "$shared/components/dropdown.svelte"; + + let labelsError = ""; + let loading = false; + let DropdownExports; + + function reset() { + DropdownExports.reset(); + console.log("Reset labels-part"); + } + + function get_selected() { + return $labels.filter((c) => Object.hasOwn(c, "selected") && c.selected === true); + } + + function select_label(id) { + DropdownExports.select(id); + } + + function select_labels(ids) { + for (const id of ids) { + DropdownExports.select(id); + } + } + + async function on_create({name}) { + loading = true; + const response = await create_label_async({ + name: name, + color: generate_random_hex_color(), + }); + loading = false; + if (response.ok) { + // Small pause to allow loading state to update everywhere. + setTimeout(() => select_label(response.data.id), 50); + } + } + + export const functions = { + get_selected, + reset, + load_labels: reload_labels, + select_labels, + select_label, + }; +</script> + +<Dropdown + entries={$labels} + label="Labels" + maxlength="50" + createable={true} + placeholder="Search or create" + multiple="{true}" + id="labels-search" + name="labels-search" + on_create_async={on_create} + noResultsText="No labels available (Create a new one by searching for it and pressing enter)" + errorText="{labelsError}" + bind:this={DropdownExports} + {loading} +/> diff --git a/apps/projects-web/src/app/pages/views/profile-modal.svelte b/apps/projects-web/src/app/pages/views/profile-modal.svelte new file mode 100644 index 0000000..839b59d --- /dev/null +++ b/apps/projects-web/src/app/pages/views/profile-modal.svelte @@ -0,0 +1,156 @@ +<script> + import {update_profile} from "$shared/lib/api/user"; + import Modal from "$shared/components/modal.svelte"; + import Alert from "$shared/components/alert.svelte"; + import Button from "$shared/components/button.svelte"; + import {is_email} from "$shared/lib/helpers"; + import {api_base} from "$shared/lib/configuration"; + import {delete_user} from "$app/lib/services/user-service"; + import {get_session_data} from "$shared/lib/session"; + + const archiveLink = api_base("_/api/account/archive"); + + let modal; + let understands = false; + + let formIsLoading = false; + let formError; + + let username = get_session_data()?.profile.username; + let usernameFieldMessage; + let usernameFieldMessageClass = "color-error"; + + let password; + let passwordFieldMessage; + let passwordFieldMessageClass = "color-error"; + + async function submit_form(e) { + e.preventDefault(); + if (!username && !password) { + console.error("Not submitting becuase both values is empty"); + return; + } + + usernameFieldMessage = ""; + passwordFieldMessage = ""; + + if (username && !is_email(username)) { + usernameFieldMessage = "Username has to be a valid email"; + return; + } + + if (password && password?.length < 6) { + passwordFieldMessage = "The new password must contain at least 6 characters"; + return; + } + + formIsLoading = true; + + const response = await update_profile({ + username, + password, + }); + + formIsLoading = false; + + if (response.ok) { + if (password) { + passwordFieldMessage = "Successfully updated"; + passwordFieldMessageClass = "color-success"; + password = ""; + } + if (username) { + usernameFieldMessage = "Successfully updated"; + usernameFieldMessageClass = "color-success"; + password = ""; + } + } else { + formError = response.data.title ?? "An unknown error occured"; + } + } + + async function handle_delete_account_button_click() { + if (understands && confirm("Are you absolutely sure that you want to delete your account?")) { + await delete_user(); + } + } + + export const functions = { + open() { + modal.open(); + }, + close() { + // modal.close(); + }, + }; +</script> + +<Modal title="Profile" + bind:functions={modal}> + <section class="margin-bottom-md"> + <p class="text-md margin-bottom-sm">Update your information</p> + <form on:submit={submit_form} + autocomplete="new-password"> + {#if formError} + <small class="color-danger">{formError}</small> + {/if} + <div class="margin-bottom-sm"> + <label for="email" + class="form-label margin-bottom-xxs">New username</label> + <input type="email" + class="form-control width-100%" + id="email" + placeholder={username} + bind:value={username}/> + {#if usernameFieldMessage} + <small class={usernameFieldMessageClass}>{usernameFieldMessage}</small> + {/if} + </div> + <div class="margin-bottom-sm"> + <label for="password" + class="form-label margin-bottom-xxs">New password</label> + <input type="password" + class="form-control width-100%" + id="password" + bind:value={password}/> + {#if passwordFieldMessage} + <small class={passwordFieldMessageClass}>{passwordFieldMessage}</small> + {/if} + </div> + <div class="flex justify-end"> + <Button text="Save" + on:click={submit_form} + variant="primary" + loading={formIsLoading}/> + </div> + </form> + </section> + <section class="margin-bottom-md"> + <p class="text-md margin-bottom-sm">Download your data</p> + <a class="btn btn--subtle" + href={archiveLink} + download>Click here to download your data</a> + </section> + <section> + <p class="text-md margin-bottom-sm">Delete account</p> + <div class="margin-bottom-sm"> + <Alert + message="Deleting your account and data means that all of your data (entries, categories, etc.) will be unrecoverable forever.<br>You should probably download your data before continuing." + type="info" + /> + </div> + <div class="form-check margin-bottom-sm"> + <input type="checkbox" + class="checkbox" + id="the-consequences" + bind:checked={understands}/> + <label for="the-consequences">I understand the consequences of deleting my account and data.</label> + </div> + <div class="flex justify-end"> + <Button text="Delete everything" + variant="accent" + disabled={!understands} + on:click={handle_delete_account_button_click}/> + </div> + </section> +</Modal> diff --git a/apps/projects-web/src/app/pages/views/settings-categories-tile.svelte b/apps/projects-web/src/app/pages/views/settings-categories-tile.svelte new file mode 100644 index 0000000..890609a --- /dev/null +++ b/apps/projects-web/src/app/pages/views/settings-categories-tile.svelte @@ -0,0 +1,127 @@ +<script> + import {IconNames} from "$shared/lib/configuration"; + import {onMount} from "svelte"; + import { + delete_time_category, + get_time_categories, + } from "$shared/lib/api/time-entry"; + import Button from "$shared/components/button.svelte"; + import Tile from "$shared/components/tile.svelte"; + import {Table, THead, TBody, TCell, TRow} from "$shared/components/table"; + + let is_loading = true; + let categories = []; + + $: active_categories = categories.filter(c => !c.archived); + $: archived_categories = categories.filter(c => c.archived); + + async function load_categories() { + is_loading = true; + const response = await get_time_categories(); + if (response.status === 200) { + categories = response.data; + } else if (response.status === 204) { + categories = []; + console.log("Empty response when getting time categories"); + } else { + categories = []; + console.error("Error when getting time categories"); + } + is_loading = false; + } + + async function handle_edit_category_click(event) { + } + + async function handle_delete_category_click(event) { + const row = event.target.closest("tr"); + if ( + row && + row.dataset.id && + confirm( + "Are you sure you want to delete this category?\nThis will delete all relating entries!" + ) + ) { + const response = await delete_time_category(row.dataset.id); + if (response.ok) { + // svelte errors if we remove the row. + row.classList.add("d-none"); + } + } + } + + onMount(() => { + load_categories(); + }); +</script> + +<Tile class="col-6@md col-12 {is_loading ? 'c-disabled loading' : ''}"> + <h2 class="margin-bottom-xxs">Categories</h2> + {#if active_categories.length > 0 && archived_categories.length > 0} + <nav class="s-tabs text-sm"> + <ul class="s-tabs__list"> + <li><a class="s-tabs__link s-tabs__link--current" + href="#0">Active ({active_categories.length})</a></li> + <li><a class="s-tabs__link" + href="#0">Archived ({archived_categories.length})</a></li> + </ul> + </nav> + {/if} + <div class="max-width-100% overflow-auto"> + <Table class="text-sm width-100%"> + <THead class="text-left"> + <TCell type="th" + thScope="col"> + Name + </TCell> + <TCell type="th" + thScope="col"> + Color + </TCell> + <TCell type="th" + thScope="col" + style="width:50px"></TCell> + </THead> + <TBody class="text-left"> + {#if categories.length > 0} + {#each categories as category} + <TRow class="text-nowrap" + data-id={category.id}> + <TCell> + {category.name} + </TCell> + <TCell> + <span style="border-left: 3px solid {category.color}; background-color:{category.color}25;"> + {category.color} + </span> + </TCell> + <TCell> + <Button icon="{IconNames.pencilSquare}" + variant="reset" + icon_width="1.2rem" + class="hide" + icon_height="1.2rem" + on:click={handle_edit_category_click} + title="Edit entry"/> + <Button icon="{IconNames.trash}" + variant="reset" + icon_width="1.2rem" + icon_height="1.2rem" + on:click={handle_delete_category_click} + title="Delete entry"/> + + </TCell> + </TRow> + {/each} + {:else} + <TRow> + <TCell type="th" + thScope="3"> + No categories + </TCell> + </TRow> + {/if} + </TBody> + </Table> + </div> +</Tile> diff --git a/apps/projects-web/src/app/pages/views/settings-labels-tile.svelte b/apps/projects-web/src/app/pages/views/settings-labels-tile.svelte new file mode 100644 index 0000000..f59e233 --- /dev/null +++ b/apps/projects-web/src/app/pages/views/settings-labels-tile.svelte @@ -0,0 +1,112 @@ +<script> + import {IconNames} from "$shared/lib/configuration"; + import {onMount} from "svelte"; + import labels, {reload_labels, delete_label_async} from "$app/lib/stores/labels"; + import Button from "$shared/components/button.svelte"; + import Tile from "$shared/components/tile.svelte"; + import {Table, THead, TBody, TCell, TRow} from "$shared/components/table"; + + let is_loading = true; + + $: active_labels = $labels.filter(c => !c.archived); + $: archived_labels = $labels.filter(c => c.archived); + + async function load_labels() { + is_loading = true; + await reload_labels(); + is_loading = false; + } + + async function handle_edit_label_click(event) { + } + + async function handle_delete_label_click(event) { + const row = event.target.closest("tr"); + if ( + row && + row.dataset.id && + confirm( + "Are you sure you want to delete this label?\nIt will be removed from all related entries!" + ) + ) { + await delete_label_async({id: row.dataset.id}); + row.classList.add("d-none"); + } + } + + onMount(() => { + load_labels(); + }); +</script> + +<Tile class="col-6@md col-12 {is_loading ? 'c-disabled loading' : ''}"> + <h2 class="margin-bottom-xxs">Labels</h2> + {#if active_labels.length > 0 && archived_labels.length > 0} + <nav class="s-tabs text-sm"> + <ul class="s-tabs__list"> + <li><a class="s-tabs__link s-tabs__link--current" + href="#0">Active ({active_labels.length})</a></li> + <li><a class="s-tabs__link" + href="#0">Archived ({archived_labels.length})</a></li> + </ul> + </nav> + {/if} + <div class="max-width-100% overflow-auto"> + <Table class="text-sm width-100%"> + <THead class="text-left"> + <TCell type="th" + thScope="row"> + Name + </TCell> + <TCell type="th" + thScope="row"> + Color + </TCell> + <TCell type="th" + thScope="row" + style="width: 50px;"> + </TCell> + </THead> + <TBody class="text-left"> + {#if $labels.length > 0} + {#each $labels as label} + <TRow class="text-nowrap" + dataId={label.id}> + <TCell> + {label.name} + </TCell> + <TCell> + <span style="border-left: 3px solid {label.color}; background-color:{label.color}25;"> + {label.color} + </span> + </TCell> + <TCell> + <Button icon="{IconNames.pencilSquare}" + variant="reset" + icon_width="1.2rem" + class="hide" + icon_height="1.2rem" + on:click={handle_edit_label_click} + title="Edit entry"/> + <Button icon="{IconNames.trash}" + variant="reset" + icon_width="1.2rem" + icon_height="1.2rem" + on:click={handle_delete_label_click} + title="Delete entry"/> + </TCell> + </TRow> + {/each} + {:else} + <TRow> + <TCell type="th" + thScope="row" + colspan="3"> + No labels + </TCell> + </TRow> + {/if} + </TBody> + </Table> + </div> +</Tile> diff --git a/apps/projects-web/src/index.html b/apps/projects-web/src/index.html new file mode 100644 index 0000000..985b62b --- /dev/null +++ b/apps/projects-web/src/index.html @@ -0,0 +1,63 @@ +<!doctype html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" + content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> + <link rel="apple-touch-icon" + sizes="180x180" + href="./_assets/pwa/apple-touch-icon.png"> + <link rel="icon" + type="image/png" + sizes="32x32" + href="./_assets/pwa/favicon-32x32.png"> + <link rel="icon" + type="image/png" + sizes="16x16" + href="./_assets/pwa/favicon-16x16.png"> + <link rel="manifest" + href="./_assets/pwa/manifest.json"> + <link rel="mask-icon" + href="./_assets/pwa/safari-pinned-tab.svg" + color="#5bbad5"> + <meta name="msapplication-TileColor" + content="#da532c"> + <link rel="icon" + href="./_assets/pwa/favicon.svg"> + <script> + const currentTheme = localStorage.getItem("theme"); + if (currentTheme === "light") { + document.querySelector("html").dataset.theme = "light"; + } else { + document.querySelector("html").dataset.theme = "dark"; + } + </script> + <link rel="stylesheet" + href="./_assets/pre.css"> + <title>Time Tracker</title> +</head> + +<body> + +<noscript> + This page is built with javascript. Allow it and try again. +</noscript> + +<div class="fill-loader fill-loader--v4" + id="loader" + role="alert"> + <p class="fill-loader__label">Loading Time Tracker...</p> + <div aria-hidden="true"> + <div class="fill-loader__base"></div> + <div class="fill-loader__fill"></div> + </div> +</div> + +<div id="root"></div> + +<script type="module" + src="./app/index.ts"></script> +</body> + +</html> diff --git a/apps/projects-web/src/package.json b/apps/projects-web/src/package.json new file mode 100644 index 0000000..8ff516d --- /dev/null +++ b/apps/projects-web/src/package.json @@ -0,0 +1,22 @@ +{ + "name": "time-tracker-public", + "version": "0.0.1", + "private": "true", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "1.0.0-next.43", + "sass": "^1.51.0", + "svelte": "^3.48.0", + "svelte-preprocess": "^4.10.6", + "svelte-spa-router": "^3.2.0", + "typescript": "4.6.4", + "vite": "^2.9.8" + }, + "dependencies": { + "@js-temporal/polyfill": "^0.4.1", + "fuzzysort": "^1.9.0" + } +} diff --git a/apps/projects-web/src/pnpm-lock.yaml b/apps/projects-web/src/pnpm-lock.yaml new file mode 100644 index 0000000..3b56115 --- /dev/null +++ b/apps/projects-web/src/pnpm-lock.yaml @@ -0,0 +1,769 @@ +lockfileVersion: 5.4 + +specifiers: + '@js-temporal/polyfill': ^0.4.1 + '@sveltejs/vite-plugin-svelte': 1.0.0-next.43 + fuzzysort: ^1.9.0 + sass: ^1.51.0 + svelte: ^3.48.0 + svelte-preprocess: ^4.10.6 + svelte-spa-router: ^3.2.0 + typescript: 4.6.4 + vite: ^2.9.8 + +dependencies: + '@js-temporal/polyfill': 0.4.1 + fuzzysort: 1.9.0 + +devDependencies: + '@sveltejs/vite-plugin-svelte': 1.0.0-next.43_svelte@3.48.0+vite@2.9.8 + sass: 1.51.0 + svelte: 3.48.0 + svelte-preprocess: 4.10.6_24ezlekk4ocevlsjgs2qnqmjum + svelte-spa-router: 3.2.0 + typescript: 4.6.4 + vite: 2.9.8_sass@1.51.0 + +packages: + + /@js-temporal/polyfill/0.4.1: + resolution: {integrity: sha512-q45ecIocpa2TLem2jNOsCrDwP/sgKZdSkt+C1Rx07OkdKsdbvVfHcD1iDiK9scxBZrBQ38uJ8VQISXBS70ql1w==} + engines: {node: '>=12'} + dependencies: + jsbi: 4.3.0 + tslib: 2.4.0 + dev: false + + /@rollup/pluginutils/4.2.1: + resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==} + engines: {node: '>= 8.0.0'} + dependencies: + estree-walker: 2.0.2 + picomatch: 2.3.1 + dev: true + + /@sveltejs/vite-plugin-svelte/1.0.0-next.43_svelte@3.48.0+vite@2.9.8: + resolution: {integrity: sha512-MzeczqGrnDmbAldw/LfXV/dhpLC2bdUzuMhcx0C2j79V2uNzQERHDinxXnG2AVTCTjSpbQxzQwMMmYflnI7W1g==} + engines: {node: ^14.13.1 || >= 16} + peerDependencies: + diff-match-patch: ^1.0.5 + svelte: ^3.44.0 + vite: ^2.9.0 + peerDependenciesMeta: + diff-match-patch: + optional: true + dependencies: + '@rollup/pluginutils': 4.2.1 + debug: 4.3.4 + deepmerge: 4.2.2 + kleur: 4.1.4 + magic-string: 0.26.1 + svelte: 3.48.0 + svelte-hmr: 0.14.11_svelte@3.48.0 + vite: 2.9.8_sass@1.51.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@types/node/17.0.31: + resolution: {integrity: sha512-AR0x5HbXGqkEx9CadRH3EBYx/VkiUgZIhP4wvPn/+5KIsgpNoyFaRlVe0Zlx9gRtg8fA06a9tskE2MSN7TcG4Q==} + dev: true + + /@types/pug/2.0.6: + resolution: {integrity: sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg==} + dev: true + + /@types/sass/1.43.1: + resolution: {integrity: sha512-BPdoIt1lfJ6B7rw35ncdwBZrAssjcwzI5LByIrYs+tpXlj/CAkuVdRsgZDdP4lq5EjyWzwxZCqAoFyHKFwp32g==} + dependencies: + '@types/node': 17.0.31 + dev: true + + /anymatch/3.1.2: + resolution: {integrity: sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==} + engines: {node: '>= 8'} + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + dev: true + + /balanced-match/1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + dev: true + + /binary-extensions/2.2.0: + resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} + engines: {node: '>=8'} + dev: true + + /brace-expansion/1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + dev: true + + /braces/3.0.2: + resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} + engines: {node: '>=8'} + dependencies: + fill-range: 7.0.1 + dev: true + + /buffer-crc32/0.2.13: + resolution: {integrity: sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=} + dev: true + + /chokidar/3.5.3: + resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.2 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /concat-map/0.0.1: + resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} + dev: true + + /debug/4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + dev: true + + /deepmerge/4.2.2: + resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==} + engines: {node: '>=0.10.0'} + dev: true + + /detect-indent/6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + dev: true + + /es6-promise/3.3.1: + resolution: {integrity: sha1-oIzd6EzNvzTQJ6FFG8kdS80ophM=} + dev: true + + /esbuild-android-64/0.14.38: + resolution: {integrity: sha512-aRFxR3scRKkbmNuGAK+Gee3+yFxkTJO/cx83Dkyzo4CnQl/2zVSurtG6+G86EQIZ+w+VYngVyK7P3HyTBKu3nw==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /esbuild-android-arm64/0.14.38: + resolution: {integrity: sha512-L2NgQRWuHFI89IIZIlpAcINy9FvBk6xFVZ7xGdOwIm8VyhX1vNCEqUJO3DPSSy945Gzdg98cxtNt8Grv1CsyhA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /esbuild-darwin-64/0.14.38: + resolution: {integrity: sha512-5JJvgXkX87Pd1Og0u/NJuO7TSqAikAcQQ74gyJ87bqWRVeouky84ICoV4sN6VV53aTW+NE87qLdGY4QA2S7KNA==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /esbuild-darwin-arm64/0.14.38: + resolution: {integrity: sha512-eqF+OejMI3mC5Dlo9Kdq/Ilbki9sQBw3QlHW3wjLmsLh+quNfHmGMp3Ly1eWm981iGBMdbtSS9+LRvR2T8B3eQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /esbuild-freebsd-64/0.14.38: + resolution: {integrity: sha512-epnPbhZUt93xV5cgeY36ZxPXDsQeO55DppzsIgWM8vgiG/Rz+qYDLmh5ts3e+Ln1wA9dQ+nZmVHw+RjaW3I5Ig==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-freebsd-arm64/0.14.38: + resolution: {integrity: sha512-/9icXUYJWherhk+y5fjPI5yNUdFPtXHQlwP7/K/zg8t8lQdHVj20SqU9/udQmeUo5pDFHMYzcEFfJqgOVeKNNQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-32/0.14.38: + resolution: {integrity: sha512-QfgfeNHRFvr2XeHFzP8kOZVnal3QvST3A0cgq32ZrHjSMFTdgXhMhmWdKzRXP/PKcfv3e2OW9tT9PpcjNvaq6g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-64/0.14.38: + resolution: {integrity: sha512-uuZHNmqcs+Bj1qiW9k/HZU3FtIHmYiuxZ/6Aa+/KHb/pFKr7R3aVqvxlAudYI9Fw3St0VCPfv7QBpUITSmBR1Q==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-arm/0.14.38: + resolution: {integrity: sha512-FiFvQe8J3VKTDXG01JbvoVRXQ0x6UZwyrU4IaLBZeq39Bsbatd94Fuc3F1RGqPF5RbIWW7RvkVQjn79ejzysnA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-arm64/0.14.38: + resolution: {integrity: sha512-HlMGZTEsBrXrivr64eZ/EO0NQM8H8DuSENRok9d+Jtvq8hOLzrxfsAT9U94K3KOGk2XgCmkaI2KD8hX7F97lvA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-mips64le/0.14.38: + resolution: {integrity: sha512-qd1dLf2v7QBiI5wwfil9j0HG/5YMFBAmMVmdeokbNAMbcg49p25t6IlJFXAeLzogv1AvgaXRXvgFNhScYEUXGQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-ppc64le/0.14.38: + resolution: {integrity: sha512-mnbEm7o69gTl60jSuK+nn+pRsRHGtDPfzhrqEUXyCl7CTOCLtWN2bhK8bgsdp6J/2NyS/wHBjs1x8aBWwP2X9Q==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-riscv64/0.14.38: + resolution: {integrity: sha512-+p6YKYbuV72uikChRk14FSyNJZ4WfYkffj6Af0/Tw63/6TJX6TnIKE+6D3xtEc7DeDth1fjUOEqm+ApKFXbbVQ==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-s390x/0.14.38: + resolution: {integrity: sha512-0zUsiDkGJiMHxBQ7JDU8jbaanUY975CdOW1YDrurjrM0vWHfjv9tLQsW9GSyEb/heSK1L5gaweRjzfUVBFoybQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-netbsd-64/0.14.38: + resolution: {integrity: sha512-cljBAApVwkpnJZfnRVThpRBGzCi+a+V9Ofb1fVkKhtrPLDYlHLrSYGtmnoTVWDQdU516qYI8+wOgcGZ4XIZh0Q==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-openbsd-64/0.14.38: + resolution: {integrity: sha512-CDswYr2PWPGEPpLDUO50mL3WO/07EMjnZDNKpmaxUPsrW+kVM3LoAqr/CE8UbzugpEiflYqJsGPLirThRB18IQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-sunos-64/0.14.38: + resolution: {integrity: sha512-2mfIoYW58gKcC3bck0j7lD3RZkqYA7MmujFYmSn9l6TiIcAMpuEvqksO+ntBgbLep/eyjpgdplF7b+4T9VJGOA==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-32/0.14.38: + resolution: {integrity: sha512-L2BmEeFZATAvU+FJzJiRLFUP+d9RHN+QXpgaOrs2klshoAm1AE6Us4X6fS9k33Uy5SzScn2TpcgecbqJza1Hjw==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-64/0.14.38: + resolution: {integrity: sha512-Khy4wVmebnzue8aeSXLC+6clo/hRYeNIm0DyikoEqX+3w3rcvrhzpoix0S+MF9vzh6JFskkIGD7Zx47ODJNyCw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-arm64/0.14.38: + resolution: {integrity: sha512-k3FGCNmHBkqdJXuJszdWciAH77PukEyDsdIryEHn9cKLQFxzhT39dSumeTuggaQcXY57UlmLGIkklWZo2qzHpw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild/0.14.38: + resolution: {integrity: sha512-12fzJ0fsm7gVZX1YQ1InkOE5f9Tl7cgf6JPYXRJtPIoE0zkWAbHdPHVPPaLi9tYAcEBqheGzqLn/3RdTOyBfcA==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + esbuild-android-64: 0.14.38 + esbuild-android-arm64: 0.14.38 + esbuild-darwin-64: 0.14.38 + esbuild-darwin-arm64: 0.14.38 + esbuild-freebsd-64: 0.14.38 + esbuild-freebsd-arm64: 0.14.38 + esbuild-linux-32: 0.14.38 + esbuild-linux-64: 0.14.38 + esbuild-linux-arm: 0.14.38 + esbuild-linux-arm64: 0.14.38 + esbuild-linux-mips64le: 0.14.38 + esbuild-linux-ppc64le: 0.14.38 + esbuild-linux-riscv64: 0.14.38 + esbuild-linux-s390x: 0.14.38 + esbuild-netbsd-64: 0.14.38 + esbuild-openbsd-64: 0.14.38 + esbuild-sunos-64: 0.14.38 + esbuild-windows-32: 0.14.38 + esbuild-windows-64: 0.14.38 + esbuild-windows-arm64: 0.14.38 + dev: true + + /estree-walker/2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + dev: true + + /fill-range/7.0.1: + resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + engines: {node: '>=8'} + dependencies: + to-regex-range: 5.0.1 + dev: true + + /fs.realpath/1.0.0: + resolution: {integrity: sha1-FQStJSMVjKpA20onh8sBQRmU6k8=} + dev: true + + /fsevents/2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /function-bind/1.1.1: + resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} + dev: true + + /fuzzysort/1.9.0: + resolution: {integrity: sha512-MOxCT0qLTwLqmEwc7UtU045RKef7mc8Qz8eR4r2bLNEq9dy/c3ZKMEFp6IEst69otkQdFZ4FfgH2dmZD+ddX1g==} + dev: false + + /glob-parent/5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + dependencies: + is-glob: 4.0.3 + dev: true + + /glob/7.2.0: + resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + dev: true + + /graceful-fs/4.2.10: + resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} + dev: true + + /has/1.0.3: + resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} + engines: {node: '>= 0.4.0'} + dependencies: + function-bind: 1.1.1 + dev: true + + /immutable/4.0.0: + resolution: {integrity: sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==} + dev: true + + /inflight/1.0.6: + resolution: {integrity: sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=} + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + dev: true + + /inherits/2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + dev: true + + /is-binary-path/2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + dependencies: + binary-extensions: 2.2.0 + dev: true + + /is-core-module/2.9.0: + resolution: {integrity: sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==} + dependencies: + has: 1.0.3 + dev: true + + /is-extglob/2.1.1: + resolution: {integrity: sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=} + engines: {node: '>=0.10.0'} + dev: true + + /is-glob/4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + dependencies: + is-extglob: 2.1.1 + dev: true + + /is-number/7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + dev: true + + /jsbi/4.3.0: + resolution: {integrity: sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g==} + dev: false + + /kleur/4.1.4: + resolution: {integrity: sha512-8QADVssbrFjivHWQU7KkMgptGTl6WAcSdlbBPY4uNF+mWr6DGcKrvY2w4FQJoXch7+fKMjj0dRrL75vk3k23OA==} + engines: {node: '>=6'} + dev: true + + /magic-string/0.25.9: + resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} + dependencies: + sourcemap-codec: 1.4.8 + dev: true + + /magic-string/0.26.1: + resolution: {integrity: sha512-ndThHmvgtieXe8J/VGPjG+Apu7v7ItcD5mhEIvOscWjPF/ccOiLxHaSuCAS2G+3x4GKsAbT8u7zdyamupui8Tg==} + engines: {node: '>=12'} + dependencies: + sourcemap-codec: 1.4.8 + dev: true + + /min-indent/1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + dev: true + + /minimatch/3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + dependencies: + brace-expansion: 1.1.11 + dev: true + + /minimist/1.2.6: + resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==} + dev: true + + /mkdirp/0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + dependencies: + minimist: 1.2.6 + dev: true + + /ms/2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + dev: true + + /nanoid/3.3.4: + resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + dev: true + + /normalize-path/3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + dev: true + + /once/1.4.0: + resolution: {integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=} + dependencies: + wrappy: 1.0.2 + dev: true + + /path-is-absolute/1.0.1: + resolution: {integrity: sha1-F0uSaHNVNP+8es5r9TpanhtcX18=} + engines: {node: '>=0.10.0'} + dev: true + + /path-parse/1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + dev: true + + /picocolors/1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + dev: true + + /picomatch/2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + dev: true + + /postcss/8.4.13: + resolution: {integrity: sha512-jtL6eTBrza5MPzy8oJLFuUscHDXTV5KcLlqAWHl5q5WYRfnNRGSmOZmOZ1T6Gy7A99mOZfqungmZMpMmCVJ8ZA==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.4 + picocolors: 1.0.0 + source-map-js: 1.0.2 + dev: true + + /readdirp/3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + dependencies: + picomatch: 2.3.1 + dev: true + + /regexparam/2.0.0: + resolution: {integrity: sha512-gJKwd2MVPWHAIFLsaYDZfyKzHNS4o7E/v8YmNf44vmeV2e4YfVoDToTOKTvE7ab68cRJ++kLuEXJBaEeJVt5ow==} + engines: {node: '>=8'} + dev: true + + /resolve/1.22.0: + resolution: {integrity: sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==} + hasBin: true + dependencies: + is-core-module: 2.9.0 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + dev: true + + /rimraf/2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + hasBin: true + dependencies: + glob: 7.2.0 + dev: true + + /rollup/2.72.1: + resolution: {integrity: sha512-NTc5UGy/NWFGpSqF1lFY8z9Adri6uhyMLI6LvPAXdBKoPRFhIIiBUpt+Qg2awixqO3xvzSijjhnb4+QEZwJmxA==} + engines: {node: '>=10.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /sander/0.5.1: + resolution: {integrity: sha1-dB4kXiMfB8r7b98PEzrfohalAq0=} + dependencies: + es6-promise: 3.3.1 + graceful-fs: 4.2.10 + mkdirp: 0.5.6 + rimraf: 2.7.1 + dev: true + + /sass/1.51.0: + resolution: {integrity: sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==} + engines: {node: '>=12.0.0'} + hasBin: true + dependencies: + chokidar: 3.5.3 + immutable: 4.0.0 + source-map-js: 1.0.2 + dev: true + + /sorcery/0.10.0: + resolution: {integrity: sha1-iukK19fLBfxZ8asMY3hF1cFaUrc=} + hasBin: true + dependencies: + buffer-crc32: 0.2.13 + minimist: 1.2.6 + sander: 0.5.1 + sourcemap-codec: 1.4.8 + dev: true + + /source-map-js/1.0.2: + resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + engines: {node: '>=0.10.0'} + dev: true + + /sourcemap-codec/1.4.8: + resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} + dev: true + + /strip-indent/3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + dependencies: + min-indent: 1.0.1 + dev: true + + /supports-preserve-symlinks-flag/1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + dev: true + + /svelte-hmr/0.14.11_svelte@3.48.0: + resolution: {integrity: sha512-R9CVfX6DXxW1Kn45Jtmx+yUe+sPhrbYSUp7TkzbW0jI5fVPn6lsNG9NEs5dFg5qRhFNAoVdRw5qQDLALNKhwbQ==} + engines: {node: ^12.20 || ^14.13.1 || >= 16} + peerDependencies: + svelte: '>=3.19.0' + dependencies: + svelte: 3.48.0 + dev: true + + /svelte-preprocess/4.10.6_24ezlekk4ocevlsjgs2qnqmjum: + resolution: {integrity: sha512-I2SV1w/AveMvgIQlUF/ZOO3PYVnhxfcpNyGt8pxpUVhPfyfL/CZBkkw/KPfuFix5FJ9TnnNYMhACK3DtSaYVVQ==} + engines: {node: '>= 9.11.2'} + requiresBuild: true + peerDependencies: + '@babel/core': ^7.10.2 + coffeescript: ^2.5.1 + less: ^3.11.3 || ^4.0.0 + node-sass: '*' + postcss: ^7 || ^8 + postcss-load-config: ^2.1.0 || ^3.0.0 + pug: ^3.0.0 + sass: ^1.26.8 + stylus: ^0.55.0 + sugarss: ^2.0.0 + svelte: ^3.23.0 + typescript: ^3.9.5 || ^4.0.0 + peerDependenciesMeta: + '@babel/core': + optional: true + coffeescript: + optional: true + less: + optional: true + node-sass: + optional: true + postcss: + optional: true + postcss-load-config: + optional: true + pug: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + typescript: + optional: true + dependencies: + '@types/pug': 2.0.6 + '@types/sass': 1.43.1 + detect-indent: 6.1.0 + magic-string: 0.25.9 + sass: 1.51.0 + sorcery: 0.10.0 + strip-indent: 3.0.0 + svelte: 3.48.0 + typescript: 4.6.4 + dev: true + + /svelte-spa-router/3.2.0: + resolution: {integrity: sha512-igemo5Vs82TGBBw+DjWt6qKameXYzNs6aDXcTxou5XbEvOjiRcAM6MLkdVRCatn6u8r42dE99bt/br7T4qe/AQ==} + dependencies: + regexparam: 2.0.0 + dev: true + + /svelte/3.48.0: + resolution: {integrity: sha512-fN2YRm/bGumvjUpu6yI3BpvZnpIm9I6A7HR4oUNYd7ggYyIwSA/BX7DJ+UXXffLp6XNcUijyLvttbPVCYa/3xQ==} + engines: {node: '>= 8'} + dev: true + + /to-regex-range/5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + dependencies: + is-number: 7.0.0 + dev: true + + /tslib/2.4.0: + resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==} + dev: false + + /typescript/4.6.4: + resolution: {integrity: sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==} + engines: {node: '>=4.2.0'} + hasBin: true + dev: true + + /vite/2.9.8_sass@1.51.0: + resolution: {integrity: sha512-zsBGwn5UT3YS0NLSJ7hnR54+vUKfgzMUh/Z9CxF1YKEBVIe213+63jrFLmZphgGI5zXwQCSmqIdbPuE8NJywPw==} + engines: {node: '>=12.2.0'} + hasBin: true + peerDependencies: + less: '*' + sass: '*' + stylus: '*' + peerDependenciesMeta: + less: + optional: true + sass: + optional: true + stylus: + optional: true + dependencies: + esbuild: 0.14.38 + postcss: 8.4.13 + resolve: 1.22.0 + rollup: 2.72.1 + sass: 1.51.0 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /wrappy/1.0.2: + resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=} + dev: true diff --git a/apps/projects-web/src/tsconfig.json b/apps/projects-web/src/tsconfig.json new file mode 100644 index 0000000..c60fce6 --- /dev/null +++ b/apps/projects-web/src/tsconfig.json @@ -0,0 +1,27 @@ +{ + "include": [ + "./**/*.d.ts", + "./**/*.ts", + "./**/*.js", + "./**/*.svelte" + ], + "exclude": [ + "./node_modules" + ], + "compilerOptions": { + "target": "esnext", + "useDefineForClassFields": true, + "module": "esnext", + "moduleResolution": "node", + "allowJs": true, + "checkJs": false, + "paths": { + "$app/*": [ + "./app/*" + ], + "$shared/*": [ + "../../web-shared/src/*" + ] + } + } +} diff --git a/apps/projects-web/src/vite.config.ts b/apps/projects-web/src/vite.config.ts new file mode 100644 index 0000000..ac44266 --- /dev/null +++ b/apps/projects-web/src/vite.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import sveltePreprocess from "svelte-preprocess"; +// @ts-ignore +import path from "path"; + +// https://vitejs.dev/config/ +export default defineConfig({ + resolve: { + alias: { + "$shared": path.resolve(__dirname, "../../web-shared/src"), + "$app": path.resolve(__dirname, "./app"), + "$public": path.resolve(__dirname, "./_public"), + } + }, + build: { + outDir: "build", + emptyOutDir: true, + rollupOptions: { + input: { + main: path.resolve(__dirname, "index.html"), + } + } + }, + + plugins: [ + svelte({ + preprocess: sveltePreprocess() + }) + ], +}); diff --git a/apps/web-shared/package.json b/apps/web-shared/package.json new file mode 100644 index 0000000..b7b83d5 --- /dev/null +++ b/apps/web-shared/package.json @@ -0,0 +1,12 @@ +{ + "name": "time-tracker-shared", + "version": "0.0.1", + "private": "true", + "devDependencies": { + "svelte": "^3.48.0", + "svelte-spa-router": "^3.2.0", + "typescript": "4.6.4", + "@js-temporal/polyfill": "^0.4.1", + "fuzzysort": "^1.9.0" + } +} diff --git a/apps/web-shared/pnpm-lock.yaml b/apps/web-shared/pnpm-lock.yaml new file mode 100644 index 0000000..3b56115 --- /dev/null +++ b/apps/web-shared/pnpm-lock.yaml @@ -0,0 +1,769 @@ +lockfileVersion: 5.4 + +specifiers: + '@js-temporal/polyfill': ^0.4.1 + '@sveltejs/vite-plugin-svelte': 1.0.0-next.43 + fuzzysort: ^1.9.0 + sass: ^1.51.0 + svelte: ^3.48.0 + svelte-preprocess: ^4.10.6 + svelte-spa-router: ^3.2.0 + typescript: 4.6.4 + vite: ^2.9.8 + +dependencies: + '@js-temporal/polyfill': 0.4.1 + fuzzysort: 1.9.0 + +devDependencies: + '@sveltejs/vite-plugin-svelte': 1.0.0-next.43_svelte@3.48.0+vite@2.9.8 + sass: 1.51.0 + svelte: 3.48.0 + svelte-preprocess: 4.10.6_24ezlekk4ocevlsjgs2qnqmjum + svelte-spa-router: 3.2.0 + typescript: 4.6.4 + vite: 2.9.8_sass@1.51.0 + +packages: + + /@js-temporal/polyfill/0.4.1: + resolution: {integrity: sha512-q45ecIocpa2TLem2jNOsCrDwP/sgKZdSkt+C1Rx07OkdKsdbvVfHcD1iDiK9scxBZrBQ38uJ8VQISXBS70ql1w==} + engines: {node: '>=12'} + dependencies: + jsbi: 4.3.0 + tslib: 2.4.0 + dev: false + + /@rollup/pluginutils/4.2.1: + resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==} + engines: {node: '>= 8.0.0'} + dependencies: + estree-walker: 2.0.2 + picomatch: 2.3.1 + dev: true + + /@sveltejs/vite-plugin-svelte/1.0.0-next.43_svelte@3.48.0+vite@2.9.8: + resolution: {integrity: sha512-MzeczqGrnDmbAldw/LfXV/dhpLC2bdUzuMhcx0C2j79V2uNzQERHDinxXnG2AVTCTjSpbQxzQwMMmYflnI7W1g==} + engines: {node: ^14.13.1 || >= 16} + peerDependencies: + diff-match-patch: ^1.0.5 + svelte: ^3.44.0 + vite: ^2.9.0 + peerDependenciesMeta: + diff-match-patch: + optional: true + dependencies: + '@rollup/pluginutils': 4.2.1 + debug: 4.3.4 + deepmerge: 4.2.2 + kleur: 4.1.4 + magic-string: 0.26.1 + svelte: 3.48.0 + svelte-hmr: 0.14.11_svelte@3.48.0 + vite: 2.9.8_sass@1.51.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@types/node/17.0.31: + resolution: {integrity: sha512-AR0x5HbXGqkEx9CadRH3EBYx/VkiUgZIhP4wvPn/+5KIsgpNoyFaRlVe0Zlx9gRtg8fA06a9tskE2MSN7TcG4Q==} + dev: true + + /@types/pug/2.0.6: + resolution: {integrity: sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg==} + dev: true + + /@types/sass/1.43.1: + resolution: {integrity: sha512-BPdoIt1lfJ6B7rw35ncdwBZrAssjcwzI5LByIrYs+tpXlj/CAkuVdRsgZDdP4lq5EjyWzwxZCqAoFyHKFwp32g==} + dependencies: + '@types/node': 17.0.31 + dev: true + + /anymatch/3.1.2: + resolution: {integrity: sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==} + engines: {node: '>= 8'} + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + dev: true + + /balanced-match/1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + dev: true + + /binary-extensions/2.2.0: + resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} + engines: {node: '>=8'} + dev: true + + /brace-expansion/1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + dev: true + + /braces/3.0.2: + resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} + engines: {node: '>=8'} + dependencies: + fill-range: 7.0.1 + dev: true + + /buffer-crc32/0.2.13: + resolution: {integrity: sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=} + dev: true + + /chokidar/3.5.3: + resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.2 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /concat-map/0.0.1: + resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} + dev: true + + /debug/4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + dev: true + + /deepmerge/4.2.2: + resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==} + engines: {node: '>=0.10.0'} + dev: true + + /detect-indent/6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + dev: true + + /es6-promise/3.3.1: + resolution: {integrity: sha1-oIzd6EzNvzTQJ6FFG8kdS80ophM=} + dev: true + + /esbuild-android-64/0.14.38: + resolution: {integrity: sha512-aRFxR3scRKkbmNuGAK+Gee3+yFxkTJO/cx83Dkyzo4CnQl/2zVSurtG6+G86EQIZ+w+VYngVyK7P3HyTBKu3nw==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /esbuild-android-arm64/0.14.38: + resolution: {integrity: sha512-L2NgQRWuHFI89IIZIlpAcINy9FvBk6xFVZ7xGdOwIm8VyhX1vNCEqUJO3DPSSy945Gzdg98cxtNt8Grv1CsyhA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /esbuild-darwin-64/0.14.38: + resolution: {integrity: sha512-5JJvgXkX87Pd1Og0u/NJuO7TSqAikAcQQ74gyJ87bqWRVeouky84ICoV4sN6VV53aTW+NE87qLdGY4QA2S7KNA==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /esbuild-darwin-arm64/0.14.38: + resolution: {integrity: sha512-eqF+OejMI3mC5Dlo9Kdq/Ilbki9sQBw3QlHW3wjLmsLh+quNfHmGMp3Ly1eWm981iGBMdbtSS9+LRvR2T8B3eQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /esbuild-freebsd-64/0.14.38: + resolution: {integrity: sha512-epnPbhZUt93xV5cgeY36ZxPXDsQeO55DppzsIgWM8vgiG/Rz+qYDLmh5ts3e+Ln1wA9dQ+nZmVHw+RjaW3I5Ig==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-freebsd-arm64/0.14.38: + resolution: {integrity: sha512-/9icXUYJWherhk+y5fjPI5yNUdFPtXHQlwP7/K/zg8t8lQdHVj20SqU9/udQmeUo5pDFHMYzcEFfJqgOVeKNNQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-32/0.14.38: + resolution: {integrity: sha512-QfgfeNHRFvr2XeHFzP8kOZVnal3QvST3A0cgq32ZrHjSMFTdgXhMhmWdKzRXP/PKcfv3e2OW9tT9PpcjNvaq6g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-64/0.14.38: + resolution: {integrity: sha512-uuZHNmqcs+Bj1qiW9k/HZU3FtIHmYiuxZ/6Aa+/KHb/pFKr7R3aVqvxlAudYI9Fw3St0VCPfv7QBpUITSmBR1Q==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-arm/0.14.38: + resolution: {integrity: sha512-FiFvQe8J3VKTDXG01JbvoVRXQ0x6UZwyrU4IaLBZeq39Bsbatd94Fuc3F1RGqPF5RbIWW7RvkVQjn79ejzysnA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-arm64/0.14.38: + resolution: {integrity: sha512-HlMGZTEsBrXrivr64eZ/EO0NQM8H8DuSENRok9d+Jtvq8hOLzrxfsAT9U94K3KOGk2XgCmkaI2KD8hX7F97lvA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-mips64le/0.14.38: + resolution: {integrity: sha512-qd1dLf2v7QBiI5wwfil9j0HG/5YMFBAmMVmdeokbNAMbcg49p25t6IlJFXAeLzogv1AvgaXRXvgFNhScYEUXGQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-ppc64le/0.14.38: + resolution: {integrity: sha512-mnbEm7o69gTl60jSuK+nn+pRsRHGtDPfzhrqEUXyCl7CTOCLtWN2bhK8bgsdp6J/2NyS/wHBjs1x8aBWwP2X9Q==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-riscv64/0.14.38: + resolution: {integrity: sha512-+p6YKYbuV72uikChRk14FSyNJZ4WfYkffj6Af0/Tw63/6TJX6TnIKE+6D3xtEc7DeDth1fjUOEqm+ApKFXbbVQ==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-s390x/0.14.38: + resolution: {integrity: sha512-0zUsiDkGJiMHxBQ7JDU8jbaanUY975CdOW1YDrurjrM0vWHfjv9tLQsW9GSyEb/heSK1L5gaweRjzfUVBFoybQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-netbsd-64/0.14.38: + resolution: {integrity: sha512-cljBAApVwkpnJZfnRVThpRBGzCi+a+V9Ofb1fVkKhtrPLDYlHLrSYGtmnoTVWDQdU516qYI8+wOgcGZ4XIZh0Q==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-openbsd-64/0.14.38: + resolution: {integrity: sha512-CDswYr2PWPGEPpLDUO50mL3WO/07EMjnZDNKpmaxUPsrW+kVM3LoAqr/CE8UbzugpEiflYqJsGPLirThRB18IQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-sunos-64/0.14.38: + resolution: {integrity: sha512-2mfIoYW58gKcC3bck0j7lD3RZkqYA7MmujFYmSn9l6TiIcAMpuEvqksO+ntBgbLep/eyjpgdplF7b+4T9VJGOA==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-32/0.14.38: + resolution: {integrity: sha512-L2BmEeFZATAvU+FJzJiRLFUP+d9RHN+QXpgaOrs2klshoAm1AE6Us4X6fS9k33Uy5SzScn2TpcgecbqJza1Hjw==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-64/0.14.38: + resolution: {integrity: sha512-Khy4wVmebnzue8aeSXLC+6clo/hRYeNIm0DyikoEqX+3w3rcvrhzpoix0S+MF9vzh6JFskkIGD7Zx47ODJNyCw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-arm64/0.14.38: + resolution: {integrity: sha512-k3FGCNmHBkqdJXuJszdWciAH77PukEyDsdIryEHn9cKLQFxzhT39dSumeTuggaQcXY57UlmLGIkklWZo2qzHpw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild/0.14.38: + resolution: {integrity: sha512-12fzJ0fsm7gVZX1YQ1InkOE5f9Tl7cgf6JPYXRJtPIoE0zkWAbHdPHVPPaLi9tYAcEBqheGzqLn/3RdTOyBfcA==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + esbuild-android-64: 0.14.38 + esbuild-android-arm64: 0.14.38 + esbuild-darwin-64: 0.14.38 + esbuild-darwin-arm64: 0.14.38 + esbuild-freebsd-64: 0.14.38 + esbuild-freebsd-arm64: 0.14.38 + esbuild-linux-32: 0.14.38 + esbuild-linux-64: 0.14.38 + esbuild-linux-arm: 0.14.38 + esbuild-linux-arm64: 0.14.38 + esbuild-linux-mips64le: 0.14.38 + esbuild-linux-ppc64le: 0.14.38 + esbuild-linux-riscv64: 0.14.38 + esbuild-linux-s390x: 0.14.38 + esbuild-netbsd-64: 0.14.38 + esbuild-openbsd-64: 0.14.38 + esbuild-sunos-64: 0.14.38 + esbuild-windows-32: 0.14.38 + esbuild-windows-64: 0.14.38 + esbuild-windows-arm64: 0.14.38 + dev: true + + /estree-walker/2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + dev: true + + /fill-range/7.0.1: + resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + engines: {node: '>=8'} + dependencies: + to-regex-range: 5.0.1 + dev: true + + /fs.realpath/1.0.0: + resolution: {integrity: sha1-FQStJSMVjKpA20onh8sBQRmU6k8=} + dev: true + + /fsevents/2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /function-bind/1.1.1: + resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} + dev: true + + /fuzzysort/1.9.0: + resolution: {integrity: sha512-MOxCT0qLTwLqmEwc7UtU045RKef7mc8Qz8eR4r2bLNEq9dy/c3ZKMEFp6IEst69otkQdFZ4FfgH2dmZD+ddX1g==} + dev: false + + /glob-parent/5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + dependencies: + is-glob: 4.0.3 + dev: true + + /glob/7.2.0: + resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + dev: true + + /graceful-fs/4.2.10: + resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} + dev: true + + /has/1.0.3: + resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} + engines: {node: '>= 0.4.0'} + dependencies: + function-bind: 1.1.1 + dev: true + + /immutable/4.0.0: + resolution: {integrity: sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==} + dev: true + + /inflight/1.0.6: + resolution: {integrity: sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=} + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + dev: true + + /inherits/2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + dev: true + + /is-binary-path/2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + dependencies: + binary-extensions: 2.2.0 + dev: true + + /is-core-module/2.9.0: + resolution: {integrity: sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==} + dependencies: + has: 1.0.3 + dev: true + + /is-extglob/2.1.1: + resolution: {integrity: sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=} + engines: {node: '>=0.10.0'} + dev: true + + /is-glob/4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + dependencies: + is-extglob: 2.1.1 + dev: true + + /is-number/7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + dev: true + + /jsbi/4.3.0: + resolution: {integrity: sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g==} + dev: false + + /kleur/4.1.4: + resolution: {integrity: sha512-8QADVssbrFjivHWQU7KkMgptGTl6WAcSdlbBPY4uNF+mWr6DGcKrvY2w4FQJoXch7+fKMjj0dRrL75vk3k23OA==} + engines: {node: '>=6'} + dev: true + + /magic-string/0.25.9: + resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} + dependencies: + sourcemap-codec: 1.4.8 + dev: true + + /magic-string/0.26.1: + resolution: {integrity: sha512-ndThHmvgtieXe8J/VGPjG+Apu7v7ItcD5mhEIvOscWjPF/ccOiLxHaSuCAS2G+3x4GKsAbT8u7zdyamupui8Tg==} + engines: {node: '>=12'} + dependencies: + sourcemap-codec: 1.4.8 + dev: true + + /min-indent/1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + dev: true + + /minimatch/3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + dependencies: + brace-expansion: 1.1.11 + dev: true + + /minimist/1.2.6: + resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==} + dev: true + + /mkdirp/0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + dependencies: + minimist: 1.2.6 + dev: true + + /ms/2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + dev: true + + /nanoid/3.3.4: + resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + dev: true + + /normalize-path/3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + dev: true + + /once/1.4.0: + resolution: {integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=} + dependencies: + wrappy: 1.0.2 + dev: true + + /path-is-absolute/1.0.1: + resolution: {integrity: sha1-F0uSaHNVNP+8es5r9TpanhtcX18=} + engines: {node: '>=0.10.0'} + dev: true + + /path-parse/1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + dev: true + + /picocolors/1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + dev: true + + /picomatch/2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + dev: true + + /postcss/8.4.13: + resolution: {integrity: sha512-jtL6eTBrza5MPzy8oJLFuUscHDXTV5KcLlqAWHl5q5WYRfnNRGSmOZmOZ1T6Gy7A99mOZfqungmZMpMmCVJ8ZA==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.4 + picocolors: 1.0.0 + source-map-js: 1.0.2 + dev: true + + /readdirp/3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + dependencies: + picomatch: 2.3.1 + dev: true + + /regexparam/2.0.0: + resolution: {integrity: sha512-gJKwd2MVPWHAIFLsaYDZfyKzHNS4o7E/v8YmNf44vmeV2e4YfVoDToTOKTvE7ab68cRJ++kLuEXJBaEeJVt5ow==} + engines: {node: '>=8'} + dev: true + + /resolve/1.22.0: + resolution: {integrity: sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==} + hasBin: true + dependencies: + is-core-module: 2.9.0 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + dev: true + + /rimraf/2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + hasBin: true + dependencies: + glob: 7.2.0 + dev: true + + /rollup/2.72.1: + resolution: {integrity: sha512-NTc5UGy/NWFGpSqF1lFY8z9Adri6uhyMLI6LvPAXdBKoPRFhIIiBUpt+Qg2awixqO3xvzSijjhnb4+QEZwJmxA==} + engines: {node: '>=10.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /sander/0.5.1: + resolution: {integrity: sha1-dB4kXiMfB8r7b98PEzrfohalAq0=} + dependencies: + es6-promise: 3.3.1 + graceful-fs: 4.2.10 + mkdirp: 0.5.6 + rimraf: 2.7.1 + dev: true + + /sass/1.51.0: + resolution: {integrity: sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==} + engines: {node: '>=12.0.0'} + hasBin: true + dependencies: + chokidar: 3.5.3 + immutable: 4.0.0 + source-map-js: 1.0.2 + dev: true + + /sorcery/0.10.0: + resolution: {integrity: sha1-iukK19fLBfxZ8asMY3hF1cFaUrc=} + hasBin: true + dependencies: + buffer-crc32: 0.2.13 + minimist: 1.2.6 + sander: 0.5.1 + sourcemap-codec: 1.4.8 + dev: true + + /source-map-js/1.0.2: + resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + engines: {node: '>=0.10.0'} + dev: true + + /sourcemap-codec/1.4.8: + resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} + dev: true + + /strip-indent/3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + dependencies: + min-indent: 1.0.1 + dev: true + + /supports-preserve-symlinks-flag/1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + dev: true + + /svelte-hmr/0.14.11_svelte@3.48.0: + resolution: {integrity: sha512-R9CVfX6DXxW1Kn45Jtmx+yUe+sPhrbYSUp7TkzbW0jI5fVPn6lsNG9NEs5dFg5qRhFNAoVdRw5qQDLALNKhwbQ==} + engines: {node: ^12.20 || ^14.13.1 || >= 16} + peerDependencies: + svelte: '>=3.19.0' + dependencies: + svelte: 3.48.0 + dev: true + + /svelte-preprocess/4.10.6_24ezlekk4ocevlsjgs2qnqmjum: + resolution: {integrity: sha512-I2SV1w/AveMvgIQlUF/ZOO3PYVnhxfcpNyGt8pxpUVhPfyfL/CZBkkw/KPfuFix5FJ9TnnNYMhACK3DtSaYVVQ==} + engines: {node: '>= 9.11.2'} + requiresBuild: true + peerDependencies: + '@babel/core': ^7.10.2 + coffeescript: ^2.5.1 + less: ^3.11.3 || ^4.0.0 + node-sass: '*' + postcss: ^7 || ^8 + postcss-load-config: ^2.1.0 || ^3.0.0 + pug: ^3.0.0 + sass: ^1.26.8 + stylus: ^0.55.0 + sugarss: ^2.0.0 + svelte: ^3.23.0 + typescript: ^3.9.5 || ^4.0.0 + peerDependenciesMeta: + '@babel/core': + optional: true + coffeescript: + optional: true + less: + optional: true + node-sass: + optional: true + postcss: + optional: true + postcss-load-config: + optional: true + pug: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + typescript: + optional: true + dependencies: + '@types/pug': 2.0.6 + '@types/sass': 1.43.1 + detect-indent: 6.1.0 + magic-string: 0.25.9 + sass: 1.51.0 + sorcery: 0.10.0 + strip-indent: 3.0.0 + svelte: 3.48.0 + typescript: 4.6.4 + dev: true + + /svelte-spa-router/3.2.0: + resolution: {integrity: sha512-igemo5Vs82TGBBw+DjWt6qKameXYzNs6aDXcTxou5XbEvOjiRcAM6MLkdVRCatn6u8r42dE99bt/br7T4qe/AQ==} + dependencies: + regexparam: 2.0.0 + dev: true + + /svelte/3.48.0: + resolution: {integrity: sha512-fN2YRm/bGumvjUpu6yI3BpvZnpIm9I6A7HR4oUNYd7ggYyIwSA/BX7DJ+UXXffLp6XNcUijyLvttbPVCYa/3xQ==} + engines: {node: '>= 8'} + dev: true + + /to-regex-range/5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + dependencies: + is-number: 7.0.0 + dev: true + + /tslib/2.4.0: + resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==} + dev: false + + /typescript/4.6.4: + resolution: {integrity: sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==} + engines: {node: '>=4.2.0'} + hasBin: true + dev: true + + /vite/2.9.8_sass@1.51.0: + resolution: {integrity: sha512-zsBGwn5UT3YS0NLSJ7hnR54+vUKfgzMUh/Z9CxF1YKEBVIe213+63jrFLmZphgGI5zXwQCSmqIdbPuE8NJywPw==} + engines: {node: '>=12.2.0'} + hasBin: true + peerDependencies: + less: '*' + sass: '*' + stylus: '*' + peerDependenciesMeta: + less: + optional: true + sass: + optional: true + stylus: + optional: true + dependencies: + esbuild: 0.14.38 + postcss: 8.4.13 + resolve: 1.22.0 + rollup: 2.72.1 + sass: 1.51.0 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /wrappy/1.0.2: + resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=} + dev: true diff --git a/apps/web-shared/src/components/alert.svelte b/apps/web-shared/src/components/alert.svelte new file mode 100644 index 0000000..4771f78 --- /dev/null +++ b/apps/web-shared/src/components/alert.svelte @@ -0,0 +1,66 @@ +<script> + import {afterUpdate} from "svelte"; + + export let title = ""; + export let message = ""; + export let type = "info"; + export let visible = true; + export let closeable = false; + + afterUpdate(() => { + if (type === "default") { + type = "primary"; + } + }); +</script> + +<div class="alert alert--{type} padding-sm radius-md" + class:alert--is-visible={visible} + role="alert"> + <div class="flex justify-between"> + <div class="flex flex-row items-center"> + <svg class="icon icon--sm alert__icon margin-right-xxs" + viewBox="0 0 24 24" + aria-hidden="true"> + <path d="M12,0C5.383,0,0,5.383,0,12s5.383,12,12,12s12-5.383,12-12S18.617,0,12,0z M14.658,18.284 c-0.661,0.26-2.952,1.354-4.272,0.191c-0.394-0.346-0.59-0.785-0.59-1.318c0-0.998,0.328-1.868,0.919-3.957 c0.104-0.395,0.231-0.907,0.231-1.313c0-0.701-0.266-0.887-0.987-0.887c-0.352,0-0.742,0.125-1.095,0.257l0.195-0.799 c0.787-0.32,1.775-0.71,2.621-0.71c1.269,0,2.203,0.633,2.203,1.837c0,0.347-0.06,0.955-0.186,1.375l-0.73,2.582 c-0.151,0.522-0.424,1.673-0.001,2.014c0.416,0.337,1.401,0.158,1.887-0.071L14.658,18.284z M13.452,8c-0.828,0-1.5-0.672-1.5-1.5 s0.672-1.5,1.5-1.5s1.5,0.672,1.5,1.5S14.28,8,13.452,8z"></path> + </svg> + {#if title} + <p class="text-sm"> + <strong class="error-title">{title}</strong> + </p> + {:else if message} + <div class="text-component text-sm break-word"> + {@html message} + </div> + {/if} + </div> + {#if closeable} + <button class="reset alert__close-btn" + on:click={() => visible = false}> + <svg class="icon" + viewBox="0 0 20 20" + fill="none" + stroke="currentColor" + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2"> + <title>Close alert</title> + <line x1="3" + y1="3" + x2="17" + y2="17"/> + <line x1="17" + y1="3" + x2="3" + y2="17"/> + </svg> + </button> + {/if} + </div> + + {#if message && title} + <div class="text-component text-sm break-word padding-top-xs"> + {@html message} + </div> + {/if} +</div> diff --git a/apps/web-shared/src/components/button.svelte b/apps/web-shared/src/components/button.svelte new file mode 100644 index 0000000..5eaf19f --- /dev/null +++ b/apps/web-shared/src/components/button.svelte @@ -0,0 +1,116 @@ +<script lang="ts"> + import Icon from "$shared/components/icon.svelte"; + + export let text = ""; + export let title = ""; + export let href = ""; + export let variant: "primary"|"secondary"|"subtle" = "primary"; + export let type: "button"|"submit"|"reset" = "button"; + export let disabled = false; + export let loading = false; + export let icon = ""; + export let icon_right_aligned = false; + export let icon_width = false; + export let icon_height = false; + export let id; + export let tabindex; + export let style; + + $: shared_props = { + type: type, + id: id || null, + title: title || null, + disabled: disabled || null, + tabindex: tabindex || null, + style: style || null, + "aria-controls": ($$restProps["aria-controls"] ?? "") || null, + class: [variant === "reset" ? "reset" : `btn btn--${variant} btn--preserve-width ${loading ? "btn--state-b" : ""}`, $$restProps.class ?? ""].filter(Boolean).join(" "), + }; +</script> + +<template> + {#if href && !disabled} + <a {href} + {...shared_props} + on:click> + <span class="btn__content-a"> + {#if icon !== ""} + {#if icon_right_aligned} + {text} + <Icon class="{text ? 'margin-left-xxxs': ''}" + width={icon_width} + height={icon_height} + name={icon}/> + {:else} + <Icon class="{text ? 'margin-left-xxxs': ''}" + width={icon_width} + height={icon_height} + name={icon}/> + {text} + {/if} + {:else} + {text} + {/if} + </span> + {#if variant !== "reset" && loading} + <span class="btn__content-b"> + <svg class="icon icon--is-spinning" + aria-hidden="true" + viewBox="0 0 16 16"> + <title>Loading</title> + <g stroke-width="1" + fill="currentColor" + stroke="currentColor"> + <path d="M.5,8a7.5,7.5,0,1,1,1.91,5" + fill="none" + stroke="currentColor" + stroke-linecap="round" + stroke-linejoin="round"/> + </g> + </svg> + </span> + {/if} + </a> + {:else} + <button {...shared_props} + on:click> + <span class="btn__content-a"> + {#if icon !== ""} + {#if icon_right_aligned} + {text} + <Icon class="{text ? 'margin-left-xxxs': ''}" + width={icon_width} + height={icon_height} + name={icon}/> + {:else} + <Icon class="{text ? 'margin-left-xxxs': ''}" + width={icon_width} + height={icon_height} + name={icon}/> + {text} + {/if} + {:else} + {text} + {/if} + </span> + {#if variant !== "reset" && loading} + <span class="btn__content-b"> + <svg class="icon icon--is-spinning" + aria-hidden="true" + viewBox="0 0 16 16"> + <title>Loading</title> + <g stroke-width="1" + fill="currentColor" + stroke="currentColor"> + <path d="M.5,8a7.5,7.5,0,1,1,1.91,5" + fill="none" + stroke="currentColor" + stroke-linecap="round" + stroke-linejoin="round"/> + </g> + </svg> + </span> + {/if} + </button> + {/if} +</template> diff --git a/apps/web-shared/src/components/chip.svelte b/apps/web-shared/src/components/chip.svelte new file mode 100644 index 0000000..7fbb445 --- /dev/null +++ b/apps/web-shared/src/components/chip.svelte @@ -0,0 +1,50 @@ +<script> + import {IconNames} from "$shared/lib/configuration"; + import {createEventDispatcher} from "svelte"; + import Button from "./button.svelte"; + + const dispatch = createEventDispatcher(); + export let removable = false; + export let clickable = false; + export let text = ""; + export let id = ""; + export let color = ""; + export let tabindex = ""; + + function handle_remove() { + if (removable) { + dispatch("remove", { + id + }); + } + } + + function handle_click() { + if (clickable) { + dispatch("clicked", { + id + }); + } + } +</script> + +<div class="chip break-word text-sm justify-between justify-start@md {clickable ? 'chip--interactive' : ''}" + on:click={handle_click} + id={id} + style={color !== "" ? `background-color: ${color}15; border: 1px solid ${color}; color: ${color}` : ""} + {tabindex} +> + <span class="chip__label">{text}</span> + + {#if removable} + <Button class="chip__btn" + variant="reset" + style="{color !== '' ? `background-color: ${color}45;` : ''}" + {tabindex} + icon="{IconNames.x}" + icon_width="initial" + icon_height="initial" + on:click={handle_remove} + /> + {/if} +</div> diff --git a/apps/web-shared/src/components/details.svelte b/apps/web-shared/src/components/details.svelte new file mode 100644 index 0000000..6ccacb0 --- /dev/null +++ b/apps/web-shared/src/components/details.svelte @@ -0,0 +1,35 @@ +<script> + import {random_string} from "$shared/lib/helpers"; + + let open = false; + export let summary; + const id = "details-" + random_string(4); + + function on_toggle(event) { + open = event.target.open; + } +</script> + +<details class="details margin-bottom-sm" + on:toggle={on_toggle} + id={id}> + <summary class="details__summary" + aria-controls={id} + aria-expanded={open}> + <span class="flex items-center"> + <svg + class="icon icon--xxs margin-right-xxxs" + aria-hidden="true" + viewBox="0 0 12 12"> + <path + d="M2.783.088A.5.5,0,0,0,2,.5v11a.5.5,0,0,0,.268.442A.49.49,0,0,0,2.5,12a.5.5,0,0,0,.283-.088l8-5.5a.5.5,0,0,0,0-.824Z"/> + </svg> + <span>{summary}</span> + </span> + </summary> + <div + class="details__content text-component margin-top-xs" + aria-hidden={!open}> + <slot/> + </div> +</details> diff --git a/apps/web-shared/src/components/dropdown.svelte b/apps/web-shared/src/components/dropdown.svelte new file mode 100644 index 0000000..b5068a7 --- /dev/null +++ b/apps/web-shared/src/components/dropdown.svelte @@ -0,0 +1,374 @@ +<script lang="ts"> + // @ts-ignore + import {go, highlight} from "fuzzysort"; + import {element_has_focus, random_string} from "$shared/lib/helpers"; + import Button from "$shared/components/button.svelte"; + import Chip from "$shared/components/chip.svelte"; + + export let name; + export let id; + export let maxlength; + export let placeholder = "Search"; + export let entries = []; + export let createable = false; + export let loading = false; + export let multiple = false; + export let noResultsText; + export let errorText; + export let label; + export let on_create_async = ({name: string}) => { + }; + + export const reset = () => methods.reset(); + export const select = (id: string) => methods.select_entry(id); + export const deselect = (id: string) => methods.deselect_entry(id); + + const INTERNAL_ID = "__dropdown-" + random_string(5); + + let entriesUlNode; + let searchInputNode; + let searchResults = []; + let searchValue = ""; + let showCreationHint = false; + let showDropdown = false; + let lastKeydownCode = ""; + let mouseIsOverDropdown = false; + let mouseIsOverComponent = false; + + $: hasSelection = entries.some((c) => c.selected === true); + $: if (searchValue.trim()) { + showCreationHint = createable && entries.every((c) => search.normalise_value(c.name) !== search.normalise_value(searchValue)); + } else { + showCreationHint = false; + entries = methods.get_sorted_array(entries); + } + + const search = { + normalise_value(value: string): string { + if (!value) { + return ""; + } + return value.toString().trim().toLowerCase(); + }, + do() { + const query = search.normalise_value(searchValue); + if (!query.trim()) { + searchResults = []; + return; + } + + const options = { + limit: 10, + allowTypo: true, + threshold: -10000, + key: "name", + }; + searchResults = go(query, entries, options); + showDropdown = true; + }, + on_input_focusout() { + if (lastKeydownCode !== "Tab" && (mouseIsOverDropdown || lastKeydownCode === "ArrowDown")) { + return; + } + const selected = entries.find((c) => c.selected === true); + if (selected && !multiple) { + searchValue = selected.name; + } + showDropdown = false; + } + }; + + const methods = { + reset(focus_input = false) { + searchValue = ""; + const copy = entries; + for (const entry of copy) { + entry.selected = false; + } + entries = methods.get_sorted_array(copy); + if (focus_input) { + searchInputNode?.focus(); + showDropdown = true; + } else { + showDropdown = false; + } + }, + async create_entry(name) { + if (!name || !createable || loading) { + console.log("Not sending creation event due to failed preconditions", {name, createable, loading}); + return; + } + try { + await on_create_async({name}); + searchValue = ""; + loading = false; + } catch (e) { + console.error(e); + } + }, + select_entry(entry_id) { + if (!entry_id || loading) { + console.log("Not selecting entry due to failed preconditions", { + entry_id, + loading, + }); + return; + } + + const copy = entries; + let selected; + for (const entry of entries) { + if (entry.id === entry_id) { + entry.selected = true; + selected = entry; + if (multiple) { + searchValue = ""; + } else { + searchValue = entry.name; + } + } else if (!multiple) { + entry.selected = false; + } + } + entries = methods.get_sorted_array(copy); + searchInputNode?.focus(); + searchResults = []; + }, + deselect_entry(entry_id) { + if (!entry_id || loading) { + console.log("Not deselecting entry due to failed preconditions", { + entry_id, + loading, + }); + return; + } + console.log("Deselecting entry", entry_id); + + const copy = entries; + let deselected; + + for (const entry of copy) { + if (entry.id === entry_id) { + entry.selected = false; + deselected = entry; + } + } + + entries = methods.get_sorted_array(copy); + searchInputNode?.focus(); + }, + get_sorted_array(entries: Array<DropdownEntry>): Array<DropdownEntry> { + if (!entries) { + return; + } + if (entries.length < 1) { + return []; + } + if (searchValue) { + return entries; + } + return (entries as any).sort((a, b) => { + search.normalise_value(a.name).localeCompare(search.normalise_value(b.name)); + }); + }, + }; + + const windowEvents = { + on_mousemove(event) { + mouseIsOverDropdown = (event.target?.closest("#" + INTERNAL_ID + " .autocomplete__results") != null ?? false); + mouseIsOverComponent = (event.target?.closest("#" + INTERNAL_ID) != null ?? false); + }, + on_click(event) { + if (showDropdown && !mouseIsOverDropdown && !mouseIsOverComponent && event.target.id !== id && event.target?.htmlFor !== id) { + showDropdown = false; + } + }, + on_keydown(event) { + lastKeydownCode = event.code; + const enterPressed = event.code === "Enter"; + const backspacePressed = event.code === "Backspace"; + const arrowUpPressed = event.code === "ArrowUp"; + const spacePressed = event.code === "Space"; + const arrowDownPressed = event.code === "ArrowDown"; + const searchInputHasFocus = element_has_focus(searchInputNode); + const focusedEntry = entriesUlNode?.querySelector("li:focus"); + + if (showDropdown && (enterPressed || arrowDownPressed)) { + event.preventDefault(); + event.stopPropagation(); + } + + if (searchInputHasFocus && backspacePressed && !searchValue && entries.length > 0) { + if (entries.filter(c => c.selected === true).at(-1)?.id ?? false) { + methods.deselect_entry(entries.filter(c => c.selected === true).at(-1)?.id ?? ""); + } + return; + } + + if (searchInputHasFocus) { + if (enterPressed && showCreationHint) { + methods.create_entry(searchValue.trim()); + return; + } + + if (arrowDownPressed) { + const firstEntry = entriesUlNode.querySelector("li:first-of-type"); + if (firstEntry) { + firstEntry.focus(); + } + return; + } + } + + if (focusedEntry && (arrowUpPressed || arrowDownPressed)) { + if (arrowDownPressed && focusedEntry.nextElementSibling) { + focusedEntry.nextElementSibling.focus(); + } else if (arrowUpPressed && focusedEntry.previousElementSibling) { + focusedEntry.previousElementSibling.focus(); + } + return; + } + + if (focusedEntry && (spacePressed || enterPressed)) { + methods.select_entry(focusedEntry.dataset.id); + return; + } + + if (lastKeydownCode === "Tab" && !searchInputHasFocus) { + showDropdown = false; + } + }, + on_touchend(event) { + windowEvents.on_mousemove(event); + } + }; + + interface DropdownEntry { + name: string, + id: string, + } +</script> + +<svelte:window + on:keydown={windowEvents.on_keydown} + on:mousemove={windowEvents.on_mousemove} + on:touchend={windowEvents.on_touchend} + on:click={windowEvents.on_click} +/> + +{#if label} + <label for="{id}" + class="form-label margin-bottom-xxs">{label}</label> +{/if} + +<div class="autocomplete position-relative select-auto" + class:cursor-wait={loading} + class:autocomplete--results-visible={showDropdown} + class:select-auto--selection-done={searchValue} + id={INTERNAL_ID} +> + <!-- input --> + <div class="select-auto__input-wrapper form-control" + class:multiple={multiple === true} + class:has-selection={hasSelection}> + {#if multiple === true && hasSelection} + {#each entries.filter((c) => c.selected === true) as entry} + <Chip id={entry.id} + removable={true} + tabindex="-1" + on:remove={() => methods.deselect_entry(entry.id)} + text={entry.name}/> + {/each} + {/if} + <input + class="reset width-100%" + style="outline:none;" + type="text" + {name} + {id} + {maxlength} + {placeholder} + bind:value={searchValue} + bind:this={searchInputNode} + on:input={() => search.do()} + on:click={() => (showDropdown = true)} + on:focus={() => (showDropdown = true)} + on:blur={search.on_input_focusout} + autocomplete="off" + /> + <div class="select-auto__input-icon-wrapper"> + <!-- arrow icon --> + <svg class="icon" + viewBox="0 0 16 16"> + <title>Open selection</title> + <polyline points="1 5 8 12 15 5" + fill="none" + stroke="currentColor" + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2"/> + </svg> + + <!-- close X icon --> + <button class="reset select-auto__input-btn" + type="button" + on:click={() => reset(true)}> + <svg class="icon" + viewBox="0 0 16 16"> + <title>Reset selection</title> + <path + d="M8,0a8,8,0,1,0,8,8A8,8,0,0,0,8,0Zm3.707,10.293a1,1,0,1,1-1.414,1.414L8,9.414,5.707,11.707a1,1,0,0,1-1.414-1.414L6.586,8,4.293,5.707A1,1,0,0,1,5.707,4.293L8,6.586l2.293-2.293a1,1,0,1,1,1.414,1.414L9.414,8Z" + /> + </svg> + </button> + </div> + </div> + + {#if errorText} + <small class="color-error">{errorText}</small> + {/if} + + <!-- dropdown --> + <div class="autocomplete__results select-auto__results"> + <ul bind:this={entriesUlNode} + on:keydown={(event) => event.code.startsWith("Arrow") && event.preventDefault()} + tabindex="-1" + class="autocomplete__list"> + {#if searchResults.length > 0} + {#each searchResults.filter((c) => !c.selected) as result} + <li class="select-auto__option padding-y-xs padding-x-sm" + data-id={result.obj.id} + on:click={(e) => methods.select_entry(e.target.dataset.id)} + tabindex="-1"> + {@html highlight(result, (open = '<span class="font-semibold">'), (close = "</span>"))} + </li> + {/each} + {:else if entries.length > 0} + {#each entries.filter((c) => !c.selected) as entry} + <li class="select-auto__option padding-y-xs padding-x-sm" + data-id={entry.id} + on:click={(e) => methods.select_entry(e.target.dataset.id)} + tabindex="-1"> + {entry.name} + </li> + {/each} + {:else} + <li class="select-auto__option text-center padding-y-xs padding-x-sm pointer-events-none" + tabindex="-1"> + {noResultsText} + </li> + {/if} + </ul> + {#if showCreationHint} + <div class="width-100% border-top border-bg-lighter padding-xxxs"> + <Button variant="reset" + type="button" + class="width-100%" + text="Press enter or click to create {searchValue.trim()}" + title="Press enter or click here to create {searchValue.trim()}" + loading={loading} + on:click={() => methods.create_entry(searchValue.trim())}/> + </div> + {/if} + </div> +</div> diff --git a/apps/web-shared/src/components/form/index.ts b/apps/web-shared/src/components/form/index.ts new file mode 100644 index 0000000..08769bd --- /dev/null +++ b/apps/web-shared/src/components/form/index.ts @@ -0,0 +1,5 @@ +import Textarea from "./textarea.svelte"; + +export { + Textarea +}; diff --git a/apps/web-shared/src/components/form/textarea.svelte b/apps/web-shared/src/components/form/textarea.svelte new file mode 100644 index 0000000..b313d2e --- /dev/null +++ b/apps/web-shared/src/components/form/textarea.svelte @@ -0,0 +1,48 @@ +<script lang="ts"> + export let id; + export let disabled = false; + export let loading = false; + export let rows = 2; + export let cols = 0; + export let name; + export let placeholder; + export let value; + export let label; + export let errorText; + + $: shared_props = { + rows: rows || null, + cols: cols || null, + name: name || null, + id: id || null, + disabled: disabled || null, + class: [`form-control ${loading ? "c-disabled loading" : ""}`, $$restProps.class ?? ""].filter(Boolean).join(" "), + }; + + let textarea; + let scrollHeight = 0; + + $:if (textarea) { + scrollHeight = textarea.scrollHeight; + } + + function on_input(event) { + event.target.style.height = "auto"; + event.target.style.height = (this.scrollHeight) + "px"; + } +</script> + +{#if label} + <label for="{id}" + class="form-label margin-bottom-xxs">{label}</label> +{/if} +<textarea {...shared_props} + {placeholder} + style="overflow-y:hidden;min-height:calc(1.5em + .75rem + 2px);{scrollHeight ? 'height:{scrollHeight}px' : ''};" + bind:value={value} + bind:this={textarea} + on:input={on_input} +></textarea> +{#if errorText} + <small class="color-error">{errorText}</small> +{/if} diff --git a/apps/web-shared/src/components/icon.svelte b/apps/web-shared/src/components/icon.svelte new file mode 100644 index 0000000..144b45d --- /dev/null +++ b/apps/web-shared/src/components/icon.svelte @@ -0,0 +1,87 @@ +<script> + import {IconNames} from "$shared/lib/configuration"; + + const icons = [ + { + box: 16, + name: IconNames.verticalDots, + svg: `<path d="M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>`, + }, + { + box: 16, + name: IconNames.clock, + svg: `<path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/><path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>`, + }, + { + box: 21, + name: IconNames.trash, + svg: `<g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(3 2)"><path d="m2.5 2.5h10v12c0 1.1045695-.8954305 2-2 2h-6c-1.1045695 0-2-.8954305-2-2zm5-2c1.0543618 0 1.91816512.81587779 1.99451426 1.85073766l.00548574.14926234h-4c0-1.1045695.8954305-2 2-2z"/><path d="m.5 2.5h14"/><path d="m5.5 5.5v8"/><path d="m9.5 5.5v8"/></g>`, + }, + { + box: 21, + name: IconNames.pencilSquare, + svg: ` + <g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(3 3)"><path d="m14 1c.8284271.82842712.8284271 2.17157288 0 3l-9.5 9.5-4 1 1-3.9436508 9.5038371-9.55252193c.7829896-.78700064 2.0312313-.82943964 2.864366-.12506788z"/><path d="m6.5 14.5h8"/><path d="m12.5 3.5 1 1"/></g> + `, + }, + { + box: 16, + name: IconNames.x, + svg: `<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>`, + }, + { + box: 16, + name: IconNames.funnel, + svg: `<path d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2zm1 .5v1.308l4.372 4.858A.5.5 0 0 1 7 8.5v5.306l2-.666V8.5a.5.5 0 0 1 .128-.334L13.5 3.308V2h-11z"/>`, + }, + { + box: 16, + name: IconNames.funnelFilled, + svg: `<path d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2z"/>`, + }, + { + box: 16, + name: IconNames.github, + svg: ` + <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/> + ` + }, + { + box: 21, + name: IconNames.refresh, + svg: `<g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(2 2)"><path d="m4.5 1.5c-2.4138473 1.37729434-4 4.02194088-4 7 0 4.418278 3.581722 8 8 8s8-3.581722 8-8-3.581722-8-8-8"/><path d="m4.5 5.5v-4h-4"/></g> ` + }, + { + box: 21, + name: IconNames.resetHard, + svg: `<g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="matrix(0 1 1 0 2.5 2.5)"><path d="m13 11 3 3v-6c0-3.36502327-2.0776-6.24479706-5.0200433-7.42656457-.9209869-.36989409-1.92670197-.57343543-2.9799567-.57343543-4.418278 0-8 3.581722-8 8s3.581722 8 8 8c1.48966767 0 3.4724708-.3698516 5.0913668-1.5380762" transform="matrix(-1 0 0 -1 16 16)"/><path d="m5 5 6 6"/><path d="m11 5-6 6"/></g>` + }, + { + box: 21, + name: IconNames.arrowUp, + svg: `<g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(6 3)"><path d="m8.5 4.5-4-4-4.029 4"/><path d="m4.5.5v13"/></g>` + }, + { + box: 21, + name: IconNames.arrowDown, + svg: `<g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(6 4)"><path d="m.5 9.499 4 4.001 4-4.001"/><path d="m4.5.5v13" transform="matrix(-1 0 0 -1 9 14)"/></g>` + }, + { + box: 21, + name: IconNames.chevronDown, + svg: `<path d="m8.5.5-4 4-4-4" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(6 8)"/>` + } + ]; + + export let name; + export let fill = false; + export let width = "1rem"; + export let height = "1rem"; + const displayIcon = icons.find((e) => e.name === name); +</script> + +<svg class="icon {$$restProps.class ?? ''}" + style="width: {width}; height:{height}; fill: currentColor;" + viewBox="0 0 {displayIcon.box} {displayIcon.box}"> + {@html displayIcon.svg} +</svg> diff --git a/apps/web-shared/src/components/menu/index.ts b/apps/web-shared/src/components/menu/index.ts new file mode 100644 index 0000000..8eb7938 --- /dev/null +++ b/apps/web-shared/src/components/menu/index.ts @@ -0,0 +1,9 @@ +import Menu from "./menu.svelte"; +import MenuItem from "./item.svelte"; +import MenuItemSeparator from "./separator.svelte"; + +export { + Menu, + MenuItem, + MenuItemSeparator +}; diff --git a/apps/web-shared/src/components/menu/item.svelte b/apps/web-shared/src/components/menu/item.svelte new file mode 100644 index 0000000..aeb0f99 --- /dev/null +++ b/apps/web-shared/src/components/menu/item.svelte @@ -0,0 +1,8 @@ +<script lang="ts"> + export let danger = false; +</script> +<li role="menuitem" on:click> + <span class="menu__content {danger ? 'bg-error-lighter@hover color-white@hover' : ''}"> + <slot/> + </span> +</li> diff --git a/apps/web-shared/src/components/menu/menu.svelte b/apps/web-shared/src/components/menu/menu.svelte new file mode 100644 index 0000000..33b1160 --- /dev/null +++ b/apps/web-shared/src/components/menu/menu.svelte @@ -0,0 +1,54 @@ +<script lang="ts"> + import {random_string} from "$shared/lib/helpers"; + + export let id = "__menu_" + random_string(3); + export let trigger: HTMLElement; + export let show = false; + + let windowInnerWidth = 0; + let windowInnerHeight = 0; + let menu: HTMLMenuElement; + + $: if (show && menu && trigger) { + const + selectedTriggerPosition = trigger.getBoundingClientRect(), + menuOnTop = (windowInnerHeight - selectedTriggerPosition.bottom) < selectedTriggerPosition.top, + left = selectedTriggerPosition.left, + right = (windowInnerWidth - selectedTriggerPosition.right), + isRight = (windowInnerWidth < selectedTriggerPosition.left + menu.offsetWidth), + vertical = menuOnTop + ? "bottom: " + (windowInnerHeight - selectedTriggerPosition.top) + "px;" + : "top: " + selectedTriggerPosition.bottom + "px;"; + + let horizontal = isRight ? "right: " + right + "px;" : "left: " + left + "px;"; + + // check right position is correct -> otherwise set left to 0 + if (isRight && (right + menu.offsetWidth) > windowInnerWidth) horizontal = ("left: " + (windowInnerWidth - menu.offsetWidth) / 2 + "px;"); + const maxHeight = menuOnTop ? selectedTriggerPosition.top - 20 : windowInnerHeight - selectedTriggerPosition.bottom - 20; + menu.setAttribute("style", horizontal + vertical + "max-height:" + Math.floor(maxHeight) + "px;"); + } + + function on_window_click(event) { + if (!event.target.closest("#" + id) && !event.target.closest("[aria-controls='" + id + "']")) show = false; + } + + function on_window_touchend(event) { + if (!event.target.closest("#" + id) && !event.target.closest("[aria-controls='" + id + "']")) show = false; + } +</script> + +<svelte:window + on:click={on_window_click} + on:touchend={on_window_touchend} + bind:innerWidth={windowInnerWidth} + bind:innerHeight={windowInnerHeight} +/> + +<menu class="menu" + id="{id}" + bind:this={menu} + class:menu--is-visible={show} + aria-expanded="{show}" + aria-haspopup="true"> + <slot name="options"/> +</menu> diff --git a/apps/web-shared/src/components/menu/separator.svelte b/apps/web-shared/src/components/menu/separator.svelte new file mode 100644 index 0000000..798dce0 --- /dev/null +++ b/apps/web-shared/src/components/menu/separator.svelte @@ -0,0 +1,2 @@ +<li class="menu__separator" + role="separator"></li> diff --git a/apps/web-shared/src/components/modal.svelte b/apps/web-shared/src/components/modal.svelte new file mode 100644 index 0000000..f3b633c --- /dev/null +++ b/apps/web-shared/src/components/modal.svelte @@ -0,0 +1,66 @@ +<script> + import {random_string} from "$shared/lib/helpers"; + + export let title = ""; + let isVisible = false; + const modal_id = "modal_" + random_string(5); + + function handle_keyup(e) { + if (e.key === "Escape") { + isVisible = false; + } + } + + export const functions = { + open() { + isVisible = true; + window.addEventListener("keyup", handle_keyup); + }, + close() { + isVisible = false; + window.removeEventListener("keyup", handle_keyup); + }, + }; +</script> + +<div class="modal modal--animate-scale flex flex-center padding-md bg-dark bg-opacity-40% {isVisible ? 'modal--is-visible' : ''}" + id={modal_id} +> + <div class="modal__content width-100% max-width-xs max-height-100% overflow-auto radius-md shadow-md bg" + role="alertdialog" + > + <header class="padding-y-sm padding-x-md flex items-center justify-between" + > + <h4 class="text-truncate">{title}</h4> + + <button class="reset modal__close-btn modal__close-btn--inner" + on:click={functions.close} + > + <svg class="icon" + viewBox="0 0 20 20"> + <title>Close modal window</title> + <g fill="none" + stroke="currentColor" + stroke-miterlimit="10" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + > + <line x1="3" + y1="3" + x2="17" + y2="17"/> + <line x1="17" + y1="3" + x2="3" + y2="17"/> + </g> + </svg> + </button> + </header> + + <div class="padding-bottom-md padding-x-md"> + <slot/> + </div> + </div> +</div> diff --git a/apps/web-shared/src/components/pre-header.svelte b/apps/web-shared/src/components/pre-header.svelte new file mode 100644 index 0000000..87a19b1 --- /dev/null +++ b/apps/web-shared/src/components/pre-header.svelte @@ -0,0 +1,37 @@ +<script> + export let closable = true; + export let show = false; +</script> + +<div class="pre-header padding-y-xs" style="{show ? '' : 'display:none'}"> + <div class="container max-width-lg position-relative"> + <div class="text-component text-sm padding-right-lg"> + <p> + <slot/> + </p> + </div> + {#if closable} + <button class="reset pre-header__close-btn" + on:click={(event) => event.target.closest(".pre-header")?.remove()}> + <svg class="icon" + viewBox="0 0 20 20"> + <title>Close header banner</title> + <g fill="none" + stroke="currentColor" + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2"> + <line x1="4" + y1="4" + x2="16" + y2="16"/> + <line x1="16" + y1="4" + x2="4" + y2="16"/> + </g> + </svg> + </button> + {/if} + </div> +</div> diff --git a/apps/web-shared/src/components/stopwatch.svelte b/apps/web-shared/src/components/stopwatch.svelte new file mode 100644 index 0000000..8287e31 --- /dev/null +++ b/apps/web-shared/src/components/stopwatch.svelte @@ -0,0 +1,161 @@ +<script lang="ts"> + import {writable_persistent} from "$shared/lib/persistent-store"; + import Button from "$shared/components/button.svelte"; + import {Textarea} from "$shared/components/form"; + import {StorageKeys} from "$shared/lib/configuration"; + import {Temporal} from "@js-temporal/polyfill"; + import {createEventDispatcher, onMount} from "svelte"; + + const state = writable_persistent({ + initialState: { + hours: 0, + minutes: 0, + seconds: 0, + startTime: null as Temporal.PlainTime, + isRunning: false, + intervalId: 0, + note: "", + }, + name: StorageKeys.stopwatch, + }); + + let timeString; + + $: if ($state.hours || $state.minutes || $state.seconds) { + timeString = $state.hours.toLocaleString(undefined, {minimumIntegerDigits: 2}) + + ":" + $state.minutes.toLocaleString(undefined, {minimumIntegerDigits: 2}) + + ":" + $state.seconds.toLocaleString(undefined, {minimumIntegerDigits: 2}); + } else { + timeString = "--:--:--"; + } + + onMount(() => { + if ($state.isRunning) { + clearInterval($state.intervalId); + $state.intervalId = setInterval(step, 1000); + } + }); + + const dispatch = createEventDispatcher(); + + function step() { + $state.seconds = $state.seconds + 1; + + if ($state.seconds == 60) { + $state.minutes = $state.minutes + 1; + $state.seconds = 0; + } + + if ($state.minutes == 60) { + $state.hours = $state.hours + 1; + $state.minutes = 0; + $state.seconds = 0; + } + + if (!$state.startTime) $state.startTime = Temporal.Now.plainTimeISO(); + } + + function reset() { + clearInterval($state.intervalId); + $state.isRunning = false; + $state.hours = 0; + $state.minutes = 0; + $state.seconds = 0; + $state.startTime = null; + $state.intervalId = 0; + $state.note = ""; + } + + let roundUpToNearest = 30; + let roundDownToNearest = 30; + + function on_round_up() { + const newTime = Temporal.PlainTime + .from({hour: $state.hours, minute: $state.minutes, second: $state.seconds}) + .round({ + roundingIncrement: roundUpToNearest, + smallestUnit: "minute", + roundingMode: "ceil" + }); + $state.hours = newTime.hour; + $state.minutes = newTime.minute; + $state.seconds = newTime.second; + } + + function on_round_down() { + const newTime = Temporal.PlainTime + .from({hour: $state.hours, minute: $state.minutes, second: $state.seconds,}) + .round({ + roundingIncrement: roundDownToNearest, + smallestUnit: "minute", + roundingMode: "trunc" + }); + $state.hours = newTime.hour; + $state.minutes = newTime.minute; + $state.seconds = newTime.second; + } + + function on_start_stop() { + if ($state.isRunning) { + clearInterval($state.intervalId); + $state.isRunning = false; + return; + } + step(); + $state.intervalId = setInterval(step, 1000); + $state.isRunning = true; + } + + function on_create_entry() { + if (!$state.startTime) return; + const plainStartTime = Temporal.PlainTime.from($state.startTime); + dispatch("create", { + from: plainStartTime, + to: plainStartTime.add({hours: $state.hours, minutes: $state.minutes, seconds: $state.seconds}), + description: $state.note + }); + reset(); + } +</script> + +<div class="grid"> + <div class="col-6"> + <slot name="header"></slot> + <pre class="text-xxl padding-y-sm">{timeString}</pre> + </div> + <div class="col-6 flex align-bottom flex-column text-xs"> + <Button title="{$state.isRunning ? 'Stop' : 'Start'}" + text="{$state.isRunning ? 'Stop' : 'Start'}" + variant="link" + on:click={on_start_stop}/> + + {#if $state.startTime} + <Button title="Reset" + text="Reset" + variant="link" + class="bg-error-lighter@hover color-white@hover" + on:click={reset}/> + {#if !$state.isRunning} + <Button title="Round up" + text="Round up" + variant="link" + on:click={on_round_up}/> + <Button title="Round down" + text="Round down" + variant="link" + on:click={on_round_down}/> + {#if $state.minutes > 0 || $state.hours > 0} + <Button title="Create entry" + text="Create entry" + variant="link" + on:click={on_create_entry}/> + {/if} + {/if} + {/if} + </div> +</div> +<Textarea class="width-100% margin-top-xs" + placeholder="What's your focus?" + rows="1" + bind:value={$state.note} +/> diff --git a/apps/web-shared/src/components/table/index.ts b/apps/web-shared/src/components/table/index.ts new file mode 100644 index 0000000..8390c0e --- /dev/null +++ b/apps/web-shared/src/components/table/index.ts @@ -0,0 +1,15 @@ +import TablePaginator from "./paginator.svelte"; +import Table from "./table.svelte"; +import THead from "./thead.svelte"; +import TBody from "./tbody.svelte"; +import TCell from "./tcell.svelte"; +import TRow from "./trow.svelte"; + +export { + TablePaginator, + Table, + THead, + TBody, + TCell, + TRow +}; diff --git a/apps/web-shared/src/components/table/paginator.svelte b/apps/web-shared/src/components/table/paginator.svelte new file mode 100644 index 0000000..53c6392 --- /dev/null +++ b/apps/web-shared/src/components/table/paginator.svelte @@ -0,0 +1,101 @@ +<script> + import {createEventDispatcher, onMount} from "svelte"; + import {restrict_input_to_numbers} from "$shared/lib/helpers"; + + const dispatch = createEventDispatcher(); + export let page = 1; + export let pageCount = 1; + let prevCount = page; + let canIncrement = false; + let canDecrement = false; + $: canIncrement = page < pageCount; + $: canDecrement = page > 1; + + onMount(() => { + restrict_input_to_numbers(document.querySelector("#curr-page")); + }); + + function increment() { + if (canIncrement) { + page++; + } + } + + function decrement() { + if (canDecrement) { + page--; + } + } + + $: if (page) { + handle_change(); + } + + function handle_change() { + if (page === prevCount) { + return; + } + prevCount = page; + if (page > pageCount) { + page = pageCount; + } + dispatch("value_change", { + newValue: page, + }); + } +</script> + +<nav class="pagination" + aria-label="Pagination"> + <ul class="pagination__list flex flex-wrap gap-xxxs justify-center justify-end@md"> + <li> + <button on:click={decrement} + class="reset pagination__item {canDecrement ? '' : 'c-disabled'}"> + <svg class="icon icon--xs flip-x" + viewBox="0 0 16 16" + ><title>Go to previous page</title> + <polyline + points="6 2 12 8 6 14" + fill="none" + stroke="currentColor" + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2" + /> + </svg> + </button> + </li> + + <li> + <span class="pagination__jumper flex items-center"> + <input + aria-label="Page number" + class="form-control" + id="curr-page" + type="text" + on:change={handle_change} + value={page} + /> + <em>of {pageCount}</em> + </span> + </li> + + <li> + <button on:click={increment} + class="reset pagination__item {canIncrement ? '' : 'c-disabled'}"> + <svg class="icon icon--xs" + viewBox="0 0 16 16" + ><title>Go to next page</title> + <polyline + points="6 2 12 8 6 14" + fill="none" + stroke="currentColor" + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2" + /> + </svg> + </button> + </li> + </ul> +</nav> diff --git a/apps/web-shared/src/components/table/table.svelte b/apps/web-shared/src/components/table/table.svelte new file mode 100644 index 0000000..4acbf37 --- /dev/null +++ b/apps/web-shared/src/components/table/table.svelte @@ -0,0 +1,3 @@ +<table class="int-table {$$restProps.class ?? ''}"> + <slot/> +</table> diff --git a/apps/web-shared/src/components/table/tbody.svelte b/apps/web-shared/src/components/table/tbody.svelte new file mode 100644 index 0000000..f0617fa --- /dev/null +++ b/apps/web-shared/src/components/table/tbody.svelte @@ -0,0 +1,3 @@ +<tbody class="int-table__body {$$restProps.class ?? ''}"> +<slot/> +</tbody> diff --git a/apps/web-shared/src/components/table/tcell.svelte b/apps/web-shared/src/components/table/tcell.svelte new file mode 100644 index 0000000..76f500f --- /dev/null +++ b/apps/web-shared/src/components/table/tcell.svelte @@ -0,0 +1,23 @@ +<script lang="ts"> + export let thScope: "row"|"col"|"rowgroup"|"colgroup"|""; + export let colspan = ""; + export let type: "th"|"td" = "td"; + export let style; + + $: shared_props = { + colspan: colspan || null, + style: style || null, + class: [type === "th" ? "int-table__cell--th" : "", "int-table__cell", $$restProps.class ?? ""].filter(Boolean).join(" "), + }; +</script> +{#if type === "th"} + <th {thScope} + {...shared_props}> + <slot/> + </th> +{/if} +{#if type === "td"} + <td {...shared_props}> + <slot/> + </td> +{/if} diff --git a/apps/web-shared/src/components/table/thead.svelte b/apps/web-shared/src/components/table/thead.svelte new file mode 100644 index 0000000..aa20bf0 --- /dev/null +++ b/apps/web-shared/src/components/table/thead.svelte @@ -0,0 +1,10 @@ +<script lang="ts"> + import TRow from "./trow.svelte"; +</script> + + +<thead class="int-table__header {$$restProps.class ?? ''}"> +<TRow> + <slot/> +</TRow> +</thead> diff --git a/apps/web-shared/src/components/table/trow.svelte b/apps/web-shared/src/components/table/trow.svelte new file mode 100644 index 0000000..35b34bb --- /dev/null +++ b/apps/web-shared/src/components/table/trow.svelte @@ -0,0 +1,6 @@ +<script> + export let dataId; +</script> +<tr class="int-table__row {$$restProps.class ?? ''}" data-id={dataId}> + <slot/> +</tr> diff --git a/apps/web-shared/src/components/tile.svelte b/apps/web-shared/src/components/tile.svelte new file mode 100644 index 0000000..b8e9cdf --- /dev/null +++ b/apps/web-shared/src/components/tile.svelte @@ -0,0 +1,4 @@ +<section class="bg-light radius-sm padding-sm inner-glow shadow-xs {$$restProps.class??''}" + style="height: fit-content;"> + <slot/> +</section> diff --git a/apps/web-shared/src/lib/api/internal-fetch.ts b/apps/web-shared/src/lib/api/internal-fetch.ts new file mode 100644 index 0000000..8659ccb --- /dev/null +++ b/apps/web-shared/src/lib/api/internal-fetch.ts @@ -0,0 +1,170 @@ +import {Temporal} from "@js-temporal/polyfill"; +import {clear_session_data} from "$shared/lib/session"; +import {resolve_references} from "$shared/lib/helpers"; +import {replace} from "svelte-spa-router"; +import type {IInternalFetchResponse} from "$shared/lib/models/IInternalFetchResponse"; +import type {IInternalFetchRequest} from "$shared/lib/models/IInternalFetchRequest"; + +export async function http_post(url: string, body?: object|string, timeout = -1, skip_401_check = false, abort_signal: AbortSignal = undefined): Promise<IInternalFetchResponse> { + const init = { + method: "post", + } as RequestInit; + + if (abort_signal) { + init.signal = abort_signal; + } + + if (body) { + init.headers = { + "Content-Type": "application/json;charset=UTF-8", + }; + init.body = JSON.stringify(body); + } + + const response = await internal_fetch({url, init, timeout}); + const result = {} as IInternalFetchResponse; + + if (!skip_401_check && await is_401(response)) return result; + + result.ok = response.ok; + result.status = response.status; + result.http_response = response; + + if (response.status !== 204) { + try { + const ct = response.headers.get("Content-Type")?.toString() ?? ""; + if (ct.startsWith("application/json")) { + const data = await response.json(); + result.data = resolve_references(data); + } else if (ct.startsWith("text/plain")) { + const text = await response.text(); + result.data = text as string; + } + } catch { + // Ignored + } + } + + return result; +} + +export async function http_get(url: string, timeout = -1, skip_401_check = false, abort_signal: AbortSignal = undefined): Promise<IInternalFetchResponse> { + const init = { + method: "get", + } as RequestInit; + + if (abort_signal) { + init.signal = abort_signal; + } + + const response = await internal_fetch({url, init, timeout}); + const result = {} as IInternalFetchResponse; + + if (!skip_401_check && await is_401(response)) return result; + + result.ok = response.ok; + result.status = response.status; + result.http_response = response; + + if (response.status !== 204) { + try { + const ct = response.headers.get("Content-Type")?.toString() ?? ""; + if (ct.startsWith("application/json")) { + const data = await response.json(); + result.data = resolve_references(data); + } else if (ct.startsWith("text/plain")) { + const text = await response.text(); + result.data = text as string; + } + } catch { + // Ignored + } + } + + return result; +} + +export async function http_delete(url: string, body?: object|string, timeout = -1, skip_401_check = false, abort_signal: AbortSignal = undefined): Promise<IInternalFetchResponse> { + const init = { + method: "delete", + } as RequestInit; + + if (abort_signal) { + init.signal = abort_signal; + } + + if (body) { + init.headers = { + "Content-Type": "application/json;charset=UTF-8", + }; + init.body = JSON.stringify(body); + } + + const response = await internal_fetch({url, init, timeout}); + const result = {} as IInternalFetchResponse; + + if (!skip_401_check && await is_401(response)) return result; + + result.ok = response.ok; + result.status = response.status; + result.http_response = response; + + if (response.status !== 204) { + try { + const ct = response.headers.get("Content-Type")?.toString() ?? ""; + if (ct.startsWith("application/json")) { + const data = await response.json(); + result.data = resolve_references(data); + } else if (ct.startsWith("text/plain")) { + const text = await response.text(); + result.data = text as string; + } + } catch (error) { + // ignored + } + } + + return result; +} + +async function internal_fetch(request: IInternalFetchRequest): Promise<Response> { + if (!request.init) request.init = {}; + request.init.credentials = "include"; + request.init.headers = { + "X-TimeZone": Temporal.Now.timeZone().id, + ...request.init.headers + }; + + const fetch_request = new Request(request.url, request.init); + let response: any; + + try { + if (request.timeout > 500) { + response = await Promise.race([ + fetch(fetch_request), + new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), request.timeout)) + ]); + } else { + response = await fetch(fetch_request); + } + } catch (error) { + if (error.message === "Timeout") { + console.error("Request timed out"); + } else if (error.message === "Network request failed") { + console.error("No internet connection"); + } else { + throw error; // rethrow other unexpected errors + } + } + + return response; +} + +async function is_401(response: Response): Promise<boolean> { + if (response.status === 401) { + clear_session_data(); + await replace("/login"); + return true; + } + return false; +} diff --git a/apps/web-shared/src/lib/api/root.ts b/apps/web-shared/src/lib/api/root.ts new file mode 100644 index 0000000..d65efc4 --- /dev/null +++ b/apps/web-shared/src/lib/api/root.ts @@ -0,0 +1,6 @@ +import {http_post} from "$shared/lib/api/internal-fetch"; +import {api_base} from "$shared/lib/configuration"; + +export function server_log(message: string): void { + http_post(api_base("_/api/log"), message); +} diff --git a/apps/web-shared/src/lib/api/time-entry.ts b/apps/web-shared/src/lib/api/time-entry.ts new file mode 100644 index 0000000..e81329d --- /dev/null +++ b/apps/web-shared/src/lib/api/time-entry.ts @@ -0,0 +1,86 @@ +import {api_base} from "$shared/lib/configuration"; +import {is_guid} from "$shared/lib/helpers"; +import {http_delete, http_get, http_post} from "./internal-fetch"; +import type {TimeCategoryDto} from "$shared/lib/models/TimeCategoryDto"; +import type {TimeLabelDto} from "$shared/lib/models/TimeLabelDto"; +import type {TimeEntryDto} from "$shared/lib/models/TimeEntryDto"; +import type {TimeEntryQuery} from "$shared/lib/models/TimeEntryQuery"; +import type {IInternalFetchResponse} from "$shared/lib/models/IInternalFetchResponse"; + + +// ENTRIES + +export async function create_time_entry(payload: TimeEntryDto): Promise<IInternalFetchResponse> { + return http_post(api_base("v1/entries/create"), payload); +} + +export async function get_time_entry(entryId: string): Promise<IInternalFetchResponse> { + if (is_guid(entryId)) { + return http_get(api_base("v1/entries/" + entryId)); + } + throw new Error("entryId is not a valid guid."); +} + +export async function get_time_entries(entryQuery: TimeEntryQuery): Promise<IInternalFetchResponse> { + return http_post(api_base("v1/entries/query"), entryQuery); +} + +export async function delete_time_entry(id: string): Promise<IInternalFetchResponse> { + if (!is_guid(id)) throw new Error("id is not a valid guid"); + return http_delete(api_base("v1/entries/" + id + "/delete")); +} + +export async function update_time_entry(entryDto: TimeEntryDto): Promise<IInternalFetchResponse> { + if (!is_guid(entryDto.id ?? "")) throw new Error("id is not a valid guid"); + if (!entryDto.category) throw new Error("category is empty"); + if (!entryDto.stop) throw new Error("stop is empty"); + if (!entryDto.start) throw new Error("start is empty"); + return http_post(api_base("v1/entries/update"), entryDto); +} + +// LABELS + + +export async function create_time_label(labelDto: TimeLabelDto): Promise<IInternalFetchResponse> { + return http_post(api_base("v1/labels/create"), labelDto); +} + +export async function get_time_labels(): Promise<IInternalFetchResponse> { + return http_get(api_base("v1/labels")); +} + +export async function delete_time_label(id: string): Promise<IInternalFetchResponse> { + if (!is_guid(id)) throw new Error("id is not a valid guid"); + return http_delete(api_base("v1/labels/" + id + "/delete")); +} + +export async function update_time_label(labelDto: TimeLabelDto): Promise<IInternalFetchResponse> { + if (!is_guid(labelDto.id ?? "")) throw new Error("id is not a valid guid"); + if (!labelDto.name) throw new Error("name is empty"); + if (!labelDto.color) throw new Error("color is empty"); + return http_post(api_base("v1/labels/update"), labelDto); +} + + +// CATEGORIES +export async function create_time_category(category: TimeCategoryDto): Promise<IInternalFetchResponse> { + if (!category.name) throw new Error("name is empty"); + if (!category.color) throw new Error("color is empty"); + return http_post(api_base("v1/categories/create"), category); +} + +export async function get_time_categories(): Promise<IInternalFetchResponse> { + return http_get(api_base("v1/categories")); +} + +export async function delete_time_category(id: string): Promise<IInternalFetchResponse> { + if (!is_guid(id)) throw new Error("id is not a valid guid"); + return http_delete(api_base("v1/categories/" + id + "/delete")); +} + +export async function update_time_category(category: TimeCategoryDto): Promise<IInternalFetchResponse> { + if (!is_guid(category.id ?? "")) throw new Error("id is not a valid guid"); + if (!category.name) throw new Error("name is empty"); + if (!category.color) throw new Error("color is empty"); + return http_post(api_base("v1/categories/update"), category); +} diff --git a/apps/web-shared/src/lib/api/user.ts b/apps/web-shared/src/lib/api/user.ts new file mode 100644 index 0000000..a3a149e --- /dev/null +++ b/apps/web-shared/src/lib/api/user.ts @@ -0,0 +1,47 @@ +import {api_base} from "$shared/lib/configuration"; +import {http_delete, http_get, http_post} from "./internal-fetch"; +import type {LoginPayload} from "$shared/lib/models/LoginPayload"; +import type {UpdateProfilePayload} from "$shared/lib/models/UpdateProfilePayload"; +import type {CreateAccountPayload} from "$shared/lib/models/CreateAccountPayload"; +import type {IInternalFetchResponse} from "$shared/lib/models/IInternalFetchResponse"; + +export async function login(payload: LoginPayload): Promise<IInternalFetchResponse> { + return http_post(api_base("_/account/login"), payload); +} + +export async function logout(): Promise<IInternalFetchResponse> { + return http_get(api_base("_/account/logout")); +} + +export async function create_forgot_password_request(username: string): Promise<IInternalFetchResponse> { + if (!username) throw new Error("Username is empty"); + return http_get(api_base("_/forgot-password-requests/create?username=" + username)); +} + +export async function check_forgot_password_request(public_id: string): Promise<IInternalFetchResponse> { + if (!public_id) throw new Error("Id is empty"); + return http_get(api_base("_/forgot-password-requests/is-valid?id=" + public_id)); +} + +export async function fulfill_forgot_password_request(public_id: string, newPassword: string): Promise<IInternalFetchResponse> { + if (!public_id) throw new Error("Id is empty"); + return http_post(api_base("_/forgot-password-requests/fulfill"), {id: public_id, newPassword}); +} + +export async function delete_account(): Promise<IInternalFetchResponse> { + return http_delete(api_base("_/account/delete")); +} + +export async function update_profile(payload: UpdateProfilePayload): Promise<IInternalFetchResponse> { + if (!payload.password && !payload.username) throw new Error("Password and Username is empty"); + return http_post(api_base("_/account/update"), payload); +} + +export async function create_account(payload: CreateAccountPayload): Promise<IInternalFetchResponse> { + if (!payload.password && !payload.username) throw new Error("Password and Username is empty"); + return http_post(api_base("_/account/create"), payload); +} + +export async function get_profile_for_active_check(): Promise<IInternalFetchResponse> { + return http_get(api_base("_/account"), 0, true); +} diff --git a/apps/web-shared/src/lib/colors.ts b/apps/web-shared/src/lib/colors.ts new file mode 100644 index 0000000..c2da03d --- /dev/null +++ b/apps/web-shared/src/lib/colors.ts @@ -0,0 +1,47 @@ +export function generate_random_hex_color(skip_contrast_check = false) { + let hex = __generate_random_hex_color(); + if (skip_contrast_check) return hex; + while ((__calculate_contrast_ratio("#ffffff", hex) < 4.5) || (__calculate_contrast_ratio("#000000", hex) < 4.5)) { + hex = __generate_random_hex_color(); + } + + return hex; +} + +// Largely copied from chroma js api +function __generate_random_hex_color(): string { + let code = "#"; + for (let i = 0; i < 6; i++) { + code += "0123456789abcdef".charAt(Math.floor(Math.random() * 16)); + } + return code; +} + +function __calculate_contrast_ratio(hex1: string, hex2: string): number { + const rgb1 = __hex_to_rgb(hex1); + const rgb2 = __hex_to_rgb(hex2); + const l1 = __get_luminance(rgb1[0], rgb1[1], rgb1[2]); + const l2 = __get_luminance(rgb2[0], rgb2[1], rgb2[2]); + const result = l1 > l2 ? (l1 + 0.05) / (l2 + 0.05) : (l2 + 0.05) / (l1 + 0.05); + return result; +} + +function __hex_to_rgb(hex: string): number[] { + if (!hex.match(/^#([A-Fa-f0-9]{6})$/)) return []; + if (hex[0] === "#") hex = hex.substring(1, hex.length); + return [parseInt(hex.substring(0, 2), 16), parseInt(hex.substring(2, 4), 16), parseInt(hex.substring(4, 6), 16)]; +} + +function __get_luminance(r, g, b) { + // relative luminance + // see http://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef + r = __luminance_x(r); + g = __luminance_x(g); + b = __luminance_x(b); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function __luminance_x(x) { + x /= 255; + return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); +} diff --git a/apps/web-shared/src/lib/configuration.ts b/apps/web-shared/src/lib/configuration.ts new file mode 100644 index 0000000..f597bb4 --- /dev/null +++ b/apps/web-shared/src/lib/configuration.ts @@ -0,0 +1,60 @@ +export const API_ADDRESS = "https://api.dev.greatoffice.life"; +export const PROJECTS_ADDRESS = "https://projects.dev.greatoffice.life"; +export const ACCOUNTS_ADDRESS = "https://a.dev.greatoffice.life"; +export const FRONTPAGE_ADDRESS = "https://greatoffice.life"; +export const DEV_ACCOUNTS_ADDRESS = "http://localhost:3001"; +export const DEV_FRONTPAGE_ADDRESS = "http://localhost:3002"; +export const DEV_API_ADDRESS = "http://localhost:5000"; +export const DEV_PROJECTS_ADDRESS = "http://localhost:3000"; +export const SECONDS_BETWEEN_SESSION_CHECK = 600; + +export function projects_base(path: string): string { + return (is_development() ? DEV_PROJECTS_ADDRESS : PROJECTS_ADDRESS) + (path ? "/" + path : "/"); +} + +export function frontpage_base(path: string): string { + return (is_development() ? DEV_FRONTPAGE_ADDRESS : FRONTPAGE_ADDRESS) + (path ? "/" + path : "/"); +} + +export function accounts_base(path: string): string { + return (is_development() ? DEV_ACCOUNTS_ADDRESS : ACCOUNTS_ADDRESS) + (path ? "/" + path : "/"); +} + +export function api_base(path: string): string { + return (is_development() ? DEV_API_ADDRESS : API_ADDRESS) + (path ? "/" + path : "/"); +} + +export function is_development(): boolean { + // @ts-ignore + return import.meta.env.DEV; +} + +export function is_debug(): boolean { + return localStorage.getItem(StorageKeys.debug) !== "true"; +} + +export const IconNames = { + github: "github", + verticalDots: "verticalDots", + clock: "clock", + trash: "trash", + pencilSquare: "pencilSquare", + x: "x", + funnel: "funnel", + funnelFilled: "funnelFilled", + refresh: "refresh", + resetHard: "resetHard", + arrowUp: "arrowUp", + arrowDown: "arrowDown", + chevronDown: "chevronDown" +}; + +export const StorageKeys = { + session: "sessionData", + theme: "theme", + debug: "debug", + categories: "categories", + labels: "labels", + entries: "entries", + stopwatch: "stopwatchState" +}; diff --git a/apps/web-shared/src/lib/helpers.ts b/apps/web-shared/src/lib/helpers.ts new file mode 100644 index 0000000..650bccf --- /dev/null +++ b/apps/web-shared/src/lib/helpers.ts @@ -0,0 +1,489 @@ +import {StorageKeys} from "$shared/lib/configuration"; +import {TimeEntryDto} from "$shared/lib/models/TimeEntryDto"; +import {UnwrappedEntryDateTime} from "$shared/lib/models/UnwrappedEntryDateTime"; +import {Temporal} from "@js-temporal/polyfill"; + +export const EMAIL_REGEX = new RegExp(/^([a-z0-9]+(?:([._\-])[a-z0-9]+)*@(?:[a-z0-9]+(?:(-)[a-z0-9]+)?\.)+[a-z0-9](?:[a-z0-9]*[a-z0-9])?)$/i); +export const URL_REGEX = new RegExp(/^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-.][a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/gm); +export const GUID_REGEX = new RegExp(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i); +export const NORWEGIAN_PHONE_NUMBER_REGEX = new RegExp(/(0047|\+47|47)?\d{8,12}/); + +export function is_email(value: string): boolean { + return EMAIL_REGEX.test(String(value).toLowerCase()); +} + +export function is_url(value: string): boolean { + return URL_REGEX.test(String(value).toLowerCase()); +} + +export function is_norwegian_phone_number(value: string): boolean { + if (value.length < 8 || value.length > 12) { + return false; + } + return NORWEGIAN_PHONE_NUMBER_REGEX.test(String(value)); +} + +export function switch_theme() { + const html = document.querySelector("html"); + if (html) { + if (html.dataset.theme === "dark") { + html.dataset.theme = "light"; + } else { + html.dataset.theme = "dark"; + } + localStorage.setItem(StorageKeys.theme, html.dataset.theme); + } +} + +export function unwrap_date_time_from_entry(entry: TimeEntryDto): UnwrappedEntryDateTime { + if (!entry) throw new Error("entry was undefined"); + const currentTimeZone = Temporal.Now.timeZone().id; + const startInstant = Temporal.Instant.from(entry.start).toZonedDateTimeISO(currentTimeZone); + const stopInstant = Temporal.Instant.from(entry.stop).toZonedDateTimeISO(currentTimeZone); + + return { + start_date: startInstant.toPlainDate(), + stop_date: stopInstant.toPlainDate(), + start_time: startInstant.toPlainTime(), + stop_time: stopInstant.toPlainTime(), + duration: Temporal.Duration.from({ + hours: stopInstant.hour, + minutes: stopInstant.minute + }).subtract(Temporal.Duration.from({ + hours: startInstant.hour, + minutes: startInstant.minute + })) + }; +} + + +export function is_guid(value: string): boolean { + if (!value) { + return false; + } + if (value[0] === "{") { + value = value.substring(1, value.length - 1); + } + return GUID_REGEX.test(value); +} + +export function is_empty_object(obj: object): boolean { + return obj !== void 0 && Object.keys(obj).length > 0; +} + +export function merge_obj_arr<T>(a: Array<T>, b: Array<T>, props: Array<string>): Array<T> { + let start = 0; + let merge = []; + + while (start < a.length) { + + if (a[start] === b[start]) { + //pushing the merged objects into array + merge.push({...a[start], ...b[start]}); + } + //incrementing start value + start = start + 1; + } + return merge; +} + +export function set_favicon(url: string) { + // Find the current favicon element + const favicon = document.querySelector("link[rel=\"icon\"]") as HTMLLinkElement; + if (favicon) { + // Update the new link + favicon.href = url; + } else { + // Create new `link` + const link = document.createElement("link"); + link.rel = "icon"; + link.href = url; + + // Append to the `head` element + document.head.appendChild(link); + } +} + +export function set_emoji_favicon(emoji: string) { + // Create a canvas element + const canvas = document.createElement("canvas"); + canvas.height = 64; + canvas.width = 64; + + // Get the canvas context + const context = canvas.getContext("2d") as CanvasRenderingContext2D; + context.font = "64px serif"; + context.fillText(emoji, 0, 64); + + // Get the custom URL + const url = canvas.toDataURL(); + + // Update the favicon + set_favicon(url); +} + + +// https://stackoverflow.com/a/48400665/11961742 +export function seconds_to_hour_minute_string(seconds: number) { + const hours = Math.floor(seconds / (60 * 60)); + seconds -= hours * (60 * 60); + const minutes = Math.floor(seconds / 60); + return hours + "h" + minutes + "m"; +} + +export function get_query_string(params: any = {}): string { + const map = Object.keys(params).reduce((arr: Array<string>, key: string) => { + if (params[key] !== undefined) { + return arr.concat(`${key}=${encodeURIComponent(params[key])}`); + } + return arr; + }, [] as any); + + if (map.length) { + return `?${map.join("&")}`; + } + + return ""; +} + +export function make_url(url: string, params: object): string { + return `${url}${get_query_string(params)}`; +} + +export function load_script(url: string) { + unload_script(url, () => { + return new Promise(function (resolve, reject) { + const script = document.createElement("script"); + script.src = url; + + script.addEventListener("load", function () { + // The script is loaded completely + resolve(true); + }); + + document.body.appendChild(script); + }); + }); +} + +export function unload_script(src: string, callback?: Function): void { + document.querySelectorAll("script[src='" + src + "']").forEach(el => el.remove()); + if (typeof callback === "function") { + callback(); + } +} + +export function noop() { +} + +export async function run_async(functionToRun: Function): Promise<any> { + return new Promise((greatSuccess, graveFailure) => { + try { + greatSuccess(functionToRun()); + } catch (exception) { + graveFailure(exception); + } + }); +} + +// https://stackoverflow.com/a/45215694/11961742 +export function get_selected_options(domElement: HTMLSelectElement): Array<string> { + const ret = []; + + // fast but not universally supported + if (domElement.selectedOptions !== undefined) { + for (let i = 0; i < domElement.selectedOptions.length; i++) { + ret.push(domElement.selectedOptions[i].value); + } + + // compatible, but can be painfully slow + } else { + for (let i = 0; i < domElement.options.length; i++) { + if (domElement.options[i].selected) { + ret.push(domElement.options[i].value); + } + } + } + return ret; +} + +export function uuid_v4(): string { + // @ts-ignore + return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c => (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)); +} + +export function random_string(length: number): string { + if (!length) { + throw new Error("length is undefined"); + } + let result = ""; + const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const charactersLength = characters.length; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; +} + +interface CreateElementOptions { + name: string, + properties?: object, + children?: Array<HTMLElement|Function|Node> +} + +export function create_element_from_object(elementOptions: CreateElementOptions): HTMLElement { + return create_element(elementOptions.name, elementOptions.properties, elementOptions.children); +} + +export function create_element(name: string, properties?: object, children?: Array<HTMLElement|any>): HTMLElement { + if (!name || name.length < 1) { + throw new Error("name is required"); + } + const node = document.createElement(name); + if (properties) { + for (const [key, value] of Object.entries(properties)) { + // @ts-ignore + node[key] = value; + } + } + + if (children && children.length > 0) { + let actualChildren = children; + if (typeof children === "function") { + // @ts-ignore + actualChildren = children(); + } + for (const child of actualChildren) { + node.appendChild(child as Node); + } + } + return node; +} + +export function get_element_position(element: HTMLElement|any) { + if (!element) return {x: 0, y: 0}; + let x = 0; + let y = 0; + while (true) { + x += element.offsetLeft; + y += element.offsetTop; + if (element.offsetParent === null) { + break; + } + element = element.offsetParent; + } + return {x, y}; +} + +export function restrict_input_to_numbers(element: HTMLElement, specials: Array<string> = [], mergeSpecialsWithDefaults: boolean = false): void { + if (element) { + element.addEventListener("keydown", (e) => { + const defaultSpecials = ["Backspace", "ArrowLeft", "ArrowRight", "Tab",]; + let keys = specials.length > 0 ? specials : defaultSpecials; + if (mergeSpecialsWithDefaults && specials) { + keys = [...specials, ...defaultSpecials]; + } + if (keys.indexOf(e.key) !== -1) { + return; + } + if (isNaN(parseInt(e.key))) { + e.preventDefault(); + } + }); + } +} + +export function element_has_focus(element: HTMLElement): boolean { + return element === document.activeElement; +} + +export function move_focus(element: HTMLElement): void { + if (!element) { + element = document.getElementsByTagName("body")[0]; + } + element.focus(); + // @ts-ignore + if (!element_has_focus(element)) { + element.setAttribute("tabindex", "-1"); + element.focus(); + } +} + +export function get_url_parameter(name: string): string { + // @ts-ignore + return new RegExp("[?&]" + name + "=([^&#]*)")?.exec(window.location.href)[1]; +} + +export function update_url_parameter(param: string, newVal: string): void { + let newAdditionalURL = ""; + let tempArray = location.href.split("?"); + const baseURL = tempArray[0]; + const additionalURL = tempArray[1]; + let temp = ""; + if (additionalURL) { + tempArray = additionalURL.split("&"); + for (let i = 0; i < tempArray.length; i++) { + if (tempArray[i].split("=")[0] !== param) { + newAdditionalURL += temp + tempArray[i]; + temp = "&"; + } + } + } + const rows_txt = temp + "" + param + "=" + newVal; + const newUrl = baseURL + "?" + newAdditionalURL + rows_txt; + window.history.replaceState("", "", newUrl); +} + + +export function get_style_string(rules: CSSRuleList) { + let styleString = ""; + for (const [key, value] of Object.entries(rules)) { + styleString += key + ":" + value + ";"; + } + return styleString; +} + +export function get_local_time_zone_date(date: Date): Date { + const timeOffsetInMS = new Date().getTimezoneOffset() * 60000; + date.setTime(date.getTime() - timeOffsetInMS); + return date; +} + +export function parse_iso_local(s: string) { + const b = s.split(/\D/); + //@ts-ignore + return new Date(b[0], b[1] - 1, b[2], b[3], b[4], b[5]); +} + +export function resolve_references(json: any) { + if (!json) return; + if (typeof json === "string") { + json = JSON.parse(json ?? "{}"); + } + const byid = {}, refs = []; + json = function recurse(obj, prop, parent) { + if (typeof obj !== "object" || !obj) { + return obj; + } + if (Object.prototype.toString.call(obj) === "[object Array]") { + for (let i = 0; i < obj.length; i++) { + if (typeof obj[i] !== "object" || !obj[i]) { + continue; + } else if ("$ref" in obj[i]) { + // @ts-ignore + obj[i] = recurse(obj[i], i, obj); + } else { + obj[i] = recurse(obj[i], prop, obj); + } + } + return obj; + } + if ("$ref" in obj) { + let ref = obj.$ref; + if (ref in byid) { + // @ts-ignore + return byid[ref]; + } + refs.push([parent, prop, ref]); + return; + } else if ("$id" in obj) { + let id = obj.$id; + delete obj.$id; + if ("$values" in obj) { + obj = obj.$values.map(recurse); + } else { + for (let prop2 in obj) { + // @ts-ignore + obj[prop2] = recurse(obj[prop2], prop2, obj); + } + } + // @ts-ignore + byid[id] = obj; + } + return obj; + }(json); + for (let i = 0; i < refs.length; i++) { + let ref = refs[i]; + // @ts-ignore + ref[0][ref[1]] = byid[ref[2]]; + } + return json; +} + +export function to_readable_date_string(date: Date, locale = "nb-NO"): string { + date.setMinutes(date.getMinutes() - date.getTimezoneOffset()); + return date.toLocaleString(locale); +} + +export function get_random_int(min: number, max: number): number { + min = Math.ceil(min); + max = Math.floor(max); + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +export function to_readable_bytes(bytes: number): string { + const s = ["bytes", "kB", "MB", "GB", "TB", "PB"]; + const e = Math.floor(Math.log(bytes) / Math.log(1024)); + return (bytes / Math.pow(1024, e)).toFixed(2) + " " + s[e]; +} + +export function can_use_dom(): boolean { + return !!(typeof window !== "undefined" && window.document && window.document.createElement); +} + +export function session_storage_remove_regex(regex: RegExp): void { + let n = sessionStorage.length; + while (n--) { + const key = sessionStorage.key(n); + if (key && regex.test(key)) { + sessionStorage.removeItem(key); + } + } +} + +export function local_storage_remove_regex(regex: RegExp): void { + let n = localStorage.length; + while (n--) { + const key = localStorage.key(n); + if (key && regex.test(key)) { + localStorage.removeItem(key); + } + } +} + +export function session_storage_set_json(key: string, value: object): void { + sessionStorage.setItem(key, JSON.stringify(value)); +} + +export function session_storage_get_json(key: string): object { + return JSON.parse(sessionStorage.getItem(key) ?? "{}"); +} + +export function local_storage_set_json(key: string, value: object): void { + localStorage.setItem(key, JSON.stringify(value)); +} + +export function local_storage_get_json(key: string): object { + return JSON.parse(localStorage.getItem(key) ?? "{}"); +} + +export function get_hash_code(value: string): number|undefined { + let hash = 0; + if (value.length === 0) { + return; + } + for (let i = 0; i < value.length; i++) { + const char = value.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash |= 0; + } + return hash; +} + +export function $(selector: string): HTMLElement|null { + return document.querySelector(selector); +} + +export function $$(selector: string): NodeListOf<Element> { + return document.querySelectorAll(selector); +} diff --git a/apps/web-shared/src/lib/models/CreateAccountPayload.ts b/apps/web-shared/src/lib/models/CreateAccountPayload.ts new file mode 100644 index 0000000..d116308 --- /dev/null +++ b/apps/web-shared/src/lib/models/CreateAccountPayload.ts @@ -0,0 +1,4 @@ +export interface CreateAccountPayload { + username: string, + password: string +} diff --git a/apps/web-shared/src/lib/models/ErrorResult.ts b/apps/web-shared/src/lib/models/ErrorResult.ts new file mode 100644 index 0000000..7c70017 --- /dev/null +++ b/apps/web-shared/src/lib/models/ErrorResult.ts @@ -0,0 +1,4 @@ +export interface ErrorResult { + title: string, + text: string +} diff --git a/apps/web-shared/src/lib/models/IInternalFetchRequest.ts b/apps/web-shared/src/lib/models/IInternalFetchRequest.ts new file mode 100644 index 0000000..68505e2 --- /dev/null +++ b/apps/web-shared/src/lib/models/IInternalFetchRequest.ts @@ -0,0 +1,6 @@ +export interface IInternalFetchRequest { + url: string, + init?: RequestInit, + timeout?: number + retry_count?: number +} diff --git a/apps/web-shared/src/lib/models/IInternalFetchResponse.ts b/apps/web-shared/src/lib/models/IInternalFetchResponse.ts new file mode 100644 index 0000000..6c91b35 --- /dev/null +++ b/apps/web-shared/src/lib/models/IInternalFetchResponse.ts @@ -0,0 +1,6 @@ +export interface IInternalFetchResponse { + ok: boolean, + status: number, + data: any, + http_response: Response +} diff --git a/apps/web-shared/src/lib/models/ISession.ts b/apps/web-shared/src/lib/models/ISession.ts new file mode 100644 index 0000000..f7ed46b --- /dev/null +++ b/apps/web-shared/src/lib/models/ISession.ts @@ -0,0 +1,7 @@ +export interface ISession { + profile: { + username: string, + id: string, + }, + lastChecked: number, +}
\ No newline at end of file diff --git a/apps/web-shared/src/lib/models/IValidationResult.ts b/apps/web-shared/src/lib/models/IValidationResult.ts new file mode 100644 index 0000000..9a21b13 --- /dev/null +++ b/apps/web-shared/src/lib/models/IValidationResult.ts @@ -0,0 +1,31 @@ +export interface IValidationResult { + errors: Array<IValidationError>, + has_errors: Function, + add_error: Function, + remove_error: Function, +} + +export interface IValidationError { + _id?: string, + title: string, + text?: string +} + +export default class ValidationResult implements IValidationResult { + errors: IValidationError[] + has_errors(): boolean { + return this.errors?.length > 0; + } + add_error(prop: string, error: IValidationError): void { + if (!this.errors) this.errors = []; + error._id = prop; + this.errors.push(error); + } + remove_error(property: string): void { + const new_errors = []; + for (const error of this.errors) { + if (error._id != property) new_errors.push(error) + } + this.errors = new_errors; + } +} diff --git a/apps/web-shared/src/lib/models/LoginPayload.ts b/apps/web-shared/src/lib/models/LoginPayload.ts new file mode 100644 index 0000000..ccd9bed --- /dev/null +++ b/apps/web-shared/src/lib/models/LoginPayload.ts @@ -0,0 +1,4 @@ +export interface LoginPayload { + username: string, + password: string +} diff --git a/apps/web-shared/src/lib/models/TimeCategoryDto.ts b/apps/web-shared/src/lib/models/TimeCategoryDto.ts new file mode 100644 index 0000000..875e8cb --- /dev/null +++ b/apps/web-shared/src/lib/models/TimeCategoryDto.ts @@ -0,0 +1,9 @@ +import { Temporal } from "@js-temporal/polyfill"; + +export interface TimeCategoryDto { + selected?: boolean; + id?: string, + modified_at?: Temporal.PlainDate, + name?: string, + color?: string +} diff --git a/apps/web-shared/src/lib/models/TimeEntryDto.ts b/apps/web-shared/src/lib/models/TimeEntryDto.ts new file mode 100644 index 0000000..71fe7a3 --- /dev/null +++ b/apps/web-shared/src/lib/models/TimeEntryDto.ts @@ -0,0 +1,13 @@ +import type { TimeLabelDto } from "./TimeLabelDto"; +import type { TimeCategoryDto } from "./TimeCategoryDto"; +import { Temporal } from "@js-temporal/polyfill"; + +export interface TimeEntryDto { + id: string, + modified_at?: Temporal.PlainDate, + start: string, + stop: string, + description: string, + labels?: Array<TimeLabelDto>, + category: TimeCategoryDto, +} diff --git a/apps/web-shared/src/lib/models/TimeEntryQuery.ts b/apps/web-shared/src/lib/models/TimeEntryQuery.ts new file mode 100644 index 0000000..6681c79 --- /dev/null +++ b/apps/web-shared/src/lib/models/TimeEntryQuery.ts @@ -0,0 +1,27 @@ +import type { TimeCategoryDto } from "./TimeCategoryDto"; +import type { TimeLabelDto } from "./TimeLabelDto"; +import type { Temporal } from "@js-temporal/polyfill"; + +export interface TimeEntryQuery { + duration: TimeEntryQueryDuration, + categories?: Array<TimeCategoryDto>, + labels?: Array<TimeLabelDto>, + dateRange?: TimeEntryQueryDateRange, + specificDate?: Temporal.PlainDateTime + page: number, + pageSize: number +} + +export interface TimeEntryQueryDateRange { + from: Temporal.PlainDateTime, + to: Temporal.PlainDateTime +} + +export enum TimeEntryQueryDuration { + TODAY = 0, + THIS_WEEK = 1, + THIS_MONTH = 2, + THIS_YEAR = 3, + SPECIFIC_DATE = 4, + DATE_RANGE = 5, +} diff --git a/apps/web-shared/src/lib/models/TimeLabelDto.ts b/apps/web-shared/src/lib/models/TimeLabelDto.ts new file mode 100644 index 0000000..2b42d07 --- /dev/null +++ b/apps/web-shared/src/lib/models/TimeLabelDto.ts @@ -0,0 +1,8 @@ +import { Temporal } from "@js-temporal/polyfill"; + +export interface TimeLabelDto { + id?: string, + modified_at?: Temporal.PlainDate, + name?: string, + color?: string +} diff --git a/apps/web-shared/src/lib/models/TimeQueryDto.ts b/apps/web-shared/src/lib/models/TimeQueryDto.ts new file mode 100644 index 0000000..607c51e --- /dev/null +++ b/apps/web-shared/src/lib/models/TimeQueryDto.ts @@ -0,0 +1,29 @@ +import type { TimeEntryDto } from "./TimeEntryDto"; +import ValidationResult, { IValidationResult } from "./IValidationResult"; + +export interface ITimeQueryDto { + results: Array<TimeEntryDto>, + page: number, + pageSize: number, + totalRecords: number, + totalPageCount: number, + is_valid: Function +} + +export class TimeQueryDto implements ITimeQueryDto { + results: TimeEntryDto[]; + page: number; + pageSize: number; + totalRecords: number; + totalPageCount: number; + + is_valid(): IValidationResult { + const result = new ValidationResult(); + if (this.page < 0) { + result.add_error("page", { + title: "Page cannot be less than zero", + }) + } + return result; + } +} diff --git a/apps/web-shared/src/lib/models/UnwrappedEntryDateTime.ts b/apps/web-shared/src/lib/models/UnwrappedEntryDateTime.ts new file mode 100644 index 0000000..e6022d8 --- /dev/null +++ b/apps/web-shared/src/lib/models/UnwrappedEntryDateTime.ts @@ -0,0 +1,9 @@ +import {Temporal} from "@js-temporal/polyfill"; + +export interface UnwrappedEntryDateTime { + start_date: Temporal.PlainDate, + stop_date: Temporal.PlainDate, + start_time: Temporal.PlainTime, + stop_time: Temporal.PlainTime, + duration: Temporal.Duration, +} diff --git a/apps/web-shared/src/lib/models/UpdateProfilePayload.ts b/apps/web-shared/src/lib/models/UpdateProfilePayload.ts new file mode 100644 index 0000000..d2983ff --- /dev/null +++ b/apps/web-shared/src/lib/models/UpdateProfilePayload.ts @@ -0,0 +1,4 @@ +export interface UpdateProfilePayload { + username?: string, + password?: string, +} diff --git a/apps/web-shared/src/lib/persistent-store.ts b/apps/web-shared/src/lib/persistent-store.ts new file mode 100644 index 0000000..922f3ab --- /dev/null +++ b/apps/web-shared/src/lib/persistent-store.ts @@ -0,0 +1,102 @@ +import { writable as _writable, readable as _readable, } from "svelte/store"; +import type { Writable, Readable, StartStopNotifier } from "svelte/store"; + +enum StoreType { + SESSION = 0, + LOCAL = 1 +} + +interface StoreOptions { + store?: StoreType; +} + +const default_store_options = { + store: StoreType.SESSION +} as StoreOptions; + +interface WritableStore<T> { + name: string, + initialState: T, + options?: StoreOptions +} + +interface ReadableStore<T> { + name: string, + initialState: T, + callback: StartStopNotifier<any>, + options?: StoreOptions +} + +function get_store(type: StoreType): Storage { + switch (type) { + case StoreType.SESSION: + return window.sessionStorage; + case StoreType.LOCAL: + return window.localStorage; + } +} + +function prepared_store_value(value: any): string { + try { + return JSON.stringify(value); + } catch (e) { + console.error(e); + return "__INVALID__"; + } +} + +function get_store_value<T>(options: WritableStore<T> | ReadableStore<T>): any { + try { + const storage = get_store(options.options.store); + const value = storage.getItem(options.name); + if (!value) return false; + return JSON.parse(value); + } catch (e) { + console.error(e); + return { __INVALID__: true }; + } +} + +function hydrate<T>(store: Writable<T>, options: WritableStore<T> | ReadableStore<T>): void { + const value = get_store_value<T>(options); + if (value && store.set) store.set(value); +} + +function subscribe<T>(store: Writable<T> | Readable<T>, options: WritableStore<T> | ReadableStore<T>): void { + const storage = get_store(options.options.store); + if (!store.subscribe) return; + store.subscribe((state: any) => { + storage.setItem(options.name, prepared_store_value(state)); + }); +} + +function writable_persistent<T>(options: WritableStore<T>): Writable<T> { + if (options.options === undefined) options.options = default_store_options; + console.log("Creating writable store with options: ", options); + const store = _writable<T>(options.initialState); + hydrate(store, options); + subscribe(store, options); + return store; +} + +function readable_persistent<T>(options: ReadableStore<T>): Readable<T> { + if (options.options === undefined) options.options = default_store_options; + console.log("Creating readable store with options: ", options); + const store = _readable<T>(options.initialState, options.callback); + // hydrate(store, options); + subscribe(store, options); + return store; +} + +export { + writable_persistent, + readable_persistent, + StoreType +}; + +export type { + WritableStore, + ReadableStore, + StoreOptions +}; + diff --git a/apps/web-shared/src/lib/session.ts b/apps/web-shared/src/lib/session.ts new file mode 100644 index 0000000..4f40a17 --- /dev/null +++ b/apps/web-shared/src/lib/session.ts @@ -0,0 +1,62 @@ +import {Temporal} from "@js-temporal/polyfill"; +import {get_profile_for_active_check} from "./api/user"; +import {is_guid, session_storage_get_json, session_storage_set_json} from "./helpers"; +import {SECONDS_BETWEEN_SESSION_CHECK, StorageKeys} from "./configuration"; +import type {ISession} from "$shared/lib/models/ISession"; + +export async function is_active(forceRefresh: boolean = false): Promise<boolean> { + const nowEpoch = Temporal.Now.instant().epochSeconds; + const data = session_storage_get_json(StorageKeys.session) as ISession; + const expiryEpoch = data?.lastChecked + SECONDS_BETWEEN_SESSION_CHECK; + const lastCheckIsStaleOrNone = !is_guid(data?.profile?.id) || (expiryEpoch < nowEpoch); + if (forceRefresh || lastCheckIsStaleOrNone) { + return await call_api(); + } else { + const sessionIsValid = data.profile && is_guid(data.profile.id); + if (!sessionIsValid) { + clear_session_data(); + console.log("Session data is not valid"); + } + return sessionIsValid; + } +} + +async function call_api(): Promise<boolean> { + console.log("Getting profile data while checking session state"); + try { + const response = await get_profile_for_active_check(); + if (response.ok) { + const userData = await response.data; + if (is_guid(userData.id) && userData.username) { + const session = { + profile: userData, + lastChecked: Temporal.Now.instant().epochSeconds + } as ISession; + session_storage_set_json(StorageKeys.session, session); + console.log("Successfully got profile data while checking session state"); + return true; + } else { + console.error("Api returned invalid data while getting profile data"); + clear_session_data(); + return false; + } + } else { + console.error("Api returned unsuccessfully while getting profile data"); + clear_session_data(); + return false; + } + } catch (e) { + console.error(e); + clear_session_data(); + return false; + } +} + +export function clear_session_data() { + session_storage_set_json(StorageKeys.session, {}); + console.log("Cleared session data."); +} + +export function get_session_data(): ISession { + return session_storage_get_json(StorageKeys.session) as ISession; +} diff --git a/apps/web-shared/src/styles/_base.scss b/apps/web-shared/src/styles/_base.scss new file mode 100644 index 0000000..414b440 --- /dev/null +++ b/apps/web-shared/src/styles/_base.scss @@ -0,0 +1,48 @@ +// -------------------------------- + +// Basic Style - Essential CSS rules and utility classes + +// -------------------------------- + +@forward 'base/breakpoints'; +@forward 'base/mixins'; +@forward 'base/grid-layout'; + +@use 'base/reset'; +@use 'base/colors'; +@use 'base/spacing'; +@use 'base/shared-styles'; +@use 'base/typography'; +@use 'base/icons'; +@use 'base/buttons'; +@use 'base/forms'; +@use 'base/z-index'; +@use 'base/visibility'; +@use 'base/accessibility'; +@use 'base/util'; + +pre { + font-family: monospace !important; +} + +*:focus-visible { + outline: 1px auto; +} + +.c-disabled { + cursor: not-allowed !important; + filter: opacity(.45); + pointer-events: none !important; + + &.loading { + cursor: wait !important; + } +} + +button.btn--state-b { + cursor: wait; +} + +button.reset { + cursor: pointer !important; +} diff --git a/apps/web-shared/src/styles/base/_accessibility.scss b/apps/web-shared/src/styles/base/_accessibility.scss new file mode 100644 index 0000000..9f71937 --- /dev/null +++ b/apps/web-shared/src/styles/base/_accessibility.scss @@ -0,0 +1,17 @@ +.sr-only { // content made available only to screen readers + position: absolute; + clip: rect(1px, 1px, 1px, 1px); + clip-path: inset(50%); + width: 1px; + height: 1px; + overflow: hidden; + padding: 0; + border: 0; + white-space: nowrap; +} + +.sr-only-focusable { // focusable, visually hidden element + &:not(:focus):not(:focus-within){ + @extend .sr-only + } +}
\ No newline at end of file diff --git a/apps/web-shared/src/styles/base/_breakpoints.scss b/apps/web-shared/src/styles/base/_breakpoints.scss new file mode 100644 index 0000000..a7f1eda --- /dev/null +++ b/apps/web-shared/src/styles/base/_breakpoints.scss @@ -0,0 +1,15 @@ +$breakpoints: ( + xs: 32rem, // ~512px + sm: 48rem, // ~768px + md: 64rem, // ~1024px + lg: 80rem, // ~1280px + xl: 90rem // ~1440px +) !default; + +@mixin breakpoint($breakpoint, $logic: false) { + @if( $logic ) { + @media #{$logic} and (min-width: map-get($map: $breakpoints, $key: $breakpoint)) { @content; } + } @else { + @media (min-width: map-get($map: $breakpoints, $key: $breakpoint)) { @content; } + } +} diff --git a/apps/web-shared/src/styles/base/_buttons.scss b/apps/web-shared/src/styles/base/_buttons.scss new file mode 100644 index 0000000..2a7ff34 --- /dev/null +++ b/apps/web-shared/src/styles/base/_buttons.scss @@ -0,0 +1,24 @@ +// don't modify this file -> edit 📁 custom-style/_buttons.scss to create your custom buttons + +.btn { // basic button style + position: relative; + display: inline-flex; + justify-content: center; + align-items: center; + white-space: nowrap; + text-decoration: none; + font-size: var(--btn-font-size, 1em); + padding-top: var(--btn-padding-y, 0.5em); + padding-bottom: var(--btn-padding-y, 0.5em); + padding-left: var(--btn-padding-x, 0.75em); + padding-right: var(--btn-padding-x, 0.75em); + border-radius: var(--btn-radius, 0.25em); +} + +// default size variations +.btn--sm { font-size: var(--btn-font-size-sm, 0.8em); } +.btn--md { font-size: var(--btn-font-size-md, 1.2em); } +.btn--lg { font-size: var(--btn-font-size-lg, 1.4em); } + +// button with (only) icon +.btn--icon { padding: var(--btn-padding-y, 0.5em); }
\ No newline at end of file diff --git a/apps/web-shared/src/styles/base/_colors.scss b/apps/web-shared/src/styles/base/_colors.scss new file mode 100644 index 0000000..f061d9a --- /dev/null +++ b/apps/web-shared/src/styles/base/_colors.scss @@ -0,0 +1,6 @@ +// don't modify this file -> edit 📁 custom-style/_colors.scss to create your color palette + +[data-theme] { + background-color: var(--color-bg, hsl(0, 0%, 100%)); + color: var(--color-contrast-high, hsl(210, 7%, 21%)); +}
\ No newline at end of file diff --git a/apps/web-shared/src/styles/base/_forms.scss b/apps/web-shared/src/styles/base/_forms.scss new file mode 100644 index 0000000..faffddd --- /dev/null +++ b/apps/web-shared/src/styles/base/_forms.scss @@ -0,0 +1,22 @@ +// don't modify this file -> edit 📁 custom-style/_forms.scss to create your custom form elements + +.form-control { + font-size: var(--form-control-font-size, 1em); + padding-top: var(--form-control-padding-y, 0.5em); + padding-bottom: var(--form-control-padding-y, 0.5em); + padding-left: var(--form-control-padding-x, 0.75em); + padding-right: var(--form-control-padding-x, 0.75em); + border-radius: var(--form-control-radius, 0.25em); +} + +.form-legend { + color: var(--color-contrast-higher, hsl(204, 28%, 7%)); + line-height: var(--heading-line-height, 1.2); + font-size: var(--text-md, 1.125rem); + margin-bottom: var(--space-sm); +} + +.form-label { + display: inline-block; + font-size: var(--text-sm, 0.75rem); +} diff --git a/apps/web-shared/src/styles/base/_grid-layout.scss b/apps/web-shared/src/styles/base/_grid-layout.scss new file mode 100644 index 0000000..bd8b6c9 --- /dev/null +++ b/apps/web-shared/src/styles/base/_grid-layout.scss @@ -0,0 +1,261 @@ +@use 'mixins' as *; +@use 'breakpoints' as *; + +// -------------------------------- + +// Container - center content on x-axis + +// -------------------------------- + +.container { + width: calc(100% - 2*var(--component-padding)); + margin-left: auto; + margin-right: auto; +} + +// -------------------------------- + +// Grid System + +// -------------------------------- + +$grid-columns: 12 !default; + +.grid, .flex, .inline-flex { + --gap: 0px; + --gap-x: var(--gap); + --gap-y: var(--gap); + gap: var(--gap-y) var(--gap-x); + + > * { + --sub-gap: 0px; + --sub-gap-x: var(--sub-gap); + --sub-gap-y: var(--sub-gap); + } +} + +.grid { + --grid-columns: 12; + display: flex; + flex-wrap: wrap; + + > * { + flex-basis: 100%; + max-width: 100%; + min-width: 0; + } +} + +/* #region (Safari < 14.1 fallback) */ +@media not all and (min-resolution:.001dpcm) { + @supports (not(translate: none)) { + .grid, .flex[class*="gap-"], .inline-flex[class*="gap-"] { + gap: 0; // reset + margin-bottom: calc(-1 * var(--gap-y)); + margin-left: calc(-1 * var(--gap-x)); + + > * { + margin-bottom: var(--sub-gap-y); + } + } + + .grid { + --offset: var(--gap-x); + --gap-modifier: 0; + --offset-modifier: 1; + + > * { + margin-left: var(--offset); + } + } + + .flex[class*="gap-"], .inline-flex[class*="gap-"] { + > * { + margin-left: var(--sub-gap-x); + } + } + } +} +/* #endregion */ + +.gap-xxxxs { --gap-x: var(--space-xxxxs); --gap-y: var(--space-xxxxs); > * { --sub-gap-x: var(--space-xxxxs); --sub-gap-y: var(--space-xxxxs); }} +.gap-xxxs { --gap-x: var(--space-xxxs); --gap-y: var(--space-xxxs); > * { --sub-gap-x: var(--space-xxxs); --sub-gap-y: var(--space-xxxs); }} +.gap-xxs { --gap-x: var(--space-xxs); --gap-y: var(--space-xxs); > * { --sub-gap-x: var(--space-xxs); --sub-gap-y: var(--space-xxs); }} +.gap-xs { --gap-x: var(--space-xs); --gap-y: var(--space-xs); > * { --sub-gap-x: var(--space-xs); --sub-gap-y: var(--space-xs); }} +.gap-sm { --gap-x: var(--space-sm); --gap-y: var(--space-sm); > * { --sub-gap-x: var(--space-sm); --sub-gap-y: var(--space-sm); }} +.gap-md { --gap-x: var(--space-md); --gap-y: var(--space-md); > * { --sub-gap-x: var(--space-md); --sub-gap-y: var(--space-md); }} +.gap-lg { --gap-x: var(--space-lg); --gap-y: var(--space-lg); > * { --sub-gap-x: var(--space-lg); --sub-gap-y: var(--space-lg); }} +.gap-xl { --gap-x: var(--space-xl); --gap-y: var(--space-xl); > * { --sub-gap-x: var(--space-xl); --sub-gap-y: var(--space-xl); }} +.gap-xxl { --gap-x: var(--space-xxl); --gap-y: var(--space-xxl); > * { --sub-gap-x: var(--space-xxl); --sub-gap-y: var(--space-xxl); }} +.gap-xxxl { --gap-x: var(--space-xxxl); --gap-y: var(--space-xxxl); > * { --sub-gap-x: var(--space-xxxl); --sub-gap-y: var(--space-xxxl); }} +.gap-xxxxl { --gap-x: var(--space-xxxxl); --gap-y: var(--space-xxxxl); > * { --sub-gap-x: var(--space-xxxxl); --sub-gap-y: var(--space-xxxxl); }} +.gap-0 { --gap-x: 0; --gap-y: 0; > * { --sub-gap-x: 0; --sub-gap-y: 0; }} + +.gap-x-xxxxs { --gap-x: var(--space-xxxxs); > * { --sub-gap-x: var(--space-xxxxs); }} +.gap-x-xxxs { --gap-x: var(--space-xxxs); > * { --sub-gap-x: var(--space-xxxs); }} +.gap-x-xxs { --gap-x: var(--space-xxs); > * { --sub-gap-x: var(--space-xxs); }} +.gap-x-xs { --gap-x: var(--space-xs); > * { --sub-gap-x: var(--space-xs); }} +.gap-x-sm { --gap-x: var(--space-sm); > * { --sub-gap-x: var(--space-sm); }} +.gap-x-md { --gap-x: var(--space-md); > * { --sub-gap-x: var(--space-md); }} +.gap-x-lg { --gap-x: var(--space-lg); > * { --sub-gap-x: var(--space-lg); }} +.gap-x-xl { --gap-x: var(--space-xl); > * { --sub-gap-x: var(--space-xl); }} +.gap-x-xxl { --gap-x: var(--space-xxl); > * { --sub-gap-x: var(--space-xxl); }} +.gap-x-xxxl { --gap-x: var(--space-xxxl); > * { --sub-gap-x: var(--space-xxxl); }} +.gap-x-xxxxl { --gap-x: var(--space-xxxxl); > * { --sub-gap-x: var(--space-xxxxl); }} +.gap-x-0 { --gap-x: 0; > * { --sub-gap-x: 0; }} + +.gap-y-xxxxs { --gap-y: var(--space-xxxxs); > * { --sub-gap-y: var(--space-xxxxs); }} +.gap-y-xxxs { --gap-y: var(--space-xxxs); > * { --sub-gap-y: var(--space-xxxs); }} +.gap-y-xxs { --gap-y: var(--space-xxs); > * { --sub-gap-y: var(--space-xxs); }} +.gap-y-xs { --gap-y: var(--space-xs); > * { --sub-gap-y: var(--space-xs); }} +.gap-y-sm { --gap-y: var(--space-sm); > * { --sub-gap-y: var(--space-sm); }} +.gap-y-md { --gap-y: var(--space-md); > * { --sub-gap-y: var(--space-md); }} +.gap-y-lg { --gap-y: var(--space-lg); > * { --sub-gap-y: var(--space-lg); }} +.gap-y-xl { --gap-y: var(--space-xl); > * { --sub-gap-y: var(--space-xl); }} +.gap-y-xxl { --gap-y: var(--space-xxl); > * { --sub-gap-y: var(--space-xxl); }} +.gap-y-xxxl { --gap-y: var(--space-xxxl); > * { --sub-gap-y: var(--space-xxxl); }} +.gap-y-xxxxl { --gap-y: var(--space-xxxxl); > * { --sub-gap-y: var(--space-xxxxl); }} +.gap-y-0 { --gap-y: 0; > * { --sub-gap-y: 0; }} + +$grid-col-class-list: ''; // list of col-{span} classes + +@for $i from 1 through $grid-columns { + $grid-col-class-list: $grid-col-class-list + ".col-#{$i}"; + @if($i < $grid-columns) { + $grid-col-class-list: $grid-col-class-list + ', '; + } + .grid-col-#{$i} { --grid-columns: #{$i}; } // set number of grid columns + .col-#{$i} { --span: #{$i}; } // set grid item span +} + +#{$grid-col-class-list} { + flex-basis: calc(((100% - (var(--grid-columns) - var(--gap-modifier, 1)) * var(--sub-gap-x)) * var(--span) / var(--grid-columns)) + (var(--span) - 1) * var(--sub-gap-x)); + max-width: calc(((100% - (var(--grid-columns) - var(--gap-modifier, 1)) * var(--sub-gap-x)) * var(--span) / var(--grid-columns)) + (var(--span) - 1) * var(--sub-gap-x)); +} + +.col { // auto-expanding column + flex-grow: 1; + flex-basis: 0; + max-width: 100%; +} + +.col-content { // column width depends on its content + flex-grow: 0; + flex-basis: initial; + max-width: initial; +} + +// offset +$grid-offset-class-list: ''; // list of offset-{span} classes + +@for $i from 1 through $grid-columns - 1 { + $grid-offset-class-list: $grid-offset-class-list + ".offset-#{$i}"; + @if($i < $grid-columns) { + $grid-offset-class-list: $grid-offset-class-list + ', '; + } + .offset-#{$i} { --offset: #{$i}; } +} + +#{$grid-offset-class-list} { + margin-left: calc(((100% - (var(--grid-columns) - var(--gap-modifier, 1)) * var(--sub-gap-x)) * var(--offset) / var(--grid-columns)) + (var(--offset) + var(--offset-modifier, 0)) * var(--sub-gap-x)); +} + +// responsive variations +@each $breakpoint, $value in $breakpoints { + @include breakpoint(#{$breakpoint}) { + .gap-xxxxs\@#{$breakpoint} { --gap-x: var(--space-xxxxs); --gap-y: var(--space-xxxxs); > * { --sub-gap-x: var(--space-xxxxs); --sub-gap-y: var(--space-xxxxs); }} + .gap-xxxs\@#{$breakpoint} { --gap-x: var(--space-xxxs); --gap-y: var(--space-xxxs); > * { --sub-gap-x: var(--space-xxxs); --sub-gap-y: var(--space-xxxs); }} + .gap-xxs\@#{$breakpoint} { --gap-x: var(--space-xxs); --gap-y: var(--space-xxs); > * { --sub-gap-x: var(--space-xxs); --sub-gap-y: var(--space-xxs); }} + .gap-xs\@#{$breakpoint} { --gap-x: var(--space-xs); --gap-y: var(--space-xs); > * { --sub-gap-x: var(--space-xs); --sub-gap-y: var(--space-xs); }} + .gap-sm\@#{$breakpoint} { --gap-x: var(--space-sm); --gap-y: var(--space-sm); > * { --sub-gap-x: var(--space-sm); --sub-gap-y: var(--space-sm); }} + .gap-md\@#{$breakpoint} { --gap-x: var(--space-md); --gap-y: var(--space-md); > * { --sub-gap-x: var(--space-md); --sub-gap-y: var(--space-md); }} + .gap-lg\@#{$breakpoint} { --gap-x: var(--space-lg); --gap-y: var(--space-lg); > * { --sub-gap-x: var(--space-lg); --sub-gap-y: var(--space-lg); }} + .gap-xl\@#{$breakpoint} { --gap-x: var(--space-xl); --gap-y: var(--space-xl); > * { --sub-gap-x: var(--space-xl); --sub-gap-y: var(--space-xl); }} + .gap-xxl\@#{$breakpoint} { --gap-x: var(--space-xxl); --gap-y: var(--space-xxl); > * { --sub-gap-x: var(--space-xxl); --sub-gap-y: var(--space-xxl); }} + .gap-xxxl\@#{$breakpoint} { --gap-x: var(--space-xxxl); --gap-y: var(--space-xxxl); > * { --sub-gap-x: var(--space-xxxl); --sub-gap-y: var(--space-xxxl); }} + .gap-xxxxl\@#{$breakpoint} { --gap-x: var(--space-xxxxl); --gap-y: var(--space-xxxxl); > * { --sub-gap-x: var(--space-xxxxl); --sub-gap-y: var(--space-xxxxl); }} + .gap-0\@#{$breakpoint} { --gap-x: 0; --gap-y: 0; > * { --sub-gap-x: 0; --sub-gap-y: 0; }} + + .gap-x-xxxxs\@#{$breakpoint} { --gap-x: var(--space-xxxxs); > * { --sub-gap-x: var(--space-xxxxs); }} + .gap-x-xxxs\@#{$breakpoint} { --gap-x: var(--space-xxxs); > * { --sub-gap-x: var(--space-xxxs); }} + .gap-x-xxs\@#{$breakpoint} { --gap-x: var(--space-xxs); > * { --sub-gap-x: var(--space-xxs); }} + .gap-x-xs\@#{$breakpoint} { --gap-x: var(--space-xs); > * { --sub-gap-x: var(--space-xs); }} + .gap-x-sm\@#{$breakpoint} { --gap-x: var(--space-sm); > * { --sub-gap-x: var(--space-sm); }} + .gap-x-md\@#{$breakpoint} { --gap-x: var(--space-md); > * { --sub-gap-x: var(--space-md); }} + .gap-x-lg\@#{$breakpoint} { --gap-x: var(--space-lg); > * { --sub-gap-x: var(--space-lg); }} + .gap-x-xl\@#{$breakpoint} { --gap-x: var(--space-xl); > * { --sub-gap-x: var(--space-xl); }} + .gap-x-xxl\@#{$breakpoint} { --gap-x: var(--space-xxl); > * { --sub-gap-x: var(--space-xxl); }} + .gap-x-xxxl\@#{$breakpoint} { --gap-x: var(--space-xxxl); > * { --sub-gap-x: var(--space-xxxl); }} + .gap-x-xxxxl\@#{$breakpoint} { --gap-x: var(--space-xxxxl); > * { --sub-gap-x: var(--space-xxxxl); }} + .gap-x-0\@#{$breakpoint} { --gap-x: 0; > * { --sub-gap-x: 0; }} + + .gap-y-xxxxs\@#{$breakpoint} { --gap-y: var(--space-xxxxs); > * { --sub-gap-y: var(--space-xxxxs); }} + .gap-y-xxxs\@#{$breakpoint} { --gap-y: var(--space-xxxs); > * { --sub-gap-y: var(--space-xxxs); }} + .gap-y-xxs\@#{$breakpoint} { --gap-y: var(--space-xxs); > * { --sub-gap-y: var(--space-xxs); }} + .gap-y-xs\@#{$breakpoint} { --gap-y: var(--space-xs); > * { --sub-gap-y: var(--space-xs); }} + .gap-y-sm\@#{$breakpoint} { --gap-y: var(--space-sm); > * { --sub-gap-y: var(--space-sm); }} + .gap-y-md\@#{$breakpoint} { --gap-y: var(--space-md); > * { --sub-gap-y: var(--space-md); }} + .gap-y-lg\@#{$breakpoint} { --gap-y: var(--space-lg); > * { --sub-gap-y: var(--space-lg); }} + .gap-y-xl\@#{$breakpoint} { --gap-y: var(--space-xl); > * { --sub-gap-y: var(--space-xl); }} + .gap-y-xxl\@#{$breakpoint} { --gap-y: var(--space-xxl); > * { --sub-gap-y: var(--space-xxl); }} + .gap-y-xxxl\@#{$breakpoint} { --gap-y: var(--space-xxxl); > * { --sub-gap-y: var(--space-xxxl); }} + .gap-y-xxxxl\@#{$breakpoint} { --gap-y: var(--space-xxxxl); > * { --sub-gap-y: var(--space-xxxxl); }} + .gap-y-0\@#{$breakpoint} { --gap-y: 0; > * { --sub-gap-y: 0; }} + + $grid-col-class-list: ''; // list of col-{span} classes + + @for $i from 1 through $grid-columns { + $grid-col-class-list: $grid-col-class-list + ".col-#{$i}\\@#{$breakpoint}"; + @if($i < $grid-columns) { + $grid-col-class-list: $grid-col-class-list + ', '; + } + .grid-col-#{$i}\@#{$breakpoint} { --grid-columns: #{$i}; } // set number of grid columns + .col-#{$i}\@#{$breakpoint} { --span: #{$i}; } // set grid item span + } + + #{$grid-col-class-list} { + flex-basis: calc(((100% - (var(--grid-columns) - var(--gap-modifier, 1)) * var(--sub-gap-x)) * var(--span) / var(--grid-columns)) + (var(--span) - 1) * var(--sub-gap-x)); + max-width: calc(((100% - (var(--grid-columns) - var(--gap-modifier, 1)) * var(--sub-gap-x)) * var(--span) / var(--grid-columns)) + (var(--span) - 1) * var(--sub-gap-x)); + } + + .col\@#{$breakpoint} { // auto-expanding column + flex-grow: 1; + flex-basis: 0; + max-width: 100%; + } + + .col-content\@#{$breakpoint} { // column width depends on its content + flex-grow: 0; + flex-basis: initial; + max-width: initial; + } + + // offset + $grid-offset-class-list: ''; // list of offset-{span} classes + + @for $i from 1 through $grid-columns - 1 { + $grid-offset-class-list: $grid-offset-class-list + ".offset-#{$i}\\@#{$breakpoint}"; + @if($i < $grid-columns) { + $grid-offset-class-list: $grid-offset-class-list + ', '; + } + .offset-#{$i}\@#{$breakpoint} { --offset: #{$i}; } + } + + #{$grid-offset-class-list} { + margin-left: calc(((100% - (var(--grid-columns) - var(--gap-modifier, 1)) * var(--sub-gap-x)) * var(--offset) / var(--grid-columns)) + (var(--offset) + var(--offset-modifier, 0)) * var(--sub-gap-x)); + } + + .offset-0\@#{$breakpoint} { + margin-left: 0; + } + + @media not all and (min-resolution:.001dpcm) { + @supports (not(translate: none)) { + .offset-0\@#{$breakpoint} { + margin-left: var(--gap-x); + } + } + } + } +} + diff --git a/apps/web-shared/src/styles/base/_icons.scss b/apps/web-shared/src/styles/base/_icons.scss new file mode 100644 index 0000000..1674a7c --- /dev/null +++ b/apps/web-shared/src/styles/base/_icons.scss @@ -0,0 +1,62 @@ +// don't modify this file -> edit 📁 custom-style/_icons.scss to set your custom icons style + +:root { + // default icon sizes + --icon-xxxs: 8px; + --icon-xxs: 12px; + --icon-xs: 16px; + --icon-sm: 24px; + --icon-md: 32px; + --icon-lg: 48px; + --icon-xl: 64px; + --icon-xxl: 96px; + --icon-xxxl: 128px; +} + +.icon { + --size: 1em; + height: var(--size); + width: var(--size); + display: inline-block; + color: inherit; + fill: currentColor; + line-height: 1; + flex-shrink: 0; + max-width: initial; +} + +// icon size +.icon--xxxs { --size: var(--icon-xxxs); } +.icon--xxs { --size: var(--icon-xxs); } +.icon--xs { --size: var(--icon-xs); } +.icon--sm { --size: var(--icon-sm); } +.icon--md { --size: var(--icon-md); } +.icon--lg { --size: var(--icon-lg); } +.icon--xl { --size: var(--icon-xl); } +.icon--xxl { --size: var(--icon-xxl); } +.icon--xxxl { --size: var(--icon-xxxl); } + +.icon--is-spinning { // rotate the icon infinitely + animation: icon-spin 1s infinite linear; +} + +@keyframes icon-spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +// -------------------------------- + +// SVG <symbol> + +// -------------------------------- + +// enable icon color corrections +.icon use { + color: inherit; + fill: currentColor; +}
\ No newline at end of file diff --git a/apps/web-shared/src/styles/base/_mixins.scss b/apps/web-shared/src/styles/base/_mixins.scss new file mode 100644 index 0000000..8fe82f6 --- /dev/null +++ b/apps/web-shared/src/styles/base/_mixins.scss @@ -0,0 +1,151 @@ +@use 'sass:math'; + +// -------------------------------- + +// Typography + +// -------------------------------- + +// edit font rendering -> tip: use for light text on dark backgrounds +@mixin fontSmooth { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +// crop top space on text elements - caused by line height +@mixin lhCrop($line-height, $capital-letter: 1) { + &::before { + content: ''; + display: block; + height: 0; + width: 0; + margin-top: calc((#{$capital-letter} - #{$line-height}) * 0.5em); + } +} + +// edit text unit on a component level +@mixin textUnit($text-unit) { + --text-unit: #{$text-unit}; + font-size: var(--text-unit); +} + +// -------------------------------- + +// Spacing + +// -------------------------------- + +// edit space unit on a component level +@mixin spaceUnit($space-unit) { + --space-unit: #{$space-unit}; +} + +// -------------------------------- + +// Reset + +// -------------------------------- + +// reset user agent style +@mixin reset { + background-color: transparent; + padding: 0; + border: 0; + border-radius: 0; + color: inherit; + line-height: inherit; + appearance: none; +} + +// -------------------------------- + +// Colors + +// -------------------------------- + +// define HSL color variable +@mixin defineColorHSL($color, $hue, $saturation, $lightness) { + #{$color}: unquote("hsl(#{$hue}, #{$saturation}, #{$lightness})");#{$color}-h: #{$hue};#{$color}-s: #{$saturation};#{$color}-l: #{$lightness}; +} + +// return color with different opacity value +@function alpha($color, $opacity) { + $color: str-replace($color, 'var('); + $color: str-replace($color, ')'); + $color-h: var(#{$color+'-h'}); + $color-s: var(#{$color+'-s'}); + $color-l: var(#{$color+'-l'}); + @return hsla($color-h, $color-s, $color-l, $opacity); +} + +// return color with different lightness value +@function lightness($color, $lightnessMultiplier) { + $color: str-replace($color, 'var('); + $color: str-replace($color, ')'); + $color-h: var(#{$color+'-h'}); + $color-s: var(#{$color+'-s'}); + $color-l: var(#{$color+'-l'}); + @return hsl($color-h, $color-s, calc(#{$color-l} * #{$lightnessMultiplier})); +} + +// modify color HSLA values +@function adjustHSLA($color, $hueMultiplier: 1, $saturationMultiplier: 1, $lightnessMultiplier: 1, $opacity: 1) { + $color: str-replace($color, 'var('); + $color: str-replace($color, ')'); + $color-h: var(#{$color+'-h'}); + $color-s: var(#{$color+'-s'}); + $color-l: var(#{$color+'-l'}); + @return hsla(calc(#{$color-h} * #{$hueMultiplier}), calc(#{$color-s} * #{$saturationMultiplier}), calc(#{$color-l} * #{$lightnessMultiplier}), $opacity); +} + +// replace substring with another string +// credits: https://css-tricks.com/snippets/sass/str-replace-function/ +@function str-replace($string, $search, $replace: '') { + $index: str-index($string, $search); + @if $index { + @return str-slice($string, 1, $index - 1) + $replace + str-replace(str-slice($string, $index + str-length($search)), $search, $replace); + } + @return $string; +} + +// -------------------------------- + +// Accessibility + +// -------------------------------- + +// hide - content made available only to screen readers +@mixin srHide { + position: absolute; + clip: rect(1px, 1px, 1px, 1px); + clip-path: inset(50%); +} + +// show +@mixin srShow { + position: static; + clip: auto; + clip-path: none; +} + +// -------------------------------- + +// CSS Triangle + +// -------------------------------- + +@mixin triangle ($direction: up, $width: 12px, $color: red) { + width: 0; + height: 0; + border: $width solid transparent; + + @if( $direction == left ) { + border-right-color: $color; + } @else if( $direction == right ) { + border-left-color: $color; + } @else if( $direction == down ) { + border-top-color: $color; + } @else { + border-bottom-color: $color; + } +} diff --git a/apps/web-shared/src/styles/base/_reset.scss b/apps/web-shared/src/styles/base/_reset.scss new file mode 100644 index 0000000..5ba4534 --- /dev/null +++ b/apps/web-shared/src/styles/base/_reset.scss @@ -0,0 +1,83 @@ +*, *::after, *::before { + box-sizing: inherit; +} + +* { + font: inherit; +} + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video, hr { + margin: 0; + padding: 0; + border: 0; +} + +html { + box-sizing: border-box; +} + +body { + background-color: var(--color-bg, white); +} + +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section, main, form legend { + display: block; +} + +ol, ul, menu { + list-style: none; +} + +blockquote, q { + quotes: none; +} + +button, input, textarea, select { + margin: 0; +} + +.btn, .form-control, .link, .reset { // reset style of buttons + form controls + background-color: transparent; + padding: 0; + border: 0; + border-radius: 0; + color: inherit; + line-height: inherit; + appearance: none; +} + +select.form-control::-ms-expand { + display: none; // hide Select default icon on IE +} + +textarea { + resize: vertical; + overflow: auto; + vertical-align: top; +} + +input::-ms-clear { + display: none; // hide X icon in IE and Edge +} + +table { + border-collapse: collapse; + border-spacing: 0; +} + +img, video, svg { + max-width: 100%; +}
\ No newline at end of file diff --git a/apps/web-shared/src/styles/base/_shared-styles.scss b/apps/web-shared/src/styles/base/_shared-styles.scss new file mode 100644 index 0000000..dae02fe --- /dev/null +++ b/apps/web-shared/src/styles/base/_shared-styles.scss @@ -0,0 +1,34 @@ +// don't modify this file -> edit 📁 custom-style/_shared-style.scss to set your custom shared styles + +:root { + // radius + --radius-sm: calc(var(--radius, 0.25em)/2); + --radius-md: var(--radius, 0.25em); + --radius-lg: calc(var(--radius, 0.25em)*2); + + // box shadow + --shadow-xs: 0 0.1px 0.3px rgba(0, 0, 0, 0.06), + 0 1px 2px rgba(0, 0, 0, 0.12); + --shadow-sm: 0 0.3px 0.4px rgba(0, 0, 0, 0.025), + 0 0.9px 1.5px rgba(0, 0, 0, 0.05), + 0 3.5px 6px rgba(0, 0, 0, 0.1); + --shadow-md: 0 0.9px 1.5px rgba(0, 0, 0, 0.03), + 0 3.1px 5.5px rgba(0, 0, 0, 0.08), + 0 14px 25px rgba(0, 0, 0, 0.12); + --shadow-lg: 0 1.2px 1.9px -1px rgba(0, 0, 0, 0.014), + 0 3.3px 5.3px -1px rgba(0, 0, 0, 0.038), + 0 8.5px 12.7px -1px rgba(0, 0, 0, 0.085), + 0 30px 42px -1px rgba(0, 0, 0, 0.15); + --shadow-xl: 0 1.5px 2.1px -6px rgba(0, 0, 0, 0.012), + 0 3.6px 5.2px -6px rgba(0, 0, 0, 0.035), + 0 7.3px 10.6px -6px rgba(0, 0, 0, 0.07), + 0 16.2px 21.9px -6px rgba(0, 0, 0, 0.117), + 0 46px 60px -6px rgba(0, 0, 0, 0.2); + + // timing functions + // credits: https://github.com/ai/easings.net + --ease-in-out: cubic-bezier(0.645, 0.045, 0.355, 1); + --ease-in: cubic-bezier(0.55, 0.055, 0.675, 0.19); + --ease-out: cubic-bezier(0.215, 0.61, 0.355, 1); + --ease-out-back: cubic-bezier(0.34, 1.56, 0.64, 1); +}
\ No newline at end of file diff --git a/apps/web-shared/src/styles/base/_spacing.scss b/apps/web-shared/src/styles/base/_spacing.scss new file mode 100644 index 0000000..24e6645 --- /dev/null +++ b/apps/web-shared/src/styles/base/_spacing.scss @@ -0,0 +1,20 @@ +// don't modify this file -> edit 📁 custom-style/_spacing.scss to set your custom spacing scale + +:root { + --space-unit: 1rem; +} + +:root, * { + --space-xxxxs: calc(0.125 * var(--space-unit)); + --space-xxxs: calc(0.25 * var(--space-unit)); + --space-xxs: calc(0.375 * var(--space-unit)); + --space-xs: calc(0.5 * var(--space-unit)); + --space-sm: calc(0.75 * var(--space-unit)); + --space-md: calc(1.25 * var(--space-unit)); + --space-lg: calc(2 * var(--space-unit)); + --space-xl: calc(3.25 * var(--space-unit)); + --space-xxl: calc(5.25 * var(--space-unit)); + --space-xxxl: calc(8.5 * var(--space-unit)); + --space-xxxxl: calc(13.75 * var(--space-unit)); + --component-padding: var(--space-md); +} diff --git a/apps/web-shared/src/styles/base/_typography.scss b/apps/web-shared/src/styles/base/_typography.scss new file mode 100644 index 0000000..85b974a --- /dev/null +++ b/apps/web-shared/src/styles/base/_typography.scss @@ -0,0 +1,185 @@ +// don't modify this file -> edit 📁 custom-style/_typography.scss to set your custom typography + +@use 'breakpoints' as *; + +:root { + --heading-line-height: 1.2; + --body-line-height: 1.4; +} + +body { + font-size: var(--text-base-size, 1rem); + font-family: var(--font-primary, sans-serif); + color: var(--color-contrast-high, hsl(210, 7%, 21%)); + font-weight: var(--body-font-weight, normal); +} + +h1, h2, h3, h4 { + color: var(--color-contrast-higher, hsl(204, 28%, 7%)); + line-height: var(--heading-line-height, 1.2); + font-weight: var(--heading-font-weight, 700); +} + +h1 { + font-size: var(--text-xxl, 2rem); +} + +h2 { + font-size: var(--text-xl, 1.75rem); +} + +h3 { + font-size: var(--text-lg, 1.375rem); +} + +h4 { + font-size: var(--text-md, 1.125rem); +} + +small { + font-size: var(--text-sm, 0.75rem); +} + +// -------------------------------- + +// Inline Text + +// -------------------------------- + +a, .link { + color: var(--color-primary, hsl(250, 84%, 54%)); + text-decoration: underline; +} + +strong { + font-weight: bold; +} + +s { + text-decoration: line-through; +} + +u { + text-decoration: underline; +} + +// -------------------------------- + +// Text Component - Class used to stylize text blocks + +// -------------------------------- + +.text-component { + h1, h2, h3, h4 { + line-height: calc(var(--heading-line-height) * var(--line-height-multiplier, 1)); + margin-bottom: calc(var(--space-unit) * 0.3125 * var(--text-space-y-multiplier, 1)); + } + + h2, h3, h4 { + margin-top: calc(var(--space-unit) * 0.9375 * var(--text-space-y-multiplier, 1)); + } + + p, blockquote, ul li, ol li { + line-height: calc(var(--body-line-height) * var(--line-height-multiplier, 1)); + } + + ul, ol, p, blockquote, .text-component__block { + margin-bottom: calc(var(--space-unit) * 0.9375 * var(--text-space-y-multiplier, 1)); + } + + ul, ol { + list-style-position: inside; + + ul, ol { + padding-left: 1em; + margin-bottom: 0; + } + } + + ul { + list-style-type: disc; + } + + ol { + list-style-type: decimal; + } + + img { + display: block; + margin: 0 auto; + } + + figcaption { + text-align: center; + margin-top: calc(var(--space-unit) * 0.5); + } + + em { + font-style: italic; + } + + hr { + margin-top: calc(var(--space-unit) * 1.875 * var(--text-space-y-multiplier, 1)); + margin-bottom: calc(var(--space-unit) * 1.875 * var(--text-space-y-multiplier, 1)); + margin-left: auto; + margin-right: auto; + } + + > *:first-child { + margin-top: 0; + } + + > *:last-child { + margin-bottom: 0; + } +} + +// text block container +.text-component__block--full-width { + width: 100vw; + margin-left: calc(50% - 50vw); +} + +@include breakpoint(sm) { + .text-component__block--left, + .text-component__block--right { + width: 45%; + + img { + width: 100%; + } + } + + .text-component__block--left { + float: left; + margin-right: calc(var(--space-unit) * 0.9375 * var(--text-space-y-multiplier, 1)); + } + + .text-component__block--right { + float: right; + margin-left: calc(var(--space-unit) * 0.9375 * var(--text-space-y-multiplier, 1)); + } +} + +// outset content +@include breakpoint(xl) { + .text-component__block--outset { + width: calc(100% + 10.5 * var(--space-unit)); + + img { + width: 100%; + } + } + + .text-component__block--outset:not(.text-component__block--right) { + margin-left: calc(-5.25 * var(--space-unit)); + } + + .text-component__block--left, .text-component__block--right { + width: 50%; + } + + .text-component__block--right.text-component__block--outset { + margin-right: calc(-5.25 * var(--space-unit)); + } +}
\ No newline at end of file diff --git a/apps/web-shared/src/styles/base/_util.scss b/apps/web-shared/src/styles/base/_util.scss new file mode 100644 index 0000000..d688e1c --- /dev/null +++ b/apps/web-shared/src/styles/base/_util.scss @@ -0,0 +1,1738 @@ +@use 'mixins' as *; +@use 'breakpoints' as *; + +// -------------------------------- + +// Flexbox + +// -------------------------------- + +.flex { display: flex; } +.inline-flex { display: inline-flex; } +.flex-wrap { flex-wrap: wrap; } +.flex-nowrap { flex-wrap: nowrap; } +.flex-column { flex-direction: column; } +.flex-column-reverse { flex-direction: column-reverse; } +.flex-row { flex-direction: row; } +.flex-row-reverse { flex-direction: row-reverse; } +.flex-center { justify-content: center; align-items: center; } + + +// flex items +.flex-grow { flex-grow: 1; } +.flex-grow-0 { flex-grow: 0; } +.flex-shrink { flex-shrink: 1; } +.flex-shrink-0 { flex-shrink: 0; } +.flex-basis-0 { flex-basis: 0; } + +// -------------------------------- + +// Justify Content + +// -------------------------------- + +.justify-start { justify-content: flex-start; } +.justify-end { justify-content: flex-end; } +.justify-center { justify-content: center; } +.justify-between { justify-content: space-between; } + +// -------------------------------- + +// Align Items + +// -------------------------------- + +.items-center { align-items: center; } +.items-start { align-items: flex-start; } +.items-end { align-items: flex-end; } +.items-baseline { align-items: baseline; } + +// -------------------------------- + +// Order + +// -------------------------------- + +.order-1 { order: 1; } +.order-2 { order: 2; } +.order-3 { order: 3; } + +// -------------------------------- + +// Aspect Ratio + +// -------------------------------- + +[class^="aspect-ratio"], [class*=" aspect-ratio"] { + --aspect-ratio: calc(16/9); + position: relative; + height: 0; + padding-bottom: calc(100%/(var(--aspect-ratio))); + + > * { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + + &:not(iframe) { + object-fit: cover; + } + } +} + +.aspect-ratio-16\:9 { --aspect-ratio: calc(16/9); } +.aspect-ratio-3\:2 { --aspect-ratio: calc(3/2); } +.aspect-ratio-4\:3 { --aspect-ratio: calc(4/3); } +.aspect-ratio-5\:4 { --aspect-ratio: calc(5/4); } +.aspect-ratio-1\:1 { --aspect-ratio: calc(1/1); } +.aspect-ratio-4\:5 { --aspect-ratio: calc(4/5); } +.aspect-ratio-3\:4 { --aspect-ratio: calc(3/4); } +.aspect-ratio-2\:3 { --aspect-ratio: calc(2/3); } +.aspect-ratio-9\:16 { --aspect-ratio: calc(9/16); } + +// -------------------------------- + +// Display + +// -------------------------------- + +.block { display: block; } +.inline-block { display: inline-block; } +.inline { display: inline; } +.contents { display: contents; } +.hide { display: none; } + +// -------------------------------- + +// Space unit + +// -------------------------------- + +.space-unit-rem { --space-unit: 1rem; } +.space-unit-em { --space-unit: 1em; } +.space-unit-px { --space-unit: 16px; } + +// -------------------------------- + +// Margin + +// -------------------------------- + +.margin-xxxxs { margin: var(--space-xxxxs); } +.margin-xxxs { margin: var(--space-xxxs); } +.margin-xxs { margin: var(--space-xxs); } +.margin-xs { margin: var(--space-xs); } +.margin-sm { margin: var(--space-sm); } +.margin-md { margin: var(--space-md); } +.margin-lg { margin: var(--space-lg); } +.margin-xl { margin: var(--space-xl); } +.margin-xxl { margin: var(--space-xxl); } +.margin-xxxl { margin: var(--space-xxxl); } +.margin-xxxxl { margin: var(--space-xxxxl); } +.margin-auto { margin: auto; } +.margin-0 { margin: 0; } + +.margin-top-xxxxs { margin-top: var(--space-xxxxs); } +.margin-top-xxxs { margin-top: var(--space-xxxs); } +.margin-top-xxs { margin-top: var(--space-xxs); } +.margin-top-xs { margin-top: var(--space-xs); } +.margin-top-sm { margin-top: var(--space-sm); } +.margin-top-md { margin-top: var(--space-md); } +.margin-top-lg { margin-top: var(--space-lg); } +.margin-top-xl { margin-top: var(--space-xl); } +.margin-top-xxl { margin-top: var(--space-xxl); } +.margin-top-xxxl { margin-top: var(--space-xxxl); } +.margin-top-xxxxl { margin-top: var(--space-xxxxl); } +.margin-top-auto { margin-top: auto; } +.margin-top-0 { margin-top: 0; } + +.margin-bottom-xxxxs { margin-bottom: var(--space-xxxxs); } +.margin-bottom-xxxs { margin-bottom: var(--space-xxxs); } +.margin-bottom-xxs { margin-bottom: var(--space-xxs); } +.margin-bottom-xs { margin-bottom: var(--space-xs); } +.margin-bottom-sm { margin-bottom: var(--space-sm); } +.margin-bottom-md { margin-bottom: var(--space-md); } +.margin-bottom-lg { margin-bottom: var(--space-lg); } +.margin-bottom-xl { margin-bottom: var(--space-xl); } +.margin-bottom-xxl { margin-bottom: var(--space-xxl); } +.margin-bottom-xxxl { margin-bottom: var(--space-xxxl); } +.margin-bottom-xxxxl { margin-bottom: var(--space-xxxxl); } +.margin-bottom-auto { margin-bottom: auto; } +.margin-bottom-0 { margin-bottom: 0; } + +.margin-right-xxxxs { margin-right: var(--space-xxxxs); } +.margin-right-xxxs { margin-right: var(--space-xxxs); } +.margin-right-xxs { margin-right: var(--space-xxs); } +.margin-right-xs { margin-right: var(--space-xs); } +.margin-right-sm { margin-right: var(--space-sm); } +.margin-right-md { margin-right: var(--space-md); } +.margin-right-lg { margin-right: var(--space-lg); } +.margin-right-xl { margin-right: var(--space-xl); } +.margin-right-xxl { margin-right: var(--space-xxl); } +.margin-right-xxxl { margin-right: var(--space-xxxl); } +.margin-right-xxxxl { margin-right: var(--space-xxxxl); } +.margin-right-auto { margin-right: auto; } +.margin-right-0 { margin-right: 0; } + +.margin-left-xxxxs { margin-left: var(--space-xxxxs); } +.margin-left-xxxs { margin-left: var(--space-xxxs); } +.margin-left-xxs { margin-left: var(--space-xxs); } +.margin-left-xs { margin-left: var(--space-xs); } +.margin-left-sm { margin-left: var(--space-sm); } +.margin-left-md { margin-left: var(--space-md); } +.margin-left-lg { margin-left: var(--space-lg); } +.margin-left-xl { margin-left: var(--space-xl); } +.margin-left-xxl { margin-left: var(--space-xxl); } +.margin-left-xxxl { margin-left: var(--space-xxxl); } +.margin-left-xxxxl { margin-left: var(--space-xxxxl); } +.margin-left-auto { margin-left: auto; } +.margin-left-0 { margin-left: 0; } + +.margin-x-xxxxs { margin-left: var(--space-xxxxs); margin-right: var(--space-xxxxs); } +.margin-x-xxxs { margin-left: var(--space-xxxs); margin-right: var(--space-xxxs); } +.margin-x-xxs { margin-left: var(--space-xxs); margin-right: var(--space-xxs); } +.margin-x-xs { margin-left: var(--space-xs); margin-right: var(--space-xs); } +.margin-x-sm { margin-left: var(--space-sm); margin-right: var(--space-sm); } +.margin-x-md { margin-left: var(--space-md); margin-right: var(--space-md); } +.margin-x-lg { margin-left: var(--space-lg); margin-right: var(--space-lg); } +.margin-x-xl { margin-left: var(--space-xl); margin-right: var(--space-xl); } +.margin-x-xxl { margin-left: var(--space-xxl); margin-right: var(--space-xxl); } +.margin-x-xxxl { margin-left: var(--space-xxxl); margin-right: var(--space-xxxl); } +.margin-x-xxxxl { margin-left: var(--space-xxxxl); margin-right: var(--space-xxxxl); } +.margin-x-auto { margin-left: auto; margin-right: auto; } +.margin-x-0 { margin-left: 0; margin-right: 0; } + +.margin-y-xxxxs { margin-top: var(--space-xxxxs); margin-bottom: var(--space-xxxxs); } +.margin-y-xxxs { margin-top: var(--space-xxxs); margin-bottom: var(--space-xxxs); } +.margin-y-xxs { margin-top: var(--space-xxs); margin-bottom: var(--space-xxs); } +.margin-y-xs { margin-top: var(--space-xs); margin-bottom: var(--space-xs); } +.margin-y-sm { margin-top: var(--space-sm); margin-bottom: var(--space-sm); } +.margin-y-md { margin-top: var(--space-md); margin-bottom: var(--space-md); } +.margin-y-lg { margin-top: var(--space-lg); margin-bottom: var(--space-lg); } +.margin-y-xl { margin-top: var(--space-xl); margin-bottom: var(--space-xl); } +.margin-y-xxl { margin-top: var(--space-xxl); margin-bottom: var(--space-xxl); } +.margin-y-xxxl { margin-top: var(--space-xxxl); margin-bottom: var(--space-xxxl); } +.margin-y-xxxxl { margin-top: var(--space-xxxxl); margin-bottom: var(--space-xxxxl); } +.margin-y-auto { margin-top: auto; margin-bottom: auto; } +.margin-y-0 { margin-top: 0; margin-bottom: 0; } + +// -------------------------------- + +// Padding + +// -------------------------------- + +.padding-xxxxs { padding: var(--space-xxxxs); } +.padding-xxxs { padding: var(--space-xxxs); } +.padding-xxs { padding: var(--space-xxs); } +.padding-xs { padding: var(--space-xs); } +.padding-sm { padding: var(--space-sm); } +.padding-md { padding: var(--space-md); } +.padding-lg { padding: var(--space-lg); } +.padding-xl { padding: var(--space-xl); } +.padding-xxl { padding: var(--space-xxl); } +.padding-xxxl { padding: var(--space-xxxl); } +.padding-xxxxl { padding: var(--space-xxxxl); } +.padding-0 { padding: 0; } +.padding-component { padding: var(--component-padding); } + +.padding-top-xxxxs { padding-top: var(--space-xxxxs); } +.padding-top-xxxs { padding-top: var(--space-xxxs); } +.padding-top-xxs { padding-top: var(--space-xxs); } +.padding-top-xs { padding-top: var(--space-xs); } +.padding-top-sm { padding-top: var(--space-sm); } +.padding-top-md { padding-top: var(--space-md); } +.padding-top-lg { padding-top: var(--space-lg); } +.padding-top-xl { padding-top: var(--space-xl); } +.padding-top-xxl { padding-top: var(--space-xxl); } +.padding-top-xxxl { padding-top: var(--space-xxxl); } +.padding-top-xxxxl { padding-top: var(--space-xxxxl); } +.padding-top-0 { padding-top: 0; } +.padding-top-component { padding-top: var(--component-padding); } + +.padding-bottom-xxxxs { padding-bottom: var(--space-xxxxs); } +.padding-bottom-xxxs { padding-bottom: var(--space-xxxs); } +.padding-bottom-xxs { padding-bottom: var(--space-xxs); } +.padding-bottom-xs { padding-bottom: var(--space-xs); } +.padding-bottom-sm { padding-bottom: var(--space-sm); } +.padding-bottom-md { padding-bottom: var(--space-md); } +.padding-bottom-lg { padding-bottom: var(--space-lg); } +.padding-bottom-xl { padding-bottom: var(--space-xl); } +.padding-bottom-xxl { padding-bottom: var(--space-xxl); } +.padding-bottom-xxxl { padding-bottom: var(--space-xxxl); } +.padding-bottom-xxxxl { padding-bottom: var(--space-xxxxl); } +.padding-bottom-0 { padding-bottom: 0; } +.padding-bottom-component { padding-bottom: var(--component-padding); } + +.padding-right-xxxxs { padding-right: var(--space-xxxxs); } +.padding-right-xxxs { padding-right: var(--space-xxxs); } +.padding-right-xxs { padding-right: var(--space-xxs); } +.padding-right-xs { padding-right: var(--space-xs); } +.padding-right-sm { padding-right: var(--space-sm); } +.padding-right-md { padding-right: var(--space-md); } +.padding-right-lg { padding-right: var(--space-lg); } +.padding-right-xl { padding-right: var(--space-xl); } +.padding-right-xxl { padding-right: var(--space-xxl); } +.padding-right-xxxl { padding-right: var(--space-xxxl); } +.padding-right-xxxxl { padding-right: var(--space-xxxxl); } +.padding-right-0 { padding-right: 0; } +.padding-right-component { padding-right: var(--component-padding); } + +.padding-left-xxxxs { padding-left: var(--space-xxxxs); } +.padding-left-xxxs { padding-left: var(--space-xxxs); } +.padding-left-xxs { padding-left: var(--space-xxs); } +.padding-left-xs { padding-left: var(--space-xs); } +.padding-left-sm { padding-left: var(--space-sm); } +.padding-left-md { padding-left: var(--space-md); } +.padding-left-lg { padding-left: var(--space-lg); } +.padding-left-xl { padding-left: var(--space-xl); } +.padding-left-xxl { padding-left: var(--space-xxl); } +.padding-left-xxxl { padding-left: var(--space-xxxl); } +.padding-left-xxxxl { padding-left: var(--space-xxxxl); } +.padding-left-0 { padding-left: 0; } +.padding-left-component { padding-left: var(--component-padding); } + +.padding-x-xxxxs { padding-left: var(--space-xxxxs); padding-right: var(--space-xxxxs); } +.padding-x-xxxs { padding-left: var(--space-xxxs); padding-right: var(--space-xxxs); } +.padding-x-xxs { padding-left: var(--space-xxs); padding-right: var(--space-xxs); } +.padding-x-xs { padding-left: var(--space-xs); padding-right: var(--space-xs); } +.padding-x-sm { padding-left: var(--space-sm); padding-right: var(--space-sm); } +.padding-x-md { padding-left: var(--space-md); padding-right: var(--space-md); } +.padding-x-lg { padding-left: var(--space-lg); padding-right: var(--space-lg); } +.padding-x-xl { padding-left: var(--space-xl); padding-right: var(--space-xl); } +.padding-x-xxl { padding-left: var(--space-xxl); padding-right: var(--space-xxl); } +.padding-x-xxxl { padding-left: var(--space-xxxl); padding-right: var(--space-xxxl); } +.padding-x-xxxxl { padding-left: var(--space-xxxxl); padding-right: var(--space-xxxxl); } +.padding-x-0 { padding-left: 0; padding-right: 0; } +.padding-x-component { padding-left: var(--component-padding); padding-right: var(--component-padding); } + +.padding-y-xxxxs { padding-top: var(--space-xxxxs); padding-bottom: var(--space-xxxxs); } +.padding-y-xxxs { padding-top: var(--space-xxxs); padding-bottom: var(--space-xxxs); } +.padding-y-xxs { padding-top: var(--space-xxs); padding-bottom: var(--space-xxs); } +.padding-y-xs { padding-top: var(--space-xs); padding-bottom: var(--space-xs); } +.padding-y-sm { padding-top: var(--space-sm); padding-bottom: var(--space-sm); } +.padding-y-md { padding-top: var(--space-md); padding-bottom: var(--space-md); } +.padding-y-lg { padding-top: var(--space-lg); padding-bottom: var(--space-lg); } +.padding-y-xl { padding-top: var(--space-xl); padding-bottom: var(--space-xl); } +.padding-y-xxl { padding-top: var(--space-xxl); padding-bottom: var(--space-xxl); } +.padding-y-xxxl { padding-top: var(--space-xxxl); padding-bottom: var(--space-xxxl); } +.padding-y-xxxxl { padding-top: var(--space-xxxxl); padding-bottom: var(--space-xxxxl); } +.padding-y-0 { padding-top: 0; padding-bottom: 0; } +.padding-y-component { padding-top: var(--component-padding); padding-bottom: var(--component-padding); } + +// -------------------------------- + +// Vertical Align + +// -------------------------------- + +.align-baseline { vertical-align: baseline; } +.align-top { vertical-align: top; } +.align-middle { vertical-align: middle; } +.align-bottom { vertical-align: bottom; } + +// -------------------------------- + +// Typography + +// -------------------------------- + +.truncate, .text-truncate { // truncate text if it exceeds its parent + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.text-replace { // replace text with bg img + overflow: hidden; + color: transparent; + text-indent: 100%; + white-space: nowrap; +} + +.break-word { + overflow-wrap: break-word; + min-width: 0; +} + +// -------------------------------- + +// Font Size + +// -------------------------------- + +.text-unit-rem, .text-unit-em, .text-unit-px { + font-size: var(--text-unit); +} + +.text-unit-rem { --text-unit: 1rem; } +.text-unit-em { --text-unit: 1em; } +.text-unit-px { --text-unit: 16px; } + +.text-xs { font-size: var(--text-xs, 0.6875rem); } +.text-sm { font-size: var(--text-sm, 0.75rem); } +.text-base { font-size: var(--text-unit, 1rem); } +.text-md { font-size: var(--text-md, 1.125rem); } +.text-lg { font-size: var(--text-lg, 1.375rem); } +.text-xl { font-size: var(--text-xl, 1.75rem); } +.text-xxl { font-size: var(--text-xxl, 2rem); } +.text-xxxl { font-size: var(--text-xxxl, 2.5rem); } +.text-xxxxl { font-size: var(--text-xxxxl, 3rem); } + +// -------------------------------- + +// Text Transform + +// -------------------------------- + +.text-uppercase { text-transform: uppercase; } +.text-capitalize { text-transform: capitalize; } + +// -------------------------------- + +// Letter Spacing + +// -------------------------------- + +.letter-spacing-xs { letter-spacing: -0.1em; } +.letter-spacing-sm { letter-spacing: -0.05em; } +.letter-spacing-md { letter-spacing: 0.05em; } +.letter-spacing-lg { letter-spacing: 0.1em; } +.letter-spacing-xl { letter-spacing: 0.2em; } + +// -------------------------------- + +// Font Weight + +// -------------------------------- + +.font-light { font-weight: 300; } +.font-normal { font-weight: 400; } +.font-medium { font-weight: 500; } +.font-semibold { font-weight: 600; } +.font-bold, .text-bold { font-weight: 700; } + +// -------------------------------- + +// Font Style + +// -------------------------------- + +.font-italic { font-style: italic; } + +// -------------------------------- + +// Font Smooth + +// -------------------------------- + +.font-smooth { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +// -------------------------------- + +// Font Family + +// -------------------------------- + +.font-primary { font-family: var(--font-primary); } + +// -------------------------------- + +// Text Align + +// -------------------------------- + +.text-center { text-align: center; } +.text-left { text-align: left; } +.text-right { text-align: right; } +.text-justify { text-align: justify; } + +// -------------------------------- + +// Text Decoration + +// -------------------------------- + +.text-line-through { text-decoration: line-through; } +.text-underline { text-decoration: underline; } +.text-decoration-none { text-decoration: none; } + +// -------------------------------- + +// Text Shadow + +// -------------------------------- + +.text-shadow-xs { text-shadow: 0 1px 1px rgba(#000, 0.15); } +.text-shadow-sm { text-shadow: 0 1px 2px rgba(#000, 0.25); } +.text-shadow-md { text-shadow: 0 1px 2px rgba(#000, 0.1), 0 2px 4px rgba(#000, 0.2); } +.text-shadow-lg { text-shadow: 0 1px 4px rgba(#000, 0.1), 0 2px 8px rgba(#000, 0.15), 0 4px 16px rgba(#000, 0.2); } +.text-shadow-xl { text-shadow: 0 1px 4px rgba(#000, 0.1), 0 2px 8px rgba(#000, 0.15), 0 4px 16px rgba(#000, 0.2), 0 6px 24px rgba(#000, 0.25); } +.text-shadow-none { text-shadow: none; } + +// -------------------------------- + +// .text-component vertical spacing + +// -------------------------------- + +.text-space-y-xxs { --text-space-y-multiplier: 0.25 !important; } +.text-space-y-xs { --text-space-y-multiplier: 0.5 !important; } +.text-space-y-sm { --text-space-y-multiplier: 0.75 !important; } +.text-space-y-md { --text-space-y-multiplier: 1.25 !important; } +.text-space-y-lg { --text-space-y-multiplier: 1.5 !important; } +.text-space-y-xl { --text-space-y-multiplier: 1.75 !important; } +.text-space-y-xxl { --text-space-y-multiplier: 2 !important; } + +// -------------------------------- + +// Line Height + +// -------------------------------- + +.line-height-xs { + --heading-line-height: 1; + --body-line-height: 1.1; + + &:not(.text-component) { + line-height: 1.1; + } +} + +.line-height-sm { + --heading-line-height: 1.1; + --body-line-height: 1.2; + + &:not(.text-component) { + line-height: 1.2; + } +} + +.line-height-md { + --heading-line-height: 1.15; + --body-line-height: 1.4; + + &:not(.text-component) { + line-height: 1.4; + } +} + +.line-height-lg { + --heading-line-height: 1.22; + --body-line-height: 1.58; + + &:not(.text-component) { + line-height: 1.58; + } +} + +.line-height-xl { + --heading-line-height: 1.3; + --body-line-height: 1.72; + + &:not(.text-component) { + line-height: 1.72; + } +} + +.line-height-body { line-height: var(--body-line-height); } +.line-height-heading { line-height: var(--heading-line-height); } +.line-height-normal { line-height: normal !important; } +.line-height-1 { line-height: 1 !important; } + +// -------------------------------- + +// White Space + +// -------------------------------- + +.ws-nowrap, .text-nowrap { white-space: nowrap; } + +// -------------------------------- + +// Cursor + +// -------------------------------- + +.cursor-pointer { cursor: pointer; } +.cursor-default { cursor: default; } + +// -------------------------------- + +// Pointer Events + +// -------------------------------- + +.pointer-events-auto { pointer-events: auto; } +.pointer-events-none { pointer-events: none; } + +// -------------------------------- + +// User Select + +// -------------------------------- + +.user-select-none { user-select: none; } +.user-select-all { user-select: all; } + +// -------------------------------- + +// Color + +// -------------------------------- + +[class^="color-"], [class*=" color-"] { --color-o: 1; } + +.color-inherit { color: inherit; } + +.color-bg-darker { color: alpha(var(--color-bg-darker), var(--color-o, 1)); } +.color-bg-dark { color: alpha(var(--color-bg-dark), var(--color-o, 1)); } +.color-bg { color: alpha(var(--color-bg), var(--color-o, 1)); } +.color-bg-light { color: alpha(var(--color-bg-light), var(--color-o, 1)); } +.color-bg-lighter { color: alpha(var(--color-bg-lighter), var(--color-o, 1)); } + +.color-contrast-lower { color: alpha(var(--color-contrast-lower), var(--color-o, 1)); } +.color-contrast-low { color: alpha(var(--color-contrast-low), var(--color-o, 1)); } +.color-contrast-medium { color: alpha(var(--color-contrast-medium), var(--color-o, 1)); } +.color-contrast-high { color: alpha(var(--color-contrast-high), var(--color-o, 1)); } +.color-contrast-higher { color: alpha(var(--color-contrast-higher), var(--color-o, 1)); } + +.color-primary-darker { color: alpha(var(--color-primary-darker), var(--color-o, 1)); } +.color-primary-dark { color: alpha(var(--color-primary-dark), var(--color-o, 1)); } +.color-primary { color: alpha(var(--color-primary), var(--color-o, 1)); } +.color-primary-light { color: alpha(var(--color-primary-light), var(--color-o, 1)); } +.color-primary-lighter { color: alpha(var(--color-primary-lighter), var(--color-o, 1)); } + +.color-accent-darker { color: alpha(var(--color-accent-darker), var(--color-o, 1)); } +.color-accent-dark { color: alpha(var(--color-accent-dark), var(--color-o, 1)); } +.color-accent { color: alpha(var(--color-accent), var(--color-o, 1)); } +.color-accent-light { color: alpha(var(--color-accent-light), var(--color-o, 1)); } +.color-accent-lighter { color: alpha(var(--color-accent-lighter), var(--color-o, 1)); } + +.color-success-darker { color: alpha(var(--color-success-darker), var(--color-o, 1)); } +.color-success-dark { color: alpha(var(--color-success-dark), var(--color-o, 1)); } +.color-success { color: alpha(var(--color-success), var(--color-o, 1)); } +.color-success-light { color: alpha(var(--color-success-light), var(--color-o, 1)); } +.color-success-lighter { color: alpha(var(--color-success-lighter), var(--color-o, 1)); } + +.color-warning-darker { color: alpha(var(--color-warning-darker), var(--color-o, 1)); } +.color-warning-dark { color: alpha(var(--color-warning-dark), var(--color-o, 1)); } +.color-warning { color: alpha(var(--color-warning), var(--color-o, 1)); } +.color-warning-light { color: alpha(var(--color-warning-light), var(--color-o, 1)); } +.color-warning-lighter { color: alpha(var(--color-warning-lighter), var(--color-o, 1)); } + +.color-error-darker { color: alpha(var(--color-error-darker), var(--color-o, 1)); } +.color-error-dark { color: alpha(var(--color-error-dark), var(--color-o, 1)); } +.color-error { color: alpha(var(--color-error), var(--color-o, 1)); } +.color-error-light { color: alpha(var(--color-error-light), var(--color-o, 1)); } +.color-error-lighter { color: alpha(var(--color-error-lighter), var(--color-o, 1)); } + +.color-white { color: alpha(var(--color-white), var(--color-o, 1)); } +.color-black { color: alpha(var(--color-black), var(--color-o, 1)); } + +.color-opacity-0 { --color-o: 0; } +.color-opacity-10\% { --color-o: 0.1; } +.color-opacity-20\% { --color-o: 0.2; } +.color-opacity-30\% { --color-o: 0.3; } +.color-opacity-40\% { --color-o: 0.4; } +.color-opacity-50\% { --color-o: 0.5; } +.color-opacity-60\% { --color-o: 0.6; } +.color-opacity-70\% { --color-o: 0.7; } +.color-opacity-80\% { --color-o: 0.8; } +.color-opacity-90\% { --color-o: 0.9; } + +// -------------------------------- + +// Gradients + +// -------------------------------- + +[class^="color-gradient"], [class*=" color-gradient"] { + color: transparent !important; + background-clip: text; +} + +// -------------------------------- + +// Width + +// -------------------------------- + +.width-xxxxs { width: var(--size-xxxxs, 0.25rem); } +.width-xxxs { width: var(--size-xxxs, 0.5rem); } +.width-xxs { width: var(--size-xxs, 0.75rem); } +.width-xs { width: var(--size-xs, 1rem); } +.width-sm { width: var(--size-sm, 1.5rem); } +.width-md { width: var(--size-md, 2rem); } +.width-lg { width: var(--size-lg, 3rem); } +.width-xl { width: var(--size-xl, 4rem); } +.width-xxl { width: var(--size-xxl, 6rem); } +.width-xxxl { width: var(--size-xxxl, 8rem); } +.width-xxxxl { width: var(--size-xxxxl, 16rem); } +.width-0 { width: 0; } +.width-10\% { width: 10%; } +.width-20\% { width: 20%; } +.width-25\% { width: 25%; } +.width-30\% { width: 30%; } +.width-33\% { width: calc(100% / 3); } +.width-40\% { width: 40%; } +.width-50\% { width: 50%; } +.width-60\% { width: 60%; } +.width-66\% { width: calc(100% / 1.5); } +.width-70\% { width: 70%; } +.width-75\% { width: 75%; } +.width-80\% { width: 80%; } +.width-90\% { width: 90%; } +.width-100\% { width: 100%; } +.width-100vw { width: 100vw; } +.width-auto { width: auto; } +.width-fit-content { width: fit-content; } +.width-max-content { width: max-content; } + +// -------------------------------- + +// Height + +// -------------------------------- + +.height-xxxxs { height: var(--size-xxxxs, 0.25rem); } +.height-xxxs { height: var(--size-xxxs, 0.5rem); } +.height-xxs { height: var(--size-xxs, 0.75rem); } +.height-xs { height: var(--size-xs, 1rem); } +.height-sm { height: var(--size-sm, 1.5rem); } +.height-md { height: var(--size-md, 2rem); } +.height-lg { height: var(--size-lg, 3rem); } +.height-xl { height: var(--size-xl, 4rem); } +.height-xxl { height: var(--size-xxl, 6rem); } +.height-xxxl { height: var(--size-xxxl, 8rem); } +.height-xxxxl { height: var(--size-xxxxl, 16rem); } +.height-0 { height: 0; } +.height-10\% { height: 10%; } +.height-20\% { height: 20%; } +.height-25\% { height: 25%; } +.height-30\% { height: 30%; } +.height-33\% { height: calc(100% / 3); } +.height-40\% { height: 40%; } +.height-50\% { height: 50%; } +.height-60\% { height: 60%; } +.height-66\% { height: calc(100% / 1.5); } +.height-70\% { height: 70%; } +.height-75\% { height: 75%; } +.height-80\% { height: 80%; } +.height-90\% { height: 90%; } +.height-100\% { height: 100%; } +.height-100vh { height: 100vh; } +.height-auto { height: auto; } +.height-fit-content { height: fit-content; } +.height-max-content { height: max-content; } + +// -------------------------------- + +// Min-Width + +// -------------------------------- + +.min-width-0 { min-width: 0; } +.min-width-25\% { min-width: 25%; } +.min-width-33\% { min-width: calc(100% / 3); } +.min-width-50\% { min-width: 50%; } +.min-width-66\% { min-width: calc(100% / 1.5); } +.min-width-75\% { min-width: 75%; } +.min-width-100\% { min-width: 100%; } +.min-width-100vw { min-width: 100vw; } +.min-width-fit-content { min-width: fit-content; } +.min-width-max-content { min-width: max-content; } + +// -------------------------------- + +// Min-Height + +// -------------------------------- + +.min-height-100\% { min-height: 100%; } +.min-height-100vh { min-height: 100vh; } +.min-height-fit-content { min-height: fit-content; } +.min-height-max-content { min-height: max-content; } + +// -------------------------------- + +// Max-Width + +// -------------------------------- + +:root { + --max-width-xxxxs: 20rem; // ~320px + --max-width-xxxs: 26rem; // ~416px + --max-width-xxs: 32rem; // ~512px + --max-width-xs: 38rem; // ~608px + --max-width-sm: 48rem; // ~768px + --max-width-md: 64rem; // ~1024px + --max-width-lg: 80rem; // ~1280px + --max-width-xl: 90rem; // ~1440px + --max-width-xxl: 100rem; // ~1600px + --max-width-xxxl: 120rem; // ~1920px + --max-width-xxxxl: 150rem; // ~2400px +} + +.max-width-xxxxs { max-width: var(--max-width-xxxxs); } +.max-width-xxxs { max-width: var(--max-width-xxxs); } +.max-width-xxs { max-width: var(--max-width-xxs); } +.max-width-xs { max-width: var(--max-width-xs); } +.max-width-sm { max-width: var(--max-width-sm); } +.max-width-md { max-width: var(--max-width-md); } +.max-width-lg { max-width: var(--max-width-lg); } +.max-width-xl { max-width: var(--max-width-xl); } +.max-width-xxl { max-width: var(--max-width-xxl); } +.max-width-xxxl { max-width: var(--max-width-xxxl); } +.max-width-xxxxl { max-width: var(--max-width-xxxxl); } +.max-width-100\% { max-width: 100%; } +.max-width-none { max-width: none; } +.max-width-fit-content { max-width: fit-content; } +.max-width-max-content { max-width: max-content; } + +// alt approach - max-width is equal to current breakpoint +$breakpointsNr: length($breakpoints); +@each $breakpoint, $value in $breakpoints { + $i: index($breakpoints, $breakpoint $value); + @if $i == 1 { + [class^="max-width-adaptive"], [class*=" max-width-adaptive"] { + max-width: map-get($map: $breakpoints, $key: #{$breakpoint}); + } + } @else { + $classList : ''; + @each $subBreakpoint, $subValue in $breakpoints { + $j: index($breakpoints, $subBreakpoint $subValue); + @if $j == $i { + $classList: '.max-width-adaptive-#{$subBreakpoint}'; + } @else if $j > $i { + $classList: $classList+', .max-width-adaptive-#{$subBreakpoint}'; + } + } + @if $i < $breakpointsNr { + $classList: $classList+', .max-width-adaptive'; + } + @include breakpoint(#{$breakpoint}) { + #{$classList} { + max-width: map-get($map: $breakpoints, $key: #{$breakpoint}); + } + } + } +} + +// -------------------------------- + +// Max-Height + +// -------------------------------- + +.max-height-100\% { max-height: 100%; } +.max-height-100vh { max-height: 100vh; } + +// -------------------------------- + +// Box-Shadow + +// -------------------------------- + +.shadow-xs { box-shadow: var(--shadow-xs); } +.shadow-sm { box-shadow: var(--shadow-sm); } +.shadow-md { box-shadow: var(--shadow-md); } +.shadow-lg { box-shadow: var(--shadow-lg); } +.shadow-xl { box-shadow: var(--shadow-xl); } +.shadow-none { box-shadow: none; } + +:root { + --inner-glow: inset 0 0 0.5px 1px hsla(0, 0%, 100%, 0.075); + --inner-glow-top: inset 0 1px 0.5px hsla(0, 0%, 100%, 0.075); +} + +.inner-glow, .inner-glow-top { + position: relative; + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: inherit; + pointer-events: none; + } +} + +.inner-glow::after { box-shadow: var(--inner-glow); } +.inner-glow-top::after { box-shadow: var(--inner-glow-top); } + +// -------------------------------- + +// Position + +// -------------------------------- + +.position-relative { position: relative; } +.position-absolute { position: absolute; } +.position-fixed { position: fixed; } +.position-sticky { position: sticky; } + +.inset-0 { top: 0; right: 0; bottom: 0; left: 0; } + +.top-0 { top: 0; } +.top-50\% { top: 50%; } +.top-xxxxs { top: var(--space-xxxxs); } +.top-xxxs { top: var(--space-xxxs); } +.top-xxs { top: var(--space-xxs); } +.top-xs { top: var(--space-xs); } +.top-sm { top: var(--space-sm); } +.top-md { top: var(--space-md); } +.top-lg { top: var(--space-lg); } +.top-xl { top: var(--space-xl); } +.top-xxl { top: var(--space-xxl); } +.top-xxxl { top: var(--space-xxxl); } +.top-xxxxl { top: var(--space-xxxxl); } + +.bottom-0 { bottom: 0; } +.bottom-unset { bottom: unset; } +.bottom-50\% { bottom: 50%; } +.bottom-xxxxs { bottom: var(--space-xxxxs); } +.bottom-xxxs { bottom: var(--space-xxxs); } +.bottom-xxs { bottom: var(--space-xxs); } +.bottom-xs { bottom: var(--space-xs); } +.bottom-sm { bottom: var(--space-sm); } +.bottom-md { bottom: var(--space-md); } +.bottom-lg { bottom: var(--space-lg); } +.bottom-xl { bottom: var(--space-xl); } +.bottom-xxl { bottom: var(--space-xxl); } +.bottom-xxxl { bottom: var(--space-xxxl); } +.bottom-xxxxl { bottom: var(--space-xxxxl); } + +.right-0 { right: 0; } +.right-50\% { right: 50%; } +.right-xxxxs { right: var(--space-xxxxs); } +.right-xxxs { right: var(--space-xxxs); } +.right-xxs { right: var(--space-xxs); } +.right-xs { right: var(--space-xs); } +.right-sm { right: var(--space-sm); } +.right-md { right: var(--space-md); } +.right-lg { right: var(--space-lg); } +.right-xl { right: var(--space-xl); } +.right-xxl { right: var(--space-xxl); } +.right-xxxl { right: var(--space-xxxl); } +.right-xxxxl { right: var(--space-xxxxl); } + +.left-0 { left: 0; } +.left-50\% { left: 50%; } +.left-xxxxs { left: var(--space-xxxxs); } +.left-xxxs { left: var(--space-xxxs); } +.left-xxs { left: var(--space-xxs); } +.left-xs { left: var(--space-xs); } +.left-sm { left: var(--space-sm); } +.left-md { left: var(--space-md); } +.left-lg { left: var(--space-lg); } +.left-xl { left: var(--space-xl); } +.left-xxl { left: var(--space-xxl); } +.left-xxxl { left: var(--space-xxxl); } +.left-xxxxl { left: var(--space-xxxxl); } + +// -------------------------------- + +// Z-Index + +// -------------------------------- + +.z-index-header { z-index: var(--z-index-header); } +.z-index-popover { z-index: var(--z-index-popover); } +.z-index-fixed-element { z-index: var(--z-index-fixed-element); } +.z-index-overlay { z-index: var(--z-index-overlay); } + +.z-index-1 { z-index: 1; } +.z-index-2 { z-index: 2; } +.z-index-3 { z-index: 3; } + +// -------------------------------- + +// Overflow + +// -------------------------------- + +.overflow-hidden { overflow: hidden; } +.overflow-auto { overflow: auto; } +.momentum-scrolling { -webkit-overflow-scrolling: touch; } + +// overscroll-behavior +.overscroll-contain { overscroll-behavior: contain; } + +// -------------------------------- + +// Scroll Behavior + +// -------------------------------- + +.scroll-smooth { scroll-behavior: smooth; } + +.scroll-padding-xxxxs { scroll-padding: var(--space-xxxxs); } +.scroll-padding-xxxs { scroll-padding: var(--space-xxxs); } +.scroll-padding-xxs { scroll-padding: var(--space-xxs); } +.scroll-padding-xs { scroll-padding: var(--space-xs); } +.scroll-padding-sm { scroll-padding: var(--space-sm); } +.scroll-padding-md { scroll-padding: var(--space-md); } +.scroll-padding-lg { scroll-padding: var(--space-lg); } +.scroll-padding-xl { scroll-padding: var(--space-xl); } +.scroll-padding-xxl { scroll-padding: var(--space-xxl); } +.scroll-padding-xxxl { scroll-padding: var(--space-xxxl); } +.scroll-padding-xxxxl { scroll-padding: var(--space-xxxxl); } + + +// -------------------------------- + +// Opacity + +// -------------------------------- + +.opacity-0 { opacity: 0; } +.opacity-10\% { opacity: 0.1; } +.opacity-20\% { opacity: 0.2; } +.opacity-30\% { opacity: 0.3; } +.opacity-40\% { opacity: 0.4; } +.opacity-50\% { opacity: 0.5; } +.opacity-60\% { opacity: 0.6; } +.opacity-70\% { opacity: 0.7; } +.opacity-80\% { opacity: 0.8; } +.opacity-90\% { opacity: 0.9; } + +// -------------------------------- + +// Float + +// -------------------------------- + +.float-left { float: left; } +.float-right { float: right; } + +.clearfix::after { + content: ""; + display: table; + clear: both; +} + +// -------------------------------- + +// Border + +// -------------------------------- + +[class^="border-"], [class*=" border-"] { + --border-o: 1; +} + +.border { border: var(--border-width, 1px) var(--border-style, solid) alpha(var(--color-contrast-lower), var(--border-o, 1)); } +.border-top { border-top: var(--border-width, 1px) var(--border-style, solid) alpha(var(--color-contrast-lower), var(--border-o, 1)); } +.border-bottom { border-bottom: var(--border-width, 1px) var(--border-style, solid) alpha(var(--color-contrast-lower), var(--border-o, 1)); } +.border-left { border-left: var(--border-width, 1px) var(--border-style, solid) alpha(var(--color-contrast-lower), var(--border-o, 1)); } +.border-right { border-right: var(--border-width, 1px) var(--border-style, solid) alpha(var(--color-contrast-lower), var(--border-o, 1)); } + +.border-2 { --border-width: 2px; } +.border-3 { --border-width: 3px; } +.border-4 { --border-width: 4px; } +.border-dotted { --border-style: dotted; } +.border-dashed { --border-style: dashed; } + +.border-bg-darker { border-color: alpha(var(--color-bg-darker), var(--border-o, 1)); } +.border-bg-dark { border-color: alpha(var(--color-bg-dark), var(--border-o, 1)); } +.border-bg { border-color: alpha(var(--color-bg), var(--border-o, 1)); } +.border-bg-light { border-color: alpha(var(--color-bg-light), var(--border-o, 1)); } +.border-bg-lighter { border-color: alpha(var(--color-bg-lighter), var(--border-o, 1)); } + +.border-contrast-lower { border-color: alpha(var(--color-contrast-lower), var(--border-o, 1)); } +.border-contrast-low { border-color: alpha(var(--color-contrast-low), var(--border-o, 1)); } +.border-contrast-medium { border-color: alpha(var(--color-contrast-medium), var(--border-o, 1)); } +.border-contrast-high { border-color: alpha(var(--color-contrast-high), var(--border-o, 1)); } +.border-contrast-higher { border-color: alpha(var(--color-contrast-higher), var(--border-o, 1)); } + +.border-primary-darker { border-color: alpha(var(--color-primary-darker), var(--border-o, 1)); } +.border-primary-dark { border-color: alpha(var(--color-primary-dark), var(--border-o, 1)); } +.border-primary { border-color: alpha(var(--color-primary), var(--border-o, 1)); } +.border-primary-light { border-color: alpha(var(--color-primary-light), var(--border-o, 1)); } +.border-primary-lighter { border-color: alpha(var(--color-primary-lighter), var(--border-o, 1)); } + +.border-accent-darker { border-color: alpha(var(--color-accent-darker), var(--border-o, 1)); } +.border-accent-dark { border-color: alpha(var(--color-accent-dark), var(--border-o, 1)); } +.border-accent { border-color: alpha(var(--color-accent), var(--border-o, 1)); } +.border-accent-light { border-color: alpha(var(--color-accent-light), var(--border-o, 1)); } +.border-accent-lighter { border-color: alpha(var(--color-accent-lighter), var(--border-o, 1)); } + +.border-success-darker { border-color: alpha(var(--color-success-darker), var(--border-o, 1)); } +.border-success-dark { border-color: alpha(var(--color-success-dark), var(--border-o, 1)); } +.border-success { border-color: alpha(var(--color-success), var(--border-o, 1)); } +.border-success-light { border-color: alpha(var(--color-success-light), var(--border-o, 1)); } +.border-success-lighter { border-color: alpha(var(--color-success-lighter), var(--border-o, 1)); } + +.border-warning-darker { border-color: alpha(var(--color-warning-darker), var(--border-o, 1)); } +.border-warning-dark { border-color: alpha(var(--color-warning-dark), var(--border-o, 1)); } +.border-warning { border-color: alpha(var(--color-warning), var(--border-o, 1)); } +.border-warning-light { border-color: alpha(var(--color-warning-light), var(--border-o, 1)); } +.border-warning-lighter { border-color: alpha(var(--color-warning-lighter), var(--border-o, 1)); } + +.border-error-darker { border-color: alpha(var(--color-error-darker), var(--border-o, 1)); } +.border-error-dark { border-color: alpha(var(--color-error-dark), var(--border-o, 1)); } +.border-error { border-color: alpha(var(--color-error), var(--border-o, 1)); } +.border-error-light { border-color: alpha(var(--color-error-light), var(--border-o, 1)); } +.border-error-lighter { border-color: alpha(var(--color-error-lighter), var(--border-o, 1)); } + +.border-white { border-color: alpha(var(--color-white), var(--border-o, 1)); } +.border-black { border-color: alpha(var(--color-black), var(--border-o, 1)); } + +.border-opacity-0 { --border-o: 0; } +.border-opacity-10\% { --border-o: 0.1; } +.border-opacity-20\% { --border-o: 0.2; } +.border-opacity-30\% { --border-o: 0.3; } +.border-opacity-40\% { --border-o: 0.4; } +.border-opacity-50\% { --border-o: 0.5; } +.border-opacity-60\% { --border-o: 0.6; } +.border-opacity-70\% { --border-o: 0.7; } +.border-opacity-80\% { --border-o: 0.8; } +.border-opacity-90\% { --border-o: 0.9; } + +// -------------------------------- + +// Border Radius + +// -------------------------------- + +.radius-sm { border-radius: var(--radius-sm); } +.radius-md { border-radius: var(--radius-md); } +.radius-lg { border-radius: var(--radius-lg); } +.radius-50\% { border-radius: 50%; } +.radius-full { border-radius: 50em; } +.radius-0 { border-radius: 0; } +.radius-inherit { border-radius: inherit; } +.radius-top-left-0 { border-top-left-radius: 0; } +.radius-top-right-0 { border-top-right-radius: 0; } +.radius-bottom-right-0 { border-bottom-right-radius: 0; } +.radius-bottom-left-0 { border-bottom-left-radius: 0; } + +// -------------------------------- + +// Background + +// -------------------------------- + +.bg, [class^="bg-"], [class*=" bg-"] { --bg-o: 1; } + +.bg-transparent { background-color: transparent; } +.bg-inherit { background-color: inherit; } + +.bg-darker { background-color: alpha(var(--color-bg-darker), var(--bg-o)); } +.bg-dark { background-color: alpha(var(--color-bg-dark), var(--bg-o)); } +.bg { background-color: alpha(var(--color-bg), var(--bg-o)); } +.bg-light { background-color: alpha(var(--color-bg-light), var(--bg-o)); } +.bg-lighter { background-color: alpha(var(--color-bg-lighter), var(--bg-o)); } + +.bg-contrast-lower { background-color: alpha(var(--color-contrast-lower), var(--bg-o, 1)); } +.bg-contrast-low { background-color: alpha(var(--color-contrast-low), var(--bg-o, 1)); } +.bg-contrast-medium { background-color: alpha(var(--color-contrast-medium), var(--bg-o, 1)); } +.bg-contrast-high { background-color: alpha(var(--color-contrast-high), var(--bg-o, 1)); } +.bg-contrast-higher { background-color: alpha(var(--color-contrast-higher), var(--bg-o, 1)); } + +.bg-primary-darker { background-color: alpha(var(--color-primary-darker), var(--bg-o, 1)); } +.bg-primary-dark { background-color: alpha(var(--color-primary-dark), var(--bg-o, 1)); } +.bg-primary { background-color: alpha(var(--color-primary), var(--bg-o, 1)); } +.bg-primary-light { background-color: alpha(var(--color-primary-light), var(--bg-o, 1)); } +.bg-primary-lighter { background-color: alpha(var(--color-primary-lighter), var(--bg-o, 1)); } + +.bg-accent-darker { background-color: alpha(var(--color-accent-darker), var(--bg-o, 1)); } +.bg-accent-dark { background-color: alpha(var(--color-accent-dark), var(--bg-o, 1)); } +.bg-accent { background-color: alpha(var(--color-accent), var(--bg-o, 1)); } +.bg-accent-light { background-color: alpha(var(--color-accent-light), var(--bg-o, 1)); } +.bg-accent-lighter { background-color: alpha(var(--color-accent-lighter), var(--bg-o, 1)); } + +.bg-success-darker { background-color: alpha(var(--color-success-darker), var(--bg-o, 1)); } +.bg-success-dark { background-color: alpha(var(--color-success-dark), var(--bg-o, 1)); } +.bg-success { background-color: alpha(var(--color-success), var(--bg-o, 1)); } +.bg-success-light { background-color: alpha(var(--color-success-light), var(--bg-o, 1)); } +.bg-success-lighter { background-color: alpha(var(--color-success-lighter), var(--bg-o, 1)); } + +.bg-warning-darker { background-color: alpha(var(--color-warning-darker), var(--bg-o, 1)); } +.bg-warning-dark { background-color: alpha(var(--color-warning-dark), var(--bg-o, 1)); } +.bg-warning { background-color: alpha(var(--color-warning), var(--bg-o, 1)); } +.bg-warning-light { background-color: alpha(var(--color-warning-light), var(--bg-o, 1)); } +.bg-warning-lighter { background-color: alpha(var(--color-warning-lighter), var(--bg-o, 1)); } + +.bg-error-darker { background-color: alpha(var(--color-error-darker), var(--bg-o, 1)); } +.bg-error-dark { background-color: alpha(var(--color-error-dark), var(--bg-o, 1)); } +.bg-error { background-color: alpha(var(--color-error), var(--bg-o, 1)); } +.bg-error-light { background-color: alpha(var(--color-error-light), var(--bg-o, 1)); } +.bg-error-lighter { background-color: alpha(var(--color-error-lighter), var(--bg-o, 1)); } + +.bg-white { background-color: alpha(var(--color-white), var(--bg-o, 1)); } +.bg-black { background-color: alpha(var(--color-black), var(--bg-o, 1)); } + +.bg-opacity-0 { --bg-o: 0; } +.bg-opacity-10\% { --bg-o: 0.1; } +.bg-opacity-20\% { --bg-o: 0.2; } +.bg-opacity-30\% { --bg-o: 0.3; } +.bg-opacity-40\% { --bg-o: 0.4; } +.bg-opacity-50\% { --bg-o: 0.5; } +.bg-opacity-60\% { --bg-o: 0.6; } +.bg-opacity-70\% { --bg-o: 0.7; } +.bg-opacity-80\% { --bg-o: 0.8; } +.bg-opacity-90\% { --bg-o: 0.9; } + +.bg-center { background-position: center; } +.bg-top { background-position: center top; } +.bg-right { background-position: right center; } +.bg-bottom { background-position: center bottom; } +.bg-left { background-position: left center; } +.bg-top-left { background-position: left top; } +.bg-top-right { background-position: right top; } +.bg-bottom-left { background-position: left bottom; } +.bg-bottom-right { background-position: right bottom; } + +.bg-cover { background-size: cover; } +.bg-no-repeat { background-repeat: no-repeat; } + +// -------------------------------- + +// Backdrop Filter + +// -------------------------------- + +.backdrop-blur-10 { backdrop-filter: blur(10px); } +.backdrop-blur-20 { backdrop-filter: blur(20px); } + +// -------------------------------- + +// Mix-Blend Mode + +// -------------------------------- + +.isolate { isolation: isolate; } +.blend-multiply { mix-blend-mode: multiply; } +.blend-overlay { mix-blend-mode: overlay; } +.blend-difference { mix-blend-mode: difference; } + +// -------------------------------- + +// Object-Fit + +// -------------------------------- + +.object-contain { object-fit: contain; } +.object-cover { object-fit: cover; } + +// -------------------------------- + +// Perspective + +// -------------------------------- + +.perspective-xs { perspective: 250px; } +.perspective-sm { perspective: 500px; } +.perspective-md { perspective: 1000px; } +.perspective-lg { perspective: 1500px; } +.perspective-xl { perspective: 3000px; } + +// -------------------------------- + +// Transform + +// -------------------------------- + +[class^="flip"], [class*=" flip"], +[class^="-rotate"], [class*=" -rotate"], +[class^="rotate"], [class*=" rotate"], +[class^="-translate"], [class*=" -translate"], +[class^="translate"], [class*=" translate"], +[class^="-scale"], [class*=" -scale"], +[class^="scale"], [class*=" scale"], +[class^="-skew"], [class*=" -skew"] +[class^="skew"], [class*=" skew"] { + --translate: 0; + --rotate: 0; + --skew: 0; + --scale: 1; + + transform: translate3d(var(--translate-x, var(--translate)), var(--translate-y, var(--translate)), var(--translate-z, 0)) rotateX(var(--rotate-x, 0)) rotateY(var(--rotate-y, 0)) rotateZ(var(--rotate-z, var(--rotate))) skewX(var(--skew-x, var(--skew))) skewY(var(--skew-y, 0)) scaleX(var(--scale-x, var(--scale))) scaleY(var(--scale-y, var(--scale))); +} + +.flip { --scale: -1; } +.flip-x { --scale-x: -1; } +.flip-y { --scale-y: -1; } + +.rotate-90 { --rotate: 90deg; } +.rotate-180 { --rotate: 180deg; } +.rotate-270 { --rotate: 270deg; } + +.-translate-50\% { --translate: -50%; } +.-translate-x-50\% { --translate-x: -50%; } +.-translate-y-50\% { --translate-y: -50%; } + +.translate-50\% { --translate: 50%; } +.translate-x-50\% { --translate-x: 50%; } +.translate-y-50\% { --translate-y: 50%; } + +// -------------------------------- + +// Transform Origin + +// -------------------------------- + +.origin-center { transform-origin: center; } +.origin-top { transform-origin: center top; } +.origin-right { transform-origin: right center; } +.origin-bottom { transform-origin: center bottom; } +.origin-left { transform-origin: left center; } +.origin-top-left { transform-origin: left top; } +.origin-top-right { transform-origin: right top; } +.origin-bottom-left { transform-origin: left bottom; } +.origin-bottom-right { transform-origin: right bottom; } + +// -------------------------------- + +// SVG + +// -------------------------------- + +.fill-current { fill: currentColor; } + +.stroke-current { stroke: currentColor; } + +.stroke-1 { stroke-width: 1px; } +.stroke-2 { stroke-width: 2px; } +.stroke-3 { stroke-width: 3px; } +.stroke-4 { stroke-width: 4px; } + +// -------------------------------- + +// Visibility + +// -------------------------------- + +.visible { visibility: visible; } +.invisible { visibility: hidden; } + +// -------------------------------- + +// Responsive Variations + +// -------------------------------- + +@each $breakpoint, $value in $breakpoints { + @include breakpoint(#{$breakpoint}) { + // flexbox + .flex\@#{$breakpoint} { display: flex; } + .inline-flex\@#{$breakpoint} { display: inline-flex; } + .flex-wrap\@#{$breakpoint} { flex-wrap: wrap; } + .flex-nowrap\@#{$breakpoint} { flex-wrap:nowrap; } + .flex-column\@#{$breakpoint} { flex-direction: column; } + .flex-column-reverse\@#{$breakpoint} { flex-direction: column-reverse; } + .flex-row\@#{$breakpoint} { flex-direction: row; } + .flex-row-reverse\@#{$breakpoint} { flex-direction: row-reverse; } + .flex-center\@#{$breakpoint} { justify-content: center; align-items: center; } + + .flex-grow\@#{$breakpoint} { flex-grow: 1; } + .flex-grow-0\@#{$breakpoint} { flex-grow: 0; } + .flex-shrink\@#{$breakpoint} { flex-shrink: 1; } + .flex-shrink-0\@#{$breakpoint} { flex-shrink: 0; } + .flex-basis-0\@#{$breakpoint} { flex-basis: 0; } + + // justify-content + .justify-start\@#{$breakpoint} { justify-content: flex-start; } + .justify-end\@#{$breakpoint} { justify-content: flex-end; } + .justify-center\@#{$breakpoint} { justify-content: center; } + .justify-between\@#{$breakpoint} { justify-content: space-between; } + + // align-items + .items-center\@#{$breakpoint} { align-items: center; } + .items-start\@#{$breakpoint} { align-items: flex-start; } + .items-end\@#{$breakpoint} { align-items: flex-end; } + .items-baseline\@#{$breakpoint} { align-items: baseline; } + + // order + .order-1\@#{$breakpoint} { order: 1; } + .order-2\@#{$breakpoint} { order: 2; } + .order-3\@#{$breakpoint} { order: 3; } + + // display + .block\@#{$breakpoint} { display: block; } + .inline-block\@#{$breakpoint} { display: inline-block; } + .inline\@#{$breakpoint} { display: inline; } + .contents\@#{$breakpoint} { display: contents; } + .hide\@#{$breakpoint} { display: none !important; } + + // margin + .margin-xxxxs\@#{$breakpoint} { margin: var(--space-xxxxs); } + .margin-xxxs\@#{$breakpoint} { margin: var(--space-xxxs); } + .margin-xxs\@#{$breakpoint} { margin: var(--space-xxs); } + .margin-xs\@#{$breakpoint} { margin: var(--space-xs); } + .margin-sm\@#{$breakpoint} { margin: var(--space-sm); } + .margin-md\@#{$breakpoint} { margin: var(--space-md); } + .margin-lg\@#{$breakpoint} { margin: var(--space-lg); } + .margin-xl\@#{$breakpoint} { margin: var(--space-xl); } + .margin-xxl\@#{$breakpoint} { margin: var(--space-xxl); } + .margin-xxxl\@#{$breakpoint} { margin: var(--space-xxxl); } + .margin-xxxxl\@#{$breakpoint} { margin: var(--space-xxxxl); } + .margin-auto\@#{$breakpoint} { margin: auto; } + .margin-0\@#{$breakpoint} { margin: 0; } + + .margin-top-xxxxs\@#{$breakpoint} { margin-top: var(--space-xxxxs); } + .margin-top-xxxs\@#{$breakpoint} { margin-top: var(--space-xxxs); } + .margin-top-xxs\@#{$breakpoint} { margin-top: var(--space-xxs); } + .margin-top-xs\@#{$breakpoint} { margin-top: var(--space-xs); } + .margin-top-sm\@#{$breakpoint} { margin-top: var(--space-sm); } + .margin-top-md\@#{$breakpoint} { margin-top: var(--space-md); } + .margin-top-lg\@#{$breakpoint} { margin-top: var(--space-lg); } + .margin-top-xl\@#{$breakpoint} { margin-top: var(--space-xl); } + .margin-top-xxl\@#{$breakpoint} { margin-top: var(--space-xxl); } + .margin-top-xxxl\@#{$breakpoint} { margin-top: var(--space-xxxl); } + .margin-top-xxxxl\@#{$breakpoint} { margin-top: var(--space-xxxxl); } + .margin-top-auto\@#{$breakpoint} { margin-top: auto; } + .margin-top-0\@#{$breakpoint} { margin-top: 0; } + + .margin-bottom-xxxxs\@#{$breakpoint} { margin-bottom: var(--space-xxxxs); } + .margin-bottom-xxxs\@#{$breakpoint} { margin-bottom: var(--space-xxxs); } + .margin-bottom-xxs\@#{$breakpoint} { margin-bottom: var(--space-xxs); } + .margin-bottom-xs\@#{$breakpoint} { margin-bottom: var(--space-xs); } + .margin-bottom-sm\@#{$breakpoint} { margin-bottom: var(--space-sm); } + .margin-bottom-md\@#{$breakpoint} { margin-bottom: var(--space-md); } + .margin-bottom-lg\@#{$breakpoint} { margin-bottom: var(--space-lg); } + .margin-bottom-xl\@#{$breakpoint} { margin-bottom: var(--space-xl); } + .margin-bottom-xxl\@#{$breakpoint} { margin-bottom: var(--space-xxl); } + .margin-bottom-xxxl\@#{$breakpoint} { margin-bottom: var(--space-xxxl); } + .margin-bottom-xxxxl\@#{$breakpoint} { margin-bottom: var(--space-xxxxl); } + .margin-bottom-auto\@#{$breakpoint} { margin-bottom: auto; } + .margin-bottom-0\@#{$breakpoint} { margin-bottom: 0; } + + .margin-right-xxxxs\@#{$breakpoint} { margin-right: var(--space-xxxxs); } + .margin-right-xxxs\@#{$breakpoint} { margin-right: var(--space-xxxs); } + .margin-right-xxs\@#{$breakpoint} { margin-right: var(--space-xxs); } + .margin-right-xs\@#{$breakpoint} { margin-right: var(--space-xs); } + .margin-right-sm\@#{$breakpoint} { margin-right: var(--space-sm); } + .margin-right-md\@#{$breakpoint} { margin-right: var(--space-md); } + .margin-right-lg\@#{$breakpoint} { margin-right: var(--space-lg); } + .margin-right-xl\@#{$breakpoint} { margin-right: var(--space-xl); } + .margin-right-xxl\@#{$breakpoint} { margin-right: var(--space-xxl); } + .margin-right-xxxl\@#{$breakpoint} { margin-right: var(--space-xxxl); } + .margin-right-xxxxl\@#{$breakpoint} { margin-right: var(--space-xxxxl); } + .margin-right-auto\@#{$breakpoint} { margin-right: auto; } + .margin-right-0\@#{$breakpoint} { margin-right: 0; } + + .margin-left-xxxxs\@#{$breakpoint} { margin-left: var(--space-xxxxs); } + .margin-left-xxxs\@#{$breakpoint} { margin-left: var(--space-xxxs); } + .margin-left-xxs\@#{$breakpoint} { margin-left: var(--space-xxs); } + .margin-left-xs\@#{$breakpoint} { margin-left: var(--space-xs); } + .margin-left-sm\@#{$breakpoint} { margin-left: var(--space-sm); } + .margin-left-md\@#{$breakpoint} { margin-left: var(--space-md); } + .margin-left-lg\@#{$breakpoint} { margin-left: var(--space-lg); } + .margin-left-xl\@#{$breakpoint} { margin-left: var(--space-xl); } + .margin-left-xxl\@#{$breakpoint} { margin-left: var(--space-xxl); } + .margin-left-xxxl\@#{$breakpoint} { margin-left: var(--space-xxxl); } + .margin-left-xxxxl\@#{$breakpoint} { margin-left: var(--space-xxxxl); } + .margin-left-auto\@#{$breakpoint} { margin-left: auto; } + .margin-left-0\@#{$breakpoint} { margin-left: 0; } + + .margin-x-xxxxs\@#{$breakpoint} { margin-left: var(--space-xxxxs); margin-right: var(--space-xxxxs); } + .margin-x-xxxs\@#{$breakpoint} { margin-left: var(--space-xxxs); margin-right: var(--space-xxxs); } + .margin-x-xxs\@#{$breakpoint} { margin-left: var(--space-xxs); margin-right: var(--space-xxs); } + .margin-x-xs\@#{$breakpoint} { margin-left: var(--space-xs); margin-right: var(--space-xs); } + .margin-x-sm\@#{$breakpoint} { margin-left: var(--space-sm); margin-right: var(--space-sm); } + .margin-x-md\@#{$breakpoint} { margin-left: var(--space-md); margin-right: var(--space-md); } + .margin-x-lg\@#{$breakpoint} { margin-left: var(--space-lg); margin-right: var(--space-lg); } + .margin-x-xl\@#{$breakpoint} { margin-left: var(--space-xl); margin-right: var(--space-xl); } + .margin-x-xxl\@#{$breakpoint} { margin-left: var(--space-xxl); margin-right: var(--space-xxl); } + .margin-x-xxxl\@#{$breakpoint} { margin-left: var(--space-xxxl); margin-right: var(--space-xxxl); } + .margin-x-xxxxl\@#{$breakpoint} { margin-left: var(--space-xxxxl); margin-right: var(--space-xxxxl); } + .margin-x-auto\@#{$breakpoint} { margin-left: auto; margin-right: auto; } + .margin-x-0\@#{$breakpoint} { margin-left: 0; margin-right: 0; } + + .margin-y-xxxxs\@#{$breakpoint} { margin-top: var(--space-xxxxs); margin-bottom: var(--space-xxxxs); } + .margin-y-xxxs\@#{$breakpoint} { margin-top: var(--space-xxxs); margin-bottom: var(--space-xxxs); } + .margin-y-xxs\@#{$breakpoint} { margin-top: var(--space-xxs); margin-bottom: var(--space-xxs); } + .margin-y-xs\@#{$breakpoint} { margin-top: var(--space-xs); margin-bottom: var(--space-xs); } + .margin-y-sm\@#{$breakpoint} { margin-top: var(--space-sm); margin-bottom: var(--space-sm); } + .margin-y-md\@#{$breakpoint} { margin-top: var(--space-md); margin-bottom: var(--space-md); } + .margin-y-lg\@#{$breakpoint} { margin-top: var(--space-lg); margin-bottom: var(--space-lg); } + .margin-y-xl\@#{$breakpoint} { margin-top: var(--space-xl); margin-bottom: var(--space-xl); } + .margin-y-xxl\@#{$breakpoint} { margin-top: var(--space-xxl); margin-bottom: var(--space-xxl); } + .margin-y-xxxl\@#{$breakpoint} { margin-top: var(--space-xxxl); margin-bottom: var(--space-xxxl); } + .margin-y-xxxxl\@#{$breakpoint} { margin-top: var(--space-xxxxl); margin-bottom: var(--space-xxxxl); } + .margin-y-auto\@#{$breakpoint} { margin-top: auto; margin-bottom: auto; } + .margin-y-0\@#{$breakpoint} { margin-top: 0; margin-bottom: 0; } + + // padding + .padding-xxxxs\@#{$breakpoint} { padding: var(--space-xxxxs); } + .padding-xxxs\@#{$breakpoint} { padding: var(--space-xxxs); } + .padding-xxs\@#{$breakpoint} { padding: var(--space-xxs); } + .padding-xs\@#{$breakpoint} { padding: var(--space-xs); } + .padding-sm\@#{$breakpoint} { padding: var(--space-sm); } + .padding-md\@#{$breakpoint} { padding: var(--space-md); } + .padding-lg\@#{$breakpoint} { padding: var(--space-lg); } + .padding-xl\@#{$breakpoint} { padding: var(--space-xl); } + .padding-xxl\@#{$breakpoint} { padding: var(--space-xxl); } + .padding-xxxl\@#{$breakpoint} { padding: var(--space-xxxl); } + .padding-xxxxl\@#{$breakpoint} { padding: var(--space-xxxxl); } + .padding-0\@#{$breakpoint} { padding: 0; } + .padding-component\@#{$breakpoint} { padding: var(--component-padding); } + + .padding-top-xxxxs\@#{$breakpoint} { padding-top: var(--space-xxxxs); } + .padding-top-xxxs\@#{$breakpoint} { padding-top: var(--space-xxxs); } + .padding-top-xxs\@#{$breakpoint} { padding-top: var(--space-xxs); } + .padding-top-xs\@#{$breakpoint} { padding-top: var(--space-xs); } + .padding-top-sm\@#{$breakpoint} { padding-top: var(--space-sm); } + .padding-top-md\@#{$breakpoint} { padding-top: var(--space-md); } + .padding-top-lg\@#{$breakpoint} { padding-top: var(--space-lg); } + .padding-top-xl\@#{$breakpoint} { padding-top: var(--space-xl); } + .padding-top-xxl\@#{$breakpoint} { padding-top: var(--space-xxl); } + .padding-top-xxxl\@#{$breakpoint} { padding-top: var(--space-xxxl); } + .padding-top-xxxxl\@#{$breakpoint} { padding-top: var(--space-xxxxl); } + .padding-top-0\@#{$breakpoint} { padding-top: 0; } + .padding-top-component\@#{$breakpoint} { padding-top: var(--component-padding); } + + .padding-bottom-xxxxs\@#{$breakpoint} { padding-bottom: var(--space-xxxxs); } + .padding-bottom-xxxs\@#{$breakpoint} { padding-bottom: var(--space-xxxs); } + .padding-bottom-xxs\@#{$breakpoint} { padding-bottom: var(--space-xxs); } + .padding-bottom-xs\@#{$breakpoint} { padding-bottom: var(--space-xs); } + .padding-bottom-sm\@#{$breakpoint} { padding-bottom: var(--space-sm); } + .padding-bottom-md\@#{$breakpoint} { padding-bottom: var(--space-md); } + .padding-bottom-lg\@#{$breakpoint} { padding-bottom: var(--space-lg); } + .padding-bottom-xl\@#{$breakpoint} { padding-bottom: var(--space-xl); } + .padding-bottom-xxl\@#{$breakpoint} { padding-bottom: var(--space-xxl); } + .padding-bottom-xxxl\@#{$breakpoint} { padding-bottom: var(--space-xxxl); } + .padding-bottom-xxxxl\@#{$breakpoint} { padding-bottom: var(--space-xxxxl); } + .padding-bottom-0\@#{$breakpoint} { padding-bottom: 0; } + .padding-bottom-component\@#{$breakpoint} { padding-bottom: var(--component-padding); } + + .padding-right-xxxxs\@#{$breakpoint} { padding-right: var(--space-xxxxs); } + .padding-right-xxxs\@#{$breakpoint} { padding-right: var(--space-xxxs); } + .padding-right-xxs\@#{$breakpoint} { padding-right: var(--space-xxs); } + .padding-right-xs\@#{$breakpoint} { padding-right: var(--space-xs); } + .padding-right-sm\@#{$breakpoint} { padding-right: var(--space-sm); } + .padding-right-md\@#{$breakpoint} { padding-right: var(--space-md); } + .padding-right-lg\@#{$breakpoint} { padding-right: var(--space-lg); } + .padding-right-xl\@#{$breakpoint} { padding-right: var(--space-xl); } + .padding-right-xxl\@#{$breakpoint} { padding-right: var(--space-xxl); } + .padding-right-xxxl\@#{$breakpoint} { padding-right: var(--space-xxxl); } + .padding-right-xxxxl\@#{$breakpoint} { padding-right: var(--space-xxxxl); } + .padding-right-0\@#{$breakpoint} { padding-right: 0; } + .padding-right-component\@#{$breakpoint} { padding-right: var(--component-padding); } + + .padding-left-xxxxs\@#{$breakpoint} { padding-left: var(--space-xxxxs); } + .padding-left-xxxs\@#{$breakpoint} { padding-left: var(--space-xxxs); } + .padding-left-xxs\@#{$breakpoint} { padding-left: var(--space-xxs); } + .padding-left-xs\@#{$breakpoint} { padding-left: var(--space-xs); } + .padding-left-sm\@#{$breakpoint} { padding-left: var(--space-sm); } + .padding-left-md\@#{$breakpoint} { padding-left: var(--space-md); } + .padding-left-lg\@#{$breakpoint} { padding-left: var(--space-lg); } + .padding-left-xl\@#{$breakpoint} { padding-left: var(--space-xl); } + .padding-left-xxl\@#{$breakpoint} { padding-left: var(--space-xxl); } + .padding-left-xxxl\@#{$breakpoint} { padding-left: var(--space-xxxl); } + .padding-left-xxxxl\@#{$breakpoint} { padding-left: var(--space-xxxxl); } + .padding-left-0\@#{$breakpoint} { padding-left: 0; } + .padding-left-component\@#{$breakpoint} { padding-left: var(--component-padding); } + + .padding-x-xxxxs\@#{$breakpoint} { padding-left: var(--space-xxxxs); padding-right: var(--space-xxxxs); } + .padding-x-xxxs\@#{$breakpoint} { padding-left: var(--space-xxxs); padding-right: var(--space-xxxs); } + .padding-x-xxs\@#{$breakpoint} { padding-left: var(--space-xxs); padding-right: var(--space-xxs); } + .padding-x-xs\@#{$breakpoint} { padding-left: var(--space-xs); padding-right: var(--space-xs); } + .padding-x-sm\@#{$breakpoint} { padding-left: var(--space-sm); padding-right: var(--space-sm); } + .padding-x-md\@#{$breakpoint} { padding-left: var(--space-md); padding-right: var(--space-md); } + .padding-x-lg\@#{$breakpoint} { padding-left: var(--space-lg); padding-right: var(--space-lg); } + .padding-x-xl\@#{$breakpoint} { padding-left: var(--space-xl); padding-right: var(--space-xl); } + .padding-x-xxl\@#{$breakpoint} { padding-left: var(--space-xxl); padding-right: var(--space-xxl); } + .padding-x-xxxl\@#{$breakpoint} { padding-left: var(--space-xxxl); padding-right: var(--space-xxxl); } + .padding-x-xxxxl\@#{$breakpoint} { padding-left: var(--space-xxxxl); padding-right: var(--space-xxxxl); } + .padding-x-0\@#{$breakpoint} { padding-left: 0; padding-right: 0; } + .padding-x-component\@#{$breakpoint} { padding-left: var(--component-padding); padding-right: var(--component-padding); } + + .padding-y-xxxxs\@#{$breakpoint} { padding-top: var(--space-xxxxs); padding-bottom: var(--space-xxxxs); } + .padding-y-xxxs\@#{$breakpoint} { padding-top: var(--space-xxxs); padding-bottom: var(--space-xxxs); } + .padding-y-xxs\@#{$breakpoint} { padding-top: var(--space-xxs); padding-bottom: var(--space-xxs); } + .padding-y-xs\@#{$breakpoint} { padding-top: var(--space-xs); padding-bottom: var(--space-xs); } + .padding-y-sm\@#{$breakpoint} { padding-top: var(--space-sm); padding-bottom: var(--space-sm); } + .padding-y-md\@#{$breakpoint} { padding-top: var(--space-md); padding-bottom: var(--space-md); } + .padding-y-lg\@#{$breakpoint} { padding-top: var(--space-lg); padding-bottom: var(--space-lg); } + .padding-y-xl\@#{$breakpoint} { padding-top: var(--space-xl); padding-bottom: var(--space-xl); } + .padding-y-xxl\@#{$breakpoint} { padding-top: var(--space-xxl); padding-bottom: var(--space-xxl); } + .padding-y-xxxl\@#{$breakpoint} { padding-top: var(--space-xxxl); padding-bottom: var(--space-xxxl); } + .padding-y-xxxxl\@#{$breakpoint} { padding-top: var(--space-xxxxl); padding-bottom: var(--space-xxxxl); } + .padding-y-0\@#{$breakpoint} { padding-top: 0; padding-bottom: 0; } + .padding-y-component\@#{$breakpoint} { padding-top: var(--component-padding); padding-bottom: var(--component-padding); } + + // text-align + .text-center\@#{$breakpoint} { text-align: center; } + .text-left\@#{$breakpoint} { text-align: left; } + .text-right\@#{$breakpoint} { text-align: right; } + .text-justify\@#{$breakpoint} { text-align: justify; } + + // font-size + .text-xs\@#{$breakpoint} { font-size: var(--text-xs, 0.6875rem); } + .text-sm\@#{$breakpoint} { font-size: var(--text-sm, 0.75rem); } + .text-base\@#{$breakpoint} { font-size: var(--text-unit, 1rem); } + .text-md\@#{$breakpoint} { font-size: var(--text-md, 1.125rem); } + .text-lg\@#{$breakpoint} { font-size: var(--text-lg, 1.375rem); } + .text-xl\@#{$breakpoint} { font-size: var(--text-xl, 1.75rem); } + .text-xxl\@#{$breakpoint} { font-size: var(--text-xxl, 2rem); } + .text-xxxl\@#{$breakpoint} { font-size: var(--text-xxxl, 2.5rem); } + .text-xxxxl\@#{$breakpoint} { font-size: var(--text-xxxxl, 3rem); } + + // width + .width-xxxxs\@#{$breakpoint} { width: var(--size-xxxxs, 0.25rem); } + .width-xxxs\@#{$breakpoint} { width: var(--size-xxxs, 0.5rem); } + .width-xxs\@#{$breakpoint} { width: var(--size-xxs, 0.75rem); } + .width-xs\@#{$breakpoint} { width: var(--size-xs, 1rem); } + .width-sm\@#{$breakpoint} { width: var(--size-sm, 1.5rem); } + .width-md\@#{$breakpoint} { width: var(--size-md, 2rem); } + .width-lg\@#{$breakpoint} { width: var(--size-lg, 3rem); } + .width-xl\@#{$breakpoint} { width: var(--size-xl, 4rem); } + .width-xxl\@#{$breakpoint} { width: var(--size-xxl, 6rem); } + .width-xxxl\@#{$breakpoint} { width: var(--size-xxxl, 8rem); } + .width-xxxxl\@#{$breakpoint} { width: var(--size-xxxxl, 16rem); } + .width-0\@#{$breakpoint} { width: 0; } + .width-10\%\@#{$breakpoint} { width: 10%; } + .width-20\%\@#{$breakpoint} { width: 20%; } + .width-25\%\@#{$breakpoint} { width: 25%; } + .width-30\%\@#{$breakpoint} { width: 30%; } + .width-33\%\@#{$breakpoint} { width: calc(100% / 3); } + .width-40\%\@#{$breakpoint} { width: 40%; } + .width-50\%\@#{$breakpoint} { width: 50%; } + .width-60\%\@#{$breakpoint} { width: 60%; } + .width-66\%\@#{$breakpoint} { width: calc(100% / 1.5); } + .width-70\%\@#{$breakpoint} { width: 70%; } + .width-75\%\@#{$breakpoint} { width: 75%; } + .width-80\%\@#{$breakpoint} { width: 80%; } + .width-90\%\@#{$breakpoint} { width: 90%; } + .width-100\%\@#{$breakpoint} { width: 100%; } + .width-100vw\@#{$breakpoint} { width: 100vw; } + .width-auto\@#{$breakpoint} { width: auto; } + + // height + .height-xxxxs\@#{$breakpoint} { height: var(--size-xxxxs, 0.25rem); } + .height-xxxs\@#{$breakpoint} { height: var(--size-xxxs, 0.5rem); } + .height-xxs\@#{$breakpoint} { height: var(--size-xxs, 0.75rem); } + .height-xs\@#{$breakpoint} { height: var(--size-xs, 1rem); } + .height-sm\@#{$breakpoint} { height: var(--size-sm, 1.5rem); } + .height-md\@#{$breakpoint} { height: var(--size-md, 2rem); } + .height-lg\@#{$breakpoint} { height: var(--size-lg, 3rem); } + .height-xl\@#{$breakpoint} { height: var(--size-xl, 4rem); } + .height-xxl\@#{$breakpoint} { height: var(--size-xxl, 6rem); } + .height-xxxl\@#{$breakpoint} { height: var(--size-xxxl, 8rem); } + .height-xxxxl\@#{$breakpoint} { height: var(--size-xxxxl, 16rem); } + .height-0\@#{$breakpoint} { height: 0; } + .height-10\%\@#{$breakpoint} { height: 10%; } + .height-20\%\@#{$breakpoint} { height: 20%; } + .height-25\%\@#{$breakpoint} { height: 25%; } + .height-30\%\@#{$breakpoint} { height: 30%; } + .height-33\%\@#{$breakpoint} { height: calc(100% / 3); } + .height-40\%\@#{$breakpoint} { height: 40%; } + .height-50\%\@#{$breakpoint} { height: 50%; } + .height-60\%\@#{$breakpoint} { height: 60%; } + .height-66\%\@#{$breakpoint} { height: calc(100% / 1.5); } + .height-70\%\@#{$breakpoint} { height: 70%; } + .height-75\%\@#{$breakpoint} { height: 75%; } + .height-80\%\@#{$breakpoint} { height: 80%; } + .height-90\%\@#{$breakpoint} { height: 90%; } + .height-100\%\@#{$breakpoint} { height: 100%; } + .height-100vh\@#{$breakpoint} { height: 100vh; } + .height-auto\@#{$breakpoint} { height: auto; } + + // max-width + .max-width-xxxxs\@#{$breakpoint} { max-width: var(--max-width-xxxxs); } + .max-width-xxxs\@#{$breakpoint} { max-width: var(--max-width-xxxs); } + .max-width-xxs\@#{$breakpoint} { max-width: var(--max-width-xxs); } + .max-width-xs\@#{$breakpoint} { max-width: var(--max-width-xs); } + .max-width-sm\@#{$breakpoint} { max-width: var(--max-width-sm); } + .max-width-md\@#{$breakpoint} { max-width: var(--max-width-md); } + .max-width-lg\@#{$breakpoint} { max-width: var(--max-width-lg); } + .max-width-xl\@#{$breakpoint} { max-width: var(--max-width-xl); } + .max-width-xxl\@#{$breakpoint} { max-width: var(--max-width-xxl); } + .max-width-xxxl\@#{$breakpoint} { max-width: var(--max-width-xxxl); } + .max-width-xxxxl\@#{$breakpoint} { max-width: var(--max-width-xxxxl); } + .max-width-100\%\@#{$breakpoint} { max-width: 100%; } + .max-width-none\@#{$breakpoint} { max-width: none; } + + // position + .position-relative\@#{$breakpoint} { position: relative; } + .position-absolute\@#{$breakpoint} { position: absolute; } + .position-fixed\@#{$breakpoint} { position: fixed; } + .position-sticky\@#{$breakpoint} { position: sticky; } + .position-static\@#{$breakpoint} { position: static; } + + .inset-0\@#{$breakpoint} { top: 0; right: 0; bottom: 0; left: 0; } + + .top-0\@#{$breakpoint} { top: 0; } + .top-50\%\@#{$breakpoint} { top: 50%; } + .top-xxxxs\@#{$breakpoint} { top: var(--space-xxxxs); } + .top-xxxs\@#{$breakpoint} { top: var(--space-xxxs); } + .top-xxs\@#{$breakpoint} { top: var(--space-xxs); } + .top-xs\@#{$breakpoint} { top: var(--space-xs); } + .top-sm\@#{$breakpoint} { top: var(--space-sm); } + .top-md\@#{$breakpoint} { top: var(--space-md); } + .top-lg\@#{$breakpoint} { top: var(--space-lg); } + .top-xl\@#{$breakpoint} { top: var(--space-xl); } + .top-xxl\@#{$breakpoint} { top: var(--space-xxl); } + .top-xxxl\@#{$breakpoint} { top: var(--space-xxxl); } + .top-xxxxl\@#{$breakpoint} { top: var(--space-xxxxl); } + + .bottom-0\@#{$breakpoint} { bottom: 0; } + .bottom-unset\@#{$breakpoint} { bottom: unset; } + .bottom-50\%\@#{$breakpoint} { bottom: 50%; } + .bottom-xxxxs\@#{$breakpoint} { bottom: var(--space-xxxxs); } + .bottom-xxxs\@#{$breakpoint} { bottom: var(--space-xxxs); } + .bottom-xxs\@#{$breakpoint} { bottom: var(--space-xxs); } + .bottom-xs\@#{$breakpoint} { bottom: var(--space-xs); } + .bottom-sm\@#{$breakpoint} { bottom: var(--space-sm); } + .bottom-md\@#{$breakpoint} { bottom: var(--space-md); } + .bottom-lg\@#{$breakpoint} { bottom: var(--space-lg); } + .bottom-xl\@#{$breakpoint} { bottom: var(--space-xl); } + .bottom-xxl\@#{$breakpoint} { bottom: var(--space-xxl); } + .bottom-xxxl\@#{$breakpoint} { bottom: var(--space-xxxl); } + .bottom-xxxxl\@#{$breakpoint} { bottom: var(--space-xxxxl); } + + .right-0\@#{$breakpoint} { right: 0; } + .right-50\%\@#{$breakpoint} { right: 50%; } + .right-xxxxs\@#{$breakpoint} { right: var(--space-xxxxs); } + .right-xxxs\@#{$breakpoint} { right: var(--space-xxxs); } + .right-xxs\@#{$breakpoint} { right: var(--space-xxs); } + .right-xs\@#{$breakpoint} { right: var(--space-xs); } + .right-sm\@#{$breakpoint} { right: var(--space-sm); } + .right-md\@#{$breakpoint} { right: var(--space-md); } + .right-lg\@#{$breakpoint} { right: var(--space-lg); } + .right-xl\@#{$breakpoint} { right: var(--space-xl); } + .right-xxl\@#{$breakpoint} { right: var(--space-xxl); } + .right-xxxl\@#{$breakpoint} { right: var(--space-xxxl); } + .right-xxxxl\@#{$breakpoint} { right: var(--space-xxxxl); } + + .left-0\@#{$breakpoint} { left: 0; } + .left-50\%\@#{$breakpoint} { left: 50%; } + .left-xxxxs\@#{$breakpoint} { left: var(--space-xxxxs); } + .left-xxxs\@#{$breakpoint} { left: var(--space-xxxs); } + .left-xxs\@#{$breakpoint} { left: var(--space-xxs); } + .left-xs\@#{$breakpoint} { left: var(--space-xs); } + .left-sm\@#{$breakpoint} { left: var(--space-sm); } + .left-md\@#{$breakpoint} { left: var(--space-md); } + .left-lg\@#{$breakpoint} { left: var(--space-lg); } + .left-xl\@#{$breakpoint} { left: var(--space-xl); } + .left-xxl\@#{$breakpoint} { left: var(--space-xxl); } + .left-xxxl\@#{$breakpoint} { left: var(--space-xxxl); } + .left-xxxxl\@#{$breakpoint} { left: var(--space-xxxxl); } + + // overflow + .overflow-hidden\@#{$breakpoint} { overflow: hidden; } + .overflow-auto\@#{$breakpoint} { overflow: auto; } + .momentum-scrolling\@#{$breakpoint} { -webkit-overflow-scrolling: touch; } + .overscroll-contain\@#{$breakpoint} { overscroll-behavior: contain; } + + // visibility + .visible\@#{$breakpoint} { visibility: visible; } + .invisible\@#{$breakpoint} { visibility: hidden; } + } + + @include breakpoint(#{$breakpoint}, "not all") { + .display\@#{$breakpoint} { display: none !important; } + } +} diff --git a/apps/web-shared/src/styles/base/_visibility.scss b/apps/web-shared/src/styles/base/_visibility.scss new file mode 100644 index 0000000..ab6a516 --- /dev/null +++ b/apps/web-shared/src/styles/base/_visibility.scss @@ -0,0 +1,23 @@ +:root { + --display: block; +} + +.is-visible { + display: var(--display) !important; +} + +.is-hidden { + display: none !important; +} + +html:not(.js) { + .no-js\:is-hidden { + display: none !important; + } +} + +@media print { + .print\:is-hidden { + display: none !important; + } +}
\ No newline at end of file diff --git a/apps/web-shared/src/styles/base/_z-index.scss b/apps/web-shared/src/styles/base/_z-index.scss new file mode 100644 index 0000000..5af9ff3 --- /dev/null +++ b/apps/web-shared/src/styles/base/_z-index.scss @@ -0,0 +1,6 @@ +:root { + --z-index-header: 3; // e.g., main header + --z-index-popover: 5; // e.g., tooltips and dropdown + --z-index-fixed-element: 10; // e.g., 'back to top' button + --z-index-overlay: 15; // e.g., modals and dialogs +}
\ No newline at end of file diff --git a/apps/web-shared/src/styles/components/alert.scss b/apps/web-shared/src/styles/components/alert.scss new file mode 100644 index 0000000..9d9008d --- /dev/null +++ b/apps/web-shared/src/styles/components/alert.scss @@ -0,0 +1,69 @@ +@use '../base' as *; + +/* -------------------------------- + +File#: _1_alert +Title: Alert +Descr: Feedback message +Usage: codyhouse.co/license + +-------------------------------- */ + +.alert { + background-color: alpha(var(--color-primary), 0.2); + color: var(--color-contrast-higher); + + // hide element + position: absolute; + clip: rect(1px, 1px, 1px, 1px); + clip-path: inset(50%); +} + +.alert__icon { + color: var(--color-primary); +} + +.alert__close-btn { + display: inline-block; + + .icon { + display: block; + } + + &:hover { + opacity: 0.7; + } +} + +// themes +.alert--success { + background-color: alpha(var(--color-success), 0.2); + + .alert__icon { + color: var(--color-success); + } +} + +.alert--error { + background-color: alpha(var(--color-error), 0.2); + + .alert__icon { + color: var(--color-error); + } +} + +.alert--warning { + background-color: alpha(var(--color-warning), 0.2); + + .alert__icon { + color: var(--color-warning); + } +} + +// toggle visibility +.alert--is-visible { + position: static; + clip: auto; + clip-path: none; +} + diff --git a/apps/web-shared/src/styles/components/autocomplete.scss b/apps/web-shared/src/styles/components/autocomplete.scss new file mode 100644 index 0000000..cde3632 --- /dev/null +++ b/apps/web-shared/src/styles/components/autocomplete.scss @@ -0,0 +1,76 @@ +@use '../base' as *; +@use 'circle-loader.scss' as *; + +/* -------------------------------- + +File#: _2_autocomplete +Title: Autocomplete +Descr: Autocomplete plugin for input elements +Usage: codyhouse.co/license + +-------------------------------- */ + +:root { + --autocomplete-dropdown-vertical-gap: 4px; // gap between input and results list + --autocomplete-dropdown-max-height: 150px; + --autocomplete-dropdown-scrollbar-width: 6px; // custom scrollbar width - webkit browsers +} + +// results dropdown +.autocomplete__results { + position: absolute; + z-index: var(--z-index-popover, 5); + width: 100%; + left: 0; + top: calc(100% + var(--autocomplete-dropdown-vertical-gap)); + background-color: var(--color-bg-light); + box-shadow: var(--inner-glow), var(--shadow-md); + border-radius: var(--radius-md); + opacity: 0; + visibility: hidden; + overflow: hidden; + + .autocomplete--results-visible & { + opacity: 1; + visibility: visible; + } +} + +.autocomplete__list { + max-height: var(--autocomplete-dropdown-max-height); + overflow: auto; + -webkit-overflow-scrolling: touch; + + // custom scrollbar + &::-webkit-scrollbar { // scrollbar width + width: var(--autocomplete-dropdown-scrollbar-width); + } + + &::-webkit-scrollbar-track { // progress bar + background-color: alpha(var(--color-contrast-higher), 0.08); + border-radius: 0; + } + + &::-webkit-scrollbar-thumb { // handle + background-color: alpha(var(--color-contrast-higher), 0.12); + border-radius: 0; + + &:hover { + background-color: alpha(var(--color-contrast-higher), 0.2); + } + } +} + +// single result item +.autocomplete__item { + cursor: pointer; + + &:hover { + background-color: alpha(var(--color-contrast-higher), 0.075); + } + + &:focus { + outline: none; + background-color: alpha(var(--color-primary), 0.15); + } +} diff --git a/apps/web-shared/src/styles/components/btn-states.scss b/apps/web-shared/src/styles/components/btn-states.scss new file mode 100644 index 0000000..a2fc6c5 --- /dev/null +++ b/apps/web-shared/src/styles/components/btn-states.scss @@ -0,0 +1,51 @@ +@use '../base' as *; + +/* -------------------------------- + +File#: _1_btn-states +Title: Buttons states +Descr: Multi-state button elements +Usage: codyhouse.co/license + +-------------------------------- */ + +.btn__content-a { + display: inline-flex; +} + +.btn__content-b { + display: none; +} + +.btn__content-a, .btn__content-b { + align-items: center; +} + +.btn--state-b { + .btn__content-a { + display: none; + } + + .btn__content-b { + display: inline-block; // fallback + display: inline-flex; + } +} + +/* preserve button width when switching from state A to state B */ +.btn--preserve-width { + .btn__content-b { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + justify-content: center; + } + + &.btn--state-b .btn__content-a { + display: inline-block; // fallback + display: inline-flex; + visibility: hidden; + } +} diff --git a/apps/web-shared/src/styles/components/chip.scss b/apps/web-shared/src/styles/components/chip.scss new file mode 100644 index 0000000..1bb93db --- /dev/null +++ b/apps/web-shared/src/styles/components/chip.scss @@ -0,0 +1,117 @@ +@use '../base' as *; + +/* -------------------------------- + +File#: _1_chips +Title: Chips +Descr: A list of compact pieces of information +Usage: codyhouse.co/license + +-------------------------------- */ + +.chip { + /* reset - in case the class is applied to a <button> or an <a> */ + border: 0; + color: inherit; + line-height: 1; + appearance: none; + + display: inline-flex; + align-items: center; + border-radius: var(--radius-sm); + + background-color: alpha(var(--color-contrast-higher), 0.1); + padding: var(--space-xxxs) 0; +} + +.chip--outline { + background-color: transparent; + box-shadow: inset 0 0 0 1px alpha(var(--color-contrast-higher), 0.25); +} + +.chip--error { + background-color: alpha(var(--color-error), 0.2); + color: var(--color-contrast-higher); +} + +.chip--success { + background-color: alpha(var(--color-success), 0.2); + color: var(--color-contrast-higher); +} + +.chip--warning { + background-color: alpha(var(--color-warning), 0.2); + color: var(--color-contrast-higher); +} + +.chip--interactive { + cursor: pointer; + + &:hover { + background-color: alpha(var(--color-contrast-higher), 0.2); + } + + &:focus { + outline: none; + box-shadow: 0 0 0 3px alpha(var(--color-contrast-higher), 0.3); + } + + &:focus:not(:focus-visible) { + box-shadow: none; + } +} + +.chip__label { + padding: 0 var(--space-xxs); +} + +.chip__img { + display: block; + width: 1.5em; + height: 1.5em; + border-radius: 50%; + object-fit: cover; +} + +.chip__icon-wrapper { + display: flex; + width: 1.5em; + height: 1.5em; + border-radius: 50%; + background-color: alpha(var(--color-contrast-higher), 0.95); + color: var(--color-bg); /* icon color */ + + .icon { + display: block; + margin: auto; + } +} + +.chip__btn { + @include reset; + display: flex; + width: 1em; + height: 1em; + background-color: alpha(var(--color-contrast-higher), 0.2); + border-radius: 50%; + cursor: pointer; + margin-right: 7px; + + .icon { + display: block; + margin: 0 auto; + } + + &:hover { + background-color: alpha(var(--color-contrast-higher), 0.3); + } + + &:focus { + outline: none; + box-shadow: 0 0 0 2px alpha(var(--color-contrast-higher), 0.5); + } + + &:focus:not(:focus-visible) { + box-shadow: none; + } +} diff --git a/apps/web-shared/src/styles/components/circle-loader.scss b/apps/web-shared/src/styles/components/circle-loader.scss new file mode 100644 index 0000000..5116d39 --- /dev/null +++ b/apps/web-shared/src/styles/components/circle-loader.scss @@ -0,0 +1,315 @@ +@use '../base' as *; + +/* -------------------------------- + +File#: _1_circle-loader +Title: Circle Loader +Descr: A collection of animated circle loaders +Usage: codyhouse.co/license + +-------------------------------- */ + +:root { + // v1 + --circle-loader-v1-size: 48px; + --circle-loader-v1-stroke-width: 4px; + + // v2 + --circle-loader-v2-size: 64px; + --circle-loader-v2-stroke-width: 2; + + // v3 + --circle-loader-v3-size: 64px; + + // v4 + --circle-loader-v4-size: 48px; + + // v5 + --circle-loader-v5-size: 64px; + + // v6 + --circle-loader-v6-size: 48px; +} + +.circle-loader { + position: relative; + display: inline-block; +} + +@supports (animation-name: this) { + .circle-loader__label { + @include srHide; // show label only to screen readers if animations are supported + } +} + +// loader v1 - rotation +@supports (animation-name: this) { + .circle-loader--v1 { + transform: rotate(45deg); + will-change: transform; + animation: circle-loader-1 0.75s infinite var(--ease-in-out); + + .circle-loader__circle { + width: var(--circle-loader-v1-size); // loader width + height: var(--circle-loader-v1-size); // loader height + border-width: var(--circle-loader-v1-stroke-width); // loader stroke width + border-style: solid; + border-color: alpha(var(--color-primary), 0.2); // loader base color + border-radius: 50%; + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-width: inherit; + border-style: inherit; + border-color: transparent; + border-top-color: var(--color-primary); // loader fill color + border-radius: inherit; + } + } + } +} + +@keyframes circle-loader-1 { + 0% { + transform: rotate(45deg); + } + + 100% { + transform: rotate(405deg); + } +} + +// loader v2 - filling +@supports (animation-name: this) { + .circle-loader--v2 { + will-change: transform; + animation: circle-loader-spinning-main 1.4s infinite linear; + + .circle-loader__svg { + display: block; + width: var(--circle-loader-v2-size); + height: var(--circle-loader-v2-size); + color: var(--color-primary); // loader color + + > * { + stroke-width: var(--circle-loader-v2-stroke-width); // loader stroke width + } + } + + .circle-loader__base { + opacity: 0.2; + } + + .circle-loader__fill { + stroke-linecap: round; // optional - remove if you prefer butt caps + stroke-dashoffset: 0; + stroke-dasharray: 90 120; + transform-origin: 50% 50%; + transform: rotate(45deg); + animation: circle-loader-dash 1.4s infinite; + } + } +} + +@keyframes circle-loader-dash { + 0%, 20% { + stroke-dashoffset: 0; + transform: rotate(0); + } + + 50%, 70% { + stroke-dashoffset: 80; + transform: rotate(270deg); + } + + 100% { + stroke-dashoffset: 0; + transform: rotate(360deg); + } +} + +@keyframes circle-loader-spinning-main { + to { + transform: rotate(360deg); + } +} + +// loader v3 - drop +@supports (animation-name: this) { + .circle-loader--v3 { + width: var(--circle-loader-v3-size); // loader width + height: var(--circle-loader-v3-size); // loader height + + .circle-loader__circle { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 50%; + background-color: var(--color-primary); // loader color + transform: scale(0); + opacity: 0.8; + will-change: transform, opacity; + animation: circle-loader-3 1.2s infinite; + } + + .circle-loader__circle--2nd { + animation-delay: 0.6s; // this should be half the duration of animation + } + } +} + +@keyframes circle-loader-3 { + to { + transform: scale(1); + opacity: 0; + } +} + +// loader v4 - eclipse +@supports (animation-name: this) { + .circle-loader--v4 { + width: var(--circle-loader-v4-size); // loader width + height: var(--circle-loader-v4-size); // loader height + border-radius: 50%; + overflow: hidden; + + .circle-loader__mask, + .circle-loader__circle { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: inherit; + } + + .circle-loader__mask { + clip-path: circle(calc(0.5 * var(--circle-loader-v4-size))); // fix iOS issue - it needs to be = half size of loader + } + + .circle-loader__circle--1st { + background-color: var(--color-contrast-low); // loader base color + } + + .circle-loader__circle--2nd { + background-color: var(--color-primary); // loader fill color + will-change: transform; + transform-origin: 50% 100%; + animation: circle-loader-4 1.2s infinite cubic-bezier(.23, .9, .75, .1); + transform: translateX(-100%); + } + } +} + +@keyframes circle-loader-4 { + to { + transform: translateX(100%); + } +} + +// loader v5 - bounce +@supports (animation-name: this) { + .circle-loader--v5 { + font-size: var(--circle-loader-v5-size); // loader size - if you edit this value all elements scale accordingly + width: 1em; + height: 1em; + + .circle-loader__label { + font-size: 1rem; + } + + .circle-loader__ball { + position: absolute; + top: 0; + left: calc(50% - 0.140625em); + width: 0.28125em; + height: 0.28125em; + background-color: var(--color-primary); + border-radius: 50%; + animation: circle-loader-5-ball 0.8s infinite; + } + + .circle-loader__shadow { + position: absolute; + bottom: 0; + left: calc(50% - 0.15625em); + width: 0.3125em; + height: 0.3125em; + background-color: var(--color-contrast-lower); + border-radius: 50%; + transform: scaleY(0.4) scaleX(1.2); + animation: circle-loader-5-shadow 0.8s infinite; + } + } +} + +@keyframes circle-loader-5-ball { + 0% { + transform: translateY(0); + animation-timing-function: cubic-bezier(.61, .12, .85, .4); + } + + 50% { + transform: translateY(0.5625em); + animation-timing-function: cubic-bezier(.12, .59, .46, .95); + } + + 100% { + transform: translateY(0); + } +} + +@keyframes circle-loader-5-shadow { + 0% { + transform: scaleY(0.4) scaleX(1.2); + background-color: var(--color-contrast-lower); + animation-timing-function: cubic-bezier(.61, .12, .85, .4); + } + + 50% { + transform: scaleY(0.2) scaleX(0.6); + background-color: var(--color-contrast-low); + animation-timing-function: cubic-bezier(.12, .59, .46, .95); + } + + 100% { + transform: scaleY(0.4) scaleX(1.2); + background-color: var(--color-contrast-lower); + } +} + +// loader v6 - worm +@supports (animation-name: this) { + .circle-loader--v6 { + .circle-loader__svg { + display: block; + width: var(--circle-loader-v6-size); + height: var(--circle-loader-v6-size); + color: var(--color-primary); // loader color + } + + .circle-loader__fill { + stroke-width: 8px; // loader stroke width + stroke-dashoffset: 35; + stroke-dasharray: 36 36; + animation: circle-loader-6 1.5s infinite; + } + } +} + +@keyframes circle-loader-6 { + 0%, 100% { + stroke-dashoffset: 35; + } + + 50% { + stroke-dashoffset: -35; + } +} diff --git a/apps/web-shared/src/styles/components/custom-checkbox.scss b/apps/web-shared/src/styles/components/custom-checkbox.scss new file mode 100644 index 0000000..5722ee0 --- /dev/null +++ b/apps/web-shared/src/styles/components/custom-checkbox.scss @@ -0,0 +1,131 @@ +@use '../base' as *; + +/* -------------------------------- + +File#: _1_custom-checkbox +Title: Custom Checkbox +Descr: Replace the native checkbox button with a custom element (e.g., an icon) +Usage: codyhouse.co/license + +-------------------------------- */ + +:root { + --custom-checkbox-size: 20px; + --custom-checkbox-radius: 4px; + --custom-checkbox-border-width: 1px; + --custom-checkbox-marker-size: 18px; +} + +.custom-checkbox { + position: relative; + z-index: 1; + display: inline-block; + font-size: var(--custom-checkbox-size); +} + +.custom-checkbox__input { + position: relative; + /* hide native input */ + margin: 0; + padding: 0; + opacity: 0; + height: 1em; + width: 1em; + display: block; + z-index: 1; +} + +.custom-checkbox__control { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + z-index: -1; + pointer-events: none; + color: alpha(var(--color-contrast-low), 0.65); /* unchecked color */ + + &::before, &::after { + content: ''; + position: absolute; + } + + &::before { /* focus circle */ + width: 160%; + height: 160%; + background-color: currentColor; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(0); + opacity: 0; + border-radius: 50%; + will-change: transform; + } + + &::after { /* custom checkbox */ + top: 0; + left: 0; + width: 100%; + height: 100%; + + /* custom checkbox style */ + background-color: var(--color-bg); + border-radius: var(--custom-checkbox-radius); + box-shadow: inset 0 0 0 var(--custom-checkbox-border-width) currentColor, var(--shadow-xs); /* border */ + } +} + +.custom-checkbox__input:checked ~ .custom-checkbox__control, +.custom-checkbox__input:indeterminate ~ .custom-checkbox__control { + &::after { + background-color: currentColor; + background-repeat: no-repeat; + background-position: center; + background-size: var(--custom-checkbox-marker-size); + box-shadow: none; + } +} + +.custom-checkbox__input:checked ~ .custom-checkbox__control { + color: var(--color-primary); /* checked color */ + + &::after { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpolyline points='2.5 8 6.5 12 13.5 3' fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5'/%3E%3C/svg%3E"); + } +} + +.custom-checkbox__input:indeterminate ~ .custom-checkbox__control { + &::after { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cline x1='2' y1='8' x2='14' y2='8' fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E"); + } +} + +.custom-checkbox__input:active ~ .custom-checkbox__control { + transform: scale(0.9); +} + +.custom-checkbox__input:checked:active ~ .custom-checkbox__control, +.custom-checkbox__input:indeterminate:active ~ .custom-checkbox__control { + transform: scale(1); +} + +.custom-checkbox__input:focus ~ .custom-checkbox__control::before { + opacity: 0.2; + transform: translate(-50%, -50%) scale(1); +} + +/* --icon */ +.custom-checkbox--icon { + --custom-checkbox-size: 32px; + + .custom-checkbox__control::after { + display: none; + } + + .icon { + display: block; + color: inherit; + position: relative; + z-index: 1; + } +} diff --git a/apps/web-shared/src/styles/components/custom-select.scss b/apps/web-shared/src/styles/components/custom-select.scss new file mode 100644 index 0000000..9cd3b5e --- /dev/null +++ b/apps/web-shared/src/styles/components/custom-select.scss @@ -0,0 +1,158 @@ +@use '../base' as *; + +/* -------------------------------- + +File#: _1_custom-select +Title: Custom Select +Descr: Custom Select Control +Usage: codyhouse.co/license + +-------------------------------- */ + +:root { + // --default variation only 👇 + --select-icon-size: 16px; + --select-icon-right-margin: var(--space-sm); // icon margin right + --select-text-icon-gap: var(--space-xxxs); // gap between text and icon +} + +.select { + position: relative; +} + +.select__input { + width: 100%; + height: 100%; + padding-right: calc(var(--select-icon-size) + var(--select-icon-right-margin) + var(--select-text-icon-gap)) !important; +} + +.select__icon { + width: var(--select-icon-size); + height: var(--select-icon-size); + pointer-events: none; + position: absolute; + right: var(--select-icon-right-margin); + top: 50%; + transform: translateY(-50%); +} + +// --custom-dropdown +:root { + --select-dropdown-gap: 4px; // distance between select control and custom dropdown +} + +.select__button { // created in JS - custom select control + width: 100%; +} + +.select__button[aria-expanded="true"] { + // custom select control if dropdown = visible +} + +.select__dropdown { // created in JS - custom select dropdown + position: absolute; + left: 0; + top: 100%; + min-width: 200px; + max-height: 1px; // updated in JS + background-color: var(--color-bg-light); + box-shadow: var(--inner-glow), var(--shadow-md); + padding: var(--space-xxxs) 0; + border-radius: var(--radius-md); + z-index: var(--z-index-popover, 5); + margin-top: var(--select-dropdown-gap); + margin-bottom: var(--select-dropdown-gap); + overflow: auto; + + // use rem units + @include spaceUnit(1rem); + @include textUnit(1rem); + + visibility: hidden; + opacity: 0; +} + +.select__dropdown--right { // change dropdown position based on the available space + right: 0; + left: auto; +} + +.select__dropdown--up { + bottom: 100%; + top: auto; +} + +.select__button[aria-expanded="true"] + .select__dropdown { + visibility: visible; + opacity: 1; +} + +// custom <optgroup> list - include all <option>s if no <optgroup> available +.select__list { + list-style: none !important; +} + +.select__list:not(:first-of-type) { + padding-top: var(--space-xxs); +} + +.select__list:not(:last-of-type) { + border-bottom: 1px solid alpha(var(--color-contrast-higher), 0.1); + padding-bottom: var(--space-xxs); +} + +.select__item { // single item inside .select__list + display: flex; + align-items: center; + padding: var(--space-xxs) var(--space-sm); + color: var(--color-contrast-high); + width: 100%; + text-align: left; + // truncate text + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.select__item--optgroup { // custom <optgroup> label + font-size: var(--text-sm); + color: var(--color-contrast-medium); +} + +.select__item--option { // custom <option> label + cursor: pointer; + + &:hover { + background-color: alpha(var(--color-contrast-higher), 0.075); + } + + &:focus { + outline: none; + background-color: alpha(var(--color-primary), 0.15); + } + + &[aria-selected=true] { // selected option + background-color: var(--color-primary); + color: var(--color-white); + position: relative; + @include fontSmooth; + + &::after { // check icon next to the selected language + content: ''; + display: block; + height: 1em; + width: 1em; + margin-left: auto; + background-color: currentColor; + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpolyline stroke-width='2' stroke='%23ffffff' fill='none' stroke-linecap='round' stroke-linejoin='round' points='1,9 5,13 15,3 '/%3E%3C/svg%3E"); + } + + &:focus { + box-shadow: inset 0 0 0 2px var(--color-primary-dark); + } + } +} + +html:not(.js ) .select .icon { // hide icon if JS = disabled + display: none; +} diff --git a/apps/web-shared/src/styles/components/details.scss b/apps/web-shared/src/styles/components/details.scss new file mode 100644 index 0000000..b4c122d --- /dev/null +++ b/apps/web-shared/src/styles/components/details.scss @@ -0,0 +1,57 @@ +@use '../base' as *; + +/* -------------------------------- + +File#: _1_details +Title: Details +Descr: A button that toggles the visibility of additional information +Usage: codyhouse.co/license + +-------------------------------- */ + +.details {} + +.details__summary { + display: inline-block; + cursor: pointer; + user-select: none; + + &:hover { + color: var(--color-primary); + } + + &:focus { + outline: 2px solid alpha(var(--color-primary), 0.2); + outline-offset: 4px; + } + + .icon { + flex-shrink: 0; + } +} + +// if JS = enabled +.js { + .details__summary { + list-style: none; // remove summary default icon + } + + .details__summary::-webkit-details-marker { + display: none; // remove default icon in webkit browsers + } + + .details__summary[aria-expanded="true"] .icon { + transform: rotate(90deg); // rotate icon when content is visible + } + + .details__content[aria-hidden="true"] { + display: none; + } +} + +// if JS = disabled +html:not(.js) .details__summary { + .icon { + display: none; + } +} diff --git a/apps/web-shared/src/styles/components/dropdown.scss b/apps/web-shared/src/styles/components/dropdown.scss new file mode 100644 index 0000000..c5ded33 --- /dev/null +++ b/apps/web-shared/src/styles/components/dropdown.scss @@ -0,0 +1,98 @@ +@use '../base' as *; + +/* -------------------------------- + +File#: _2_dropdown +Title: Dropdown +Descr: A hoverable link that toggles the visibility of a dropdown list +Usage: codyhouse.co/license + +-------------------------------- */ + +:root { + --dropdown-item-padding: var(--space-xxs) var(--space-sm); +} + +.dropdown { + position: relative; +} + +.dropdown__menu { + border-radius: var(--radius-md); + background-color: var(--color-bg-light); + box-shadow: var(--inner-glow), var(--shadow-sm); + z-index: var(--z-index-popover, 5); + position: absolute; + left: 0; + top: 100%; + opacity: 0; + visibility: hidden; +} + +.dropdown__wrapper { + max-height: 24px; +} + +@media (pointer: fine) { // user has pointing device (e.g., mouse) + .dropdown__wrapper, + .open-dropdown { + &:hover .dropdown__menu, + &:focus .dropdown__menu { + opacity: 1; + visibility: visible; + } + } + + .dropdown__sub-wrapper:hover > .dropdown__menu { + left: 100%; + } +} + +@media not all and (pointer: fine) { + .dropdown__trigger-icon { + display: none; + } +} + +.dropdown__item { + display: block; + text-decoration: none; + color: var(--color-contrast-high); + padding: var(--dropdown-item-padding); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:hover, &.dropdown__item--hover { + background-color: alpha(var(--color-contrast-higher), 0.075); + } +} + +.dropdown__separator { // h line divider + height: 1px; + background-color: var(--color-contrast-lower); + margin: var(--dropdown-item-padding); +} + +.dropdown__sub-wrapper { + position: relative; + + > .dropdown__item { // item w/ right arrow + position: relative; + padding-right: calc(var(--space-sm) + 12px); + + .icon { // right arrow + position: absolute; + display: block; + width: 12px; + height: 12px; + right: var(--space-xxs); + top: calc(50% - 6px); + } + } + + > .dropdown__menu { // sub menu + top: calc(var(--space-xxs) * -1); + box-shadow: var(--inner-glow), var(--shadow-md); + } +} diff --git a/apps/web-shared/src/styles/components/form-validator.scss b/apps/web-shared/src/styles/components/form-validator.scss new file mode 100644 index 0000000..cc9f9a3 --- /dev/null +++ b/apps/web-shared/src/styles/components/form-validator.scss @@ -0,0 +1,18 @@ +@use '../base' as *; + +/* -------------------------------- + +File#: _1_form-validator +Title: Form Validator +Descr: A plugin to validate form fields +Usage: codyhouse.co/license + +-------------------------------- */ + +.form-validate__error-msg { + display: none; // hide error message by default + + .form-validate__input-wrapper--error & { + display: block; // show error message + } +} diff --git a/apps/web-shared/src/styles/components/interactive-table.scss b/apps/web-shared/src/styles/components/interactive-table.scss new file mode 100644 index 0000000..f239c62 --- /dev/null +++ b/apps/web-shared/src/styles/components/interactive-table.scss @@ -0,0 +1,156 @@ +@use '../base' as *; +@use 'menu.scss' as *; +@use 'menu-bar.scss' as *; + +/* -------------------------------- + +File#: _3_interactive-table +Title: Interactive Table +Descr: Table with the option of sorting data and selecting rows to perform specific actions +Usage: codyhouse.co/license + +-------------------------------- */ + +.int-table { + overflow: hidden; + border-bottom: 2px solid var(--color-contrast-lower); +} + +.int-table__inner { + position: relative; + overflow: auto; + + &::-webkit-scrollbar { // custom scrollbar style + height: 8px; + width: 8px; + } + + &::-webkit-scrollbar-track { // progress bar + background-color: var(--color-contrast-lower); + } + + &::-webkit-scrollbar-thumb { // handle + background-color: alpha(var(--color-contrast-higher), 0.9); + border-radius: 50em; + } + + &::-webkit-scrollbar-thumb:hover { + background-color: var(--color-contrast-higher); + } +} + +.int-table__table { + width: 100%; +} + +.int-table__header { + .int-table__cell { + background-color: var(--color-bg); + box-shadow: 0 2px 0 var(--color-contrast-lower); + } +} + +.int-table__body { + .int-table__row { + border-bottom: 1px solid var(--color-contrast-lower); + + &:last-child { + border-bottom: none; + } + } + + .int-table__row--checked { + background-color: alpha(var(--color-primary), 0.1); + border-color: alpha(var(--color-primary), 0.25); + } +} + +.int-table__cell { // standard cell + padding: var(--space-xxxs); +} + +.int-table__cell--th { // header cell + font-weight: 600; +} + +.int-table__cell--sort { // header cell with sorting option + user-select: none; + + &:hover, &:focus-within { + background-color: alpha(var(--color-contrast-higher), 0.075); + } + + &:hover { + cursor: pointer; + } +} + +.int-table__cell--focus { + background-color: alpha(var(--color-primary), 0.15); +} + +.int-table__sort-icon { // sorting icon indicator + .arrow-up, .arrow-down { + fill: alpha(var(--color-contrast-higher), 0.3); + } +} + +.int-table__cell--asc .int-table__sort-icon .arrow-up, +.int-table__cell--desc .int-table__sort-icon .arrow-down { + fill: var(--color-contrast-higher); +} + +.int-table__checkbox { + --custom-checkbox-size: 18px; + --custom-checkbox-marker-size: 16px; + display: block; + width: var(--custom-checkbox-size); + height: var(--custom-checkbox-size); +} + +.int-table__menu-btn { + display: flex; + align-items: center; + justify-content: center; + width: 2em; + cursor: pointer; + + .icon { + display: block; + width: 16px; + height: 16px; + } +} + +// --sticky-header +.int-table--sticky-header { + position: relative; + z-index: 1; + + .int-table__inner { + max-height: 605px; + } + + .int-table__header { + .int-table__cell { + position: sticky; + top: 0; + z-index: 2; + } + } +} + +// actions +.int-table-actions { + .menu-bar { + --menu-bar-button-size: 38px; // size of the menu buttons + --menu-bar-icon-size: 16px; // size of the icons inside the buttons + --menu-bar-horizontal-gap: var(--space-xxxxs); // horizontal gap between buttons + --menu-bar-vertical-gap: 4px; // vertical gap between buttons and labels (tooltips) + --menu-bar-label-size: var(--text-xs); // label font size + } + + .menu-bar__icon { + color: alpha(var(--color-contrast-higher), 0.5); + } +} diff --git a/apps/web-shared/src/styles/components/list.scss b/apps/web-shared/src/styles/components/list.scss new file mode 100644 index 0000000..df600a3 --- /dev/null +++ b/apps/web-shared/src/styles/components/list.scss @@ -0,0 +1,195 @@ +@use '../base' as *; + +/* -------------------------------- + +File#: _1_list +Title: List +Descr: Custom list component +Usage: codyhouse.co/license + +-------------------------------- */ + +:root { + --list-space-y: 0.375em; // vertical gaps + --list-offset: 1em; // sublist horizontal offset + --list-line-height-multiplier: 1; // line-height multiplier +} + +.list, .text-component .list { + padding-left: 0; + list-style: none; + + ul, ol { + list-style: none; + margin: 0; // reset + margin-top: calc((var(--list-space-y) / 2) * var(--text-space-y-multiplier, 1)); + padding-top: calc((var(--list-space-y) / 2) * var(--text-space-y-multiplier, 1)); + padding-left: var(--list-offset); + } + + li { + padding-bottom: calc((var(--list-space-y) / 2) * var(--text-space-y-multiplier, 1)); + margin-bottom: calc((var(--list-space-y) / 2) * var(--text-space-y-multiplier, 1)); + line-height: calc(var(--body-line-height) * var(--list-line-height-multiplier)); + } + + > li:last-child, ul > li:last-child, ol > li:last-child { + margin-bottom: 0; + } + + &:not(.list--border) > li:last-child, ul > li:last-child, ol > li:last-child { + padding-bottom: 0; + } +} + +/* #region (ul + ol) */ +.list--ul, .text-component .list--ul, +.list--ol, .text-component .list--ol { + --list-offset: calc(var(--list-bullet-size) + var(--list-bullet-margin-right)); + + ul, ol { + padding-left: 0; + } + + li { + @supports (--css: variables) { + padding-left: var(--list-offset) !important; + } + } + + li::before { + display: inline-flex; + justify-content: center; + align-items: center; + vertical-align: middle; + position: relative; + top: -0.1em; + + @supports (--css: variables) { + width: var(--list-bullet-size) !important; + height: var(--list-bullet-size) !important; + margin-left: calc(var(--list-bullet-size) * -1) !important; + left: calc(var(--list-bullet-margin-right) * -1) !important; + } + } +} + +// unordered list +.list--ul, .text-component .list--ul { + --list-bullet-size: 7px; // dot width and height + --list-bullet-margin-right: 12px; // gap between bullet and content + + > li { + padding-left: 19px; // IE fallback + } + + > li::before { // bullet + content: ''; + border-radius: 50%; + color: var(--color-contrast-lower); // bullet color + background-color: currentColor; + + // IE fallback + width: 7px; + height: 7px; + margin-left: -7px; + left: -12px; + // end - IE fallback + } + + ul li::before { + background-color: transparent; + box-shadow: inset 0 0 0 2px currentColor; + } +} + +// ordered list +.list--ol, .text-component .list--ol { + --list-bullet-size: 26px; // ⚠️ use px or rem units - circle width and height + --list-bullet-margin-right: 6px; // ⚠️ use px or rem units - gap between circle and content + --list-bullet-font-size: 14px; // ⚠️ use px or rem units - bullet font size + counter-reset: list-items; + + > li { + counter-increment: list-items; + padding-left: 32px; // IE fallback + } + + ol { + counter-reset: list-items; + } + + > li::before { + content: counter(list-items); + font-size: var(--list-bullet-font-size, 14px); + background-color: var(--color-contrast-lower); + color: var(--color-contrast-high); + line-height: 1; + border-radius: 50%; + + // IE fallback + width: 26px; + height: 26px; + margin-left: -26px; + left: -6px; + // end - IE fallback + } + + ol > li::before { + background-color: transparent; + box-shadow: inset 0 0 0 2px var(--color-contrast-lower); + } +} +/* #endregion */ + +/* #region (border) */ +.list--border, .text-component .list--border { // show border divider among list items + li:not(:last-child) { + border-bottom: 1px solid var(--color-contrast-lower); + } + + ul, ol { + border-top: 1px solid var(--color-contrast-lower); + } +} +/* #endregion */ + +/* #region (icons) */ +.list--icons, .text-component .list--icons { // use icons as bullet points + --list-bullet-size: 24px; + --list-bullet-margin-right: 8px; // gap between icon and text + --list-offset: calc(var(--list-bullet-size) + var(--list-bullet-margin-right)); + + ul, ol { + padding-left: 32px; // IE fallback + + @supports (--css: variables) { + padding-left: var(--list-offset); + } + } +} + +.list__icon { + position: relative; + + // IE fallback + width: 24px; + height: 24px; + margin-right: 8px; + + &:not(.top-0) { + top: calc((1em * var(--body-line-height) - 24px) / 2); + } + // end - IE fallback + + @supports (--css: variables) { + width: var(--list-bullet-size); + height: var(--list-bullet-size); + margin-right: var(--list-bullet-margin-right); + + &:not(.top-0) { + top: calc((1em * var(--body-line-height) * var(--list-line-height-multiplier) - var(--list-bullet-size)) / 2); + } + } +} +/* #endregion */ diff --git a/apps/web-shared/src/styles/components/menu-bar.scss b/apps/web-shared/src/styles/components/menu-bar.scss new file mode 100644 index 0000000..3f70fbe --- /dev/null +++ b/apps/web-shared/src/styles/components/menu-bar.scss @@ -0,0 +1,139 @@ +@use '../base' as *; +@use 'menu.scss' as *; + +/* -------------------------------- + +File#: _2_menu-bar +Title: Menu Bar +Descr: Application menu with a list of common actions that users can perform +Usage: codyhouse.co/license + +-------------------------------- */ + +:root { + --menu-bar-button-size: 2.5em; // size of the menu buttons + --menu-bar-icon-size: 1em; // size of the icons inside the buttons + --menu-bar-horizontal-gap: var(--space-xxs); // horizontal gap between buttons + --menu-bar-vertical-gap: 4px; // vertical gap between buttons and labels (tooltips) + --menu-bar-label-size: var(--text-xs); // label font size +} + +.menu-bar { + list-style: none; + display: inline-flex; + align-items: center; +} + +.menu-bar__item { // menu button + position: relative; + display: inline-block; // flex fallback + display: flex; + align-items: center; + justify-content: center; + height: var(--menu-bar-button-size); + width: var(--menu-bar-button-size); + border-radius: 50%; + cursor: pointer; + + &:not(:last-child) { + margin-right: var(--menu-bar-horizontal-gap); + } + + &:hover, + &.menu-control--active { + background-color: alpha(var(--color-contrast-higher), 0.1); + + > .menu-bar__icon { + color: var(--color-contrast-higher); + } + + > .menu-bar__label { // show label + clip: auto; + clip-path: none; + height: auto; + width: auto; + } + } + + &:focus { + outline: none; + background-color: alpha(var(--color-primary), 0.1); + } + + &:active { + background-color: var(--color-contrast-low); + } + + &:focus:active { + background-color: alpha(var(--color-primary), 0.2); + } +} + +.menu-bar__item--trigger { // button used to show hidden actions - visibile only if menu = collapsed + display: none; +} + +.menu-bar__icon { + display: block; + color: var(--color-contrast-high); + font-size: var(--menu-bar-icon-size); // set icon size +} + +.menu-bar__label { // label visible on :hover + // hide + position: absolute; + z-index: var(--z-index-popover, 5); + clip: rect(1px, 1px, 1px, 1px); + clip-path: inset(50%); + width: 1px; + height: 1px; + overflow: hidden; + white-space: nowrap; + // style + top: 100%; + left: 50%; + transform: translateX(-50%) translateY(var(--menu-bar-vertical-gap)); + padding: var(--space-xxs) var(--space-xs); + color: var(--color-bg); + background-color: alpha(var(--color-contrast-higher), 0.95); + border-radius: var(--radius-md); + font-size: var(--menu-bar-label-size); + @include fontSmooth; + pointer-events: none; + user-select: none; +} + +.menu-bar--collapsed { // mobile layout style + .menu-bar__item--hide { // hide buttons + display: none; + } + + .menu-bar__item--trigger { // show submenu trigger + display: inline-block; // flex fallback + display: flex; + } +} + +// detect when the menu needs to switch from the mobile layout to an expanded one - used in JS +.js { + .menu-bar { + opacity: 0; // hide menu bar while it is initialized in JS + + &::before { + display: none; + content: 'collapsed'; + } + } + + .menu-bar--loaded { + opacity: 1; + } + + @each $breakpoint, $value in $breakpoints { + .menu-bar--expanded\@#{$breakpoint}::before { + @include breakpoint(#{$breakpoint}) { + content: 'expanded'; + } + } + } +} diff --git a/apps/web-shared/src/styles/components/menu.scss b/apps/web-shared/src/styles/components/menu.scss new file mode 100644 index 0000000..8e211a5 --- /dev/null +++ b/apps/web-shared/src/styles/components/menu.scss @@ -0,0 +1,81 @@ +@use '../base' as *; + +/* -------------------------------- + +File#: _1_menu +Title: Menu +Descr: Application menu that provides access to a set of functionalities +Usage: codyhouse.co/license + +-------------------------------- */ + +.menu { + --menu-vertical-gap: 5px; // vertical gap between the Menu element and its control + --menu-item-padding: var(--space-xxxs) var(--space-xs); + list-style: none; + position: fixed; // top/left position set in JS + background-color: var(--color-bg-light); + //padding: var(--space-xxs) 0; + border-radius: var(--radius-md); + z-index: var(--z-index-popover, 5); + user-select: none; + margin-top: var(--menu-vertical-gap); + margin-bottom: var(--menu-vertical-gap); + overflow: auto; + + + // use rem units + @include spaceUnit(1rem); + @include textUnit(1rem); + + visibility: hidden; + opacity: 0; +} + +.menu--is-visible { + visibility: visible; + opacity: 1; +} + +.menu--overlay { + z-index: var(--z-index-overlay, 15); +} + +.menu__content { + display: block; // fallback + display: flex; + align-items: center; + text-decoration: none; // reset link style + padding: var(--menu-item-padding); + color: var(--color-contrast-high); + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:hover { + background-color: alpha(var(--color-contrast-higher), 0.075); + } + + &:focus { + outline: none; + background-color: alpha(var(--color-primary), 0.15); + } +} + +.menu__label { + padding: var(--menu-item-padding); + font-size: var(--text-sm); + color: var(--color-contrast-medium); +} + +.menu__separator { + height: 1px; + background-color: var(--color-contrast-lower); + margin: var(--menu-item-padding); +} + +.menu__icon { + color: alpha(var(--color-contrast-higher), 0.5); + margin-right: var(--space-xxs); +} diff --git a/apps/web-shared/src/styles/components/modal.scss b/apps/web-shared/src/styles/components/modal.scss new file mode 100644 index 0000000..1beec76 --- /dev/null +++ b/apps/web-shared/src/styles/components/modal.scss @@ -0,0 +1,105 @@ +@use '../base' as *; + +/* -------------------------------- + +File#: _1_modal-window +Title: Modal Window +Descr: A modal dialog used to display critical information +Usage: codyhouse.co/license + +-------------------------------- */ + +.modal { + position: fixed; + z-index: var(--z-index-overlay, 15); + width: 100%; + height: 100%; + left: 0; + top: 0; + opacity: 0; + visibility: hidden; + + &:not(.modal--is-visible) { + pointer-events: none; + background-color: transparent; + } +} + +.modal--is-visible { + opacity: 1; + visibility: visible; +} + +// close button +.modal__close-btn { + display: flex; + flex-shrink: 0; + border-radius: 50%; + cursor: pointer; + + .icon { + display: block; + margin: auto; + } +} + +.modal__close-btn--outer { // close button - outside the modal__content + width: 48px; + height: 48px; + position: fixed; + top: var(--space-sm); + right: var(--space-sm); + z-index: var(--z-index-fixed-element, 10); + background-color: alpha(var(--color-black), 0.9); + + .icon { + color: var(--color-white); // icon color + } + + &:hover { + background-color: alpha(var(--color-black), 1); + + .icon { + transform: scale(1.1); + } + } +} + +.modal__close-btn--inner { // close button - inside the modal__content + width: 2em; + height: 2em; + background-color: var(--color-bg-light); + box-shadow: var(--inner-glow), var(--shadow-sm); + + .icon { + color: inherit; // icon color + } + + &:hover { + background-color: var(--color-bg-lighter); + box-shadow: var(--inner-glow), var(--shadow-md); + } +} + +// load content - optional +.modal--is-loading { + .modal__content { + visibility: hidden; + } + + .modal__loader { + display: flex; + } +} + +.modal__loader { // loader icon + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + justify-content: center; + align-items: center; + display: none; + pointer-events: none; +} diff --git a/apps/web-shared/src/styles/components/pagination.scss b/apps/web-shared/src/styles/components/pagination.scss new file mode 100644 index 0000000..0a09210 --- /dev/null +++ b/apps/web-shared/src/styles/components/pagination.scss @@ -0,0 +1,77 @@ +@use '../base' as *; + +/* -------------------------------- + +File#: _1_pagination +Title: Pagination +Descr: Component used to navigate through pages of related content +Usage: codyhouse.co/license + +-------------------------------- */ + +.pagination {} + +.pagination__list > li { + display: inline-block; // flex fallback +} + +// --split - push first + last item to sides +.pagination--split { + .pagination__list { + width: 100%; + + > *:first-child { + margin-right: auto; + } + + > *:last-child { + margin-left: auto; + } + } +} + +.pagination__item { + display: inline-block; // flex fallback + display: inline-flex; + height: 100%; + align-items: center; + padding: var(--space-xs) calc(1.355 * var(--space-xs)); + + white-space: nowrap; + line-height: 1; + border-radius: var(--radius-md); + + text-decoration: none; + color: var(--color-contrast-high); + @include fontSmooth; + + will-change: transform; + + &:hover:not(.pagination__item--selected):not(.pagination__item--ellipsis) { + background-color: alpha(var(--color-contrast-higher), 0.1); + } +} + +.pagination__item--selected { + background-color: var(--color-contrast-higher); + color: var(--color-bg); + box-shadow: var(--shadow-sm); +} + +.pagination__item--disabled { + opacity: 0.5; + pointer-events: none; +} + +// --jumper +.pagination__jumper { + .form-control { + width: 3em; + margin-right: var(--space-xs); + } + + em { + flex-shrink: 0; + white-space: nowrap; + } +} diff --git a/apps/web-shared/src/styles/components/popover.scss b/apps/web-shared/src/styles/components/popover.scss new file mode 100644 index 0000000..7f423a0 --- /dev/null +++ b/apps/web-shared/src/styles/components/popover.scss @@ -0,0 +1,38 @@ +@use '../base'as *; + +/* -------------------------------- + +File#: _1_popover +Title: Popover +Descr: A pop-up box controlled by a trigger element +Usage: codyhouse.co/license + +-------------------------------- */ +:root { + --popover-width: 250px; + --popover-control-gap: 4px; // ⚠️ use px units - vertical gap between the popover and its control + --popover-viewport-gap: 20px; // ⚠️ use px units - vertical gap between the popover and the viewport - visible if popover height > viewport height + --popover-transition-duration: 0.2s; +} + +.popover { + position: fixed; // top/left position set in JS + width: var(--popover-width); + z-index: var(--z-index-popover, 5); + margin-top: var(--popover-control-gap); + margin-bottom: var(--popover-control-gap); + overflow: auto; + -webkit-overflow-scrolling: touch; + + visibility: hidden; + opacity: 0; +} + +.popover--is-visible { + visibility: visible; + opacity: 1; +} + +.popover-control--active { + // class added to the trigger when popover is visible +} diff --git a/apps/web-shared/src/styles/components/pre-header.scss b/apps/web-shared/src/styles/components/pre-header.scss new file mode 100644 index 0000000..1e803e7 --- /dev/null +++ b/apps/web-shared/src/styles/components/pre-header.scss @@ -0,0 +1,46 @@ +@use '../base' as *; + +/* -------------------------------- + +File#: _1_pre-header +Title: Pre-header +Descr: Pre-header (top) banner +Usage: codyhouse.co/license + +-------------------------------- */ + +.pre-header { + display: block; + background-color: var(--color-contrast-higher); + color: var(--color-bg); + @include fontSmooth; +} + +.pre-header--is-hidden { + display: none; +} + +.pre-header__close-btn { + position: absolute; + right: 0; + top: calc(50% - 0.5em); + will-change: transform; + + &:hover { + transform: scale(1.1); + } + + .icon { + display: block; + } +} + +// --link +a.pre-header { + text-decoration: none; + + &:hover { + text-decoration: underline; + background-color: var(--color-contrast-high); + } +} diff --git a/apps/web-shared/src/styles/components/radios-checkboxes.scss b/apps/web-shared/src/styles/components/radios-checkboxes.scss new file mode 100644 index 0000000..c4009f9 --- /dev/null +++ b/apps/web-shared/src/styles/components/radios-checkboxes.scss @@ -0,0 +1,134 @@ +@use '../base' as *; + +/* -------------------------------- + +File#: _1_radios-checkboxes +Title: Radios and Checkboxes +Descr: Custom radio and checkbox buttons +Usage: codyhouse.co/license + +-------------------------------- */ + +:root { + // radios and checkboxes + --checkbox-radio-size: 18px; + --checkbox-radio-gap: var(--space-xxs); // gap between button and label + --checkbox-radio-border-width: 1px; + --checkbox-radio-line-height: var(--body-line-height); + + // radio buttons + --radio-marker-size: 8px; + + // checkboxes + --checkbox-marker-size: 12px; + --checkbox-radius: 4px; +} + +// hide native buttons +.radio, +.checkbox { + position: absolute; + padding: 0; + margin: 0; + margin-top: calc((1em * var(--checkbox-radio-line-height) - var(--checkbox-radio-size)) / 2); + opacity: 0; + height: var(--checkbox-radio-size); + width: var(--checkbox-radio-size); + pointer-events: none; +} + +// label +.radio + label, +.checkbox + label { + display: inline-block; + line-height: var(--checkbox-radio-line-height); + user-select: none; + cursor: pointer; + padding-left: calc(var(--checkbox-radio-size) + var(--checkbox-radio-gap)); +} + +// custom inputs - basic style +.radio + label::before, +.checkbox + label::before { + content: ''; + box-sizing: border-box; + display: inline-block; + position: relative; + vertical-align: middle; + top: -0.1em; + margin-left: calc(-1 * (var(--checkbox-radio-size) + var(--checkbox-radio-gap))); + flex-shrink: 0; + width: var(--checkbox-radio-size); + height: var(--checkbox-radio-size); + background-color: var(--color-bg); + border-width: var(--checkbox-radio-border-width); + border-color: alpha(var(--color-contrast-low), 0.65); + border-style: solid; + box-shadow: var(--shadow-xs); + background-repeat: no-repeat; + background-position: center; + margin-right: var(--checkbox-radio-gap); +} + +// :hover +.radio:not(:checked):not(:focus) + label:hover::before, +.checkbox:not(:checked):not(:focus) + label:hover::before { + border-color: alpha(var(--color-contrast-low), 1); +} + +// radio only style +.radio + label::before { + border-radius: 50%; +} + +// checkbox only style +.checkbox + label::before { + border-radius: var(--checkbox-radius); +} + +// :checked +.radio:checked + label::before, +.checkbox:checked + label::before { + background-color: var(--color-primary); + box-shadow: var(--shadow-xs); + border-color: var(--color-primary); +} + +// radio button icon +.radio:checked + label::before { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cg class='nc-icon-wrapper' fill='%23ffffff'%3E%3Ccircle cx='8' cy='8' r='8' fill='%23ffffff'%3E%3C/circle%3E%3C/g%3E%3C/svg%3E"); + background-size: var(--radio-marker-size); +} + +// checkbox button icon +.checkbox:checked + label::before { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpolyline points='1 6.5 4 9.5 11 2.5' fill='none' stroke='%23FFFFFF' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E"); + background-size: var(--checkbox-marker-size); +} + +// :focus +.radio:checked:active + label::before, +.checkbox:checked:active + label::before, +.radio:focus + label::before, +.checkbox:focus + label::before { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px alpha(var(--color-primary), 0.2); +} + +// --radio--bg, --checkbox--bg -> variation with background color +.radio--bg + label, .checkbox--bg + label { + padding: var(--space-xxxxs) var(--space-xxxs); + padding-left: calc(var(--checkbox-radio-size) + var(--checkbox-radio-gap) + var(--space-xxxs)); + border-radius: var(--radius-md); +} + +.radio--bg + label:hover, .checkbox--bg + label:hover { + background-color: alpha(var(--color-contrast-higher), 0.075); +} + +.radio--bg:active + label, +.checkbox--bg:active + label, +.radio--bg:focus + label, +.checkbox--bg:focus + label { + background-color: alpha(var(--color-primary), 0.1); +} diff --git a/apps/web-shared/src/styles/components/select-autocomplete.scss b/apps/web-shared/src/styles/components/select-autocomplete.scss new file mode 100644 index 0000000..78a0fb0 --- /dev/null +++ b/apps/web-shared/src/styles/components/select-autocomplete.scss @@ -0,0 +1,173 @@ +@use '../base' as *; +@use 'autocomplete.scss' as *; + +/* -------------------------------- + +File#: _3_select-autocomplete +Title: Select Autocomplete +Descr: Selection dropdown with autocomplete +Usage: codyhouse.co/license + +-------------------------------- */ + +.select-auto { + &.autocomplete { + --autocomplete-dropdown-vertical-gap: 4px; // gap between input and results list + --autocomplete-dropdown-max-height: 250px; + --autocomplete-dropdown-scrollbar-width: 6px; // custom scrollbar - webkit browsers + } +} + +// input +.select-auto__input-wrapper { + --input-btn-size: 1.25em; // btn/icon size + --input-btn-icon-size: 16px; // btn/icon size + --input-btn-text-gap: var(--space-xxs); // gap between button/icon and text + + position: relative; + background: var(--color-bg-dark); + line-height: 1.2; + box-shadow: inset 0 0 0 1px var(--color-contrast-lower); + + &.multiple { + display: flex; + flex-direction: row; + flex-flow: wrap; + + .chip { + white-space: nowrap; + margin-right: 1px; + } + + input[type="text"] { + width: auto; + } + + @media (max-width: 756px) { + flex-flow: column !important; + + &.has-selection { + input[type="text"] { + margin-top: 5px; + } + + .chip { + justify-content: space-between; + + .chip__btn { + margin-right: 0 !important;; + } + } + } + } + } + + &::placeholder { + opacity: 1; + color: var(--color-contrast-low); + } + + &:focus-within { + background: var(--color-bg); + box-shadow: inset 0 0 0 1px alpha(var(--color-contrast-lower), 0), 0px 0px 0px 1px var(--color-primary); + outline: none; + } + + .form-control { + width: 100%; + height: 100%; + padding-right: calc(var(--form-control-padding-x) + var(--input-btn-size) + var(--input-btn-text-gap)); + } +} + + +.select-auto__input-icon-wrapper { + width: var(--input-btn-size); + height: var(--input-btn-size); + + position: absolute; + bottom: calc(var(--input-btn-size) / 3); + right: var(--form-control-padding-x); + display: flex; + pointer-events: none; + + .icon { + display: block; + margin: auto; + width: var(--input-btn-icon-size, 16px); + height: var(--input-btn-icon-size, 16px); + } +} + +.select-auto__input-btn { + display: none; + justify-content: center; + align-items: center; + width: inherit; + height: inherit; + pointer-events: auto; + cursor: pointer; + color: var(--color-contrast-medium); // icon color + + &:hover { + color: var(--color-contrast-high); + } +} + +.select-auto--selection-done { + .select-auto__input-icon-wrapper > .icon { + display: none; + } + + .select-auto__input-btn { + display: flex; + } +} + +// dropdown +.select-auto__results { + // reset spacing and typography + @include spaceUnit(1rem); + @include textUnit(1rem); +} + +// single result item +.select-auto__option { + position: relative; + cursor: pointer; + + &:hover { + background-color: alpha(var(--color-contrast-higher), 0.05); + } + + &:focus { + outline: none; + background-color: alpha(var(--color-primary), 0.12); + } + + &.select-auto__option--selected { + background-color: var(--color-primary); + color: var(--color-white); + padding-right: calc(1em + var(--space-sm)); + @include fontSmooth; + + &:focus { + background-color: var(--color-primary-dark); + } + + &::after { + content: ''; + position: absolute; + right: var(--space-sm); + top: calc(50% - 0.5em); + height: 1em; + width: 1em; + background-color: currentColor; + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpolyline stroke-width='2' stroke='%23ffffff' fill='none' stroke-linecap='round' stroke-linejoin='round' points='1,9 5,13 15,3 '/%3E%3C/svg%3E"); + } + } +} + +.select-auto__group-title, .select-auto__no-results-msg { + outline: none; +} diff --git a/apps/web-shared/src/styles/components/tabbed-navigation.scss b/apps/web-shared/src/styles/components/tabbed-navigation.scss new file mode 100644 index 0000000..4090fca --- /dev/null +++ b/apps/web-shared/src/styles/components/tabbed-navigation.scss @@ -0,0 +1,133 @@ +@use '../base' as *; + +/* -------------------------------- + +File#: _1_tabbed-navigation-v2 +Title: Tabbed Navigation v2 +Descr: Tabbed (secondary) navigation +Usage: codyhouse.co/license + +-------------------------------- */ + +.tabs-nav-v2 { + display: flex; + flex-wrap: wrap; + + .tab-v2 { + display: inline-block; // flexbox fallback + display: inline-flex; + align-items: center; + } +} + +.tabs-nav-v2__item { + display: inline-block; + padding: var(--space-xxs) var(--space-sm); + color: inherit; + white-space: nowrap; + text-decoration: none; +} + +.tabs-nav-v2__item--selected, +.tabs-nav-v2__item[aria-selected="true"] { + color: var(--color-bg); + background-color: var(--color-contrast-higher); +} + +@include breakpoint(md) { + .tabs-nav-v2 { + .tab-v2 { + margin: 0; + } + } + + .tabs-nav-v2__item { + background-color: transparent; + margin: var(--space-xxs) var(--space-sm); + padding: var(--space-xxxs) var(--space-xxs) !important; + border-radius: var(--radius-md); + + &:hover { + background-color: alpha(var(--color-primary), 0.035); + color: var(--color-primary); + } + } + + .tabs-nav-v2__item--selected, + .tabs-nav-v2__item[aria-selected="true"] { + + background-color: alpha(var(--color-primary), 0.075); + color: var(--color-primary); + position: relative; + + &::after { + content: ''; + position: absolute; + bottom: calc(var(--tabs-nav-border-width) * -1); + left: 0; + width: 100%; + height: var(--tabs-nav-border-width); + background-color: var(--color-bg); + } + + &:hover { + background-color: alpha(var(--color-primary), 0.075); + } + } +} + +:root { + --s-tabs-border-bottom-width: 1px; + --s-tabs-selected-item-border-bottom-width: 1px; +} + +.s-tabs { + position: relative; + + &::after { /* gradient - truncate text */ + content: ''; + position: absolute; + right: -1px; + top: 0; + height: calc(100% - var(--s-tabs-border-bottom-width)); + width: 2em; + pointer-events: none; + z-index: 1; + } +} + +.s-tabs__list { + display: flex; + overflow: auto; + -webkit-overflow-scrolling: auto; + + &::after { /* border bottom */ + content: ''; + position: absolute; + width: 100%; + height: var(--s-tabs-border-bottom-width); + left: 0; + bottom: 0; + background-color: var(--color-contrast-lower); + } +} + +.s-tabs__link { + color: var(--color-contrast-medium); + text-decoration: none; + display: inline-block; + padding: var(--space-xs) var(--space-sm); + white-space: nowrap; + border-bottom: var(--s-tabs-selected-item-border-bottom-width) solid transparent; + z-index: 1; + + &:hover:not(.s-tabs__link--current) { + color: var(--color-contrast-high); + } +} + +.s-tabs__link--current { + position: relative; + color: var(--color-primary); + border-bottom-color: var(--color-primary); +} diff --git a/apps/web-shared/src/styles/components/table.scss b/apps/web-shared/src/styles/components/table.scss new file mode 100644 index 0000000..af8207f --- /dev/null +++ b/apps/web-shared/src/styles/components/table.scss @@ -0,0 +1,147 @@ +@use '../base'as *; + +/* -------------------------------- + +File#: _1_table +Title: Table +Descr: Data tables used to organize and display information in rows and columns +Usage: codyhouse.co/license + +-------------------------------- */ + +// >>> style affecting all (block + expanded) versions 👇 +.table { + position: relative; + z-index: 1; +} + +// <<< end style affecting all versions + +// >>> block version only (mobile) 👇 +.table:not(.table--expanded) { + border-collapse: separate; + border-spacing: 0 var(--space-md); // row gap + margin-top: calc(-2 * var(--space-md)); // set spacing variable = row gap ☝️ + + .table__header { + // hide table header - but keep it accessible to SR + @include srHide; + } + + .table__row { + .table__cell:first-child { + border-radius: var(--radius-md) var(--radius-md) 0 0; + } + + .table__cell:last-child { + border-radius: 0 0 var(--radius-md) var(--radius-md); + + &::after { + // hide border bottom + display: none; + } + } + } + + .table__cell { + position: relative; + display: flex; + justify-content: space-between; + width: 100%; + text-align: right; + padding: var(--space-md); + background-color: var(--color-bg-light); + + &::after { + // border bottom + content: ''; + position: absolute; + bottom: 0; + left: var(--space-md); + width: calc(100% - (2 * var(--space-md))); + height: 1px; + background-color: var(--color-contrast-lower); + } + } + + .table__label { + // inline labels -> visible when table header is hidden + font-weight: bold; + text-align: left; + color: var(--color-contrast-higher); + margin-right: var(--space-md); + } +} + +// <<< end block version + +// >>> expanded version only (desktop) 👇 -> show standard rows and cols +.table--expanded { + border-bottom: 1px solid var(--color-contrast-lower); // table border bottom + + .table__header { + .table__cell { + // header cell style + position: relative; + z-index: 10; + background-color: var(--color-bg); + border-bottom: 1px solid var(--color-contrast-lower); // header border bottom + font-weight: bold; + color: var(--color-contrast-higher); + } + } + + .table__body { + .table__row { + &:nth-child(odd) { + background-color: alpha(var(--color-bg-dark), 0.85); + } + } + } + + .table__cell { + padding: var(--space-xxxs); + } + + .table__label { + // hide inline labels + display: none; + } + + // --header-sticky + .table__header--sticky { + .table__cell { + // header cell style + position: sticky; + top: 0; + } + } +} + +// <<< end expanded version + +.js { + .table { + opacity: 0; // hide table while it is initialized in JS + } + + .table--loaded { + opacity: 1; + } +} + +// detect when the table needs to switch from the mobile layout to an expanded one - used in JS +[class*="table--expanded"]::before { + display: none; +} + +@each $breakpoint, +$value in $breakpoints { + .table--expanded\@#{$breakpoint}::before { + content: 'collapsed'; + + @include breakpoint(#{$breakpoint}) { + content: 'expanded'; + } + } +} diff --git a/apps/web-shared/src/styles/components/user-menu.scss b/apps/web-shared/src/styles/components/user-menu.scss new file mode 100644 index 0000000..1b5c1d5 --- /dev/null +++ b/apps/web-shared/src/styles/components/user-menu.scss @@ -0,0 +1,81 @@ +@use '../base' as *; +@use 'menu.scss' as *; + +/* -------------------------------- + +File#: _2_user-menu +Title: User Menu +Descr: A menu controlled by the user profile image +Usage: codyhouse.co/license + +-------------------------------- */ + +.user-menu-control { + --profile-figure-size: 40px; + + cursor: pointer; + display: inline-flex; + align-items: center; + text-align: left; + + &:hover { + .user-menu-control__img-wrapper { + opacity: 0.8; + } + + .user-menu__meta-title { + color: var(--color-primary); + } + } + + &:focus, &.menu-control--active { + outline: none; + + .user-menu-control__img-wrapper::after { + opacity: 1; + transform: scale(1); + } + } +} + +.user-menu-control__img-wrapper { + width: var(--profile-figure-size); + height: var(--profile-figure-size); + position: relative; + transition: opacity 0.2s; + + &::after { + content: ''; + position: absolute; + z-index: -1; + left: -4px; + top: -4px; + width: 100%; + height: 100%; + border-radius: inherit; + width: calc(var(--profile-figure-size) + 8px); + height: calc(var(--profile-figure-size) + 8px); + border: 2px solid var(--color-primary); + pointer-events: none; + + opacity: 0; + transform: scale(0.8); + + transition: all 0.2s; + } +} + +.user-menu-control__img { + display: block; + width: 100%; + object-fit: cover; + border-radius: inherit; +} + +.user-menu__meta { + max-width: 100px; +} + +.user-menu__meta-title { + transition: color 0.2s; +} diff --git a/apps/web-shared/src/styles/custom-style/_buttons.scss b/apps/web-shared/src/styles/custom-style/_buttons.scss new file mode 100644 index 0000000..e396f8d --- /dev/null +++ b/apps/web-shared/src/styles/custom-style/_buttons.scss @@ -0,0 +1,111 @@ +@use '../base' as *; + +// -------------------------------- + +// (START) Global editor code https://codyhouse.co/ds/globals/buttons + +// -------------------------------- + +:root { + --btn-font-size: 1em; + --btn-padding-x: var(--space-xxs); + --btn-padding-y: var(--space-xxxs); + --btn-radius: var(--radius-sm); +} + +.btn { + background: var(--color-bg-dark); + color: var(--color-contrast-higher); + cursor: pointer; + text-decoration: none; + line-height: 1.2; + @include fontSmooth; + will-change: transform; + + &:focus { + box-shadow: 0px 0px 0px 2px alpha(var(--color-contrast-higher), 0.15); + outline: none; + } +} + +.btn--link { + @include reset; + color: inherit; + cursor: pointer; + text-decoration: none; + + &:hover { + color: var(--color-primary); + } +} + +// themes +.btn--primary { + background: var(--color-primary); + color: var(--color-white); + box-shadow: inset 0px 1px 0px alpha(var(--color-white), 0.15), var(--shadow-xs); + + &:hover { + background: var(--color-primary-light); + box-shadow: inset 0px 1px 0px alpha(var(--color-white), 0.15), var(--shadow-sm); + } + + &:focus { + box-shadow: inset 0px 1px 0px alpha(var(--color-white), 0.15), 0px 0px 0px 2px alpha(var(--color-primary), 0.2); + } +} + +.btn--subtle { + background: var(--color-bg-light); + color: var(--color-contrast-higher); + box-shadow: inset 0px 0px 0px 1px var(--color-contrast-lower), var(--shadow-xs); + + &:hover { + background: var(--color-bg-lighter); + box-shadow: inset 0px 0px 0px 1px var(--color-contrast-lower), var(--shadow-sm); + } + + &:focus { + box-shadow: inset 0px 0px 0px 1px var(--color-contrast-lower), 0px 0px 0px 2px alpha(var(--color-contrast-higher), 0.05); + } +} + +.btn--accent { + background: var(--color-accent); + color: var(--color-white); + box-shadow: inset 0px 1px 0px alpha(var(--color-white), 0.15), var(--shadow-xs); + + &:hover { + background: var(--color-accent-light); + box-shadow: inset 0px 1px 0px alpha(var(--color-white), 0.15), var(--shadow-sm); + } + + &:focus { + box-shadow: inset 0px 1px 0px alpha(var(--color-white), 0.15), 0px 0px 0px 2px alpha(var(--color-accent), 0.2); + } +} + +// feedback +.btn--disabled, .btn[disabled], .btn[readonly] { + opacity: 0.6; + cursor: not-allowed; +} + +// size +.btn--sm { + font-size: 0.8em; +} + +.btn--md { + font-size: 1.2em; +} + +.btn--lg { + font-size: 1.4em; +} + +// -------------------------------- + +// (END) Global editor code + +// -------------------------------- diff --git a/apps/web-shared/src/styles/custom-style/_colors.scss b/apps/web-shared/src/styles/custom-style/_colors.scss new file mode 100644 index 0000000..76d3fa6 --- /dev/null +++ b/apps/web-shared/src/styles/custom-style/_colors.scss @@ -0,0 +1,119 @@ +@use '../base' as *; + +// -------------------------------- + +// (START) Global editor code https://codyhouse.co/ds/globals/colors + +// -------------------------------- + +:root, [data-theme="default"] { + // main + @include defineColorHSL(--color-primary-darker, 250, 84%, 38%); + @include defineColorHSL(--color-primary-dark, 250, 84%, 46%); + @include defineColorHSL(--color-primary, 250, 84%, 54%); + @include defineColorHSL(--color-primary-light, 250, 84%, 60%); + @include defineColorHSL(--color-primary-lighter, 250, 84%, 67%); + + @include defineColorHSL(--color-accent-darker, 342, 89%, 38%); + @include defineColorHSL(--color-accent-dark, 342, 89%, 43%); + @include defineColorHSL(--color-accent, 342, 89%, 48%); + @include defineColorHSL(--color-accent-light, 342, 89%, 56%); + @include defineColorHSL(--color-accent-lighter, 342, 89%, 62%); + + @include defineColorHSL(--color-black, 204, 28%, 7%); + @include defineColorHSL(--color-white, 0, 0%, 100%); + + // feedback + @include defineColorHSL(--color-warning-darker, 46, 100%, 47%); + @include defineColorHSL(--color-warning-dark, 46, 100%, 50%); + @include defineColorHSL(--color-warning, 46, 100%, 61%); + @include defineColorHSL(--color-warning-light, 46, 100%, 71%); + @include defineColorHSL(--color-warning-lighter, 46, 100%, 80%); + + @include defineColorHSL(--color-success-darker, 122, 50%, 47%); + @include defineColorHSL(--color-success-dark, 122, 50%, 52%); + @include defineColorHSL(--color-success, 122, 50%, 60%); + @include defineColorHSL(--color-success-light, 122, 50%, 69%); + @include defineColorHSL(--color-success-lighter, 122, 50%, 76%); + + @include defineColorHSL(--color-error-darker, 342, 89%, 38%); + @include defineColorHSL(--color-error-dark, 342, 89%, 43%); + @include defineColorHSL(--color-error, 342, 89%, 48%); + @include defineColorHSL(--color-error-light, 342, 89%, 56%); + @include defineColorHSL(--color-error-lighter, 342, 89%, 62%); + + // background + @include defineColorHSL(--color-bg-darker, 210, 4%, 89%); + @include defineColorHSL(--color-bg-dark, 180, 3%, 94%); + @include defineColorHSL(--color-bg, 210, 17%, 98%); + @include defineColorHSL(--color-bg-light, 180, 3%, 100%); + @include defineColorHSL(--color-bg-lighter, 210, 4%, 100%); + + // color contrasts + @include defineColorHSL(--color-contrast-lower, 180, 1%, 84%); + @include defineColorHSL(--color-contrast-low, 210, 2%, 64%); + @include defineColorHSL(--color-contrast-medium, 204, 2%, 46%); + @include defineColorHSL(--color-contrast-high, 210, 7%, 21%); + @include defineColorHSL(--color-contrast-higher, 204, 28%, 7%); +} + +[data-theme="dark"] { + // main + @include defineColorHSL(--color-primary-darker, 250, 93%, 57%); + @include defineColorHSL(--color-primary-dark, 250, 93%, 61%); + @include defineColorHSL(--color-primary, 250, 93%, 65%); + @include defineColorHSL(--color-primary-light, 250, 93%, 69%); + @include defineColorHSL(--color-primary-lighter, 250, 93%, 72%); + + @include defineColorHSL(--color-accent-darker, 342, 92%, 41%); + @include defineColorHSL(--color-accent-dark, 342, 92%, 47%); + @include defineColorHSL(--color-accent, 342, 92%, 54%); + @include defineColorHSL(--color-accent-light, 342, 92%, 60%); + @include defineColorHSL(--color-accent-lighter, 342, 92%, 65%); + + @include defineColorHSL(--color-black, 204, 28%, 7%); + @include defineColorHSL(--color-white, 0, 0%, 100%); + + // feedback + @include defineColorHSL(--color-warning-darker, 46, 100%, 47%); + @include defineColorHSL(--color-warning-dark, 46, 100%, 50%); + @include defineColorHSL(--color-warning, 46, 100%, 61%); + @include defineColorHSL(--color-warning-light, 46, 100%, 71%); + @include defineColorHSL(--color-warning-lighter, 46, 100%, 80%); + + @include defineColorHSL(--color-success-darker, 122, 50%, 47%); + @include defineColorHSL(--color-success-dark, 122, 50%, 52%); + @include defineColorHSL(--color-success, 122, 50%, 60%); + @include defineColorHSL(--color-success-light, 122, 50%, 69%); + @include defineColorHSL(--color-success-lighter, 122, 50%, 76%); + + @include defineColorHSL(--color-error-darker, 342, 92%, 41%); + @include defineColorHSL(--color-error-dark, 342, 92%, 47%); + @include defineColorHSL(--color-error, 342, 92%, 54%); + @include defineColorHSL(--color-error-light, 342, 92%, 60%); + @include defineColorHSL(--color-error-lighter, 342, 92%, 65%); + + // background + @include defineColorHSL(--color-bg-darker, 204, 15%, 6%); + @include defineColorHSL(--color-bg-dark, 203, 18%, 9%); + @include defineColorHSL(--color-bg, 203, 24%, 13%); + @include defineColorHSL(--color-bg-light, 203, 18%, 17%); + @include defineColorHSL(--color-bg-lighter, 204, 15%, 20%); + + // color contrasts + @include defineColorHSL(--color-contrast-lower, 208, 12%, 24%); + @include defineColorHSL(--color-contrast-low, 208, 6%, 40%); + @include defineColorHSL(--color-contrast-medium, 213, 5%, 56%); + @include defineColorHSL(--color-contrast-high, 223, 8%, 82%); + @include defineColorHSL(--color-contrast-higher, 240, 100%, 99%); + + // font rendering + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +// -------------------------------- + +// (END) Global editor code + +// -------------------------------- diff --git a/apps/web-shared/src/styles/custom-style/_forms.scss b/apps/web-shared/src/styles/custom-style/_forms.scss new file mode 100644 index 0000000..0048941 --- /dev/null +++ b/apps/web-shared/src/styles/custom-style/_forms.scss @@ -0,0 +1,56 @@ +@use '../base' as *; + +// -------------------------------- + +// (START) Global editor code https://codyhouse.co/ds/globals/forms + +// -------------------------------- + +:root { + --form-control-font-size: 1em; + --form-control-padding-x: var(--space-xs); + --form-control-padding-y: var(--space-xxs); + --form-control-radius: var(--radius-sm); +} + +.form-control { + background: var(--color-bg-dark); + line-height: 1.2; + box-shadow: inset 0 0 0 1px var(--color-contrast-lower); + + &::placeholder { + opacity: 1; + color: var(--color-contrast-low); + } + + &:focus { + background: var(--color-bg); + box-shadow: inset 0 0 0 1px alpha(var(--color-contrast-lower), 0), 0px 0px 0px 1px var(--color-primary); + outline: none; + } +} + +.form-control--disabled, .form-control[disabled], .form-control[readonly] { + opacity: 0.5; + cursor: not-allowed; +} + +.form-control[aria-invalid="true"], .form-control.form-control--error { + box-shadow: inset 0 0 0 1px alpha(var(--color-contrast-lower), 0), 0px 0px 0px 2px var(--color-error); + + &:focus { + box-shadow: inset 0 0 0 1px alpha(var(--color-contrast-lower), 0), 0px 0px 0px 2px var(--color-error), var(--shadow-sm); + } +} + +.form-legend { +} + +.form-label { +} + +// -------------------------------- + +// (END) Global editor code + +// -------------------------------- diff --git a/apps/web-shared/src/styles/custom-style/_icons.scss b/apps/web-shared/src/styles/custom-style/_icons.scss new file mode 100644 index 0000000..a9fcb46 --- /dev/null +++ b/apps/web-shared/src/styles/custom-style/_icons.scss @@ -0,0 +1,19 @@ +@use '../base' as *; + +:root { + // size - 👇 uncomment to modify default icon sizes + // --icon-xxxs: 8px; + // --icon-xxs: 12px; + // --icon-xs: 16px; + // --icon-sm: 24px; + // --icon-md: 32px; + // --icon-lg: 48px; + // --icon-xl: 64px; + // --icon-xxl: 96px; + // --icon-xxxl: 128px; +} + +.icon { + // 👇 include the font-family declaration here if you are using an icon font + // font-family: 'fontName'; +}
\ No newline at end of file diff --git a/apps/web-shared/src/styles/custom-style/_shared-styles.scss b/apps/web-shared/src/styles/custom-style/_shared-styles.scss new file mode 100644 index 0000000..e9a32b8 --- /dev/null +++ b/apps/web-shared/src/styles/custom-style/_shared-styles.scss @@ -0,0 +1,59 @@ +@use '../base' as *; + +:root { + --radius: 0.25em; // border radius base size + --radius-sm: calc(var(--radius) / 2); + --radius-md: var(--radius); + --radius-lg: calc(var(--radius) * 2); + --shadow-xs: 0; + --shadow-sm: 0; + --shadow-md: 0; + --shadow-lg: 0; + --shadow-xl: 0; +} + +// -------------------------------- + +// (START) Global editor code https://codyhouse.co/ds/globals/shared-styles + +// -------------------------------- + +.hover\:reduce-opacity { + opacity: 1; + + &:hover { + opacity: 0.8; + } +} + +.hover\:scale { + + &:hover { + transform: scale(1.1); + } +} + +.hover\:elevate { + box-shadow: var(--shadow-sm); + + &:hover { + box-shadow: var(--shadow-md); + } +} + +// text styles +.link-subtle { + color: inherit; + cursor: pointer; + text-decoration: none; + + &:hover { + color: var(--color-primary); + } +} + +// -------------------------------- + +// (END) Global editor code + +// -------------------------------- diff --git a/apps/web-shared/src/styles/custom-style/_spacing.scss b/apps/web-shared/src/styles/custom-style/_spacing.scss new file mode 100644 index 0000000..56cd451 --- /dev/null +++ b/apps/web-shared/src/styles/custom-style/_spacing.scss @@ -0,0 +1,49 @@ +@use '../base' as *; + +// -------------------------------- + +// (START) Global editor code https://codyhouse.co/ds/globals/spacing + +// -------------------------------- + +// 👇 uncomment to modify default spacing scale +// :root { +// --space-unit: 1rem; +// } + + :root, * { + --space-xxxxs: calc(0.125 * var(--space-unit)); + --space-xxxs: calc(0.25 * var(--space-unit)); + --space-xxs: calc(0.375 * var(--space-unit)); + --space-xs: calc(0.5 * var(--space-unit)); + --space-sm: calc(0.75 * var(--space-unit)); + --space-md: calc(1.25 * var(--space-unit)); + --space-lg: calc(2 * var(--space-unit)); + --space-xl: calc(3.25 * var(--space-unit)); + --space-xxl: calc(5.25 * var(--space-unit)); + --space-xxxl: calc(8.5 * var(--space-unit)); + --space-xxxxl: calc(13.75 * var(--space-unit)); + --component-padding: var(--space-sm); + } + +@include breakpoint(md) { + :root, * { + --space-xxxxs: calc(0.1875 * var(--space-unit)); + --space-xxxs: calc(0.375 * var(--space-unit)); + --space-xxs: calc(0.5625 * var(--space-unit)); + --space-xs: calc(0.75 * var(--space-unit)); + --space-sm: calc(1.125 * var(--space-unit)); + --space-md: calc(2 * var(--space-unit)); + --space-lg: calc(3.125 * var(--space-unit)); + --space-xl: calc(5.125 * var(--space-unit)); + --space-xxl: calc(8.25 * var(--space-unit)); + --space-xxxl: calc(13.25 * var(--space-unit)); + --space-xxxxl: calc(21.5 * var(--space-unit)); + } +} + +// -------------------------------- + +// (END) Global editor code + +// -------------------------------- diff --git a/apps/web-shared/src/styles/custom-style/_typography.scss b/apps/web-shared/src/styles/custom-style/_typography.scss new file mode 100644 index 0000000..d0bb431 --- /dev/null +++ b/apps/web-shared/src/styles/custom-style/_typography.scss @@ -0,0 +1,92 @@ +@use '../base' as *; + +// -------------------------------- + +// (START) Global editor code https://codyhouse.co/ds/globals/typography + +// -------------------------------- + +:root { + // font family + //--font-primary: Inter, system-ui, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, helvetica neue, helvetica, Cantarell, Ubuntu, roboto, noto, arial, sans-serif; + + // font size + --text-base-size: 1.25rem; // body font-size + --text-scale-ratio: 1.1; // multiplier used to generate the type scale values 👇 + + // line-height + --body-line-height: 1.2; + --heading-line-height: 1.34; + + // capital letters - used in combo with the lhCrop mixin + --font-primary-capital-letter: 1; + + // unit - don't modify unless you want to change the typography unit (e.g., from Rem to Em units) + --text-unit: var(--text-base-size); // if Em units → --text-unit: var(--text-base-size); +} + +:root, * { + --text-xs: calc((var(--text-unit) / var(--text-scale-ratio)) / var(--text-scale-ratio)); + --text-sm: calc(var(--text-xs) * var(--text-scale-ratio)); + --text-md: calc(var(--text-sm) * var(--text-scale-ratio) * var(--text-scale-ratio)); + --text-lg: calc(var(--text-md) * var(--text-scale-ratio)); + --text-xl: calc(var(--text-lg) * var(--text-scale-ratio)); + --text-xxl: calc(var(--text-xl) * var(--text-scale-ratio)); + --text-xxxl: calc(var(--text-xxl) * var(--text-scale-ratio)); + --text-xxxxl: calc(var(--text-xxxl) * var(--text-scale-ratio)); +} + +body { + font-family: var(--font-primary); +} + +h1, h2, h3, h4 { + font-family: var(--font-primary); + --heading-font-weight: 600; +} + +// font family +.font-primary { font-family: var(--font-primary); } + +// -------------------------------- + +// (END) Global editor code + +// -------------------------------- + +// link style +a, .link {} + +mark { + background-color: alpha(var(--color-accent), 0.2); + color: inherit; +} + +.text-component { + --text-unit: 1em; + --space-unit: 1em; + --line-height-multiplier: 1; + --text-space-y-multiplier: 1; + + blockquote { + padding-left: 1em; + border-left: 4px solid var(--color-contrast-lower); + font-style: italic; + } + + hr { + background: var(--color-contrast-lower); + height: 1px; + } + + figcaption { + font-size: var(--text-sm); + color: var(--color-contrast-low); + } +} + +.article { // e.g., blog posts + --body-line-height: 1.58; // set body line-height + --text-space-y-multiplier: 1.2; // control vertical spacing +} diff --git a/apps/web-shared/src/styles/custom-style/_util.scss b/apps/web-shared/src/styles/custom-style/_util.scss new file mode 100644 index 0000000..5677630 --- /dev/null +++ b/apps/web-shared/src/styles/custom-style/_util.scss @@ -0,0 +1,41 @@ +@use '../base' as *; + +// -------------------------------- + +// How to create custom utility classes 👇 + +// -------------------------------- + +.border-none { + border: none !important; +} + +@each $breakpoint, $value in $breakpoints { + @include breakpoint(#{$breakpoint}) { + .border-none\@#{$breakpoint} { + border: none !important; + } + } +} + +.left-unset { + left: unset !important; +} + +.cursor-wait { + cursor: wait !important; +} + +.bg-error-lighter\@hover { + &:hover, + &:active { + background-color: var(--color-error-lighter) !important; + } +} + +.color-white\@hover { + &:hover, + &:active { + color: var(--color-white) !important; + } +} diff --git a/apps/web-shared/tsconfig.json b/apps/web-shared/tsconfig.json new file mode 100644 index 0000000..7716e44 --- /dev/null +++ b/apps/web-shared/tsconfig.json @@ -0,0 +1,24 @@ +{ + "include": [ + "./src/**/*.d.ts", + "./src/**/*.ts", + "./src/**/*.js", + "./src/**/*.svelte" + ], + "exclude": [ + "./node_modules" + ], + "compilerOptions": { + "target": "esnext", + "useDefineForClassFields": true, + "module": "esnext", + "moduleResolution": "node", + "allowJs": true, + "checkJs": false, + "paths": { + "$shared/*": [ + "./src/*" + ] + } + } +} @@ -0,0 +1,2 @@ +#!/bin/bash +cloc --exclude-dir=bin,obj,build,node_modules,bootstrap,wwwroot --exclude-ext=json,xml . diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..2b24da3 --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,10 @@ +*/**/bin* +*/**/obj* +server-secrets.* +.git +*/**/node_modules/* +src/web-app +src/web-shared +src/tests +build_and_push.sh +cloc.sh diff --git a/server/.version b/server/.version new file mode 100644 index 0000000..3f430af --- /dev/null +++ b/server/.version @@ -0,0 +1 @@ +v18 diff --git a/server/.version-dev b/server/.version-dev new file mode 100644 index 0000000..863c2f5 --- /dev/null +++ b/server/.version-dev @@ -0,0 +1 @@ +v35-server-dev diff --git a/server/CHANGELOG.md b/server/CHANGELOG.md new file mode 100644 index 0000000..47e1f6c --- /dev/null +++ b/server/CHANGELOG.md @@ -0,0 +1,68 @@ +# Changelog + +## [unreleased] + +### Bug Fixes + +- BUildscript +- . +- . +- Incorrect paths +- Inncorrect paths + +### Features + +- Add more configuration for seperated apps and evironments +- Print env when starting server +- Seperate projects app and profile app + +### Miscellaneous Tasks + +- Bump version +- Update CHANGELOG.md for v8-frontpage +- Bump version +- Bump version +- Bump version +- Bump version +- Bump version +- Bump version +- Bump version +- Update CHANGELOG.md for v9-web-app-dev +- Bump version +- Update CHANGELOG.md for v14-web-app-dev +- Bump version +- Update CHANGELOG.md for v8-web-app-dev +- Bump version +- Update CHANGELOG.md for v7-web-app-dev +- Bump version +- Bump version +- Bump version +- Bump version +- Bump version +- Bump version +- Update CHANGELOG.md for v13-web-app-dev +- Bump version +- Bump version +- Update CHANGELOG.md for v20-web-app +- Bump version +- Update CHANGELOG.md for v19-web-app +- Bump version +- Bump version +- Bump version +- Bump version +- Bump version +- Bump version +- Bump version +- Update CHANGELOG.md for v35-server-dev + +### Refactor + +- Remove CANONICAL_NAME from app and expected environment variables + +## [unreleased] + +### Miscellaneous Tasks + +- Bump version +- Update CHANGELOG.md for v34-server-dev + diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..adc4be3 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,16 @@ +FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build-env +WORKDIR /source + +# Copy csproj and restore as distinct layers +COPY src/*.csproj ./ +RUN dotnet restore + +# Copy everything else and build +COPY src/ ./ +RUN dotnet publish -c Release -o out + +# Build runtime image +FROM mcr.microsoft.com/dotnet/aspnet:6.0 +WORKDIR /app +COPY --from=build-env /source/out . +ENTRYPOINT ["dotnet", "IOL.GreatOffice.Api.dll"] diff --git a/server/build_and_push.sh b/server/build_and_push.sh new file mode 100755 index 0000000..5bcc0d0 --- /dev/null +++ b/server/build_and_push.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash + +set -Eueo pipefail + +CURRENT_DEV_VERSION=$(cat .version-dev) +CURRENT_DEV_VERSION_INT=${CURRENT_DEV_VERSION//[!0-9]/} +CURRENT_VERSION=$(cat .version) +CURRENT_VERSION_INT=${CURRENT_VERSION//[!0-9]/} +if [ ${1-prod} == "dev" ]; then + NEW_VERSION="v$((CURRENT_DEV_VERSION_INT+1))-server-dev" + OLD_VERSION=$CURRENT_DEV_VERSION +else + NEW_VERSION="v$((CURRENT_VERSION_INT+1))-server" + OLD_VERSION=$CURRENT_VERSION +fi +IMAGE_NAME="greatoffice-api" +HUB_NAME="dr.ivar.systems/greatoffice-api" + +# Check for uncommited changes and optionally commit them +if [ "$(git status --untracked-files=no --porcelain)" ]; then + echo "Unclean git tree! press CTRL+C to exit or press ENTER to commit and push to the default branch" + read -n 1 + + read -p "Enter commit message: " COMMIT_MESSAGE + git add .. + git commit --quiet -m "$COMMIT_MESSAGE" +fi + +if [ ${1-prod} == "dev" ]; then + echo $NEW_VERSION >| .version-dev + git add .version-dev +else + echo $NEW_VERSION >| .version + git add .version +fi + +echo "Starting build of $HUB_NAME:$NEW_VERSION at $(date -u)..." +echo + +# Put version.txt inside of server +pushd src/wwwroot +echo "$NEW_VERSION" >version.txt +git add version.txt +popd + +git commit --quiet -m "chore(release): Bump version"; + + +read -p "Do you want to tag this build? (y/n) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]] +then + read -p "Enter tag message (can be empty): " TAG_MESSAGE + commit_msg="chore(release): Update CHANGELOG.md for $NEW_VERSION" + git cliff -r ../ $OLD_VERSION..HEAD --with-commit "$commit_msg" --prepend CHANGELOG.md + git add CHANGELOG.md + git commit --quiet -m "$commit_msg"; + git tag -am "$TAG_MESSAGE" $NEW_VERSION +fi + +read -p "Do you want to push the latest commits and tags to origin? (y/n) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]] +then + echo "Pushing latest changes to remotes..." + echo + git push --quiet --follow-tags +fi + + +# Build docker image +echo "Building docker image" +echo +docker build -t $IMAGE_NAME:$NEW_VERSION . + +docker tag $IMAGE_NAME:$NEW_VERSION $HUB_NAME:$NEW_VERSION + +if [ ${1-prod} == "dev" ]; then + docker tag $IMAGE_NAME:$NEW_VERSION $HUB_NAME:latest-dev +fi +if [ ${1-prod} == "prod" ]; then + docker tag $IMAGE_NAME:$NEW_VERSION $HUB_NAME:latest +fi + +# Optionally push images to docker registry +echo "Press CTRL+C to exit or press ENTER to push docker image to registry" +read -n 1 +docker push $HUB_NAME:$NEW_VERSION + +if [ ${1-prod} == "dev" ]; then + docker push $HUB_NAME:latest-dev +fi + +if [ ${1-prod} == "prod" ]; then + docker push $HUB_NAME:latest +fi diff --git a/server/cliff.toml b/server/cliff.toml new file mode 100644 index 0000000..955a72b --- /dev/null +++ b/server/cliff.toml @@ -0,0 +1,62 @@ +# configuration file for git-cliff (0.1.0) + +[changelog] +# changelog header +header = """ +# Changelog\n +""" +# template for the changelog body +# https://tera.netlify.app/docs/#introduction +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | upper_first }} + {% for commit in commits %} + - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\ + {% endfor %} +{% endfor %}\n +""" +# remove the leading and trailing whitespace from the template +trim = true +# changelog footer +footer = """ +<!-- generated by git-cliff --> +""" + +[git] +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# regex for preprocessing the commit messages +commit_preprocessors = [ + { pattern = "([ \\n])(([a-f0-9]{7})[a-f0-9]*)", replace = "${1}commit # [${3}](https://git.ivarlovlie.no/time-tracker/commit/${2})" }, + { pattern = "https://git.ivarlovlie.no/time-tracker/commit/([a-f0-9]{7})[a-f0-9]*", replace = "commit # [${1}](${0})" }, +] +# regex for parsing and grouping commits +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^doc", group = "Documentation" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactor" }, + { message = "^style", group = "Styling" }, + { message = "^test", group = "Testing" }, + { message = "^chore", group = "Miscellaneous Tasks" }, +] +# filter out the commits that are not matched by commit parsers +filter_commits = true +# glob pattern for matching git tags +tag_pattern = "v.*" +# regex for skipping tags +skip_tags = "v0.1.0-beta.1" +# regex for ignoring tags +ignore_tags = "" +# sort the tags chronologically +date_order = true +# sort the commits inside sections by oldest/newest order +sort_commits = "newest" diff --git a/server/src/Data/AppDbContext.cs b/server/src/Data/AppDbContext.cs new file mode 100644 index 0000000..3f949dd --- /dev/null +++ b/server/src/Data/AppDbContext.cs @@ -0,0 +1,58 @@ +namespace IOL.GreatOffice.Api.Data; + +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } + public DbSet<User> Users { get; set; } + public DbSet<ForgotPasswordRequest> ForgotPasswordRequests { get; set; } + public DbSet<TimeLabel> TimeLabels { get; set; } + public DbSet<TimeEntry> TimeEntries { get; set; } + public DbSet<TimeCategory> TimeCategories { get; set; } + public DbSet<GithubUserMapping> GithubUserMappings { get; set; } + public DbSet<ApiAccessToken> AccessTokens { get; set; } + public DbSet<Tenant> Tenants { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity<User>(e => { + e.ToTable("users"); + }); + + modelBuilder.Entity<ForgotPasswordRequest>(e => { + e.HasOne(c => c.User); + e.ToTable("forgot_password_requests"); + }); + + modelBuilder.Entity<TimeCategory>(e => { + e.HasOne(c => c.User); + e.ToTable("time_categories"); + }); + + modelBuilder.Entity<TimeLabel>(e => { + e.HasOne(c => c.User); + e.ToTable("time_labels"); + }); + + modelBuilder.Entity<TimeEntry>(e => { + e.HasOne(c => c.User); + e.HasOne(c => c.Category); + e.HasMany(c => c.Labels); + e.ToTable("time_entries"); + }); + + modelBuilder.Entity<GithubUserMapping>(e => { + e.HasOne(c => c.User); + e.HasKey(c => c.GithubId); + e.ToTable("github_user_mappings"); + }); + + modelBuilder.Entity<ApiAccessToken>(e => { + e.ToTable("api_access_tokens"); + }); + + modelBuilder.Entity<Tenant>(e => { + e.ToTable("tenants"); + }); + + base.OnModelCreating(modelBuilder); + } +} diff --git a/server/src/Data/Database/ApiAccessToken.cs b/server/src/Data/Database/ApiAccessToken.cs new file mode 100644 index 0000000..3eff5f3 --- /dev/null +++ b/server/src/Data/Database/ApiAccessToken.cs @@ -0,0 +1,31 @@ +namespace IOL.GreatOffice.Api.Data.Database; + +public class ApiAccessToken : Base +{ + public User User { get; set; } + public DateTime ExpiryDate { get; set; } + public bool AllowRead { get; set; } + public bool AllowCreate { get; set; } + public bool AllowUpdate { get; set; } + public bool AllowDelete { get; set; } + public bool HasExpired => ExpiryDate < DateTime.UtcNow; + public ApiAccessTokenDto AsDto => new(this); + + public class ApiAccessTokenDto + { + public ApiAccessTokenDto(ApiAccessToken source) { + ExpiryDate = source.ExpiryDate; + AllowRead = source.AllowRead; + AllowCreate = source.AllowCreate; + AllowUpdate = source.AllowUpdate; + AllowDelete = source.AllowDelete; + } + + public DateTime ExpiryDate { get; set; } + public bool AllowRead { get; set; } + public bool AllowCreate { get; set; } + public bool AllowUpdate { get; set; } + public bool AllowDelete { get; set; } + public bool HasExpired => ExpiryDate < DateTime.UtcNow; + } +} diff --git a/server/src/Data/Database/Base.cs b/server/src/Data/Database/Base.cs new file mode 100644 index 0000000..2439668 --- /dev/null +++ b/server/src/Data/Database/Base.cs @@ -0,0 +1,14 @@ +namespace IOL.GreatOffice.Api.Data.Database; + +public class Base +{ + protected Base() { + Id = Guid.NewGuid(); + CreatedAt = DateTime.UtcNow; + } + + public Guid Id { get; init; } + public DateTime CreatedAt { get; init; } + public DateTime? ModifiedAt { get; private set; } + public void Modified() => ModifiedAt = DateTime.UtcNow; +} diff --git a/server/src/Data/Database/BaseWithOwner.cs b/server/src/Data/Database/BaseWithOwner.cs new file mode 100644 index 0000000..eb4438d --- /dev/null +++ b/server/src/Data/Database/BaseWithOwner.cs @@ -0,0 +1,24 @@ +namespace IOL.GreatOffice.Api.Data.Database; + +/// <summary> +/// Base class for all entities. +/// </summary> +public class BaseWithOwner : Base +{ + protected BaseWithOwner() { } + + protected BaseWithOwner(Guid userId) { + UserId = userId; + } + + public Guid UserId { get; init; } + public User User { get; init; } + public Guid TenantId { get; set; } + public Tenant Tenant { get; init; } + public Guid ModifiedById { get; init; } + public User ModifiedBy { get; set; } + public Guid CreatedById { get; init; } + public User CreatedBy { get; set; } + public Guid DeletedById { get; init; } + public User DeletedBy { get; set; } +} diff --git a/server/src/Data/Database/ForgotPasswordRequest.cs b/server/src/Data/Database/ForgotPasswordRequest.cs new file mode 100644 index 0000000..164f09d --- /dev/null +++ b/server/src/Data/Database/ForgotPasswordRequest.cs @@ -0,0 +1,23 @@ +namespace IOL.GreatOffice.Api.Data.Database; + +public class ForgotPasswordRequest +{ + public ForgotPasswordRequest() { } + + public ForgotPasswordRequest(User user) { + CreatedAt = DateTime.UtcNow; + Id = Guid.NewGuid(); + User = user; + } + + public Guid Id { get; set; } + public Guid UserId { get; set; } + public User User { get; set; } + public DateTime CreatedAt { get; set; } + + [NotMapped] + public DateTime ExpirationDate => CreatedAt.AddMinutes(15); + + [NotMapped] + public bool IsExpired => DateTime.Compare(ExpirationDate, DateTime.UtcNow) < 0; +} diff --git a/server/src/Data/Database/GithubUserMapping.cs b/server/src/Data/Database/GithubUserMapping.cs new file mode 100644 index 0000000..dbdb2b7 --- /dev/null +++ b/server/src/Data/Database/GithubUserMapping.cs @@ -0,0 +1,9 @@ +namespace IOL.GreatOffice.Api.Data.Database; + +public class GithubUserMapping +{ + public User User { get; set; } + public string GithubId { get; set; } + public string Email { get; set; } + public string RefreshToken { get; set; } +} diff --git a/server/src/Data/Database/Tenant.cs b/server/src/Data/Database/Tenant.cs new file mode 100644 index 0000000..3028d13 --- /dev/null +++ b/server/src/Data/Database/Tenant.cs @@ -0,0 +1,10 @@ +namespace IOL.GreatOffice.Api.Data.Database; + +public class Tenant : BaseWithOwner +{ + public string Name { get; set; } + public string Description { get; set; } + public string ContactEmail { get; set; } + public Guid MasterUserId { get; set; } + public string MasterUserPassword { get; set; } +} diff --git a/server/src/Data/Database/TimeCategory.cs b/server/src/Data/Database/TimeCategory.cs new file mode 100644 index 0000000..69c6957 --- /dev/null +++ b/server/src/Data/Database/TimeCategory.cs @@ -0,0 +1,31 @@ +namespace IOL.GreatOffice.Api.Data.Database; + +public class TimeCategory : BaseWithOwner +{ + public TimeCategory() { } + public TimeCategory(Guid userId) : base(userId) { } + public string Name { get; set; } + public string Color { get; set; } + public TimeCategoryDto AsDto => new(this); + + public class TimeCategoryDto + { + public TimeCategoryDto() { } + + public TimeCategoryDto(TimeCategory sourceEntry = default) { + if (sourceEntry == default) { + return; + } + + Id = sourceEntry.Id; + ModifiedAt = sourceEntry.ModifiedAt; + Name = sourceEntry.Name; + Color = sourceEntry.Color; + } + + public Guid Id { get; set; } + public DateTime? ModifiedAt { get; set; } + public string Name { get; set; } + public string Color { get; set; } + } +} diff --git a/server/src/Data/Database/TimeEntry.cs b/server/src/Data/Database/TimeEntry.cs new file mode 100644 index 0000000..46c62e1 --- /dev/null +++ b/server/src/Data/Database/TimeEntry.cs @@ -0,0 +1,45 @@ +namespace IOL.GreatOffice.Api.Data.Database; + +public class TimeEntry : BaseWithOwner +{ + public TimeEntry() { } + public TimeEntry(Guid userId) : base(userId) { } + public DateTime Start { get; set; } + public DateTime Stop { get; set; } + public string Description { get; set; } + public ICollection<TimeLabel> Labels { get; set; } + public TimeCategory Category { get; set; } + public TimeEntryDto AsDto => new(this); + + public class TimeEntryDto + { + public TimeEntryDto() { } + + public TimeEntryDto(TimeEntry sourceEntry = default) { + if (sourceEntry == default) { + return; + } + + Id = sourceEntry.Id; + ModifiedAt = sourceEntry.ModifiedAt; + Stop = sourceEntry.Stop; + Start = sourceEntry.Start; + Description = sourceEntry.Description; + if (sourceEntry.Labels != default) { + Labels = sourceEntry.Labels + .Select(t => t.AsDto) + .ToList(); + } + + Category = sourceEntry.Category.AsDto; + } + + public Guid? Id { get; set; } + public DateTime? ModifiedAt { get; set; } + public DateTime Start { get; set; } + public DateTime Stop { get; set; } + public string Description { get; set; } + public List<TimeLabel.TimeLabelDto> Labels { get; set; } + public TimeCategory.TimeCategoryDto Category { get; set; } + } +} diff --git a/server/src/Data/Database/TimeLabel.cs b/server/src/Data/Database/TimeLabel.cs new file mode 100644 index 0000000..55e20b0 --- /dev/null +++ b/server/src/Data/Database/TimeLabel.cs @@ -0,0 +1,31 @@ +namespace IOL.GreatOffice.Api.Data.Database; + +public class TimeLabel : BaseWithOwner +{ + public TimeLabel() { } + public TimeLabel(Guid userId) : base(userId) { } + public string Name { get; set; } + public string Color { get; set; } + + [NotMapped] + public TimeLabelDto AsDto => new(this); + + public class TimeLabelDto + { + public TimeLabelDto() { } + + public TimeLabelDto(TimeLabel sourceEntry) { + Id = sourceEntry.Id; + CreatedAt = sourceEntry.CreatedAt; + ModifiedAt = sourceEntry.ModifiedAt; + Name = sourceEntry.Name; + Color = sourceEntry.Color; + } + + public Guid Id { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? ModifiedAt { get; set; } + public string Name { get; set; } + public string Color { get; set; } + } +} diff --git a/server/src/Data/Database/User.cs b/server/src/Data/Database/User.cs new file mode 100644 index 0000000..c5063f6 --- /dev/null +++ b/server/src/Data/Database/User.cs @@ -0,0 +1,29 @@ +namespace IOL.GreatOffice.Api.Data.Database; + +public class User : Base +{ + public User() { } + + public User(string username) { + Username = username; + } + + public string Username { get; set; } + public string Password { get; set; } + + + public void HashAndSetPassword(string password) { + Password = PasswordHelper.HashPassword(password); + } + + public bool VerifyPassword(string password) { + return PasswordHelper.Verify(password, Password); + } + + public IEnumerable<Claim> DefaultClaims() { + return new Claim[] { + new(AppClaims.USER_ID, Id.ToString()), + new(AppClaims.NAME, Username), + }; + } +} diff --git a/server/src/Data/Dtos/TimeQueryDto.cs b/server/src/Data/Dtos/TimeQueryDto.cs new file mode 100644 index 0000000..f734cb1 --- /dev/null +++ b/server/src/Data/Dtos/TimeQueryDto.cs @@ -0,0 +1,34 @@ + +namespace IOL.GreatOffice.Api.Data.Dtos; + +public class TimeQueryDto +{ + public TimeQueryDto() { + Results = new List<TimeEntry.TimeEntryDto>(); + } + + /// <summary> + /// List of entries. + /// </summary> + public List<TimeEntry.TimeEntryDto> Results { get; set; } + + /// <summary> + /// Curren page. + /// </summary> + public int Page { get; set; } + + /// <summary> + /// Maximum count of entries in a page. + /// </summary> + public int PageSize { get; set; } + + /// <summary> + /// Total count of entries. + /// </summary> + public int TotalSize { get; set; } + + /// <summary> + /// Total count of pages. + /// </summary> + public int TotalPageCount { get; set; } +} diff --git a/server/src/Data/Dtos/UserArchiveDto.cs b/server/src/Data/Dtos/UserArchiveDto.cs new file mode 100644 index 0000000..63b1470 --- /dev/null +++ b/server/src/Data/Dtos/UserArchiveDto.cs @@ -0,0 +1,131 @@ + +namespace IOL.GreatOffice.Api.Data.Dtos; + +/// <summary> +/// Represents a user archive as it is provided to users. +/// </summary> +public class UserArchiveDto +{ + /// <inheritdoc cref="UserArchiveDto"/> + public UserArchiveDto(User user) { + Meta = new MetaDto { + GeneratedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ") + }; + User = new UserDto(user); + Entries = new List<EntryDto>(); + } + + /// <summary> + /// Metadata for the user archive. + /// </summary> + public MetaDto Meta { get; } + + /// <summary> + /// Relevant user data for the archive. + /// </summary> + public UserDto User { get; } + + /// <summary> + /// List of entries that the user has created. + /// </summary> + public List<EntryDto> Entries { get; } + + public void CountEntries() { + Meta.EntryCount = Entries.Count; + } + + /// <summary> + /// Represents a time entry in the data archive. + /// </summary> + public class EntryDto + { + public string CreatedAt { get; init; } + + [JsonIgnore] + public DateTime StartDateTime { get; init; } + + /// <summary> + /// ISO 8601 string of the UTC date the time entry started. + /// </summary> + public string Start => StartDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ"); + + [JsonIgnore] + public DateTime StopDateTime { get; init; } + + /// <summary> + /// ISO 8601 string of the UTC date the time entry stopped. + /// </summary> + public string Stop => StopDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ"); + + /// <summary> + /// Total amount of minutes elapsed from start to stop on this time entry. + /// </summary> + public double Minutes => StopDateTime.Subtract(StartDateTime).TotalMinutes; + + public string Description { get; init; } + + /// <summary> + /// Archive spesific category for this time entry. + /// </summary> + public CategoryDto Category { get; init; } + + /// <summary> + /// Archive spesific list of labels for this time entry. + /// </summary> + public List<LabelDto> Labels { get; init; } + } + + /// <summary> + /// Time entry category as it is written to the user archive. + /// </summary> + public class CategoryDto + { + public string Name { get; init; } + public string Color { get; init; } + } + + /// <summary> + /// Time entry label as it is written to the user archive. + /// </summary> + public class LabelDto + { + public string Name { get; init; } + public string Color { get; init; } + } + + + /// <summary> + /// Represents the user who this archive's data is based on. + /// </summary> + public class UserDto + { + /// <inheritdoc cref="UserDto"/> + public UserDto(User user) { + Username = user.Username; + CreatedAt = user.CreatedAt; + } + + /// <summary> + /// UTC date this user was created. + /// </summary> + public DateTime CreatedAt { get; } + + public string Username { get; } + } + + /// <summary> + /// Represents the meta object which contains metdata for this archive. + /// </summary> + public class MetaDto + { + /// <summary> + /// ISO 8601 UTC date string for when this archive was created. + /// </summary> + public string GeneratedAt { get; init; } + + /// <summary> + /// Amount of entries in the archive. + /// </summary> + public int EntryCount { get; set; } + } +} diff --git a/server/src/Data/Enums/TimeEntryQueryDuration.cs b/server/src/Data/Enums/TimeEntryQueryDuration.cs new file mode 100644 index 0000000..af70ca6 --- /dev/null +++ b/server/src/Data/Enums/TimeEntryQueryDuration.cs @@ -0,0 +1,37 @@ +namespace IOL.GreatOffice.Api.Data.Enums; + +/// <summary> +/// Specify a duration filter for time entry queries. +/// </summary> +public enum TimeEntryQueryDuration +{ + /// <summary> + /// Only query entries created today. + /// </summary> + TODAY = 0, + + /// <summary> + /// Only query entries created this week. + /// </summary> + THIS_WEEK = 1, + + /// <summary> + /// Only query entries created this month. + /// </summary> + THIS_MONTH = 2, + + /// <summary> + /// Only query entries created this year. + /// </summary> + THIS_YEAR = 3, + + /// <summary> + /// Only query entries created at a spesific date. + /// </summary> + SPECIFIC_DATE = 4, + + /// <summary> + /// Only query entries created between two dates. + /// </summary> + DATE_RANGE = 5, +} diff --git a/server/src/Data/Exceptions/ForgotPasswordRequestNotFoundException.cs b/server/src/Data/Exceptions/ForgotPasswordRequestNotFoundException.cs new file mode 100644 index 0000000..02474b4 --- /dev/null +++ b/server/src/Data/Exceptions/ForgotPasswordRequestNotFoundException.cs @@ -0,0 +1,21 @@ +namespace IOL.GreatOffice.Api.Data.Exceptions; + +[Serializable] +public class ForgotPasswordRequestNotFoundException : Exception +{ + // + // For guidelines regarding the creation of new exception types, see + // http://msdn.microsoft.com/library/default.asp?url=/library/en-us/cpgenref/html/cpconerrorraisinghandlingguidelines.asp + // and + // http://msdn.microsoft.com/library/default.asp?url=/library/en-us/dncscol/html/csharp07192001.asp + // + + public ForgotPasswordRequestNotFoundException() { } + public ForgotPasswordRequestNotFoundException(string message) : base(message) { } + public ForgotPasswordRequestNotFoundException(string message, Exception inner) : base(message, inner) { } + + protected ForgotPasswordRequestNotFoundException( + SerializationInfo info, + StreamingContext context + ) : base(info, context) { } +} diff --git a/server/src/Data/Exceptions/UserNotFoundException.cs b/server/src/Data/Exceptions/UserNotFoundException.cs new file mode 100644 index 0000000..06b57a9 --- /dev/null +++ b/server/src/Data/Exceptions/UserNotFoundException.cs @@ -0,0 +1,19 @@ +namespace IOL.GreatOffice.Api.Data.Exceptions; + +[Serializable] +public class UserNotFoundException : Exception +{ + // For guidelines regarding the creation of new exception types, see + // http://msdn.microsoft.com/library/default.asp?url=/library/en-us/cpgenref/html/cpconerrorraisinghandlingguidelines.asp + // and + // http://msdn.microsoft.com/library/default.asp?url=/library/en-us/dncscol/html/csharp07192001.asp + + public UserNotFoundException() { } + public UserNotFoundException(string message) : base(message) { } + public UserNotFoundException(string message, Exception inner) : base(message, inner) { } + + protected UserNotFoundException( + SerializationInfo info, + StreamingContext context + ) : base(info, context) { } +} diff --git a/server/src/Data/Models/ApiSpecDocument.cs b/server/src/Data/Models/ApiSpecDocument.cs new file mode 100644 index 0000000..1c7d936 --- /dev/null +++ b/server/src/Data/Models/ApiSpecDocument.cs @@ -0,0 +1,9 @@ +namespace IOL.GreatOffice.Api.Data.Models; + +public class ApiSpecDocument +{ + public string VersionName { get; set; } + public string SwaggerPath { get; set; } + public ApiVersion Version { get; set; } + public OpenApiInfo OpenApiInfo { get; set; } +} diff --git a/server/src/Data/Models/AppPath.cs b/server/src/Data/Models/AppPath.cs new file mode 100644 index 0000000..e47e48c --- /dev/null +++ b/server/src/Data/Models/AppPath.cs @@ -0,0 +1,23 @@ +namespace IOL.GreatOffice.Api.Data.Models; + +public sealed record AppPath +{ + public string HostPath { get; init; } + public string WebPath { get; init; } + + public string GetHostPathForFilename(string filename, string fallback = "") { + if (filename.IsNullOrWhiteSpace()) { + return fallback; + } + + return Path.Combine(HostPath, filename); + } + + public string GetWebPathForFilename(string filename, string fallback = "") { + if (filename.IsNullOrWhiteSpace()) { + return fallback; + } + + return Path.Combine(WebPath, filename); + } +} diff --git a/server/src/Data/Models/LoggedInUserModel.cs b/server/src/Data/Models/LoggedInUserModel.cs new file mode 100644 index 0000000..4a5bef9 --- /dev/null +++ b/server/src/Data/Models/LoggedInUserModel.cs @@ -0,0 +1,8 @@ +namespace IOL.GreatOffice.Api.Data.Models; + +public class LoggedInUserModel +{ + public LoggedInUserModel() { } + public Guid Id { get; set; } + public string Username { get; set; } +} diff --git a/server/src/Data/Results/ErrorResult.cs b/server/src/Data/Results/ErrorResult.cs new file mode 100644 index 0000000..fd2fd6a --- /dev/null +++ b/server/src/Data/Results/ErrorResult.cs @@ -0,0 +1,12 @@ +namespace IOL.GreatOffice.Api.Data.Results; + +public class ErrorResult +{ + public ErrorResult(string title = default, string text = default) { + Title = title; + Text = text; + } + + public string Title { get; set; } + public string Text { get; set; } +} diff --git a/server/src/Data/Static/AppClaims.cs b/server/src/Data/Static/AppClaims.cs new file mode 100644 index 0000000..8b6d3a8 --- /dev/null +++ b/server/src/Data/Static/AppClaims.cs @@ -0,0 +1,8 @@ +namespace IOL.GreatOffice.Api.Data.Static; + +public static class AppClaims +{ + public const string USER_ID = "user_id"; + public const string NAME = "name"; + public const string GITHUB_ACCESS_TOKEN = ""; +} diff --git a/server/src/Data/Static/AppConstants.cs b/server/src/Data/Static/AppConstants.cs new file mode 100644 index 0000000..61e5cd5 --- /dev/null +++ b/server/src/Data/Static/AppConstants.cs @@ -0,0 +1,11 @@ +namespace IOL.GreatOffice.Api.Data.Static; + +public static class AppConstants +{ + public const string API_NAME = "Great Office API"; + public const string BASIC_AUTH_SCHEME = "BasicAuthenticationScheme"; + public const string TOKEN_ALLOW_READ = "TOKEN_ALLOW_READ"; + public const string TOKEN_ALLOW_CREATE = "TOKEN_ALLOW_CREATE"; + public const string TOKEN_ALLOW_UPDATE = "TOKEN_ALLOW_UPDATE"; + public const string TOKEN_ALLOW_DELETE = "TOKEN_ALLOW_DELETE"; +} diff --git a/server/src/Data/Static/AppEnvironmentVariables.cs b/server/src/Data/Static/AppEnvironmentVariables.cs new file mode 100644 index 0000000..a734146 --- /dev/null +++ b/server/src/Data/Static/AppEnvironmentVariables.cs @@ -0,0 +1,27 @@ +namespace IOL.GreatOffice.Api.Data.Static; + +public static class AppEnvironmentVariables +{ + public const string DB_HOST = "DB_HOST"; + public const string DB_PORT = "DB_PORT"; + public const string DB_USER = "DB_USER"; + public const string DB_PASSWORD = "DB_PASSWORD"; + public const string DB_NAME = "DB_NAME"; + public const string QUARTZ_DB_HOST = "QUARTZ_DB_HOST"; + public const string QUARTZ_DB_PORT = "QUARTZ_DB_PORT"; + public const string QUARTZ_DB_USER = "QUARTZ_DB_USER"; + public const string QUARTZ_DB_PASSWORD = "QUARTZ_DB_PASSWORD"; + public const string QUARTZ_DB_NAME = "QUARTZ_DB_NAME"; + public const string SEQ_API_KEY = "SEQ_API_KEY"; + public const string SEQ_API_URL = "SEQ_API_URL"; + public const string SMTP_HOST = "SMTP_HOST"; + public const string SMTP_PORT = "SMTP_PORT"; + public const string SMTP_USER = "SMTP_USER"; + public const string SMTP_PASSWORD = "SMTP_PASSWORD"; + public const string EMAIL_FROM_ADDRESS = "EMAIL_FROM_ADDRESS"; + public const string EMAIL_FROM_DISPLAY_NAME = "EMAIL_FROM_DISPLAY_NAME"; + public const string ACCOUNTS_URL = "ACCOUNTS_URL"; + public const string GITHUB_CLIENT_ID = "GH_CLIENT_ID"; + public const string GITHUB_CLIENT_SECRET = "GH_CLIENT_SECRET"; + public const string APP_AES_KEY = "APP_AES_KEY"; +} diff --git a/server/src/Data/Static/AppHeaders.cs b/server/src/Data/Static/AppHeaders.cs new file mode 100644 index 0000000..41a3085 --- /dev/null +++ b/server/src/Data/Static/AppHeaders.cs @@ -0,0 +1,6 @@ +namespace IOL.GreatOffice.Api.Data.Static; + +public static class AppHeaders +{ + public const string BROWSER_TIME_ZONE = "X-TimeZone"; +} diff --git a/server/src/Data/Static/AppPaths.cs b/server/src/Data/Static/AppPaths.cs new file mode 100644 index 0000000..a24f5af --- /dev/null +++ b/server/src/Data/Static/AppPaths.cs @@ -0,0 +1,17 @@ + +namespace IOL.GreatOffice.Api.Data.Static; + +public static class AppPaths +{ + public static AppPath AppData => new() { + HostPath = Path.Combine(Directory.GetCurrentDirectory(), "AppData") + }; + + public static AppPath DataProtectionKeys => new() { + HostPath = Path.Combine(Directory.GetCurrentDirectory(), "AppData", "dp-keys") + }; + + public static AppPath Frontend => new() { + HostPath = Path.Combine(Directory.GetCurrentDirectory(), "Frontend") + }; +} diff --git a/server/src/Data/Static/JsonSettings.cs b/server/src/Data/Static/JsonSettings.cs new file mode 100644 index 0000000..a163c11 --- /dev/null +++ b/server/src/Data/Static/JsonSettings.cs @@ -0,0 +1,11 @@ +namespace IOL.GreatOffice.Api.Data.Static; + +public static class JsonSettings +{ + public static Action<JsonOptions> Default { get; } = options => { + options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; + options.JsonSerializerOptions.PropertyNameCaseInsensitive = true; + options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowReadingFromString; + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + }; +} diff --git a/server/src/Endpoints/Internal/Account/CreateAccountPayload.cs b/server/src/Endpoints/Internal/Account/CreateAccountPayload.cs new file mode 100644 index 0000000..dc73e68 --- /dev/null +++ b/server/src/Endpoints/Internal/Account/CreateAccountPayload.cs @@ -0,0 +1,17 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +/// <summary> +/// Payload for creating new user accounts. +/// </summary> +public class CreateAccountPayload +{ + /// <summary> + /// Username for the new account. + /// </summary> + public string Username { get; set; } + + /// <summary> + /// Password for the new account. + /// </summary> + public string Password { get; set; } +} diff --git a/server/src/Endpoints/Internal/Account/CreateAccountRoute.cs b/server/src/Endpoints/Internal/Account/CreateAccountRoute.cs new file mode 100644 index 0000000..954fbf5 --- /dev/null +++ b/server/src/Endpoints/Internal/Account/CreateAccountRoute.cs @@ -0,0 +1,44 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +/// <inheritdoc /> +public class CreateAccountRoute : RouteBaseAsync.WithRequest<CreateAccountPayload>.WithActionResult +{ + private readonly AppDbContext _context; + private readonly UserService _userService; + + /// <inheritdoc /> + public CreateAccountRoute(UserService userService, AppDbContext context) { + _userService = userService; + _context = context; + } + + /// <summary> + /// Create a new user account. + /// </summary> + /// <param name="request"></param> + /// <param name="cancellationToken"></param> + /// <returns></returns> + [AllowAnonymous] + [HttpPost("~/_/account/create")] + public override async Task<ActionResult> HandleAsync(CreateAccountPayload request, CancellationToken cancellationToken = default) { + if (request.Username.IsValidEmailAddress() == false) { + return BadRequest(new ErrorResult("Invalid form", request.Username + " does not look like a valid email")); + } + + if (request.Password.Length < 6) { + return BadRequest(new ErrorResult("Invalid form", "The password requires 6 or more characters.")); + } + + var username = request.Username.Trim(); + if (_context.Users.Any(c => c.Username == username)) { + return BadRequest(new ErrorResult("Username is not available", "There is already a user registered with email: " + username)); + } + + var user = new User(username); + user.HashAndSetPassword(request.Password); + _context.Users.Add(user); + await _context.SaveChangesAsync(cancellationToken); + await _userService.LogInUser(HttpContext, user); + return Ok(); + } +} diff --git a/server/src/Endpoints/Internal/Account/CreateGithubSessionRoute.cs b/server/src/Endpoints/Internal/Account/CreateGithubSessionRoute.cs new file mode 100644 index 0000000..0cd1aa5 --- /dev/null +++ b/server/src/Endpoints/Internal/Account/CreateGithubSessionRoute.cs @@ -0,0 +1,17 @@ +using AspNet.Security.OAuth.GitHub; + +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +public class CreateGithubSessionRoute : RouteBaseSync.WithRequest<string>.WithActionResult +{ + public CreateGithubSessionRoute(IConfiguration configuration) { } + + [AllowAnonymous] + [HttpGet("~/_/account/create-github-session")] + public override ActionResult Handle(string next) { + return Challenge(new AuthenticationProperties { + RedirectUri = next + }, + GitHubAuthenticationDefaults.AuthenticationScheme); + } +} diff --git a/server/src/Endpoints/Internal/Account/CreateInitialAccountRoute.cs b/server/src/Endpoints/Internal/Account/CreateInitialAccountRoute.cs new file mode 100644 index 0000000..13fbdf4 --- /dev/null +++ b/server/src/Endpoints/Internal/Account/CreateInitialAccountRoute.cs @@ -0,0 +1,34 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +/// <inheritdoc /> +public class CreateInitialAccountRoute : RouteBaseAsync.WithoutRequest.WithActionResult +{ + private readonly AppDbContext _context; + private readonly UserService _userService; + + /// <inheritdoc /> + public CreateInitialAccountRoute(AppDbContext context, UserService userService) { + _context = context; + _userService = userService; + } + + /// <summary> + /// Create an initial user account. + /// </summary> + /// <param name="cancellationToken"></param> + /// <returns></returns> + [AllowAnonymous] + [HttpGet("~/_/account/create-initial")] + public override async Task<ActionResult> HandleAsync(CancellationToken cancellationToken = default) { + if (_context.Users.Any()) { + return NotFound(); + } + + var user = new User("admin@ivarlovlie.no"); + user.HashAndSetPassword("ivar123"); + _context.Users.Add(user); + await _context.SaveChangesAsync(cancellationToken); + await _userService.LogInUser(HttpContext, user); + return Redirect("/"); + } +} diff --git a/server/src/Endpoints/Internal/Account/DeleteAccountRoute.cs b/server/src/Endpoints/Internal/Account/DeleteAccountRoute.cs new file mode 100644 index 0000000..2149e15 --- /dev/null +++ b/server/src/Endpoints/Internal/Account/DeleteAccountRoute.cs @@ -0,0 +1,49 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +public class DeleteAccountRoute : RouteBaseAsync.WithoutRequest.WithActionResult +{ + private readonly AppDbContext _context; + private readonly UserService _userService; + + /// <inheritdoc /> + public DeleteAccountRoute(AppDbContext context, UserService userService) { + _context = context; + _userService = userService; + } + + /// <summary> + /// Delete the logged on user's account. + /// </summary> + /// <param name="cancellationToken"></param> + /// <returns></returns> + [HttpDelete("~/_/account/delete")] + public override async Task<ActionResult> HandleAsync(CancellationToken cancellationToken = default) { + var user = _context.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id); + if (user == default) { + await _userService.LogOutUser(HttpContext); + return Unauthorized(); + } + + if (user.Username == "demo@demo.demo") { + await _userService.LogOutUser(HttpContext); + return Ok(); + } + + var githubMappings = _context.TimeCategories.Where(c => c.UserId == user.Id); + var passwordResets = _context.ForgotPasswordRequests.Where(c => c.UserId == user.Id); + var entries = _context.TimeEntries.Where(c => c.UserId == user.Id); + var labels = _context.TimeLabels.Where(c => c.UserId == user.Id); + var categories = _context.TimeCategories.Where(c => c.UserId == user.Id); + + _context.TimeCategories.RemoveRange(githubMappings); + _context.ForgotPasswordRequests.RemoveRange(passwordResets); + _context.TimeEntries.RemoveRange(entries); + _context.TimeLabels.RemoveRange(labels); + _context.TimeCategories.RemoveRange(categories); + _context.Users.Remove(user); + + await _context.SaveChangesAsync(cancellationToken); + await _userService.LogOutUser(HttpContext); + return Ok(); + } +} diff --git a/server/src/Endpoints/Internal/Account/GetArchiveRoute.cs b/server/src/Endpoints/Internal/Account/GetArchiveRoute.cs new file mode 100644 index 0000000..44f5249 --- /dev/null +++ b/server/src/Endpoints/Internal/Account/GetArchiveRoute.cs @@ -0,0 +1,62 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +public class GetAccountArchiveRoute : RouteBaseAsync.WithoutRequest.WithActionResult<UserArchiveDto> +{ + private readonly AppDbContext _context; + + /// <inheritdoc /> + public GetAccountArchiveRoute(AppDbContext context) { + _context = context; + } + + /// <summary> + /// Get a data archive with the currently logged on user's data. + /// </summary> + /// <param name="cancellationToken"></param> + /// <returns></returns> + [HttpGet("~/_/account/archive")] + public override async Task<ActionResult<UserArchiveDto>> HandleAsync(CancellationToken cancellationToken = default) { + var user = _context.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id); + if (user == default) { + await HttpContext.SignOutAsync(); + return Unauthorized(); + } + + var entries = _context.TimeEntries + .AsNoTracking() + .Include(c => c.Labels) + .Include(c => c.Category) + .Where(c => c.User.Id == user.Id) + .ToList(); + + var jsonOptions = new JsonSerializerOptions { + WriteIndented = true + }; + + var dto = new UserArchiveDto(user); + dto.Entries.AddRange(entries.Select(entry => new UserArchiveDto.EntryDto { + CreatedAt = entry.CreatedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"), + StartDateTime = entry.Start, + StopDateTime = entry.Stop, + Description = entry.Description, + Labels = entry.Labels + .Select(c => new UserArchiveDto.LabelDto { + Name = c.Name, + Color = c.Color + }) + .ToList(), + Category = new UserArchiveDto.CategoryDto { + Name = entry.Category.Name, + Color = entry.Category.Color + }, + })); + + dto.CountEntries(); + + var entriesSerialized = JsonSerializer.SerializeToUtf8Bytes(dto, jsonOptions); + + return File(entriesSerialized, + "application/json", + user.Username + "-time-tracker-archive-" + DateTime.UtcNow.ToString("yyyyMMddTHHmmss") + ".json"); + } +} diff --git a/server/src/Endpoints/Internal/Account/GetRoute.cs b/server/src/Endpoints/Internal/Account/GetRoute.cs new file mode 100644 index 0000000..34a3c97 --- /dev/null +++ b/server/src/Endpoints/Internal/Account/GetRoute.cs @@ -0,0 +1,30 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +public class GetAccountRoute : RouteBaseAsync.WithoutRequest.WithActionResult<LoggedInUserModel> +{ + private readonly AppDbContext _context; + + /// <inheritdoc /> + public GetAccountRoute(AppDbContext context) { + _context = context; + } + + /// <summary> + /// Get the logged on user's session data. + /// </summary> + /// <param name="cancellationToken"></param> + /// <returns></returns> + [HttpGet("~/_/account")] + public override async Task<ActionResult<LoggedInUserModel>> HandleAsync(CancellationToken cancellationToken = default) { + var user = _context.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id); + if (user != default) { + return Ok(new LoggedInUserModel { + Id = LoggedInUser.Id, + Username = LoggedInUser.Username + }); + } + + await HttpContext.SignOutAsync(); + return Unauthorized(); + } +} diff --git a/server/src/Endpoints/Internal/Account/LoginPayload.cs b/server/src/Endpoints/Internal/Account/LoginPayload.cs new file mode 100644 index 0000000..807662c --- /dev/null +++ b/server/src/Endpoints/Internal/Account/LoginPayload.cs @@ -0,0 +1,22 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +/// <summary> +/// Payload for logging in a user. +/// </summary> +public class LoginPayload +{ + /// <summary> + /// Username of the user's account. + /// </summary> + public string Username { get; set; } + + /// <summary> + /// Password of the user's account. + /// </summary> + public string Password { get; set; } + + /// <summary> + /// Specify that the created session should be long lived and continually refreshed. + /// </summary> + public bool Persist { get; set; } +} diff --git a/server/src/Endpoints/Internal/Account/LoginRoute.cs b/server/src/Endpoints/Internal/Account/LoginRoute.cs new file mode 100644 index 0000000..5b41c61 --- /dev/null +++ b/server/src/Endpoints/Internal/Account/LoginRoute.cs @@ -0,0 +1,37 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +public class LoginRoute : RouteBaseAsync + .WithRequest<LoginPayload> + .WithActionResult +{ + private readonly AppDbContext _context; + private readonly UserService _userService; + + /// <inheritdoc /> + public LoginRoute(AppDbContext context, UserService userService) { + _context = context; + _userService = userService; + } + + /// <summary> + /// Login a user. + /// </summary> + /// <param name="request"></param> + /// <param name="cancellationToken"></param> + /// <returns></returns> + [AllowAnonymous] + [HttpPost("~/_/account/login")] + public override async Task<ActionResult> HandleAsync(LoginPayload request, CancellationToken cancellationToken = default) { + if (!ModelState.IsValid) { + return BadRequest(ModelState); + } + + var user = _context.Users.SingleOrDefault(u => u.Username == request.Username); + if (user == default || !user.VerifyPassword(request.Password)) { + return BadRequest(new ErrorResult("Invalid username or password")); + } + + await _userService.LogInUser(HttpContext, user, request.Persist); + return Ok(); + } +} diff --git a/server/src/Endpoints/Internal/Account/LogoutRoute.cs b/server/src/Endpoints/Internal/Account/LogoutRoute.cs new file mode 100644 index 0000000..4a06f4a --- /dev/null +++ b/server/src/Endpoints/Internal/Account/LogoutRoute.cs @@ -0,0 +1,22 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +public class LogoutRoute : RouteBaseAsync.WithoutRequest.WithActionResult +{ + private readonly UserService _userService; + + public LogoutRoute(UserService userService) { + _userService = userService; + } + + /// <summary> + /// Logout a user. + /// </summary> + /// <param name="cancellationToken"></param> + /// <returns></returns> + [AllowAnonymous] + [HttpGet("~/_/account/logout")] + public override async Task<ActionResult> HandleAsync(CancellationToken cancellationToken = default) { + await _userService.LogOutUser(HttpContext); + return Ok(); + } +} diff --git a/server/src/Endpoints/Internal/Account/UpdateAccountPayload.cs b/server/src/Endpoints/Internal/Account/UpdateAccountPayload.cs new file mode 100644 index 0000000..88a3237 --- /dev/null +++ b/server/src/Endpoints/Internal/Account/UpdateAccountPayload.cs @@ -0,0 +1,17 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +/// <summary> +/// Payload for updating an account. +/// </summary> +public class UpdatePayload +{ + /// <summary> + /// Username to set on the logged on user's account. + /// </summary> + public string Username { get; set; } + + /// <summary> + /// Password to set on the logged on user's account. + /// </summary> + public string Password { get; set; } +} diff --git a/server/src/Endpoints/Internal/Account/UpdateAccountRoute.cs b/server/src/Endpoints/Internal/Account/UpdateAccountRoute.cs new file mode 100644 index 0000000..a997dcb --- /dev/null +++ b/server/src/Endpoints/Internal/Account/UpdateAccountRoute.cs @@ -0,0 +1,51 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +public class UpdateAccountRoute : RouteBaseAsync.WithRequest<UpdatePayload>.WithActionResult +{ + private readonly AppDbContext _context; + + /// <inheritdoc /> + public UpdateAccountRoute(AppDbContext context) { + _context = context; + } + + /// <summary> + /// Update the logged on user's data. + /// </summary> + /// <param name="request"></param> + /// <param name="cancellationToken"></param> + /// <returns></returns> + [HttpPost("~/_/account/update")] + public override async Task<ActionResult> HandleAsync(UpdatePayload request, CancellationToken cancellationToken = default) { + var user = _context.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id); + if (user == default) { + await HttpContext.SignOutAsync(); + return Unauthorized(); + } + + if (request.Password.IsNullOrWhiteSpace() && request.Username.IsNullOrWhiteSpace()) { + return BadRequest(new ErrorResult("Invalid request", "No data was submitted")); + } + + if (request.Password.HasValue() && request.Password.Length < 6) { + return BadRequest(new ErrorResult("Invalid request", + "The new password must contain at least 6 characters")); + } + + if (request.Password.HasValue()) { + user.HashAndSetPassword(request.Password); + } + + if (request.Username.HasValue() && !request.Username.IsValidEmailAddress()) { + return BadRequest(new ErrorResult("Invalid request", + "The new username does not look like a valid email address")); + } + + if (request.Username.HasValue()) { + user.Username = request.Username.Trim(); + } + + await _context.SaveChangesAsync(cancellationToken); + return Ok(); + } +} diff --git a/server/src/Endpoints/Internal/BaseRoute.cs b/server/src/Endpoints/Internal/BaseRoute.cs new file mode 100644 index 0000000..3e2c6af --- /dev/null +++ b/server/src/Endpoints/Internal/BaseRoute.cs @@ -0,0 +1,16 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal; + +[Authorize] +[ApiController] +[ApiExplorerSettings(IgnoreApi = true)] +[ApiVersionNeutral] +public class BaseRoute : ControllerBase +{ + /// <summary> + /// User data for the currently logged on user. + /// </summary> + protected LoggedInUserModel LoggedInUser => new() { + Username = User.FindFirstValue(AppClaims.NAME), + Id = User.FindFirstValue(AppClaims.USER_ID).AsGuid(), + }; +} diff --git a/server/src/Endpoints/Internal/PasswordResetRequests/CreateResetRequestRoute.cs b/server/src/Endpoints/Internal/PasswordResetRequests/CreateResetRequestRoute.cs new file mode 100644 index 0000000..3e086f6 --- /dev/null +++ b/server/src/Endpoints/Internal/PasswordResetRequests/CreateResetRequestRoute.cs @@ -0,0 +1,59 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.PasswordResetRequests; + +/// <inheritdoc /> +public class CreateResetRequestRoute : RouteBaseAsync.WithRequest<string>.WithActionResult +{ + private readonly ILogger<CreateResetRequestRoute> _logger; + private readonly ForgotPasswordService _forgotPasswordService; + private readonly AppDbContext _context; + + /// <inheritdoc /> + public CreateResetRequestRoute(ILogger<CreateResetRequestRoute> logger, ForgotPasswordService forgotPasswordService, AppDbContext context) { + _logger = logger; + _forgotPasswordService = forgotPasswordService; + _context = context; + } + + /// <summary> + /// Create a new password reset request. + /// </summary> + /// <param name="username"></param> + /// <param name="cancellationToken"></param> + /// <returns></returns> + [AllowAnonymous] + [HttpGet("~/_/forgot-password-requests/create")] + public override async Task<ActionResult> HandleAsync(string username, CancellationToken cancellationToken = default) { + if (!username.IsValidEmailAddress()) { + _logger.LogInformation("Username is invalid, not doing request for password change"); + return BadRequest(new ErrorResult("Invalid email address", username + " looks like an invalid email address")); + } + + Request.Headers.TryGetValue(AppHeaders.BROWSER_TIME_ZONE, out var timeZoneHeader); + var tz = TimeZoneInfo.FindSystemTimeZoneById(timeZoneHeader.ToString().HasValue() ? timeZoneHeader.ToString() : "UTC"); + var offset = tz.BaseUtcOffset.Hours; + + // this is fine as long as the client is not connecting from Australia: Lord Howe Island + // according to https://en.wikipedia.org/wiki/Daylight_saving_time_by_country + if (tz.IsDaylightSavingTime(DateTime.UtcNow)) { + offset++; + } + + _logger.LogInformation("Request time zone (" + tz.Id + ") offset is: " + offset + " hours"); + var requestDateTime = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz); + _logger.LogInformation("Creating forgot password request with date time: " + requestDateTime.ToString("u")); + + try { + var user = _context.Users.SingleOrDefault(c => c.Username.Equals(username)); + if (user != default) { + await _forgotPasswordService.AddRequestAsync(user, tz, cancellationToken); + return Ok(); + } + + _logger.LogInformation("User was not found, not doing request for password change"); + return Ok(); + } catch (Exception e) { + _logger.LogError(e, "ForgotAction failed badly"); + return Ok(); + } + } +} diff --git a/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestPayload.cs b/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestPayload.cs new file mode 100644 index 0000000..f0fb59f --- /dev/null +++ b/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestPayload.cs @@ -0,0 +1,14 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.PasswordResetRequests; + +public class FulfillResetRequestPayload +{ + /// <summary> + /// Id of the password reset request to fulfill + /// </summary> + public Guid Id { get; set; } + + /// <summary> + /// New password to set on the relevant account + /// </summary> + public string NewPassword { get; set; } +} diff --git a/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestRoute.cs b/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestRoute.cs new file mode 100644 index 0000000..e33a4fb --- /dev/null +++ b/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestRoute.cs @@ -0,0 +1,34 @@ + +namespace IOL.GreatOffice.Api.Endpoints.Internal.PasswordResetRequests; + +/// <inheritdoc /> +public class FulfillResetRequestRoute : RouteBaseAsync.WithRequest<FulfillResetRequestPayload>.WithActionResult +{ + private readonly ForgotPasswordService _forgotPasswordService; + + /// <inheritdoc /> + public FulfillResetRequestRoute(ForgotPasswordService forgotPasswordService) { + _forgotPasswordService = forgotPasswordService; + } + + /// <summary> + /// Fulfill a password reset request. + /// </summary> + /// <param name="request"></param> + /// <param name="cancellationToken"></param> + /// <returns></returns> + [AllowAnonymous] + [HttpPost("~/_/forgot-password-requests/fulfill")] + public override async Task<ActionResult> HandleAsync(FulfillResetRequestPayload request, CancellationToken cancellationToken = default) { + try { + var fulfilled = await _forgotPasswordService.FullFillRequestAsync(request.Id, request.NewPassword, cancellationToken); + return Ok(fulfilled); + } catch (Exception e) { + if (e is ForgotPasswordRequestNotFoundException or UserNotFoundException) { + return NotFound(); + } + + throw; + } + } +} diff --git a/server/src/Endpoints/Internal/PasswordResetRequests/IsResetRequestValidRoute.cs b/server/src/Endpoints/Internal/PasswordResetRequests/IsResetRequestValidRoute.cs new file mode 100644 index 0000000..9984094 --- /dev/null +++ b/server/src/Endpoints/Internal/PasswordResetRequests/IsResetRequestValidRoute.cs @@ -0,0 +1,29 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.PasswordResetRequests; + +/// <inheritdoc /> +public class IsResetRequestValidRoute : RouteBaseAsync.WithRequest<Guid>.WithActionResult +{ + private readonly ForgotPasswordService _forgotPasswordService; + + /// <inheritdoc /> + public IsResetRequestValidRoute(ForgotPasswordService forgotPasswordService) { + _forgotPasswordService = forgotPasswordService; + } + + /// <summary> + /// Check if a given password reset request is still valid. + /// </summary> + /// <param name="id"></param> + /// <param name="cancellationToken"></param> + /// <returns></returns> + [AllowAnonymous] + [HttpGet("~/_/forgot-password-requests/is-valid")] + public override async Task<ActionResult> HandleAsync(Guid id, CancellationToken cancellationToken = default) { + var request = await _forgotPasswordService.GetRequestAsync(id, cancellationToken); + if (request == default) { + return NotFound(); + } + + return Ok(request.IsExpired == false); + } +} diff --git a/server/src/Endpoints/Internal/Root/GetApplicationVersionRoute.cs b/server/src/Endpoints/Internal/Root/GetApplicationVersionRoute.cs new file mode 100644 index 0000000..5fb8213 --- /dev/null +++ b/server/src/Endpoints/Internal/Root/GetApplicationVersionRoute.cs @@ -0,0 +1,21 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Root; + +public class GetApplicationVersionRoute : RouteBaseSync.WithoutRequest.WithActionResult<string> +{ + private readonly IWebHostEnvironment _environment; + + /// <inheritdoc /> + public GetApplicationVersionRoute(IWebHostEnvironment environment) { + _environment = environment; + } + + /// <summary> + /// Get the running api version number. + /// </summary> + /// <returns></returns> + [HttpGet("~/_/version")] + public override ActionResult<string> Handle() { + var versionFilePath = Path.Combine(_environment.WebRootPath, "version.txt"); + return Ok(System.IO.File.ReadAllText(versionFilePath)); + } +} diff --git a/server/src/Endpoints/Internal/Root/LogRoute.cs b/server/src/Endpoints/Internal/Root/LogRoute.cs new file mode 100644 index 0000000..48b497a --- /dev/null +++ b/server/src/Endpoints/Internal/Root/LogRoute.cs @@ -0,0 +1,16 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Root; + +public class LogRoute : RouteBaseSync.WithRequest<string>.WithoutResult +{ + private readonly ILogger<LogRoute> _logger; + + public LogRoute(ILogger<LogRoute> logger) { + _logger = logger; + } + + [AllowAnonymous] + [HttpPost("~/_/log")] + public override void Handle([FromBody] string request) { + _logger.LogInformation(request); + } +} diff --git a/server/src/Endpoints/Internal/RouteBaseAsync.cs b/server/src/Endpoints/Internal/RouteBaseAsync.cs new file mode 100644 index 0000000..1bb0af0 --- /dev/null +++ b/server/src/Endpoints/Internal/RouteBaseAsync.cs @@ -0,0 +1,73 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal; + +/// <summary> +/// A base class for an endpoint that accepts parameters. +/// </summary> +public static class RouteBaseAsync +{ + public static class WithRequest<TRequest> + { + public abstract class WithResult<TResponse> : BaseRoute + { + public abstract Task<TResponse> HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + + public abstract class WithoutResult : BaseRoute + { + public abstract Task HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult<TResponse> : BaseRoute + { + public abstract Task<ActionResult<TResponse>> HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract Task<ActionResult> HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + } + + public static class WithoutRequest + { + public abstract class WithResult<TResponse> : BaseRoute + { + public abstract Task<TResponse> HandleAsync( + CancellationToken cancellationToken = default + ); + } + + public abstract class WithoutResult : BaseRoute + { + public abstract Task HandleAsync( + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult<TResponse> : BaseRoute + { + public abstract Task<ActionResult<TResponse>> HandleAsync( + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract Task<ActionResult> HandleAsync( + CancellationToken cancellationToken = default + ); + } + } +} diff --git a/server/src/Endpoints/Internal/RouteBaseSync.cs b/server/src/Endpoints/Internal/RouteBaseSync.cs new file mode 100644 index 0000000..173999d --- /dev/null +++ b/server/src/Endpoints/Internal/RouteBaseSync.cs @@ -0,0 +1,53 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal; + +/// <summary> +/// A base class for an endpoint that accepts parameters. +/// </summary> +public static class RouteBaseSync +{ + public static class WithRequest<TRequest> + { + public abstract class WithResult<TResponse> : BaseRoute + { + public abstract TResponse Handle(TRequest request); + } + + public abstract class WithoutResult : BaseRoute + { + public abstract void Handle(TRequest request); + } + + public abstract class WithActionResult<TResponse> : BaseRoute + { + public abstract ActionResult<TResponse> Handle(TRequest request); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract ActionResult Handle(TRequest request); + } + } + + public static class WithoutRequest + { + public abstract class WithResult<TResponse> : BaseRoute + { + public abstract TResponse Handle(); + } + + public abstract class WithoutResult : BaseRoute + { + public abstract void Handle(); + } + + public abstract class WithActionResult<TResponse> : BaseRoute + { + public abstract ActionResult<TResponse> Handle(); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract ActionResult Handle(); + } + } +} diff --git a/server/src/Endpoints/V1/ApiSpecV1.cs b/server/src/Endpoints/V1/ApiSpecV1.cs new file mode 100644 index 0000000..e4f9cc9 --- /dev/null +++ b/server/src/Endpoints/V1/ApiSpecV1.cs @@ -0,0 +1,18 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1; + +public static class ApiSpecV1 +{ + private const int MAJOR = 1; + private const int MINOR = 0; + public const string VERSION_STRING = "1.0"; + + public static ApiSpecDocument Document => new() { + Version = new ApiVersion(MAJOR, MINOR), + VersionName = VERSION_STRING, + SwaggerPath = $"/swagger/{VERSION_STRING}/swagger.json", + OpenApiInfo = new OpenApiInfo { + Title = AppConstants.API_NAME, + Version = VERSION_STRING + } + }; +} diff --git a/server/src/Endpoints/V1/ApiTokens/CreateTokenRoute.cs b/server/src/Endpoints/V1/ApiTokens/CreateTokenRoute.cs new file mode 100644 index 0000000..e8abbf8 --- /dev/null +++ b/server/src/Endpoints/V1/ApiTokens/CreateTokenRoute.cs @@ -0,0 +1,52 @@ +using System.Text; + +namespace IOL.GreatOffice.Api.Endpoints.V1.ApiTokens; + +public class CreateTokenRoute : RouteBaseSync.WithRequest<ApiAccessToken.ApiAccessTokenDto>.WithActionResult +{ + private readonly AppDbContext _context; + private readonly IConfiguration _configuration; + private readonly ILogger<CreateTokenRoute> _logger; + + public CreateTokenRoute(AppDbContext context, IConfiguration configuration, ILogger<CreateTokenRoute> logger) { + _context = context; + _configuration = configuration; + _logger = logger; + } + + /// <summary> + /// Create a new api token with the provided claims. + /// </summary> + /// <param name="request">The claims to set on the api token</param> + /// <returns></returns> + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [HttpPost("~/v{version:apiVersion}/api-tokens/create")] + [ProducesResponseType(200, Type = typeof(string))] + [ProducesResponseType(404, Type = typeof(ErrorResult))] + public override ActionResult Handle(ApiAccessToken.ApiAccessTokenDto request) { + var user = _context.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id); + if (user == default) { + return NotFound(new ErrorResult("User does not exist")); + } + + var token_entropy = _configuration.GetValue<string>("TOKEN_ENTROPY"); + if (token_entropy.IsNullOrWhiteSpace()) { + _logger.LogWarning("No token entropy is available in env:TOKEN_ENTROPY, Basic auth is disabled"); + return NotFound(); + } + + var access_token = new ApiAccessToken() { + Id = Guid.NewGuid(), + User = user, + ExpiryDate = request.ExpiryDate.ToUniversalTime(), + AllowCreate = request.AllowCreate, + AllowRead = request.AllowRead, + AllowDelete = request.AllowDelete, + AllowUpdate = request.AllowUpdate + }; + + _context.AccessTokens.Add(access_token); + _context.SaveChanges(); + return Ok(Convert.ToBase64String(Encoding.UTF8.GetBytes(access_token.Id.ToString().EncryptWithAes(token_entropy)))); + } +} diff --git a/server/src/Endpoints/V1/ApiTokens/DeleteTokenRoute.cs b/server/src/Endpoints/V1/ApiTokens/DeleteTokenRoute.cs new file mode 100644 index 0000000..a90b4c0 --- /dev/null +++ b/server/src/Endpoints/V1/ApiTokens/DeleteTokenRoute.cs @@ -0,0 +1,33 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.ApiTokens; + +public class DeleteTokenRoute : RouteBaseSync.WithRequest<Guid>.WithActionResult +{ + private readonly AppDbContext _context; + private readonly ILogger<DeleteTokenRoute> _logger; + + public DeleteTokenRoute(AppDbContext context, ILogger<DeleteTokenRoute> logger) { + _context = context; + _logger = logger; + } + + /// <summary> + /// Delete an api token, rendering it unusable + /// </summary> + /// <param name="id">Id of the token to delete</param> + /// <returns>Nothing</returns> + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [HttpDelete("~/v{version:apiVersion}/api-tokens/delete")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public override ActionResult Handle(Guid id) { + var token = _context.AccessTokens.SingleOrDefault(c => c.Id == id); + if (token == default) { + _logger.LogWarning("A deletion request of an already deleted (maybe) api token was received."); + return NotFound(); + } + + _context.AccessTokens.Remove(token); + _context.SaveChanges(); + return Ok(); + } +} diff --git a/server/src/Endpoints/V1/ApiTokens/GetTokensRoute.cs b/server/src/Endpoints/V1/ApiTokens/GetTokensRoute.cs new file mode 100644 index 0000000..59fd077 --- /dev/null +++ b/server/src/Endpoints/V1/ApiTokens/GetTokensRoute.cs @@ -0,0 +1,22 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.ApiTokens; + +public class GetTokensRoute : RouteBaseSync.WithoutRequest.WithResult<ActionResult<List<ApiAccessToken.ApiAccessTokenDto>>> +{ + private readonly AppDbContext _context; + + public GetTokensRoute(AppDbContext context) { + _context = context; + } + + /// <summary> + /// Get all tokens, both active and inactive. + /// </summary> + /// <returns>A list of tokens</returns> + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [HttpGet("~/v{version:apiVersion}/api-tokens")] + [ProducesResponseType(200, Type = typeof(List<ApiAccessToken.ApiAccessTokenDto>))] + [ProducesResponseType(204)] + public override ActionResult<List<ApiAccessToken.ApiAccessTokenDto>> Handle() { + return Ok(_context.AccessTokens.Where(c => c.User.Id == LoggedInUser.Id).Select(c => c.AsDto)); + } +} diff --git a/server/src/Endpoints/V1/BaseRoute.cs b/server/src/Endpoints/V1/BaseRoute.cs new file mode 100644 index 0000000..e7d72ac --- /dev/null +++ b/server/src/Endpoints/V1/BaseRoute.cs @@ -0,0 +1,39 @@ +using System.Net.Http.Headers; + +namespace IOL.GreatOffice.Api.Endpoints.V1; + +/// <inheritdoc /> +[ApiVersion(ApiSpecV1.VERSION_STRING)] +[Authorize(AuthenticationSchemes = AuthSchemes)] +[ApiController] +public class BaseRoute : ControllerBase +{ + private const string AuthSchemes = CookieAuthenticationDefaults.AuthenticationScheme + "," + AppConstants.BASIC_AUTH_SCHEME; + + /// <summary> + /// User data for the currently logged on user. + /// </summary> + protected LoggedInUserModel LoggedInUser => new() { + Username = User.FindFirstValue(AppClaims.NAME), + Id = User.FindFirstValue(AppClaims.USER_ID).AsGuid(), + }; + + protected bool IsApiCall() { + if (!Request.Headers.ContainsKey("Authorization")) return false; + try { + var authHeader = AuthenticationHeaderValue.Parse(Request.Headers["Authorization"]); + if (authHeader.Parameter == null) return false; + } catch { + return false; + } + + return true; + } + + protected bool HasApiPermission(string permission_key) { + var permission_claim = User.Claims.SingleOrDefault(c => c.Type == permission_key); + return permission_claim is { + Value: "True" + }; + } +} diff --git a/server/src/Endpoints/V1/Categories/CreateCategoryRoute.cs b/server/src/Endpoints/V1/Categories/CreateCategoryRoute.cs new file mode 100644 index 0000000..fac2b5e --- /dev/null +++ b/server/src/Endpoints/V1/Categories/CreateCategoryRoute.cs @@ -0,0 +1,43 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Categories; + +public class CreateCategoryRoute : RouteBaseSync.WithRequest<TimeCategory.TimeCategoryDto>.WithActionResult<TimeCategory.TimeCategoryDto> +{ + private readonly AppDbContext _context; + + public CreateCategoryRoute(AppDbContext context) { + _context = context; + } + + /// <summary> + /// Create a new time entry category. + /// </summary> + /// <param name="categoryTimeCategoryDto"></param> + /// <returns></returns> + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_CREATE)] + [HttpPost("~/v{version:apiVersion}/categories/create")] + [ProducesResponseType(200, Type = typeof(TimeCategory.TimeCategoryDto))] + public override ActionResult<TimeCategory.TimeCategoryDto> Handle(TimeCategory.TimeCategoryDto categoryTimeCategoryDto) { + var duplicate = _context.TimeCategories + .Where(c => c.UserId == LoggedInUser.Id) + .Any(c => c.Name.Trim() == categoryTimeCategoryDto.Name.Trim()); + if (duplicate) { + var category = _context.TimeCategories + .Where(c => c.UserId == LoggedInUser.Id) + .SingleOrDefault(c => c.Name.Trim() == categoryTimeCategoryDto.Name.Trim()); + if (category != default) { + return Ok(category.AsDto); + } + } + + var newCategory = new TimeCategory(LoggedInUser.Id) { + Name = categoryTimeCategoryDto.Name.Trim(), + Color = categoryTimeCategoryDto.Color + }; + + _context.TimeCategories.Add(newCategory); + _context.SaveChanges(); + categoryTimeCategoryDto.Id = newCategory.Id; + return Ok(categoryTimeCategoryDto); + } +} diff --git a/server/src/Endpoints/V1/Categories/DeleteCategoryRoute.cs b/server/src/Endpoints/V1/Categories/DeleteCategoryRoute.cs new file mode 100644 index 0000000..3d438a0 --- /dev/null +++ b/server/src/Endpoints/V1/Categories/DeleteCategoryRoute.cs @@ -0,0 +1,38 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Categories; + +public class DeleteCategoryRoute : RouteBaseSync.WithRequest<Guid>.WithActionResult +{ + private readonly AppDbContext _context; + + public DeleteCategoryRoute(AppDbContext context) { + _context = context; + } + + /// <summary> + /// Delete a time entry category. + /// </summary> + /// <param name="id"></param> + /// <returns></returns> + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_DELETE)] + [HttpDelete("~/v{version:apiVersion}/categories/{id:guid}/delete")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public override ActionResult Handle(Guid id) { + var category = _context.TimeCategories + .Where(c => c.UserId == LoggedInUser.Id) + .SingleOrDefault(c => c.Id == id); + + if (category == default) { + return NotFound(); + } + + var entries = _context.TimeEntries + .Include(c => c.Category) + .Where(c => c.Category.Id == category.Id); + _context.TimeEntries.RemoveRange(entries); + _context.TimeCategories.Remove(category); + _context.SaveChanges(); + return Ok(); + } +} diff --git a/server/src/Endpoints/V1/Categories/GetCategoriesRoute.cs b/server/src/Endpoints/V1/Categories/GetCategoriesRoute.cs new file mode 100644 index 0000000..a40a832 --- /dev/null +++ b/server/src/Endpoints/V1/Categories/GetCategoriesRoute.cs @@ -0,0 +1,35 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Categories; + +/// <inheritdoc /> +public class GetCategoriesRoute : RouteBaseSync.WithoutRequest.WithActionResult<List<TimeCategory.TimeCategoryDto>> +{ + private readonly AppDbContext _context; + + /// <inheritdoc /> + public GetCategoriesRoute(AppDbContext context) { + _context = context; + } + + /// <summary> + /// Get a minimal list of time entry categories. + /// </summary> + /// <returns></returns> + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [ProducesResponseType(200, Type = typeof(List<TimeCategory.TimeCategoryDto>))] + [ProducesResponseType(204)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_READ)] + [HttpGet("~/v{version:apiVersion}/categories")] + public override ActionResult<List<TimeCategory.TimeCategoryDto>> Handle() { + var categories = _context.TimeCategories + .Where(c => c.UserId == LoggedInUser.Id) + .OrderByDescending(c => c.CreatedAt) + .Select(c => c.AsDto) + .ToList(); + + if (categories.Count == 0) { + return NoContent(); + } + + return Ok(categories); + } +} diff --git a/server/src/Endpoints/V1/Categories/UpdateCategoryRoute.cs b/server/src/Endpoints/V1/Categories/UpdateCategoryRoute.cs new file mode 100644 index 0000000..ca7dfdf --- /dev/null +++ b/server/src/Endpoints/V1/Categories/UpdateCategoryRoute.cs @@ -0,0 +1,39 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Categories; + +public class UpdateCategoryRoute : RouteBaseSync.WithRequest<TimeCategory.TimeCategoryDto>.WithActionResult +{ + private readonly AppDbContext _context; + + public UpdateCategoryRoute(AppDbContext context) { + _context = context; + } + + /// <summary> + /// Update a time entry category. + /// </summary> + /// <param name="categoryTimeCategoryDto"></param> + /// <returns></returns> + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_UPDATE)] + [HttpPost("~/v{version:apiVersion}/categories/update")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + [ProducesResponseType(403)] + public override ActionResult Handle(TimeCategory.TimeCategoryDto categoryTimeCategoryDto) { + var category = _context.TimeCategories + .Where(c => c.UserId == LoggedInUser.Id) + .SingleOrDefault(c => c.Id == categoryTimeCategoryDto.Id); + if (category == default) { + return NotFound(); + } + + if (LoggedInUser.Id != category.UserId) { + return Forbid(); + } + + category.Name = categoryTimeCategoryDto.Name; + category.Color = categoryTimeCategoryDto.Color; + _context.SaveChanges(); + return Ok(); + } +} diff --git a/server/src/Endpoints/V1/Entries/CreateEntryRoute.cs b/server/src/Endpoints/V1/Entries/CreateEntryRoute.cs new file mode 100644 index 0000000..362e430 --- /dev/null +++ b/server/src/Endpoints/V1/Entries/CreateEntryRoute.cs @@ -0,0 +1,65 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Entries; + +public class CreateEntryRoute : RouteBaseSync.WithRequest<TimeEntry.TimeEntryDto>.WithActionResult<TimeEntry.TimeEntryDto> +{ + private readonly AppDbContext _context; + + public CreateEntryRoute(AppDbContext context) { + _context = context; + } + + /// <summary> + /// Create a time entry. + /// </summary> + /// <param name="timeEntryTimeEntryDto"></param> + /// <returns></returns> + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_CREATE)] + [ProducesResponseType(200)] + [ProducesResponseType(400, Type = typeof(ErrorResult))] + [ProducesResponseType(404, Type = typeof(ErrorResult))] + [HttpPost("~/v{version:apiVersion}/entries/create")] + public override ActionResult<TimeEntry.TimeEntryDto> Handle(TimeEntry.TimeEntryDto timeEntryTimeEntryDto) { + if (timeEntryTimeEntryDto.Stop == default) { + return BadRequest(new ErrorResult("Invalid form", "A stop date is required")); + } + + if (timeEntryTimeEntryDto.Start == default) { + return BadRequest(new ErrorResult("Invalid form", "A start date is required")); + } + + if (timeEntryTimeEntryDto.Category == default) { + return BadRequest(new ErrorResult("Invalid form", "A category is required")); + } + + var category = _context.TimeCategories + .Where(c => c.UserId == LoggedInUser.Id) + .SingleOrDefault(c => c.Id == timeEntryTimeEntryDto.Category.Id); + if (category == default) { + return NotFound(new ErrorResult("Not found", $"Could not find category {timeEntryTimeEntryDto.Category.Name}")); + } + + var entry = new TimeEntry(LoggedInUser.Id) { + Category = category, + Start = timeEntryTimeEntryDto.Start.ToUniversalTime(), + Stop = timeEntryTimeEntryDto.Stop.ToUniversalTime(), + Description = timeEntryTimeEntryDto.Description, + }; + + if (timeEntryTimeEntryDto.Labels?.Count > 0) { + var labels = _context.TimeLabels + .Where(c => c.UserId == LoggedInUser.Id) + .Where(c => timeEntryTimeEntryDto.Labels.Select(p => p.Id).Contains(c.Id)) + .ToList(); + if (labels.Count != timeEntryTimeEntryDto.Labels.Count) { + return NotFound(new ErrorResult("Not found", "Could not find all of the specified labels")); + } + + entry.Labels = labels; + } + + _context.TimeEntries.Add(entry); + _context.SaveChanges(); + return Ok(entry.AsDto); + } +} diff --git a/server/src/Endpoints/V1/Entries/DeleteEntryRoute.cs b/server/src/Endpoints/V1/Entries/DeleteEntryRoute.cs new file mode 100644 index 0000000..0850af0 --- /dev/null +++ b/server/src/Endpoints/V1/Entries/DeleteEntryRoute.cs @@ -0,0 +1,35 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Entries; + +/// <inheritdoc /> +public class DeleteEntryRoute : RouteBaseSync.WithRequest<Guid>.WithActionResult +{ + private readonly AppDbContext _context; + + /// <inheritdoc /> + public DeleteEntryRoute(AppDbContext context) { + _context = context; + } + + /// <summary> + /// Delete a time entry. + /// </summary> + /// <param name="id"></param> + /// <returns></returns> + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_DELETE)] + [HttpDelete("~/v{version:apiVersion}/entries/{id:guid}/delete")] + [ProducesResponseType(404)] + [ProducesResponseType(200)] + public override ActionResult Handle(Guid id) { + var entry = _context.TimeEntries + .Where(c => c.UserId == LoggedInUser.Id) + .SingleOrDefault(c => c.Id == id); + if (entry == default) { + return NotFound(); + } + + _context.TimeEntries.Remove(entry); + _context.SaveChanges(); + return Ok(); + } +} diff --git a/server/src/Endpoints/V1/Entries/EntryQueryPayload.cs b/server/src/Endpoints/V1/Entries/EntryQueryPayload.cs new file mode 100644 index 0000000..763ac8b --- /dev/null +++ b/server/src/Endpoints/V1/Entries/EntryQueryPayload.cs @@ -0,0 +1,60 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Entries; + +/// <summary> +/// Query model for querying time entries. +/// </summary> +public class EntryQueryPayload +{ + /// <summary> + /// Duration to filter with. + /// </summary> + public TimeEntryQueryDuration Duration { get; set; } + + /// <summary> + /// List of categories to filter with. + /// </summary> + public List<TimeCategory.TimeCategoryDto> Categories { get; set; } + + /// <summary> + /// List of labels to filter with. + /// </summary> + public List<TimeLabel.TimeLabelDto> Labels { get; set; } + + /// <summary> + /// Date range to filter with, only respected if Duration is set to TimeEntryQueryDuration.DATE_RANGE. + /// </summary> + /// <see cref="TimeEntryQueryDuration"/> + public QueryDateRange DateRange { get; set; } + + /// <summary> + /// Spesific date to filter with, only respected if Duration is set to TimeEntryQueryDuration.SPECIFIC_DATE. + /// </summary> + /// <see cref="TimeEntryQueryDuration"/> + public DateTime SpecificDate { get; set; } + + /// <summary> + /// Optional page number to show, goes well with PageSize. + /// </summary> + public int Page { get; set; } + + /// <summary> + /// Optional page size to show, goes well with Page. + /// </summary> + public int PageSize { get; set; } + + /// <summary> + /// Represents a date range. + /// </summary> + public class QueryDateRange + { + /// <summary> + /// Range start + /// </summary> + public DateTime From { get; set; } + + /// <summary> + /// Range end + /// </summary> + public DateTime To { get; set; } + } +} diff --git a/server/src/Endpoints/V1/Entries/EntryQueryResponse.cs b/server/src/Endpoints/V1/Entries/EntryQueryResponse.cs new file mode 100644 index 0000000..b1b07a3 --- /dev/null +++ b/server/src/Endpoints/V1/Entries/EntryQueryResponse.cs @@ -0,0 +1,37 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Entries; + +/// <summary> +/// Response given for a successful query. +/// </summary> +public class EntryQueryResponse +{ + /// <inheritdoc cref="EntryQueryResponse"/> + public EntryQueryResponse() { + Results = new List<TimeEntry.TimeEntryDto>(); + } + + /// <summary> + /// List of entries. + /// </summary> + public List<TimeEntry.TimeEntryDto> Results { get; set; } + + /// <summary> + /// Current page. + /// </summary> + public int Page { get; set; } + + /// <summary> + /// Current page size (amount of entries). + /// </summary> + public int PageSize { get; set; } + + /// <summary> + /// Total amount of entries in query. + /// </summary> + public int TotalSize { get; set; } + + /// <summary> + /// Total amount of page(s) in query. + /// </summary> + public int TotalPageCount { get; set; } +} diff --git a/server/src/Endpoints/V1/Entries/EntryQueryRoute.cs b/server/src/Endpoints/V1/Entries/EntryQueryRoute.cs new file mode 100644 index 0000000..c037b72 --- /dev/null +++ b/server/src/Endpoints/V1/Entries/EntryQueryRoute.cs @@ -0,0 +1,186 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Entries; + +public class EntryQueryRoute : RouteBaseSync.WithRequest<EntryQueryPayload>.WithActionResult<EntryQueryResponse> +{ + private readonly ILogger<EntryQueryRoute> _logger; + private readonly AppDbContext _context; + + public EntryQueryRoute(ILogger<EntryQueryRoute> logger, AppDbContext context) { + _logger = logger; + _context = context; + } + + /// <summary> + /// Get a list of entries based on a given query. + /// </summary> + /// <param name="entryQuery"></param> + /// <returns></returns> + /// <exception cref="ArgumentOutOfRangeException"></exception> + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_READ)] + [HttpPost("~/v{version:apiVersion}/entries/query")] + [ProducesResponseType(204)] + [ProducesResponseType(400, Type = typeof(ErrorResult))] + [ProducesResponseType(200, Type = typeof(EntryQueryResponse))] + public override ActionResult<EntryQueryResponse> Handle(EntryQueryPayload entryQuery) { + var result = new TimeQueryDto(); + + Request.Headers.TryGetValue(AppHeaders.BROWSER_TIME_ZONE, out var timeZoneHeader); + var tz = TimeZoneInfo.FindSystemTimeZoneById(timeZoneHeader.ToString().HasValue() ? timeZoneHeader.ToString() : "UTC"); + var offsetInHours = tz.BaseUtcOffset.Hours; + + // this is fine as long as the client is not connecting from Australia: Lord Howe Island + // according to https://en.wikipedia.org/wiki/Daylight_saving_time_by_country + if (tz.IsDaylightSavingTime(DateTime.UtcNow)) { + offsetInHours++; + } + + _logger.LogInformation("Request time zone (" + tz.Id + ") offset is: " + offsetInHours + " hours"); + var requestDateTime = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz); + _logger.LogInformation("Querying data with date time: " + requestDateTime.ToString("u")); + + var skipCount = 0; + if (entryQuery.Page > 1) { + skipCount = entryQuery.PageSize * entryQuery.Page; + } + + result.Page = entryQuery.Page; + result.PageSize = entryQuery.PageSize; + + var baseQuery = _context.TimeEntries + .AsNoTracking() + .Include(c => c.Category) + .Include(c => c.Labels) + .Where(c => c.UserId == LoggedInUser.Id) + .ConditionalWhere(entryQuery.Categories?.Any() ?? false, c => entryQuery.Categories.Any(p => p.Id == c.Category.Id)) + .ConditionalWhere(entryQuery.Labels?.Any() ?? false, c => c.Labels.Any(l => entryQuery.Labels.Any(p => p.Id == l.Id))) + .OrderByDescending(c => c.Start); + + switch (entryQuery.Duration) { + case TimeEntryQueryDuration.TODAY: + var baseTodaysEntries = baseQuery + .Where(c => DateTime.Compare(c.Start.AddHours(offsetInHours).Date, DateTime.UtcNow.Date) == 0); + var baseTodaysEntriesCount = baseTodaysEntries.Count(); + + if (baseTodaysEntriesCount == 0) { + return NoContent(); + } + + result.TotalSize = baseTodaysEntriesCount; + result.TotalPageCount = Convert.ToInt32(Math.Round((double)baseTodaysEntriesCount / entryQuery.PageSize)); + + var pagedTodaysEntries = baseTodaysEntries.Skip(skipCount).Take(entryQuery.PageSize); + + result.Results.AddRange(pagedTodaysEntries.Select(c => c.AsDto)); + break; + case TimeEntryQueryDuration.THIS_WEEK: + var lastMonday = DateTime.UtcNow.StartOfWeek(DayOfWeek.Monday); + + var baseEntriesThisWeek = baseQuery + .Where(c => c.Start.AddHours(offsetInHours).Date >= lastMonday.Date && c.Start.AddHours(offsetInHours).Date <= DateTime.UtcNow.Date); + + var baseEntriesThisWeekCount = baseEntriesThisWeek.Count(); + + if (baseEntriesThisWeekCount == 0) { + return NoContent(); + } + + result.TotalSize = baseEntriesThisWeekCount; + result.TotalPageCount = Convert.ToInt32(Math.Round((double)baseEntriesThisWeekCount / entryQuery.PageSize)); + + var pagedEntriesThisWeek = baseEntriesThisWeek.Skip(skipCount).Take(entryQuery.PageSize); + + result.Results.AddRange(pagedEntriesThisWeek.Select(c => c.AsDto)); + break; + case TimeEntryQueryDuration.THIS_MONTH: + var baseEntriesThisMonth = baseQuery + .Where(c => c.Start.AddHours(offsetInHours).Month == DateTime.UtcNow.Month + && c.Start.AddHours(offsetInHours).Year == DateTime.UtcNow.Year); + var baseEntriesThisMonthCount = baseEntriesThisMonth.Count(); + if (baseEntriesThisMonthCount == 0) { + return NoContent(); + } + + result.TotalSize = baseEntriesThisMonthCount; + result.TotalPageCount = Convert.ToInt32(Math.Round((double)baseEntriesThisMonthCount / entryQuery.PageSize)); + + var pagedEntriesThisMonth = baseEntriesThisMonth.Skip(skipCount).Take(entryQuery.PageSize); + + result.Results.AddRange(pagedEntriesThisMonth.Select(c => c.AsDto)); + break; + case TimeEntryQueryDuration.THIS_YEAR: + var baseEntriesThisYear = baseQuery + .Where(c => c.Start.AddHours(offsetInHours).Year == DateTime.UtcNow.Year); + + var baseEntriesThisYearCount = baseEntriesThisYear.Count(); + if (baseEntriesThisYearCount == 0) { + return NoContent(); + } + + result.TotalSize = baseEntriesThisYearCount; + result.TotalPageCount = Convert.ToInt32(Math.Round((double)baseEntriesThisYearCount / entryQuery.PageSize)); + + var pagedEntriesThisYear = baseEntriesThisYear.Skip(skipCount).Take(entryQuery.PageSize); + + result.Results.AddRange(pagedEntriesThisYear.Select(c => c.AsDto)); + break; + case TimeEntryQueryDuration.SPECIFIC_DATE: + var date = DateTime.SpecifyKind(entryQuery.SpecificDate, DateTimeKind.Utc); + var baseEntriesOnThisDate = baseQuery.Where(c => c.Start.AddHours(offsetInHours).Date == date.Date); + var baseEntriesOnThisDateCount = baseEntriesOnThisDate.Count(); + + if (baseEntriesOnThisDateCount == 0) { + return NoContent(); + } + + result.TotalSize = baseEntriesOnThisDateCount; + result.TotalPageCount = Convert.ToInt32(Math.Round((double)baseEntriesOnThisDateCount / entryQuery.PageSize)); + + var pagedEntriesOnThisDate = baseEntriesOnThisDate.Skip(skipCount).Take(entryQuery.PageSize); + + result.Results.AddRange(pagedEntriesOnThisDate.Select(c => c.AsDto)); + break; + case TimeEntryQueryDuration.DATE_RANGE: + if (entryQuery.DateRange.From == default) { + return BadRequest(new ErrorResult("Invalid query", "From date cannot be empty")); + } + + var fromDate = DateTime.SpecifyKind(entryQuery.DateRange.From, DateTimeKind.Utc); + + if (entryQuery.DateRange.To == default) { + return BadRequest(new ErrorResult("Invalid query", "To date cannot be empty")); + } + + var toDate = DateTime.SpecifyKind(entryQuery.DateRange.To, DateTimeKind.Utc); + + if (DateTime.Compare(fromDate, toDate) > 0) { + return BadRequest(new ErrorResult("Invalid query", "To date cannot be less than From date")); + } + + var baseDateRangeEntries = baseQuery + .Where(c => c.Start.AddHours(offsetInHours).Date > fromDate && c.Start.AddHours(offsetInHours).Date <= toDate); + + var baseDateRangeEntriesCount = baseDateRangeEntries.Count(); + if (baseDateRangeEntriesCount == 0) { + return NoContent(); + } + + result.TotalSize = baseDateRangeEntriesCount; + result.TotalPageCount = Convert.ToInt32(Math.Round((double)baseDateRangeEntriesCount / entryQuery.PageSize)); + + var pagedDateRangeEntries = baseDateRangeEntries.Skip(skipCount).Take(entryQuery.PageSize); + + result.Results.AddRange(pagedDateRangeEntries.Select(c => c.AsDto)); + break; + default: + throw new ArgumentOutOfRangeException(nameof(entryQuery), "Unknown duration for query"); + } + + if (result.Results.Any() && result.Page == 0) { + result.Page = 1; + result.TotalPageCount = 1; + } + + return Ok(result); + } +} diff --git a/server/src/Endpoints/V1/Entries/GetEntryRoute.cs b/server/src/Endpoints/V1/Entries/GetEntryRoute.cs new file mode 100644 index 0000000..87038db --- /dev/null +++ b/server/src/Endpoints/V1/Entries/GetEntryRoute.cs @@ -0,0 +1,34 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Entries; + +public class GetEntryRoute : RouteBaseSync.WithRequest<Guid>.WithActionResult<TimeEntry.TimeEntryDto> +{ + private readonly AppDbContext _context; + + public GetEntryRoute(AppDbContext context) { + _context = context; + } + + /// <summary> + /// Get a spesific time entry. + /// </summary> + /// <param name="id"></param> + /// <returns></returns> + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_READ)] + [HttpGet("~/v{version:apiVersion}/entries/{id:guid}")] + [ProducesResponseType(404)] + [ProducesResponseType(200, Type = typeof(TimeEntry.TimeEntryDto))] + public override ActionResult<TimeEntry.TimeEntryDto> Handle(Guid id) { + var entry = _context.TimeEntries + .Where(c => c.UserId == LoggedInUser.Id) + .Include(c => c.Category) + .Include(c => c.Labels) + .SingleOrDefault(c => c.Id == id); + + if (entry == default) { + return NotFound(); + } + + return Ok(entry); + } +} diff --git a/server/src/Endpoints/V1/Entries/UpdateEntryRoute.cs b/server/src/Endpoints/V1/Entries/UpdateEntryRoute.cs new file mode 100644 index 0000000..ac233e0 --- /dev/null +++ b/server/src/Endpoints/V1/Entries/UpdateEntryRoute.cs @@ -0,0 +1,66 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Entries; + +public class UpdateEntryRoute : RouteBaseSync.WithRequest<TimeEntry.TimeEntryDto>.WithActionResult<TimeEntry.TimeEntryDto> +{ + private readonly AppDbContext _context; + + public UpdateEntryRoute(AppDbContext context) { + _context = context; + } + + /// <summary> + /// Update a time entry. + /// </summary> + /// <param name="timeEntryTimeEntryDto"></param> + /// <returns></returns> + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_UPDATE)] + [HttpPost("~/v{version:apiVersion}/entries/update")] + [ProducesResponseType(404, Type = typeof(ErrorResult))] + [ProducesResponseType(200, Type = typeof(TimeEntry.TimeEntryDto))] + public override ActionResult<TimeEntry.TimeEntryDto> Handle(TimeEntry.TimeEntryDto timeEntryTimeEntryDto) { + var entry = _context.TimeEntries + .Where(c => c.UserId == LoggedInUser.Id) + .Include(c => c.Labels) + .SingleOrDefault(c => c.Id == timeEntryTimeEntryDto.Id); + + if (entry == default) { + return NotFound(); + } + + var category = _context.TimeCategories + .Where(c => c.UserId == LoggedInUser.Id) + .SingleOrDefault(c => c.Id == timeEntryTimeEntryDto.Category.Id); + if (category == default) { + return NotFound(new ErrorResult("Not found", $"Could not find category {timeEntryTimeEntryDto.Category.Name}")); + } + + entry.Start = timeEntryTimeEntryDto.Start.ToUniversalTime(); + entry.Stop = timeEntryTimeEntryDto.Stop.ToUniversalTime(); + entry.Description = timeEntryTimeEntryDto.Description; + entry.Category = category; + + if (timeEntryTimeEntryDto.Labels?.Count > 0) { + var labels = new List<TimeLabel>(); + + foreach (var labelDto in timeEntryTimeEntryDto.Labels) { + var label = _context.TimeLabels + .Where(c => c.UserId == LoggedInUser.Id) + .SingleOrDefault(c => c.Id == labelDto.Id); + + if (label == default) { + continue; + } + + labels.Add(label); + } + + entry.Labels = labels; + } else { + entry.Labels = default; + } + + _context.SaveChanges(); + return Ok(entry.AsDto); + } +} diff --git a/server/src/Endpoints/V1/Labels/CreateLabelRoute.cs b/server/src/Endpoints/V1/Labels/CreateLabelRoute.cs new file mode 100644 index 0000000..31ef7d0 --- /dev/null +++ b/server/src/Endpoints/V1/Labels/CreateLabelRoute.cs @@ -0,0 +1,46 @@ + +namespace IOL.GreatOffice.Api.Endpoints.V1.Labels; + +/// <inheritdoc /> +public class CreateLabelRoute : RouteBaseSync.WithRequest<TimeLabel.TimeLabelDto>.WithActionResult<TimeLabel.TimeLabelDto> +{ + private readonly AppDbContext _context; + + /// <inheritdoc /> + public CreateLabelRoute(AppDbContext context) { + _context = context; + } + + /// <summary> + /// Create a time entry label. + /// </summary> + /// <param name="labelTimeLabelDto"></param> + /// <returns></returns> + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_CREATE)] + [HttpPost("~/v{version:apiVersion}/labels/create")] + public override ActionResult<TimeLabel.TimeLabelDto> Handle(TimeLabel.TimeLabelDto labelTimeLabelDto) { + var duplicate = _context.TimeLabels + .Where(c => c.UserId == LoggedInUser.Id) + .Any(c => c.Name.Trim() == labelTimeLabelDto.Name.Trim()); + if (duplicate) { + var label = _context.TimeLabels + .Where(c => c.UserId == LoggedInUser.Id) + .SingleOrDefault(c => c.Name.Trim() == labelTimeLabelDto.Name.Trim()); + + if (label != default) { + return Ok(label.AsDto); + } + } + + var newLabel = new TimeLabel(LoggedInUser.Id) { + Name = labelTimeLabelDto.Name.Trim(), + Color = labelTimeLabelDto.Color + }; + + _context.TimeLabels.Add(newLabel); + _context.SaveChanges(); + labelTimeLabelDto.Id = newLabel.Id; + return Ok(labelTimeLabelDto); + } +} diff --git a/server/src/Endpoints/V1/Labels/DeleteLabelRoute.cs b/server/src/Endpoints/V1/Labels/DeleteLabelRoute.cs new file mode 100644 index 0000000..d845a6f --- /dev/null +++ b/server/src/Endpoints/V1/Labels/DeleteLabelRoute.cs @@ -0,0 +1,35 @@ + +namespace IOL.GreatOffice.Api.Endpoints.V1.Labels; + +/// <inheritdoc /> +public class DeleteLabelEndpoint : RouteBaseSync.WithRequest<Guid>.WithActionResult +{ + private readonly AppDbContext _context; + + /// <inheritdoc /> + public DeleteLabelEndpoint(AppDbContext context) { + _context = context; + } + + /// <summary> + /// Delete a time entry label. + /// </summary> + /// <param name="id"></param> + /// <returns></returns> + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_DELETE)] + [HttpDelete("~/v{version:apiVersion}/labels/{id:guid}/delete")] + public override ActionResult Handle(Guid id) { + var label = _context.TimeLabels + .Where(c => c.UserId == LoggedInUser.Id) + .SingleOrDefault(c => c.Id == id); + + if (label == default) { + return NotFound(); + } + + _context.TimeLabels.Remove(label); + _context.SaveChanges(); + return Ok(); + } +} diff --git a/server/src/Endpoints/V1/Labels/GetLabelRoute.cs b/server/src/Endpoints/V1/Labels/GetLabelRoute.cs new file mode 100644 index 0000000..c9ccef3 --- /dev/null +++ b/server/src/Endpoints/V1/Labels/GetLabelRoute.cs @@ -0,0 +1,34 @@ + +namespace IOL.GreatOffice.Api.Endpoints.V1.Labels; + +/// <inheritdoc /> +public class GetEndpoint : RouteBaseSync.WithoutRequest.WithActionResult<List<TimeLabel.TimeLabelDto>> +{ + private readonly AppDbContext _context; + + /// <inheritdoc /> + public GetEndpoint(AppDbContext context) { + _context = context; + } + + /// <summary> + /// Get a minimal list of time entry labels. + /// </summary> + /// <returns></returns> + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_READ)] + [HttpGet("~/v{version:apiVersion}/labels")] + public override ActionResult<List<TimeLabel.TimeLabelDto>> Handle() { + var labels = _context.TimeLabels + .Where(c => c.UserId == LoggedInUser.Id) + .OrderByDescending(c => c.CreatedAt) + .Select(c => c.AsDto) + .ToList(); + + if (labels.Count == 0) { + return NoContent(); + } + + return Ok(labels); + } +} diff --git a/server/src/Endpoints/V1/Labels/UpdateLabelRoute.cs b/server/src/Endpoints/V1/Labels/UpdateLabelRoute.cs new file mode 100644 index 0000000..0868671 --- /dev/null +++ b/server/src/Endpoints/V1/Labels/UpdateLabelRoute.cs @@ -0,0 +1,38 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Labels; + +/// <inheritdoc /> +public class UpdateLabelEndpoint : RouteBaseSync.WithRequest<TimeLabel.TimeLabelDto>.WithActionResult +{ + private readonly AppDbContext _context; + + /// <inheritdoc /> + public UpdateLabelEndpoint(AppDbContext context) { + _context = context; + } + + /// <summary> + /// Update a time entry label. + /// </summary> + /// <param name="labelTimeLabelDto"></param> + /// <returns></returns> + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_UPDATE)] + [HttpPost("~/v{version:apiVersion}/labels/update")] + public override ActionResult Handle(TimeLabel.TimeLabelDto labelTimeLabelDto) { + var label = _context.TimeLabels + .Where(c => c.UserId == LoggedInUser.Id) + .SingleOrDefault(c => c.Id == labelTimeLabelDto.Id); + if (label == default) { + return NotFound(); + } + + if (LoggedInUser.Id != label.User.Id) { + return Forbid(); + } + + label.Name = labelTimeLabelDto.Name; + label.Color = labelTimeLabelDto.Color; + _context.SaveChanges(); + return Ok(); + } +} diff --git a/server/src/Endpoints/V1/RouteBaseAsync.cs b/server/src/Endpoints/V1/RouteBaseAsync.cs new file mode 100644 index 0000000..1d179f7 --- /dev/null +++ b/server/src/Endpoints/V1/RouteBaseAsync.cs @@ -0,0 +1,73 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1; + +/// <summary> +/// A base class for an endpoint that accepts parameters. +/// </summary> +public static class RouteBaseAsync +{ + public static class WithRequest<TRequest> + { + public abstract class WithResult<TResponse> : BaseRoute + { + public abstract Task<TResponse> HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + + public abstract class WithoutResult : BaseRoute + { + public abstract Task HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult<TResponse> : BaseRoute + { + public abstract Task<ActionResult<TResponse>> HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract Task<ActionResult> HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + } + + public static class WithoutRequest + { + public abstract class WithResult<TResponse> : BaseRoute + { + public abstract Task<TResponse> HandleAsync( + CancellationToken cancellationToken = default + ); + } + + public abstract class WithoutResult : BaseRoute + { + public abstract Task HandleAsync( + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult<TResponse> : BaseRoute + { + public abstract Task<ActionResult<TResponse>> HandleAsync( + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract Task<ActionResult> HandleAsync( + CancellationToken cancellationToken = default + ); + } + } +} diff --git a/server/src/Endpoints/V1/RouteBaseSync.cs b/server/src/Endpoints/V1/RouteBaseSync.cs new file mode 100644 index 0000000..cb27c14 --- /dev/null +++ b/server/src/Endpoints/V1/RouteBaseSync.cs @@ -0,0 +1,53 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1; + +/// <summary> +/// A base class for an endpoint that accepts parameters. +/// </summary> +public static class RouteBaseSync +{ + public static class WithRequest<TRequest> + { + public abstract class WithResult<TResponse> : BaseRoute + { + public abstract TResponse Handle(TRequest request); + } + + public abstract class WithoutResult : BaseRoute + { + public abstract void Handle(TRequest request); + } + + public abstract class WithActionResult<TResponse> : BaseRoute + { + public abstract ActionResult<TResponse> Handle(TRequest request); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract ActionResult Handle(TRequest request); + } + } + + public static class WithoutRequest + { + public abstract class WithResult<TResponse> : BaseRoute + { + public abstract TResponse Handle(); + } + + public abstract class WithoutResult : BaseRoute + { + public abstract void Handle(); + } + + public abstract class WithActionResult<TResponse> : BaseRoute + { + public abstract ActionResult<TResponse> Handle(); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract ActionResult Handle(); + } + } +} diff --git a/server/src/IOL.GreatOffice.Api.csproj b/server/src/IOL.GreatOffice.Api.csproj new file mode 100644 index 0000000..0bd2c48 --- /dev/null +++ b/server/src/IOL.GreatOffice.Api.csproj @@ -0,0 +1,48 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <PropertyGroup> + <TargetFramework>net6.0</TargetFramework> + <RuntimeIdentifier>linux-x64</RuntimeIdentifier> + <UserSecretsId>ed5ff3e5-46e2-4d7e-8272-7081f5abfee4</UserSecretsId> + <GenerateDocumentationFile>true</GenerateDocumentationFile> + <ImplicitUsings>true</ImplicitUsings> + <Nullable>disable</Nullable> + <NoWarn>CS1591</NoWarn> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="AspNet.Security.OAuth.GitHub" Version="6.0.6" /> + <PackageReference Include="Duende.IdentityServer" Version="6.1.0" /> + <PackageReference Include="Duende.IdentityServer.EntityFramework.Storage" Version="6.1.0" /> + <PackageReference Include="EFCore.NamingConventions" Version="6.0.0" /> + <PackageReference Include="IOL.Helpers" Version="3.0.0" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="5.0.0" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer" Version="5.0.0" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.5"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.4" /> + <PackageReference Include="Quartz.Extensions.Hosting" Version="3.4.0" /> + <PackageReference Include="Serilog.AspNetCore" Version="5.0.0" /> + <PackageReference Include="Serilog.Expressions" Version="3.4.0" /> + <PackageReference Include="Serilog.Sinks.Seq" Version="5.1.1" /> + <PackageReference Include="Swashbuckle.AspNetCore" Version="6.3.1" /> + <PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.3.1" /> + <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="6.3.1" /> + </ItemGroup> + + <ItemGroup Condition="'$(Configuration)' == 'Release'"> + <Content Remove="AppData" /> + </ItemGroup> + + <ItemGroup> + <Content Include="..\..\README.md"> + <Link>README.md</Link> + </Content> + </ItemGroup> + + <ItemGroup> + <Folder Include="wwwroot" /> + </ItemGroup> +</Project> diff --git a/server/src/Jobs/JobRegister.cs b/server/src/Jobs/JobRegister.cs new file mode 100644 index 0000000..72c2cc7 --- /dev/null +++ b/server/src/Jobs/JobRegister.cs @@ -0,0 +1,18 @@ +using Quartz; + +namespace IOL.GreatOffice.Api.Jobs; + +public static class JobRegister +{ + public static readonly JobKey TokenCleanupKey = new("TokenCleanupJob"); + + public static IServiceCollectionQuartzConfigurator RegisterJobs(this IServiceCollectionQuartzConfigurator configurator) { + configurator.AddJob<TokenCleanupJob>(TokenCleanupKey); + configurator.AddTrigger(options => { + options.ForJob(TokenCleanupKey) + .WithIdentity(TokenCleanupKey.Name + "-trigger") + .WithCronSchedule(CronScheduleBuilder.DailyAtHourAndMinute(1, 0)); + }); + return configurator; + } +} diff --git a/server/src/Jobs/TokenCleanupJob.cs b/server/src/Jobs/TokenCleanupJob.cs new file mode 100644 index 0000000..22b60d1 --- /dev/null +++ b/server/src/Jobs/TokenCleanupJob.cs @@ -0,0 +1,21 @@ +using Quartz; + +namespace IOL.GreatOffice.Api.Jobs; + +public class TokenCleanupJob : IJob +{ + private readonly ILogger<TokenCleanupJob> _logger; + private readonly AppDbContext _context; + + public TokenCleanupJob(ILogger<TokenCleanupJob> logger, AppDbContext context) { + _logger = logger; + _context = context; + } + + public Task Execute(IJobExecutionContext context) { + var staleTokens = _context.AccessTokens.Where(c => c.HasExpired); + _logger.LogInformation("Removing {0} stale tokens", staleTokens.Count()); + _context.AccessTokens.RemoveRange(); + return Task.CompletedTask; + } +} diff --git a/server/src/Migrations/20210517202115_InitialMigration.Designer.cs b/server/src/Migrations/20210517202115_InitialMigration.Designer.cs new file mode 100644 index 0000000..b6a01ff --- /dev/null +++ b/server/src/Migrations/20210517202115_InitialMigration.Designer.cs @@ -0,0 +1,238 @@ +// <auto-generated /> +using System; +using IOL.GreatOffice.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +namespace IOL.GreatOffice.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20210517202115_InitialMigration")] + partial class InitialMigration + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Relational:MaxIdentifierLength", 63) + .HasAnnotation("ProductVersion", "5.0.6") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_forgot_password_requests"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_forgot_password_requests_user_id"); + + b.ToTable("forgot_password_requests"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_categories"); + + b.ToTable("time_categories"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<Guid?>("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property<string>("Description") + .HasColumnType("text") + .HasColumnName("note"); + + b.Property<DateTime>("Start") + .HasColumnType("timestamp with time zone") + .HasColumnName("start"); + + b.Property<DateTime>("Stop") + .HasColumnType("timestamp with time zone") + .HasColumnName("stop"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_entries"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_time_entries_category_id"); + + b.ToTable("time_entries"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_labels"); + + b.ToTable("time_labels"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property<string>("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property<string>("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.ToTable("users"); + + b.HasData( + new + { + Id = new Guid("784938f0-cc0e-46ec-afa6-fc60b47b28db"), + Created = new DateTime(2021, 5, 17, 20, 21, 14, 827, DateTimeKind.Utc).AddTicks(4868), + Password = "AAAAAAEAACcQAAAAEJdtrX3pEeIbcgY+BDAr56gvfbc420ag1TllA0cK6Q6Gw3+gGDIQtYIZnisW3dmqaQ==", + Username = "admin@ivarlovlie.no" + }); + }); + + modelBuilder.Entity("TimeEntryTimeLabel", b => + { + b.Property<Guid>("EntriesId") + .HasColumnType("uuid") + .HasColumnName("entries_id"); + + b.Property<Guid>("LabelsId") + .HasColumnType("uuid") + .HasColumnName("labels_id"); + + b.HasKey("EntriesId", "LabelsId") + .HasName("pk_time_entry_time_label"); + + b.HasIndex("LabelsId") + .HasDatabaseName("ix_time_entry_time_label_labels_id"); + + b.ToTable("time_entry_time_label"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_forgot_password_requests_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_time_entries_time_categories_category_id"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("TimeEntryTimeLabel", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null) + .WithMany() + .HasForeignKey("EntriesId") + .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null) + .WithMany() + .HasForeignKey("LabelsId") + .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Migrations/20210517202115_InitialMigration.cs b/server/src/Migrations/20210517202115_InitialMigration.cs new file mode 100644 index 0000000..8bfaf61 --- /dev/null +++ b/server/src/Migrations/20210517202115_InitialMigration.cs @@ -0,0 +1,162 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace IOL.GreatOffice.Api.Migrations +{ + public partial class InitialMigration : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "time_categories", + columns: table => new + { + id = table.Column<Guid>(type: "uuid", nullable: false), + name = table.Column<string>(type: "text", nullable: true), + color = table.Column<string>(type: "text", nullable: true), + user_id = table.Column<Guid>(type: "uuid", nullable: false), + created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_time_categories", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "time_labels", + columns: table => new + { + id = table.Column<Guid>(type: "uuid", nullable: false), + name = table.Column<string>(type: "text", nullable: true), + color = table.Column<string>(type: "text", nullable: true), + user_id = table.Column<Guid>(type: "uuid", nullable: false), + created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_time_labels", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "users", + columns: table => new + { + id = table.Column<Guid>(type: "uuid", nullable: false), + username = table.Column<string>(type: "text", nullable: true), + password = table.Column<string>(type: "text", nullable: true), + created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_users", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "time_entries", + columns: table => new + { + id = table.Column<Guid>(type: "uuid", nullable: false), + start = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), + stop = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), + note = table.Column<string>(type: "text", nullable: true), + user_id = table.Column<Guid>(type: "uuid", nullable: false), + category_id = table.Column<Guid>(type: "uuid", nullable: true), + created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_time_entries", x => x.id); + table.ForeignKey( + name: "fk_time_entries_time_categories_category_id", + column: x => x.category_id, + principalTable: "time_categories", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "forgot_password_requests", + columns: table => new + { + id = table.Column<Guid>(type: "uuid", nullable: false), + user_id = table.Column<Guid>(type: "uuid", nullable: true), + created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_forgot_password_requests", x => x.id); + table.ForeignKey( + name: "fk_forgot_password_requests_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "time_entry_time_label", + columns: table => new + { + entries_id = table.Column<Guid>(type: "uuid", nullable: false), + labels_id = table.Column<Guid>(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_time_entry_time_label", x => new { x.entries_id, x.labels_id }); + table.ForeignKey( + name: "fk_time_entry_time_label_time_entries_entries_id", + column: x => x.entries_id, + principalTable: "time_entries", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_time_entry_time_label_time_labels_labels_id", + column: x => x.labels_id, + principalTable: "time_labels", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + table: "users", + columns: new[] { "id", "created", "password", "username" }, + values: new object[] { new Guid("784938f0-cc0e-46ec-afa6-fc60b47b28db"), new DateTime(2021, 5, 17, 20, 21, 14, 827, DateTimeKind.Utc).AddTicks(4868), "AAAAAAEAACcQAAAAEJdtrX3pEeIbcgY+BDAr56gvfbc420ag1TllA0cK6Q6Gw3+gGDIQtYIZnisW3dmqaQ==", "admin@ivarlovlie.no" }); + + migrationBuilder.CreateIndex( + name: "ix_forgot_password_requests_user_id", + table: "forgot_password_requests", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "ix_time_entries_category_id", + table: "time_entries", + column: "category_id"); + + migrationBuilder.CreateIndex( + name: "ix_time_entry_time_label_labels_id", + table: "time_entry_time_label", + column: "labels_id"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "forgot_password_requests"); + + migrationBuilder.DropTable( + name: "time_entry_time_label"); + + migrationBuilder.DropTable( + name: "users"); + + migrationBuilder.DropTable( + name: "time_entries"); + + migrationBuilder.DropTable( + name: "time_labels"); + + migrationBuilder.DropTable( + name: "time_categories"); + } + } +} diff --git a/server/src/Migrations/20210522165932_RenameNoteToDescription.Designer.cs b/server/src/Migrations/20210522165932_RenameNoteToDescription.Designer.cs new file mode 100644 index 0000000..368e6b3 --- /dev/null +++ b/server/src/Migrations/20210522165932_RenameNoteToDescription.Designer.cs @@ -0,0 +1,229 @@ +// <auto-generated /> +using System; +using IOL.GreatOffice.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +namespace IOL.GreatOffice.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20210522165932_RenameNoteToDescription")] + partial class RenameNoteToDescription + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Relational:MaxIdentifierLength", 63) + .HasAnnotation("ProductVersion", "5.0.6") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp without time zone") + .HasColumnName("created"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_forgot_password_requests"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_forgot_password_requests_user_id"); + + b.ToTable("forgot_password_requests"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp without time zone") + .HasColumnName("created"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_categories"); + + b.ToTable("time_categories"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<Guid?>("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp without time zone") + .HasColumnName("created"); + + b.Property<string>("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property<DateTime>("Start") + .HasColumnType("timestamp without time zone") + .HasColumnName("start"); + + b.Property<DateTime>("Stop") + .HasColumnType("timestamp without time zone") + .HasColumnName("stop"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_entries"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_time_entries_category_id"); + + b.ToTable("time_entries"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp without time zone") + .HasColumnName("created"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_labels"); + + b.ToTable("time_labels"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp without time zone") + .HasColumnName("created"); + + b.Property<string>("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property<string>("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.ToTable("users"); + }); + + modelBuilder.Entity("TimeEntryTimeLabel", b => + { + b.Property<Guid>("EntriesId") + .HasColumnType("uuid") + .HasColumnName("entries_id"); + + b.Property<Guid>("LabelsId") + .HasColumnType("uuid") + .HasColumnName("labels_id"); + + b.HasKey("EntriesId", "LabelsId") + .HasName("pk_time_entry_time_label"); + + b.HasIndex("LabelsId") + .HasDatabaseName("ix_time_entry_time_label_labels_id"); + + b.ToTable("time_entry_time_label"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_forgot_password_requests_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_time_entries_time_categories_category_id"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("TimeEntryTimeLabel", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null) + .WithMany() + .HasForeignKey("EntriesId") + .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null) + .WithMany() + .HasForeignKey("LabelsId") + .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Migrations/20210522165932_RenameNoteToDescription.cs b/server/src/Migrations/20210522165932_RenameNoteToDescription.cs new file mode 100644 index 0000000..e5bae54 --- /dev/null +++ b/server/src/Migrations/20210522165932_RenameNoteToDescription.cs @@ -0,0 +1,34 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace IOL.GreatOffice.Api.Migrations +{ + public partial class RenameNoteToDescription : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + table: "users", + keyColumn: "id", + keyValue: new Guid("784938f0-cc0e-46ec-afa6-fc60b47b28db")); + + migrationBuilder.RenameColumn( + name: "note", + table: "time_entries", + newName: "description"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "description", + table: "time_entries", + newName: "note"); + + migrationBuilder.InsertData( + table: "users", + columns: new[] { "id", "created", "password", "username" }, + values: new object[] { new Guid("784938f0-cc0e-46ec-afa6-fc60b47b28db"), new DateTime(2021, 5, 17, 20, 21, 14, 827, DateTimeKind.Utc).AddTicks(4868), "AAAAAAEAACcQAAAAEJdtrX3pEeIbcgY+BDAr56gvfbc420ag1TllA0cK6Q6Gw3+gGDIQtYIZnisW3dmqaQ==", "admin@ivarlovlie.no" }); + } + } +} diff --git a/server/src/Migrations/20211002113037_V6Migration.Designer.cs b/server/src/Migrations/20211002113037_V6Migration.Designer.cs new file mode 100644 index 0000000..59e6112 --- /dev/null +++ b/server/src/Migrations/20211002113037_V6Migration.Designer.cs @@ -0,0 +1,233 @@ +// <auto-generated /> + + +#nullable disable + +using System; +using IOL.GreatOffice.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +namespace IOL.GreatOffice.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20211002113037_V6Migration")] + partial class V6Migration + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.0-rc.1.21452.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_forgot_password_requests"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_forgot_password_requests_user_id"); + + b.ToTable("forgot_password_requests", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_categories"); + + b.ToTable("time_categories", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<Guid?>("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property<string>("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property<DateTime>("Start") + .HasColumnType("timestamp with time zone") + .HasColumnName("start"); + + b.Property<DateTime>("Stop") + .HasColumnType("timestamp with time zone") + .HasColumnName("stop"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_entries"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_time_entries_category_id"); + + b.ToTable("time_entries", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_labels"); + + b.ToTable("time_labels", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property<string>("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property<string>("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("TimeEntryTimeLabel", b => + { + b.Property<Guid>("EntriesId") + .HasColumnType("uuid") + .HasColumnName("entries_id"); + + b.Property<Guid>("LabelsId") + .HasColumnType("uuid") + .HasColumnName("labels_id"); + + b.HasKey("EntriesId", "LabelsId") + .HasName("pk_time_entry_time_label"); + + b.HasIndex("LabelsId") + .HasDatabaseName("ix_time_entry_time_label_labels_id"); + + b.ToTable("time_entry_time_label", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_forgot_password_requests_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_time_entries_time_categories_category_id"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("TimeEntryTimeLabel", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null) + .WithMany() + .HasForeignKey("EntriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id"); + + b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null) + .WithMany() + .HasForeignKey("LabelsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Migrations/20211002113037_V6Migration.cs b/server/src/Migrations/20211002113037_V6Migration.cs new file mode 100644 index 0000000..c7ac971 --- /dev/null +++ b/server/src/Migrations/20211002113037_V6Migration.cs @@ -0,0 +1,130 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace IOL.GreatOffice.Api.Migrations +{ + public partial class V6Migration : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("SET TimeZone='UTC'"); + migrationBuilder.AlterColumn<DateTime>( + name: "created", + table: "users", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn<DateTime>( + name: "created", + table: "time_labels", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn<DateTime>( + name: "stop", + table: "time_entries", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn<DateTime>( + name: "start", + table: "time_entries", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn<DateTime>( + name: "created", + table: "time_entries", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn<DateTime>( + name: "created", + table: "time_categories", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn<DateTime>( + name: "created", + table: "forgot_password_requests", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("SET TimeZone='UTC'"); + migrationBuilder.AlterColumn<DateTime>( + name: "created", + table: "users", + type: "timestamp without time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn<DateTime>( + name: "created", + table: "time_labels", + type: "timestamp without time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn<DateTime>( + name: "stop", + table: "time_entries", + type: "timestamp without time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn<DateTime>( + name: "start", + table: "time_entries", + type: "timestamp without time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn<DateTime>( + name: "created", + table: "time_entries", + type: "timestamp without time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn<DateTime>( + name: "created", + table: "time_categories", + type: "timestamp without time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn<DateTime>( + name: "created", + table: "forgot_password_requests", + type: "timestamp without time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + } + } +} diff --git a/server/src/Migrations/20220225143559_GithubUserMappings.Designer.cs b/server/src/Migrations/20220225143559_GithubUserMappings.Designer.cs new file mode 100644 index 0000000..2b95f9d --- /dev/null +++ b/server/src/Migrations/20220225143559_GithubUserMappings.Designer.cs @@ -0,0 +1,270 @@ +// <auto-generated /> + + +#nullable disable + +using System; +using IOL.GreatOffice.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +namespace IOL.GreatOffice.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20220225143559_GithubUserMappings")] + partial class GithubUserMappings + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_forgot_password_requests"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_forgot_password_requests_user_id"); + + b.ToTable("forgot_password_requests", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b => + { + b.Property<string>("GithubId") + .HasColumnType("text") + .HasColumnName("github_id"); + + b.Property<string>("Email") + .HasColumnType("text") + .HasColumnName("email"); + + b.Property<string>("RefreshToken") + .HasColumnType("text") + .HasColumnName("refresh_token"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("GithubId") + .HasName("pk_github_user_mappings"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_github_user_mappings_user_id"); + + b.ToTable("github_user_mappings", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_categories"); + + b.ToTable("time_categories", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<Guid?>("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property<string>("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property<DateTime>("Start") + .HasColumnType("timestamp with time zone") + .HasColumnName("start"); + + b.Property<DateTime>("Stop") + .HasColumnType("timestamp with time zone") + .HasColumnName("stop"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_entries"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_time_entries_category_id"); + + b.ToTable("time_entries", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_labels"); + + b.ToTable("time_labels", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property<string>("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property<string>("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("TimeEntryTimeLabel", b => + { + b.Property<Guid>("EntriesId") + .HasColumnType("uuid") + .HasColumnName("entries_id"); + + b.Property<Guid>("LabelsId") + .HasColumnType("uuid") + .HasColumnName("labels_id"); + + b.HasKey("EntriesId", "LabelsId") + .HasName("pk_time_entry_time_label"); + + b.HasIndex("LabelsId") + .HasDatabaseName("ix_time_entry_time_label_labels_id"); + + b.ToTable("time_entry_time_label", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_forgot_password_requests_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_github_user_mappings_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_time_entries_time_categories_category_id"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("TimeEntryTimeLabel", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null) + .WithMany() + .HasForeignKey("EntriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id"); + + b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null) + .WithMany() + .HasForeignKey("LabelsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Migrations/20220225143559_GithubUserMappings.cs b/server/src/Migrations/20220225143559_GithubUserMappings.cs new file mode 100644 index 0000000..fc30c7a --- /dev/null +++ b/server/src/Migrations/20220225143559_GithubUserMappings.cs @@ -0,0 +1,43 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace IOL.GreatOffice.Api.Migrations +{ + public partial class GithubUserMappings : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "github_user_mappings", + columns: table => new + { + github_id = table.Column<string>(type: "text", nullable: false), + user_id = table.Column<Guid>(type: "uuid", nullable: true), + email = table.Column<string>(type: "text", nullable: true), + refresh_token = table.Column<string>(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_github_user_mappings", x => x.github_id); + table.ForeignKey( + name: "fk_github_user_mappings_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id"); + }); + + migrationBuilder.CreateIndex( + name: "ix_github_user_mappings_user_id", + table: "github_user_mappings", + column: "user_id"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "github_user_mappings"); + } + } +} diff --git a/server/src/Migrations/20220319135910_RenameCreated.Designer.cs b/server/src/Migrations/20220319135910_RenameCreated.Designer.cs new file mode 100644 index 0000000..3d57f1a --- /dev/null +++ b/server/src/Migrations/20220319135910_RenameCreated.Designer.cs @@ -0,0 +1,270 @@ +// <auto-generated /> + + +#nullable disable + +using System; +using IOL.GreatOffice.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +namespace IOL.GreatOffice.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20220319135910_RenameCreated")] + partial class RenameCreated + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_forgot_password_requests"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_forgot_password_requests_user_id"); + + b.ToTable("forgot_password_requests", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b => + { + b.Property<string>("GithubId") + .HasColumnType("text") + .HasColumnName("github_id"); + + b.Property<string>("Email") + .HasColumnType("text") + .HasColumnName("email"); + + b.Property<string>("RefreshToken") + .HasColumnType("text") + .HasColumnName("refresh_token"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("GithubId") + .HasName("pk_github_user_mappings"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_github_user_mappings_user_id"); + + b.ToTable("github_user_mappings", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_categories"); + + b.ToTable("time_categories", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<Guid?>("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<string>("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property<DateTime>("Start") + .HasColumnType("timestamp with time zone") + .HasColumnName("start"); + + b.Property<DateTime>("Stop") + .HasColumnType("timestamp with time zone") + .HasColumnName("stop"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_entries"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_time_entries_category_id"); + + b.ToTable("time_entries", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_labels"); + + b.ToTable("time_labels", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<string>("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property<string>("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("TimeEntryTimeLabel", b => + { + b.Property<Guid>("EntriesId") + .HasColumnType("uuid") + .HasColumnName("entries_id"); + + b.Property<Guid>("LabelsId") + .HasColumnType("uuid") + .HasColumnName("labels_id"); + + b.HasKey("EntriesId", "LabelsId") + .HasName("pk_time_entry_time_label"); + + b.HasIndex("LabelsId") + .HasDatabaseName("ix_time_entry_time_label_labels_id"); + + b.ToTable("time_entry_time_label", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_forgot_password_requests_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_github_user_mappings_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_time_entries_time_categories_category_id"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("TimeEntryTimeLabel", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null) + .WithMany() + .HasForeignKey("EntriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id"); + + b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null) + .WithMany() + .HasForeignKey("LabelsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Migrations/20220319135910_RenameCreated.cs b/server/src/Migrations/20220319135910_RenameCreated.cs new file mode 100644 index 0000000..6571e50 --- /dev/null +++ b/server/src/Migrations/20220319135910_RenameCreated.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace IOL.GreatOffice.Api.Migrations +{ + public partial class RenameCreated : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "created", + table: "users", + newName: "created_at"); + + migrationBuilder.RenameColumn( + name: "created", + table: "time_labels", + newName: "created_at"); + + migrationBuilder.RenameColumn( + name: "created", + table: "time_entries", + newName: "created_at"); + + migrationBuilder.RenameColumn( + name: "created", + table: "time_categories", + newName: "created_at"); + + migrationBuilder.RenameColumn( + name: "created", + table: "forgot_password_requests", + newName: "created_at"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "created_at", + table: "users", + newName: "created"); + + migrationBuilder.RenameColumn( + name: "created_at", + table: "time_labels", + newName: "created"); + + migrationBuilder.RenameColumn( + name: "created_at", + table: "time_entries", + newName: "created"); + + migrationBuilder.RenameColumn( + name: "created_at", + table: "time_categories", + newName: "created"); + + migrationBuilder.RenameColumn( + name: "created_at", + table: "forgot_password_requests", + newName: "created"); + } + } +} diff --git a/server/src/Migrations/20220319144958_ModifiedAt.Designer.cs b/server/src/Migrations/20220319144958_ModifiedAt.Designer.cs new file mode 100644 index 0000000..f75400e --- /dev/null +++ b/server/src/Migrations/20220319144958_ModifiedAt.Designer.cs @@ -0,0 +1,290 @@ +// <auto-generated /> + + +#nullable disable + +using System; +using IOL.GreatOffice.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +namespace IOL.GreatOffice.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20220319144958_ModifiedAt")] + partial class ModifiedAt + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_forgot_password_requests"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_forgot_password_requests_user_id"); + + b.ToTable("forgot_password_requests", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b => + { + b.Property<string>("GithubId") + .HasColumnType("text") + .HasColumnName("github_id"); + + b.Property<string>("Email") + .HasColumnType("text") + .HasColumnName("email"); + + b.Property<string>("RefreshToken") + .HasColumnType("text") + .HasColumnName("refresh_token"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("GithubId") + .HasName("pk_github_user_mappings"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_github_user_mappings_user_id"); + + b.ToTable("github_user_mappings", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_categories"); + + b.ToTable("time_categories", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<Guid?>("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<string>("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<DateTime>("Start") + .HasColumnType("timestamp with time zone") + .HasColumnName("start"); + + b.Property<DateTime>("Stop") + .HasColumnType("timestamp with time zone") + .HasColumnName("stop"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_entries"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_time_entries_category_id"); + + b.ToTable("time_entries", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_labels"); + + b.ToTable("time_labels", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<string>("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property<string>("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("TimeEntryTimeLabel", b => + { + b.Property<Guid>("EntriesId") + .HasColumnType("uuid") + .HasColumnName("entries_id"); + + b.Property<Guid>("LabelsId") + .HasColumnType("uuid") + .HasColumnName("labels_id"); + + b.HasKey("EntriesId", "LabelsId") + .HasName("pk_time_entry_time_label"); + + b.HasIndex("LabelsId") + .HasDatabaseName("ix_time_entry_time_label_labels_id"); + + b.ToTable("time_entry_time_label", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_forgot_password_requests_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_github_user_mappings_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_time_entries_time_categories_category_id"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("TimeEntryTimeLabel", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null) + .WithMany() + .HasForeignKey("EntriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id"); + + b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null) + .WithMany() + .HasForeignKey("LabelsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Migrations/20220319144958_ModifiedAt.cs b/server/src/Migrations/20220319144958_ModifiedAt.cs new file mode 100644 index 0000000..028473d --- /dev/null +++ b/server/src/Migrations/20220319144958_ModifiedAt.cs @@ -0,0 +1,66 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace IOL.GreatOffice.Api.Migrations +{ + public partial class ModifiedAt : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<DateTime>( + name: "modified_at", + table: "users", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn<DateTime>( + name: "modified_at", + table: "time_labels", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn<DateTime>( + name: "modified_at", + table: "time_entries", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn<DateTime>( + name: "modified_at", + table: "time_categories", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn<DateTime>( + name: "modified_at", + table: "forgot_password_requests", + type: "timestamp with time zone", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "modified_at", + table: "users"); + + migrationBuilder.DropColumn( + name: "modified_at", + table: "time_labels"); + + migrationBuilder.DropColumn( + name: "modified_at", + table: "time_entries"); + + migrationBuilder.DropColumn( + name: "modified_at", + table: "time_categories"); + + migrationBuilder.DropColumn( + name: "modified_at", + table: "forgot_password_requests"); + } + } +} diff --git a/server/src/Migrations/20220319203018_UserBase.Designer.cs b/server/src/Migrations/20220319203018_UserBase.Designer.cs new file mode 100644 index 0000000..6c7a76f --- /dev/null +++ b/server/src/Migrations/20220319203018_UserBase.Designer.cs @@ -0,0 +1,322 @@ +// <auto-generated /> + + +#nullable disable + +using System; +using IOL.GreatOffice.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +namespace IOL.GreatOffice.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20220319203018_UserBase")] + partial class UserBase + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_forgot_password_requests"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_forgot_password_requests_user_id"); + + b.ToTable("forgot_password_requests", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b => + { + b.Property<string>("GithubId") + .HasColumnType("text") + .HasColumnName("github_id"); + + b.Property<string>("Email") + .HasColumnType("text") + .HasColumnName("email"); + + b.Property<string>("RefreshToken") + .HasColumnType("text") + .HasColumnName("refresh_token"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("GithubId") + .HasName("pk_github_user_mappings"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_github_user_mappings_user_id"); + + b.ToTable("github_user_mappings", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_categories"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_time_categories_user_id"); + + b.ToTable("time_categories", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<Guid?>("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<string>("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<DateTime>("Start") + .HasColumnType("timestamp with time zone") + .HasColumnName("start"); + + b.Property<DateTime>("Stop") + .HasColumnType("timestamp with time zone") + .HasColumnName("stop"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_entries"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_time_entries_category_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_time_entries_user_id"); + + b.ToTable("time_entries", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_labels"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_time_labels_user_id"); + + b.ToTable("time_labels", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<string>("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.Property<string>("password") + .HasColumnType("text") + .HasColumnName("password"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("TimeEntryTimeLabel", b => + { + b.Property<Guid>("EntriesId") + .HasColumnType("uuid") + .HasColumnName("entries_id"); + + b.Property<Guid>("LabelsId") + .HasColumnType("uuid") + .HasColumnName("labels_id"); + + b.HasKey("EntriesId", "LabelsId") + .HasName("pk_time_entry_time_label"); + + b.HasIndex("LabelsId") + .HasDatabaseName("ix_time_entry_time_label_labels_id"); + + b.ToTable("time_entry_time_label", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_forgot_password_requests_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_github_user_mappings_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_time_categories_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_time_entries_time_categories_category_id"); + + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_time_entries_users_user_id"); + + b.Navigation("Category"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_time_labels_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TimeEntryTimeLabel", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null) + .WithMany() + .HasForeignKey("EntriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id"); + + b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null) + .WithMany() + .HasForeignKey("LabelsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Migrations/20220319203018_UserBase.cs b/server/src/Migrations/20220319203018_UserBase.cs new file mode 100644 index 0000000..14d3f4b --- /dev/null +++ b/server/src/Migrations/20220319203018_UserBase.cs @@ -0,0 +1,140 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace IOL.GreatOffice.Api.Migrations +{ + public partial class UserBase : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "modified_at", + table: "forgot_password_requests"); + + migrationBuilder.AlterColumn<Guid>( + name: "user_id", + table: "time_labels", + type: "uuid", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uuid"); + + migrationBuilder.AlterColumn<Guid>( + name: "user_id", + table: "time_entries", + type: "uuid", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uuid"); + + migrationBuilder.AlterColumn<Guid>( + name: "user_id", + table: "time_categories", + type: "uuid", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uuid"); + + migrationBuilder.CreateIndex( + name: "ix_time_labels_user_id", + table: "time_labels", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "ix_time_entries_user_id", + table: "time_entries", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "ix_time_categories_user_id", + table: "time_categories", + column: "user_id"); + + migrationBuilder.AddForeignKey( + name: "fk_time_categories_users_user_id", + table: "time_categories", + column: "user_id", + principalTable: "users", + principalColumn: "id"); + + migrationBuilder.AddForeignKey( + name: "fk_time_entries_users_user_id", + table: "time_entries", + column: "user_id", + principalTable: "users", + principalColumn: "id"); + + migrationBuilder.AddForeignKey( + name: "fk_time_labels_users_user_id", + table: "time_labels", + column: "user_id", + principalTable: "users", + principalColumn: "id"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_time_categories_users_user_id", + table: "time_categories"); + + migrationBuilder.DropForeignKey( + name: "fk_time_entries_users_user_id", + table: "time_entries"); + + migrationBuilder.DropForeignKey( + name: "fk_time_labels_users_user_id", + table: "time_labels"); + + migrationBuilder.DropIndex( + name: "ix_time_labels_user_id", + table: "time_labels"); + + migrationBuilder.DropIndex( + name: "ix_time_entries_user_id", + table: "time_entries"); + + migrationBuilder.DropIndex( + name: "ix_time_categories_user_id", + table: "time_categories"); + + migrationBuilder.AlterColumn<Guid>( + name: "user_id", + table: "time_labels", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true); + + migrationBuilder.AlterColumn<Guid>( + name: "user_id", + table: "time_entries", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true); + + migrationBuilder.AlterColumn<Guid>( + name: "user_id", + table: "time_categories", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true); + + migrationBuilder.AddColumn<DateTime>( + name: "modified_at", + table: "forgot_password_requests", + type: "timestamp with time zone", + nullable: true); + } + } +} diff --git a/server/src/Migrations/20220320115601_Update1.Designer.cs b/server/src/Migrations/20220320115601_Update1.Designer.cs new file mode 100644 index 0000000..c7463fb --- /dev/null +++ b/server/src/Migrations/20220320115601_Update1.Designer.cs @@ -0,0 +1,342 @@ +// <auto-generated /> + + +#nullable disable + +using System; +using IOL.GreatOffice.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +namespace IOL.GreatOffice.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20220320115601_Update1")] + partial class Update1 + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_forgot_password_requests"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_forgot_password_requests_user_id"); + + b.ToTable("forgot_password_requests", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b => + { + b.Property<string>("GithubId") + .HasColumnType("text") + .HasColumnName("github_id"); + + b.Property<string>("Email") + .HasColumnType("text") + .HasColumnName("email"); + + b.Property<string>("RefreshToken") + .HasColumnType("text") + .HasColumnName("refresh_token"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("GithubId") + .HasName("pk_github_user_mappings"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_github_user_mappings_user_id"); + + b.ToTable("github_user_mappings", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_categories"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_time_categories_user_id"); + + b.ToTable("time_categories", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<Guid?>("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<string>("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<DateTime>("Start") + .HasColumnType("timestamp with time zone") + .HasColumnName("start"); + + b.Property<DateTime>("Stop") + .HasColumnType("timestamp with time zone") + .HasColumnName("stop"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_entries"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_time_entries_category_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_time_entries_user_id"); + + b.ToTable("time_entries", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_labels"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_time_labels_user_id"); + + b.ToTable("time_labels", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<string>("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.Property<string>("password") + .HasColumnType("text") + .HasColumnName("password"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("TimeEntryTimeLabel", b => + { + b.Property<Guid>("EntriesId") + .HasColumnType("uuid") + .HasColumnName("entries_id"); + + b.Property<Guid>("LabelsId") + .HasColumnType("uuid") + .HasColumnName("labels_id"); + + b.HasKey("EntriesId", "LabelsId") + .HasName("pk_time_entry_time_label"); + + b.HasIndex("LabelsId") + .HasDatabaseName("ix_time_entry_time_label_labels_id"); + + b.ToTable("time_entry_time_label", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_forgot_password_requests_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_github_user_mappings_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany("Categories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_categories_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category") + .WithMany("Entries") + .HasForeignKey("CategoryId") + .HasConstraintName("fk_time_entries_time_categories_category_id"); + + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany("Entries") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entries_users_user_id"); + + b.Navigation("Category"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany("Labels") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_labels_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TimeEntryTimeLabel", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null) + .WithMany() + .HasForeignKey("EntriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id"); + + b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null) + .WithMany() + .HasForeignKey("LabelsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b => + { + b.Navigation("Entries"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b => + { + b.Navigation("Categories"); + + b.Navigation("Entries"); + + b.Navigation("Labels"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Migrations/20220320115601_Update1.cs b/server/src/Migrations/20220320115601_Update1.cs new file mode 100644 index 0000000..8b06fb7 --- /dev/null +++ b/server/src/Migrations/20220320115601_Update1.cs @@ -0,0 +1,139 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace IOL.GreatOffice.Api.Migrations +{ + public partial class Update1 : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_time_categories_users_user_id", + table: "time_categories"); + + migrationBuilder.DropForeignKey( + name: "fk_time_entries_users_user_id", + table: "time_entries"); + + migrationBuilder.DropForeignKey( + name: "fk_time_labels_users_user_id", + table: "time_labels"); + + migrationBuilder.AlterColumn<Guid>( + name: "user_id", + table: "time_labels", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true); + + migrationBuilder.AlterColumn<Guid>( + name: "user_id", + table: "time_entries", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true); + + migrationBuilder.AlterColumn<Guid>( + name: "user_id", + table: "time_categories", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true); + + migrationBuilder.AddForeignKey( + name: "fk_time_categories_users_user_id", + table: "time_categories", + column: "user_id", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_time_entries_users_user_id", + table: "time_entries", + column: "user_id", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_time_labels_users_user_id", + table: "time_labels", + column: "user_id", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_time_categories_users_user_id", + table: "time_categories"); + + migrationBuilder.DropForeignKey( + name: "fk_time_entries_users_user_id", + table: "time_entries"); + + migrationBuilder.DropForeignKey( + name: "fk_time_labels_users_user_id", + table: "time_labels"); + + migrationBuilder.AlterColumn<Guid>( + name: "user_id", + table: "time_labels", + type: "uuid", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uuid"); + + migrationBuilder.AlterColumn<Guid>( + name: "user_id", + table: "time_entries", + type: "uuid", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uuid"); + + migrationBuilder.AlterColumn<Guid>( + name: "user_id", + table: "time_categories", + type: "uuid", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uuid"); + + migrationBuilder.AddForeignKey( + name: "fk_time_categories_users_user_id", + table: "time_categories", + column: "user_id", + principalTable: "users", + principalColumn: "id"); + + migrationBuilder.AddForeignKey( + name: "fk_time_entries_users_user_id", + table: "time_entries", + column: "user_id", + principalTable: "users", + principalColumn: "id"); + + migrationBuilder.AddForeignKey( + name: "fk_time_labels_users_user_id", + table: "time_labels", + column: "user_id", + principalTable: "users", + principalColumn: "id"); + } + } +} diff --git a/server/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.Designer.cs b/server/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.Designer.cs new file mode 100644 index 0000000..3a18463 --- /dev/null +++ b/server/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.Designer.cs @@ -0,0 +1,344 @@ +// <auto-generated /> + + +#nullable disable + +using System; +using IOL.GreatOffice.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +namespace IOL.GreatOffice.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20220320132220_UpdatedForgotPasswordRequests")] + partial class UpdatedForgotPasswordRequests + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_forgot_password_requests"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_forgot_password_requests_user_id"); + + b.ToTable("forgot_password_requests", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b => + { + b.Property<string>("GithubId") + .HasColumnType("text") + .HasColumnName("github_id"); + + b.Property<string>("Email") + .HasColumnType("text") + .HasColumnName("email"); + + b.Property<string>("RefreshToken") + .HasColumnType("text") + .HasColumnName("refresh_token"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("GithubId") + .HasName("pk_github_user_mappings"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_github_user_mappings_user_id"); + + b.ToTable("github_user_mappings", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_categories"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_time_categories_user_id"); + + b.ToTable("time_categories", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<Guid?>("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<string>("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<DateTime>("Start") + .HasColumnType("timestamp with time zone") + .HasColumnName("start"); + + b.Property<DateTime>("Stop") + .HasColumnType("timestamp with time zone") + .HasColumnName("stop"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_entries"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_time_entries_category_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_time_entries_user_id"); + + b.ToTable("time_entries", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_labels"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_time_labels_user_id"); + + b.ToTable("time_labels", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<string>("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property<string>("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("TimeEntryTimeLabel", b => + { + b.Property<Guid>("EntriesId") + .HasColumnType("uuid") + .HasColumnName("entries_id"); + + b.Property<Guid>("LabelsId") + .HasColumnType("uuid") + .HasColumnName("labels_id"); + + b.HasKey("EntriesId", "LabelsId") + .HasName("pk_time_entry_time_label"); + + b.HasIndex("LabelsId") + .HasDatabaseName("ix_time_entry_time_label_labels_id"); + + b.ToTable("time_entry_time_label", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_forgot_password_requests_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_github_user_mappings_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany("Categories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_categories_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category") + .WithMany("Entries") + .HasForeignKey("CategoryId") + .HasConstraintName("fk_time_entries_time_categories_category_id"); + + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany("Entries") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entries_users_user_id"); + + b.Navigation("Category"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.User", "User") + .WithMany("Labels") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_labels_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TimeEntryTimeLabel", b => + { + b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null) + .WithMany() + .HasForeignKey("EntriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id"); + + b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null) + .WithMany() + .HasForeignKey("LabelsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b => + { + b.Navigation("Entries"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b => + { + b.Navigation("Categories"); + + b.Navigation("Entries"); + + b.Navigation("Labels"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.cs b/server/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.cs new file mode 100644 index 0000000..df7a195 --- /dev/null +++ b/server/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace IOL.GreatOffice.Api.Migrations +{ + public partial class UpdatedForgotPasswordRequests : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_forgot_password_requests_users_user_id", + table: "forgot_password_requests"); + + migrationBuilder.AlterColumn<Guid>( + name: "user_id", + table: "forgot_password_requests", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true); + + migrationBuilder.AddForeignKey( + name: "fk_forgot_password_requests_users_user_id", + table: "forgot_password_requests", + column: "user_id", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_forgot_password_requests_users_user_id", + table: "forgot_password_requests"); + + migrationBuilder.AlterColumn<Guid>( + name: "user_id", + table: "forgot_password_requests", + type: "uuid", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uuid"); + + migrationBuilder.AddForeignKey( + name: "fk_forgot_password_requests_users_user_id", + table: "forgot_password_requests", + column: "user_id", + principalTable: "users", + principalColumn: "id"); + } + } +} diff --git a/server/src/Migrations/20220529190359_ApiAccessTokens.Designer.cs b/server/src/Migrations/20220529190359_ApiAccessTokens.Designer.cs new file mode 100644 index 0000000..74f9b40 --- /dev/null +++ b/server/src/Migrations/20220529190359_ApiAccessTokens.Designer.cs @@ -0,0 +1,401 @@ +// <auto-generated /> +using System; +using IOL.GreatOffice.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace IOL.GreatOffice.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20220529190359_ApiAccessTokens")] + partial class ApiAccessTokens + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<bool>("AllowCreate") + .HasColumnType("boolean") + .HasColumnName("allow_create"); + + b.Property<bool>("AllowDelete") + .HasColumnType("boolean") + .HasColumnName("allow_delete"); + + b.Property<bool>("AllowRead") + .HasColumnType("boolean") + .HasColumnName("allow_read"); + + b.Property<bool>("AllowUpdate") + .HasColumnType("boolean") + .HasColumnName("allow_update"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<DateTime>("ExpiryDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiry_date"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_api_access_tokens"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_api_access_tokens_user_id"); + + b.ToTable("api_access_tokens", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_forgot_password_requests"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_forgot_password_requests_user_id"); + + b.ToTable("forgot_password_requests", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b => + { + b.Property<string>("GithubId") + .HasColumnType("text") + .HasColumnName("github_id"); + + b.Property<string>("Email") + .HasColumnType("text") + .HasColumnName("email"); + + b.Property<string>("RefreshToken") + .HasColumnType("text") + .HasColumnName("refresh_token"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("GithubId") + .HasName("pk_github_user_mappings"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_github_user_mappings_user_id"); + + b.ToTable("github_user_mappings", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_categories"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_time_categories_user_id"); + + b.ToTable("time_categories", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<Guid?>("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<string>("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<DateTime>("Start") + .HasColumnType("timestamp with time zone") + .HasColumnName("start"); + + b.Property<DateTime>("Stop") + .HasColumnType("timestamp with time zone") + .HasColumnName("stop"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_entries"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_time_entries_category_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_time_entries_user_id"); + + b.ToTable("time_entries", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_labels"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_time_labels_user_id"); + + b.ToTable("time_labels", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<string>("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property<string>("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("TimeEntryTimeLabel", b => + { + b.Property<Guid>("EntriesId") + .HasColumnType("uuid") + .HasColumnName("entries_id"); + + b.Property<Guid>("LabelsId") + .HasColumnType("uuid") + .HasColumnName("labels_id"); + + b.HasKey("EntriesId", "LabelsId") + .HasName("pk_time_entry_time_label"); + + b.HasIndex("LabelsId") + .HasDatabaseName("ix_time_entry_time_label_labels_id"); + + b.ToTable("time_entry_time_label", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_api_access_tokens_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_forgot_password_requests_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_github_user_mappings_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany("Categories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_categories_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeCategory", "Category") + .WithMany("Entries") + .HasForeignKey("CategoryId") + .HasConstraintName("fk_time_entries_time_categories_category_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany("Entries") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entries_users_user_id"); + + b.Navigation("Category"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany("Labels") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_labels_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TimeEntryTimeLabel", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeEntry", null) + .WithMany() + .HasForeignKey("EntriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeLabel", null) + .WithMany() + .HasForeignKey("LabelsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b => + { + b.Navigation("Entries"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.User", b => + { + b.Navigation("Categories"); + + b.Navigation("Entries"); + + b.Navigation("Labels"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Migrations/20220529190359_ApiAccessTokens.cs b/server/src/Migrations/20220529190359_ApiAccessTokens.cs new file mode 100644 index 0000000..dc44bee --- /dev/null +++ b/server/src/Migrations/20220529190359_ApiAccessTokens.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace IOL.GreatOffice.Api.Migrations +{ + public partial class ApiAccessTokens : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "api_access_tokens", + columns: table => new + { + id = table.Column<Guid>(type: "uuid", nullable: false), + user_id = table.Column<Guid>(type: "uuid", nullable: true), + expiry_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), + allow_read = table.Column<bool>(type: "boolean", nullable: false), + allow_create = table.Column<bool>(type: "boolean", nullable: false), + allow_update = table.Column<bool>(type: "boolean", nullable: false), + allow_delete = table.Column<bool>(type: "boolean", nullable: false), + created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), + modified_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_api_access_tokens", x => x.id); + table.ForeignKey( + name: "fk_api_access_tokens_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id"); + }); + + migrationBuilder.CreateIndex( + name: "ix_api_access_tokens_user_id", + table: "api_access_tokens", + column: "user_id"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "api_access_tokens"); + } + } +} diff --git a/server/src/Migrations/20220530174741_Tenants.Designer.cs b/server/src/Migrations/20220530174741_Tenants.Designer.cs new file mode 100644 index 0000000..678c52d --- /dev/null +++ b/server/src/Migrations/20220530174741_Tenants.Designer.cs @@ -0,0 +1,710 @@ +// <auto-generated /> +using System; +using IOL.GreatOffice.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace IOL.GreatOffice.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20220530174741_Tenants")] + partial class Tenants + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<bool>("AllowCreate") + .HasColumnType("boolean") + .HasColumnName("allow_create"); + + b.Property<bool>("AllowDelete") + .HasColumnType("boolean") + .HasColumnName("allow_delete"); + + b.Property<bool>("AllowRead") + .HasColumnType("boolean") + .HasColumnName("allow_read"); + + b.Property<bool>("AllowUpdate") + .HasColumnType("boolean") + .HasColumnName("allow_update"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<DateTime>("ExpiryDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiry_date"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_api_access_tokens"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_api_access_tokens_user_id"); + + b.ToTable("api_access_tokens", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_forgot_password_requests"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_forgot_password_requests_user_id"); + + b.ToTable("forgot_password_requests", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b => + { + b.Property<string>("GithubId") + .HasColumnType("text") + .HasColumnName("github_id"); + + b.Property<string>("Email") + .HasColumnType("text") + .HasColumnName("email"); + + b.Property<string>("RefreshToken") + .HasColumnType("text") + .HasColumnName("refresh_token"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("GithubId") + .HasName("pk_github_user_mappings"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_github_user_mappings_user_id"); + + b.ToTable("github_user_mappings", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.Tenant", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("ContactEmail") + .HasColumnType("text") + .HasColumnName("contact_email"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Guid>("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property<Guid>("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property<string>("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property<Guid>("MasterUserId") + .HasColumnType("uuid") + .HasColumnName("master_user_id"); + + b.Property<string>("MasterUserPassword") + .HasColumnType("text") + .HasColumnName("master_user_password"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<Guid>("ModifiedById") + .HasColumnType("uuid") + .HasColumnName("modified_by_id"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property<Guid?>("TenantId1") + .HasColumnType("uuid") + .HasColumnName("tenant_id1"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_tenants"); + + b.HasIndex("CreatedById") + .HasDatabaseName("ix_tenants_created_by_id"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_tenants_deleted_by_id"); + + b.HasIndex("ModifiedById") + .HasDatabaseName("ix_tenants_modified_by_id"); + + b.HasIndex("TenantId1") + .HasDatabaseName("ix_tenants_tenant_id1"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_tenants_user_id"); + + b.ToTable("tenants", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Guid>("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property<Guid>("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<Guid>("ModifiedById") + .HasColumnType("uuid") + .HasColumnName("modified_by_id"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_categories"); + + b.HasIndex("CreatedById") + .HasDatabaseName("ix_time_categories_created_by_id"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_time_categories_deleted_by_id"); + + b.HasIndex("ModifiedById") + .HasDatabaseName("ix_time_categories_modified_by_id"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_time_categories_tenant_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_time_categories_user_id"); + + b.ToTable("time_categories", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<Guid?>("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Guid>("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property<Guid>("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property<string>("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<Guid>("ModifiedById") + .HasColumnType("uuid") + .HasColumnName("modified_by_id"); + + b.Property<DateTime>("Start") + .HasColumnType("timestamp with time zone") + .HasColumnName("start"); + + b.Property<DateTime>("Stop") + .HasColumnType("timestamp with time zone") + .HasColumnName("stop"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_entries"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_time_entries_category_id"); + + b.HasIndex("CreatedById") + .HasDatabaseName("ix_time_entries_created_by_id"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_time_entries_deleted_by_id"); + + b.HasIndex("ModifiedById") + .HasDatabaseName("ix_time_entries_modified_by_id"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_time_entries_tenant_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_time_entries_user_id"); + + b.ToTable("time_entries", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Guid>("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property<Guid>("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<Guid>("ModifiedById") + .HasColumnType("uuid") + .HasColumnName("modified_by_id"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_labels"); + + b.HasIndex("CreatedById") + .HasDatabaseName("ix_time_labels_created_by_id"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_time_labels_deleted_by_id"); + + b.HasIndex("ModifiedById") + .HasDatabaseName("ix_time_labels_modified_by_id"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_time_labels_tenant_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_time_labels_user_id"); + + b.ToTable("time_labels", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<string>("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property<string>("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("TimeEntryTimeLabel", b => + { + b.Property<Guid>("EntriesId") + .HasColumnType("uuid") + .HasColumnName("entries_id"); + + b.Property<Guid>("LabelsId") + .HasColumnType("uuid") + .HasColumnName("labels_id"); + + b.HasKey("EntriesId", "LabelsId") + .HasName("pk_time_entry_time_label"); + + b.HasIndex("LabelsId") + .HasDatabaseName("ix_time_entry_time_label_labels_id"); + + b.ToTable("time_entry_time_label", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_api_access_tokens_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_forgot_password_requests_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_github_user_mappings_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.Tenant", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenants_users_created_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy") + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenants_users_deleted_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy") + .WithMany() + .HasForeignKey("ModifiedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenants_users_modified_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId1") + .HasConstraintName("fk_tenants_tenants_tenant_id1"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenants_users_user_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("DeletedBy"); + + b.Navigation("ModifiedBy"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_categories_users_created_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy") + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_categories_users_deleted_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy") + .WithMany() + .HasForeignKey("ModifiedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_categories_users_modified_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_categories_tenants_tenant_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_categories_users_user_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("DeletedBy"); + + b.Navigation("ModifiedBy"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeCategory", "Category") + .WithMany("Entries") + .HasForeignKey("CategoryId") + .HasConstraintName("fk_time_entries_time_categories_category_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entries_users_created_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy") + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entries_users_deleted_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy") + .WithMany() + .HasForeignKey("ModifiedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entries_users_modified_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entries_tenants_tenant_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entries_users_user_id"); + + b.Navigation("Category"); + + b.Navigation("CreatedBy"); + + b.Navigation("DeletedBy"); + + b.Navigation("ModifiedBy"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_labels_users_created_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy") + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_labels_users_deleted_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy") + .WithMany() + .HasForeignKey("ModifiedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_labels_users_modified_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_labels_tenants_tenant_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_labels_users_user_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("DeletedBy"); + + b.Navigation("ModifiedBy"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TimeEntryTimeLabel", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeEntry", null) + .WithMany() + .HasForeignKey("EntriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeLabel", null) + .WithMany() + .HasForeignKey("LabelsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b => + { + b.Navigation("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Migrations/20220530174741_Tenants.cs b/server/src/Migrations/20220530174741_Tenants.cs new file mode 100644 index 0000000..ea02ddd --- /dev/null +++ b/server/src/Migrations/20220530174741_Tenants.cs @@ -0,0 +1,481 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace IOL.GreatOffice.Api.Migrations +{ + public partial class Tenants : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<Guid>( + name: "created_by_id", + table: "time_labels", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn<Guid>( + name: "deleted_by_id", + table: "time_labels", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn<Guid>( + name: "modified_by_id", + table: "time_labels", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn<Guid>( + name: "tenant_id", + table: "time_labels", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn<Guid>( + name: "created_by_id", + table: "time_entries", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn<Guid>( + name: "deleted_by_id", + table: "time_entries", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn<Guid>( + name: "modified_by_id", + table: "time_entries", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn<Guid>( + name: "tenant_id", + table: "time_entries", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn<Guid>( + name: "created_by_id", + table: "time_categories", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn<Guid>( + name: "deleted_by_id", + table: "time_categories", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn<Guid>( + name: "modified_by_id", + table: "time_categories", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn<Guid>( + name: "tenant_id", + table: "time_categories", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.CreateTable( + name: "tenants", + columns: table => new + { + id = table.Column<Guid>(type: "uuid", nullable: false), + name = table.Column<string>(type: "text", nullable: true), + description = table.Column<string>(type: "text", nullable: true), + contact_email = table.Column<string>(type: "text", nullable: true), + master_user_id = table.Column<Guid>(type: "uuid", nullable: false), + master_user_password = table.Column<string>(type: "text", nullable: true), + created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), + modified_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true), + user_id = table.Column<Guid>(type: "uuid", nullable: false), + tenant_id = table.Column<Guid>(type: "uuid", nullable: false), + tenant_id1 = table.Column<Guid>(type: "uuid", nullable: true), + modified_by_id = table.Column<Guid>(type: "uuid", nullable: false), + created_by_id = table.Column<Guid>(type: "uuid", nullable: false), + deleted_by_id = table.Column<Guid>(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_tenants", x => x.id); + table.ForeignKey( + name: "fk_tenants_tenants_tenant_id1", + column: x => x.tenant_id1, + principalTable: "tenants", + principalColumn: "id"); + table.ForeignKey( + name: "fk_tenants_users_created_by_id", + column: x => x.created_by_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_tenants_users_deleted_by_id", + column: x => x.deleted_by_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_tenants_users_modified_by_id", + column: x => x.modified_by_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_tenants_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_time_labels_created_by_id", + table: "time_labels", + column: "created_by_id"); + + migrationBuilder.CreateIndex( + name: "ix_time_labels_deleted_by_id", + table: "time_labels", + column: "deleted_by_id"); + + migrationBuilder.CreateIndex( + name: "ix_time_labels_modified_by_id", + table: "time_labels", + column: "modified_by_id"); + + migrationBuilder.CreateIndex( + name: "ix_time_labels_tenant_id", + table: "time_labels", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "ix_time_entries_created_by_id", + table: "time_entries", + column: "created_by_id"); + + migrationBuilder.CreateIndex( + name: "ix_time_entries_deleted_by_id", + table: "time_entries", + column: "deleted_by_id"); + + migrationBuilder.CreateIndex( + name: "ix_time_entries_modified_by_id", + table: "time_entries", + column: "modified_by_id"); + + migrationBuilder.CreateIndex( + name: "ix_time_entries_tenant_id", + table: "time_entries", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "ix_time_categories_created_by_id", + table: "time_categories", + column: "created_by_id"); + + migrationBuilder.CreateIndex( + name: "ix_time_categories_deleted_by_id", + table: "time_categories", + column: "deleted_by_id"); + + migrationBuilder.CreateIndex( + name: "ix_time_categories_modified_by_id", + table: "time_categories", + column: "modified_by_id"); + + migrationBuilder.CreateIndex( + name: "ix_time_categories_tenant_id", + table: "time_categories", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "ix_tenants_created_by_id", + table: "tenants", + column: "created_by_id"); + + migrationBuilder.CreateIndex( + name: "ix_tenants_deleted_by_id", + table: "tenants", + column: "deleted_by_id"); + + migrationBuilder.CreateIndex( + name: "ix_tenants_modified_by_id", + table: "tenants", + column: "modified_by_id"); + + migrationBuilder.CreateIndex( + name: "ix_tenants_tenant_id1", + table: "tenants", + column: "tenant_id1"); + + migrationBuilder.CreateIndex( + name: "ix_tenants_user_id", + table: "tenants", + column: "user_id"); + + migrationBuilder.AddForeignKey( + name: "fk_time_categories_tenants_tenant_id", + table: "time_categories", + column: "tenant_id", + principalTable: "tenants", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_time_categories_users_created_by_id", + table: "time_categories", + column: "created_by_id", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_time_categories_users_deleted_by_id", + table: "time_categories", + column: "deleted_by_id", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_time_categories_users_modified_by_id", + table: "time_categories", + column: "modified_by_id", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_time_entries_tenants_tenant_id", + table: "time_entries", + column: "tenant_id", + principalTable: "tenants", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_time_entries_users_created_by_id", + table: "time_entries", + column: "created_by_id", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_time_entries_users_deleted_by_id", + table: "time_entries", + column: "deleted_by_id", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_time_entries_users_modified_by_id", + table: "time_entries", + column: "modified_by_id", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_time_labels_tenants_tenant_id", + table: "time_labels", + column: "tenant_id", + principalTable: "tenants", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_time_labels_users_created_by_id", + table: "time_labels", + column: "created_by_id", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_time_labels_users_deleted_by_id", + table: "time_labels", + column: "deleted_by_id", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "fk_time_labels_users_modified_by_id", + table: "time_labels", + column: "modified_by_id", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_time_categories_tenants_tenant_id", + table: "time_categories"); + + migrationBuilder.DropForeignKey( + name: "fk_time_categories_users_created_by_id", + table: "time_categories"); + + migrationBuilder.DropForeignKey( + name: "fk_time_categories_users_deleted_by_id", + table: "time_categories"); + + migrationBuilder.DropForeignKey( + name: "fk_time_categories_users_modified_by_id", + table: "time_categories"); + + migrationBuilder.DropForeignKey( + name: "fk_time_entries_tenants_tenant_id", + table: "time_entries"); + + migrationBuilder.DropForeignKey( + name: "fk_time_entries_users_created_by_id", + table: "time_entries"); + + migrationBuilder.DropForeignKey( + name: "fk_time_entries_users_deleted_by_id", + table: "time_entries"); + + migrationBuilder.DropForeignKey( + name: "fk_time_entries_users_modified_by_id", + table: "time_entries"); + + migrationBuilder.DropForeignKey( + name: "fk_time_labels_tenants_tenant_id", + table: "time_labels"); + + migrationBuilder.DropForeignKey( + name: "fk_time_labels_users_created_by_id", + table: "time_labels"); + + migrationBuilder.DropForeignKey( + name: "fk_time_labels_users_deleted_by_id", + table: "time_labels"); + + migrationBuilder.DropForeignKey( + name: "fk_time_labels_users_modified_by_id", + table: "time_labels"); + + migrationBuilder.DropTable( + name: "tenants"); + + migrationBuilder.DropIndex( + name: "ix_time_labels_created_by_id", + table: "time_labels"); + + migrationBuilder.DropIndex( + name: "ix_time_labels_deleted_by_id", + table: "time_labels"); + + migrationBuilder.DropIndex( + name: "ix_time_labels_modified_by_id", + table: "time_labels"); + + migrationBuilder.DropIndex( + name: "ix_time_labels_tenant_id", + table: "time_labels"); + + migrationBuilder.DropIndex( + name: "ix_time_entries_created_by_id", + table: "time_entries"); + + migrationBuilder.DropIndex( + name: "ix_time_entries_deleted_by_id", + table: "time_entries"); + + migrationBuilder.DropIndex( + name: "ix_time_entries_modified_by_id", + table: "time_entries"); + + migrationBuilder.DropIndex( + name: "ix_time_entries_tenant_id", + table: "time_entries"); + + migrationBuilder.DropIndex( + name: "ix_time_categories_created_by_id", + table: "time_categories"); + + migrationBuilder.DropIndex( + name: "ix_time_categories_deleted_by_id", + table: "time_categories"); + + migrationBuilder.DropIndex( + name: "ix_time_categories_modified_by_id", + table: "time_categories"); + + migrationBuilder.DropIndex( + name: "ix_time_categories_tenant_id", + table: "time_categories"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "time_labels"); + + migrationBuilder.DropColumn( + name: "deleted_by_id", + table: "time_labels"); + + migrationBuilder.DropColumn( + name: "modified_by_id", + table: "time_labels"); + + migrationBuilder.DropColumn( + name: "tenant_id", + table: "time_labels"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "time_entries"); + + migrationBuilder.DropColumn( + name: "deleted_by_id", + table: "time_entries"); + + migrationBuilder.DropColumn( + name: "modified_by_id", + table: "time_entries"); + + migrationBuilder.DropColumn( + name: "tenant_id", + table: "time_entries"); + + migrationBuilder.DropColumn( + name: "created_by_id", + table: "time_categories"); + + migrationBuilder.DropColumn( + name: "deleted_by_id", + table: "time_categories"); + + migrationBuilder.DropColumn( + name: "modified_by_id", + table: "time_categories"); + + migrationBuilder.DropColumn( + name: "tenant_id", + table: "time_categories"); + } + } +} diff --git a/server/src/Migrations/20220530175322_RemoveUnusedNavs.Designer.cs b/server/src/Migrations/20220530175322_RemoveUnusedNavs.Designer.cs new file mode 100644 index 0000000..8fd6b40 --- /dev/null +++ b/server/src/Migrations/20220530175322_RemoveUnusedNavs.Designer.cs @@ -0,0 +1,686 @@ +// <auto-generated /> +using System; +using IOL.GreatOffice.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace IOL.GreatOffice.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20220530175322_RemoveUnusedNavs")] + partial class RemoveUnusedNavs + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<bool>("AllowCreate") + .HasColumnType("boolean") + .HasColumnName("allow_create"); + + b.Property<bool>("AllowDelete") + .HasColumnType("boolean") + .HasColumnName("allow_delete"); + + b.Property<bool>("AllowRead") + .HasColumnType("boolean") + .HasColumnName("allow_read"); + + b.Property<bool>("AllowUpdate") + .HasColumnType("boolean") + .HasColumnName("allow_update"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<DateTime>("ExpiryDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiry_date"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_api_access_tokens"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_api_access_tokens_user_id"); + + b.ToTable("api_access_tokens", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_forgot_password_requests"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_forgot_password_requests_user_id"); + + b.ToTable("forgot_password_requests", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b => + { + b.Property<string>("GithubId") + .HasColumnType("text") + .HasColumnName("github_id"); + + b.Property<string>("Email") + .HasColumnType("text") + .HasColumnName("email"); + + b.Property<string>("RefreshToken") + .HasColumnType("text") + .HasColumnName("refresh_token"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("GithubId") + .HasName("pk_github_user_mappings"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_github_user_mappings_user_id"); + + b.ToTable("github_user_mappings", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.Tenant", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("ContactEmail") + .HasColumnType("text") + .HasColumnName("contact_email"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Guid>("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property<Guid>("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property<string>("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property<Guid>("MasterUserId") + .HasColumnType("uuid") + .HasColumnName("master_user_id"); + + b.Property<string>("MasterUserPassword") + .HasColumnType("text") + .HasColumnName("master_user_password"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<Guid>("ModifiedById") + .HasColumnType("uuid") + .HasColumnName("modified_by_id"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property<Guid?>("TenantId1") + .HasColumnType("uuid") + .HasColumnName("tenant_id1"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_tenants"); + + b.HasIndex("CreatedById") + .HasDatabaseName("ix_tenants_created_by_id"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_tenants_deleted_by_id"); + + b.HasIndex("ModifiedById") + .HasDatabaseName("ix_tenants_modified_by_id"); + + b.HasIndex("TenantId1") + .HasDatabaseName("ix_tenants_tenant_id1"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_tenants_user_id"); + + b.ToTable("tenants", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Guid>("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property<Guid>("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<Guid>("ModifiedById") + .HasColumnType("uuid") + .HasColumnName("modified_by_id"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_categories"); + + b.HasIndex("CreatedById") + .HasDatabaseName("ix_time_categories_created_by_id"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_time_categories_deleted_by_id"); + + b.HasIndex("ModifiedById") + .HasDatabaseName("ix_time_categories_modified_by_id"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_time_categories_tenant_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_time_categories_user_id"); + + b.ToTable("time_categories", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<Guid?>("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Guid>("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property<Guid>("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property<string>("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<Guid>("ModifiedById") + .HasColumnType("uuid") + .HasColumnName("modified_by_id"); + + b.Property<DateTime>("Start") + .HasColumnType("timestamp with time zone") + .HasColumnName("start"); + + b.Property<DateTime>("Stop") + .HasColumnType("timestamp with time zone") + .HasColumnName("stop"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_entries"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_time_entries_category_id"); + + b.HasIndex("CreatedById") + .HasDatabaseName("ix_time_entries_created_by_id"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_time_entries_deleted_by_id"); + + b.HasIndex("ModifiedById") + .HasDatabaseName("ix_time_entries_modified_by_id"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_time_entries_tenant_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_time_entries_user_id"); + + b.ToTable("time_entries", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Guid>("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property<Guid>("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<Guid>("ModifiedById") + .HasColumnType("uuid") + .HasColumnName("modified_by_id"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property<Guid?>("TimeEntryId") + .HasColumnType("uuid") + .HasColumnName("time_entry_id"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_labels"); + + b.HasIndex("CreatedById") + .HasDatabaseName("ix_time_labels_created_by_id"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_time_labels_deleted_by_id"); + + b.HasIndex("ModifiedById") + .HasDatabaseName("ix_time_labels_modified_by_id"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_time_labels_tenant_id"); + + b.HasIndex("TimeEntryId") + .HasDatabaseName("ix_time_labels_time_entry_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_time_labels_user_id"); + + b.ToTable("time_labels", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<string>("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property<string>("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_api_access_tokens_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_forgot_password_requests_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_github_user_mappings_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.Tenant", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenants_users_created_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy") + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenants_users_deleted_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy") + .WithMany() + .HasForeignKey("ModifiedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenants_users_modified_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId1") + .HasConstraintName("fk_tenants_tenants_tenant_id1"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenants_users_user_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("DeletedBy"); + + b.Navigation("ModifiedBy"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_categories_users_created_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy") + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_categories_users_deleted_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy") + .WithMany() + .HasForeignKey("ModifiedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_categories_users_modified_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_categories_tenants_tenant_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_categories_users_user_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("DeletedBy"); + + b.Navigation("ModifiedBy"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_time_entries_time_categories_category_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entries_users_created_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy") + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entries_users_deleted_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy") + .WithMany() + .HasForeignKey("ModifiedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entries_users_modified_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entries_tenants_tenant_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entries_users_user_id"); + + b.Navigation("Category"); + + b.Navigation("CreatedBy"); + + b.Navigation("DeletedBy"); + + b.Navigation("ModifiedBy"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_labels_users_created_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy") + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_labels_users_deleted_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy") + .WithMany() + .HasForeignKey("ModifiedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_labels_users_modified_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_labels_tenants_tenant_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeEntry", null) + .WithMany("Labels") + .HasForeignKey("TimeEntryId") + .HasConstraintName("fk_time_labels_time_entries_time_entry_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_labels_users_user_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("DeletedBy"); + + b.Navigation("ModifiedBy"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b => + { + b.Navigation("Labels"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Migrations/20220530175322_RemoveUnusedNavs.cs b/server/src/Migrations/20220530175322_RemoveUnusedNavs.cs new file mode 100644 index 0000000..36b3cf1 --- /dev/null +++ b/server/src/Migrations/20220530175322_RemoveUnusedNavs.cs @@ -0,0 +1,78 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace IOL.GreatOffice.Api.Migrations +{ + public partial class RemoveUnusedNavs : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "time_entry_time_label"); + + migrationBuilder.AddColumn<Guid>( + name: "time_entry_id", + table: "time_labels", + type: "uuid", + nullable: true); + + migrationBuilder.CreateIndex( + name: "ix_time_labels_time_entry_id", + table: "time_labels", + column: "time_entry_id"); + + migrationBuilder.AddForeignKey( + name: "fk_time_labels_time_entries_time_entry_id", + table: "time_labels", + column: "time_entry_id", + principalTable: "time_entries", + principalColumn: "id"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_time_labels_time_entries_time_entry_id", + table: "time_labels"); + + migrationBuilder.DropIndex( + name: "ix_time_labels_time_entry_id", + table: "time_labels"); + + migrationBuilder.DropColumn( + name: "time_entry_id", + table: "time_labels"); + + migrationBuilder.CreateTable( + name: "time_entry_time_label", + columns: table => new + { + entries_id = table.Column<Guid>(type: "uuid", nullable: false), + labels_id = table.Column<Guid>(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_time_entry_time_label", x => new { x.entries_id, x.labels_id }); + table.ForeignKey( + name: "fk_time_entry_time_label_time_entries_entries_id", + column: x => x.entries_id, + principalTable: "time_entries", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_time_entry_time_label_time_labels_labels_id", + column: x => x.labels_id, + principalTable: "time_labels", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_time_entry_time_label_labels_id", + table: "time_entry_time_label", + column: "labels_id"); + } + } +} diff --git a/server/src/Migrations/AppDbContextModelSnapshot.cs b/server/src/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..e040cfb --- /dev/null +++ b/server/src/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,684 @@ +// <auto-generated /> +using System; +using IOL.GreatOffice.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace IOL.GreatOffice.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<bool>("AllowCreate") + .HasColumnType("boolean") + .HasColumnName("allow_create"); + + b.Property<bool>("AllowDelete") + .HasColumnType("boolean") + .HasColumnName("allow_delete"); + + b.Property<bool>("AllowRead") + .HasColumnType("boolean") + .HasColumnName("allow_read"); + + b.Property<bool>("AllowUpdate") + .HasColumnType("boolean") + .HasColumnName("allow_update"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<DateTime>("ExpiryDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiry_date"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_api_access_tokens"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_api_access_tokens_user_id"); + + b.ToTable("api_access_tokens", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_forgot_password_requests"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_forgot_password_requests_user_id"); + + b.ToTable("forgot_password_requests", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b => + { + b.Property<string>("GithubId") + .HasColumnType("text") + .HasColumnName("github_id"); + + b.Property<string>("Email") + .HasColumnType("text") + .HasColumnName("email"); + + b.Property<string>("RefreshToken") + .HasColumnType("text") + .HasColumnName("refresh_token"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("GithubId") + .HasName("pk_github_user_mappings"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_github_user_mappings_user_id"); + + b.ToTable("github_user_mappings", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.Tenant", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("ContactEmail") + .HasColumnType("text") + .HasColumnName("contact_email"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Guid>("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property<Guid>("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property<string>("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property<Guid>("MasterUserId") + .HasColumnType("uuid") + .HasColumnName("master_user_id"); + + b.Property<string>("MasterUserPassword") + .HasColumnType("text") + .HasColumnName("master_user_password"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<Guid>("ModifiedById") + .HasColumnType("uuid") + .HasColumnName("modified_by_id"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property<Guid?>("TenantId1") + .HasColumnType("uuid") + .HasColumnName("tenant_id1"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_tenants"); + + b.HasIndex("CreatedById") + .HasDatabaseName("ix_tenants_created_by_id"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_tenants_deleted_by_id"); + + b.HasIndex("ModifiedById") + .HasDatabaseName("ix_tenants_modified_by_id"); + + b.HasIndex("TenantId1") + .HasDatabaseName("ix_tenants_tenant_id1"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_tenants_user_id"); + + b.ToTable("tenants", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Guid>("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property<Guid>("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<Guid>("ModifiedById") + .HasColumnType("uuid") + .HasColumnName("modified_by_id"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_categories"); + + b.HasIndex("CreatedById") + .HasDatabaseName("ix_time_categories_created_by_id"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_time_categories_deleted_by_id"); + + b.HasIndex("ModifiedById") + .HasDatabaseName("ix_time_categories_modified_by_id"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_time_categories_tenant_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_time_categories_user_id"); + + b.ToTable("time_categories", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<Guid?>("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Guid>("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property<Guid>("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property<string>("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<Guid>("ModifiedById") + .HasColumnType("uuid") + .HasColumnName("modified_by_id"); + + b.Property<DateTime>("Start") + .HasColumnType("timestamp with time zone") + .HasColumnName("start"); + + b.Property<DateTime>("Stop") + .HasColumnType("timestamp with time zone") + .HasColumnName("stop"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_entries"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_time_entries_category_id"); + + b.HasIndex("CreatedById") + .HasDatabaseName("ix_time_entries_created_by_id"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_time_entries_deleted_by_id"); + + b.HasIndex("ModifiedById") + .HasDatabaseName("ix_time_entries_modified_by_id"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_time_entries_tenant_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_time_entries_user_id"); + + b.ToTable("time_entries", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("Color") + .HasColumnType("text") + .HasColumnName("color"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Guid>("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property<Guid>("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<Guid>("ModifiedById") + .HasColumnType("uuid") + .HasColumnName("modified_by_id"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property<Guid?>("TimeEntryId") + .HasColumnType("uuid") + .HasColumnName("time_entry_id"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_time_labels"); + + b.HasIndex("CreatedById") + .HasDatabaseName("ix_time_labels_created_by_id"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_time_labels_deleted_by_id"); + + b.HasIndex("ModifiedById") + .HasDatabaseName("ix_time_labels_modified_by_id"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_time_labels_tenant_id"); + + b.HasIndex("TimeEntryId") + .HasDatabaseName("ix_time_labels_time_entry_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_time_labels_user_id"); + + b.ToTable("time_labels", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<DateTime?>("ModifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_at"); + + b.Property<string>("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property<string>("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_api_access_tokens_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_forgot_password_requests_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_github_user_mappings_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.Tenant", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenants_users_created_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy") + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenants_users_deleted_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy") + .WithMany() + .HasForeignKey("ModifiedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenants_users_modified_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId1") + .HasConstraintName("fk_tenants_tenants_tenant_id1"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenants_users_user_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("DeletedBy"); + + b.Navigation("ModifiedBy"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_categories_users_created_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy") + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_categories_users_deleted_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy") + .WithMany() + .HasForeignKey("ModifiedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_categories_users_modified_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_categories_tenants_tenant_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_categories_users_user_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("DeletedBy"); + + b.Navigation("ModifiedBy"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_time_entries_time_categories_category_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entries_users_created_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy") + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entries_users_deleted_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy") + .WithMany() + .HasForeignKey("ModifiedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entries_users_modified_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entries_tenants_tenant_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_entries_users_user_id"); + + b.Navigation("Category"); + + b.Navigation("CreatedBy"); + + b.Navigation("DeletedBy"); + + b.Navigation("ModifiedBy"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b => + { + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_labels_users_created_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy") + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_labels_users_deleted_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy") + .WithMany() + .HasForeignKey("ModifiedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_labels_users_modified_by_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_labels_tenants_tenant_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeEntry", null) + .WithMany("Labels") + .HasForeignKey("TimeEntryId") + .HasConstraintName("fk_time_labels_time_entries_time_entry_id"); + + b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_time_labels_users_user_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("DeletedBy"); + + b.Navigation("ModifiedBy"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b => + { + b.Navigation("Labels"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Program.cs b/server/src/Program.cs new file mode 100644 index 0000000..b449117 --- /dev/null +++ b/server/src/Program.cs @@ -0,0 +1,261 @@ +global using System; +global using System.Linq; +global using System.IO; +global using System.Net.Mail; +global using System.Net; +global using System.Threading; +global using System.Threading.Tasks; +global using System.Collections.Generic; +global using System.Runtime.Serialization; +global using System.ComponentModel.DataAnnotations.Schema; +global using System.Security.Claims; +global using System.Text.Json; +global using System.Text.Json.Serialization; +global using IOL.GreatOffice.Api.Data.Database; +global using IOL.GreatOffice.Api.Data.Exceptions; +global using IOL.GreatOffice.Api.Data.Dtos; +global using IOL.GreatOffice.Api.Data.Enums; +global using IOL.GreatOffice.Api.Data.Models; +global using IOL.GreatOffice.Api.Data.Results; +global using IOL.Helpers; +global using Microsoft.OpenApi.Models; +global using Microsoft.AspNetCore.Authentication.Cookies; +global using Microsoft.AspNetCore.Builder; +global using Microsoft.AspNetCore.DataProtection; +global using Microsoft.AspNetCore.Hosting; +global using Microsoft.AspNetCore.Authorization; +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Authentication; +global using Microsoft.AspNetCore.Mvc; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Hosting; +global using Microsoft.Extensions.Logging; +global using Serilog; +global using IOL.GreatOffice.Api.Data; +global using IOL.GreatOffice.Api.Data.Static; +global using IOL.GreatOffice.Api.Services; +global using IOL.GreatOffice.Api.Utilities; +using System.Diagnostics; +using System.Reflection; +using IOL.GreatOffice.Api.Endpoints.V1; +using IOL.GreatOffice.Api.Jobs; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Mvc.Versioning; +using Quartz; + +namespace IOL.GreatOffice.Api; + +public static class Program +{ + public static WebApplicationBuilder CreateAppBuilder(string[] args) { + var builder = WebApplication.CreateBuilder(args); + + var seqUrl = builder.Configuration.GetValue<string>(AppEnvironmentVariables.SEQ_API_URL); + var seqKey = builder.Configuration.GetValue<string>(AppEnvironmentVariables.SEQ_API_KEY); + var logger = new LoggerConfiguration() + .Enrich.FromLogContext() + .ReadFrom.Configuration(builder.Configuration) + .WriteTo.Console(); + + if (!builder.Environment.IsDevelopment() && seqUrl.HasValue() && seqKey.HasValue()) { + logger.WriteTo.Seq(seqUrl, apiKey: seqKey); + } + + Log.Logger = logger.CreateLogger(); + Log.Information("Starting web host" + + JsonSerializer.Serialize(new { + DateTime.UtcNow, + PID = Environment.ProcessId, + DB_HOST = builder.Configuration.GetValue<string>(AppEnvironmentVariables.DB_HOST), + DB_PORT = builder.Configuration.GetValue<string>(AppEnvironmentVariables.DB_PORT), + DB_USER = builder.Configuration.GetValue<string>(AppEnvironmentVariables.DB_USER), + DB_NAME = builder.Configuration.GetValue<string>(AppEnvironmentVariables.DB_NAME), + DB_PASS = builder.Configuration.GetValue<string>(AppEnvironmentVariables.DB_PASSWORD).Obfuscate() ?? "!!!Empty!!!", + QUARTZ_DB_HOST = builder.Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_HOST), + QUARTZ_DB_PORT = builder.Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_PORT), + QUARTZ_DB_USER = builder.Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_USER), + QUARTZ_DB_NAME = builder.Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_NAME), + QUARTZ_DB_PASS = builder.Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_PASSWORD).Obfuscate() + ?? "!!!Empty!!!", + }, + new JsonSerializerOptions() { + WriteIndented = true + })); + + builder.Host.UseSerilog(Log.Logger); + builder.WebHost.ConfigureKestrel(kestrel => { + kestrel.AddServerHeader = false; + }); + + if (builder.Environment.IsDevelopment()) { + builder.Services.AddCors(); + } + + if (builder.Environment.IsProduction()) { + builder.Services.Configure<ForwardedHeadersOptions>(options => { + options.ForwardedHeaders = ForwardedHeaders.XForwardedProto; + }); + } + + builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(AppPaths.DataProtectionKeys.HostPath)); + + builder.Services.Configure(JsonSettings.Default); + builder.Services.AddQuartz(options => { + options.UsePersistentStore(o => { + o.UsePostgres(builder.Configuration.GetQuartzDatabaseConnectionString()); + o.UseSerializer<QuartzJsonSerializer>(); + }); + options.UseMicrosoftDependencyInjectionJobFactory(); + options.RegisterJobs(); + }); + builder.Services.AddQuartzHostedService(options => { + options.WaitForJobsToComplete = true; + }); + + builder.Services.AddAuthentication(options => { + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme; + }) + .AddCookie(options => { + options.Cookie.Name = "go_session"; + options.Cookie.SameSite = SameSiteMode.Strict; + options.Cookie.HttpOnly = true; + options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always; + options.Cookie.IsEssential = true; + options.SlidingExpiration = true; + options.Events.OnRedirectToAccessDenied = + options.Events.OnRedirectToLogin = c => { + c.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.FromResult<object>(null); + }; + }) + .AddGitHub(options => { + options.ClientSecret = builder.Configuration.GetValue<string>(AppEnvironmentVariables.GITHUB_CLIENT_SECRET); + options.ClientId = builder.Configuration.GetValue<string>(AppEnvironmentVariables.GITHUB_CLIENT_ID); + options.SaveTokens = true; + options.CorrelationCookie.Name = "gh_correlation"; + options.CorrelationCookie.SameSite = SameSiteMode.Lax; + options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always; + options.CorrelationCookie.HttpOnly = true; + options.Events.OnCreatingTicket = context => GithubAuthenticationHelpers.HandleGithubTicketCreation(context, builder.Configuration); + }) + .AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>(AppConstants.BASIC_AUTH_SCHEME, default); + + + builder.Services.AddDbContext<AppDbContext>(options => { + options.UseNpgsql(builder.Configuration.GetAppDatabaseConnectionString(), + npgsqlDbContextOptionsBuilder => { + npgsqlDbContextOptionsBuilder.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); + npgsqlDbContextOptionsBuilder.EnableRetryOnFailure(5, TimeSpan.FromSeconds(10), default); + }) + .UseSnakeCaseNamingConvention(); + if (builder.Environment.IsDevelopment()) { + options.EnableSensitiveDataLogging(); + } + }); + + builder.Services.AddApiVersioning(options => { + options.ApiVersionReader = new UrlSegmentApiVersionReader(); + options.ReportApiVersions = true; + options.AssumeDefaultVersionWhenUnspecified = false; + }); + builder.Services.AddVersionedApiExplorer(options => { + options.SubstituteApiVersionInUrl = true; + }); + builder.Services.AddSwaggerGen(options => { + options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, Assembly.GetExecutingAssembly().GetName().Name + ".xml")); + options.UseApiEndpoints(); + options.OperationFilter<SwaggerDefaultValues>(); + options.SwaggerDoc(ApiSpecV1.Document.VersionName, ApiSpecV1.Document.OpenApiInfo); + options.AddSecurityDefinition("Basic", + new OpenApiSecurityScheme { + Name = "Authorization", + Type = SecuritySchemeType.ApiKey, + Scheme = "Basic", + BearerFormat = "Basic", + In = ParameterLocation.Header, + Description = + "Enter your token in the text input below.\r\n\r\nExample: \"Basic 12345abcdef\"", + }); + + options.AddSecurityRequirement(new OpenApiSecurityRequirement { + { + new OpenApiSecurityScheme { + Reference = new OpenApiReference { + Type = ReferenceType.SecurityScheme, + Id = "Basic" + } + }, + Array.Empty<string>() + } + }); + }); + + builder.Services.AddScoped<MailService>(); + builder.Services.AddScoped<ForgotPasswordService>(); + builder.Services.AddScoped<UserService>(); + builder.Services.AddLogging(); + builder.Services.AddHttpClient(); + builder.Services + .AddControllers() + .AddJsonOptions(JsonSettings.Default); + + + return builder; + } + + public static WebApplication CreateWebApplication(WebApplicationBuilder builder) { + var app = builder.Build(); + + if (app.Environment.IsDevelopment()) { + app.UseDeveloperExceptionPage(); + app.UseCors(cors => { + cors.AllowAnyMethod(); + cors.AllowAnyHeader(); + cors.WithOrigins("http://localhost:3000", "http://localhost:3002", "http://localhost:3001"); + cors.AllowCredentials(); + }); + } + + if (app.Environment.IsProduction()) { + app.UseForwardedHeaders(); + } + + app.UseDefaultFiles() + .UseStaticFiles() + .UseRouting() + .UseSerilogRequestLogging() + .UseStatusCodePages() + .UseAuthentication() + .UseAuthorization() + .UseSwagger() + .UseSwaggerUI(options => { + options.SwaggerEndpoint(ApiSpecV1.Document.SwaggerPath, ApiSpecV1.Document.VersionName); + options.DocumentTitle = AppConstants.API_NAME; + }) + .UseEndpoints(endpoints => { + endpoints.MapControllers(); + }); + return app; + } + + public static int Main(string[] args) { + try { + CreateWebApplication(CreateAppBuilder(args)).Run(); + return 0; + } catch (Exception ex) { + // This is subject to change in future .net versions, see https://github.com/dotnet/runtime/issues/60600. + if (ex.GetType().Name.Equals("StopTheHostException", StringComparison.Ordinal)) { + throw; + } + + Log.Fatal(ex, "Unhandled exception"); + return 1; + } finally { + Log.Information("Shut down complete, flusing logs..."); + Log.CloseAndFlush(); + } + } +} diff --git a/server/src/Properties/launchSettings.json b/server/src/Properties/launchSettings.json new file mode 100644 index 0000000..6403d71 --- /dev/null +++ b/server/src/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "IOL.GreatOffice": { + "commandName": "Project", + "dotnetRunMessages": "true", + "launchBrowser": false, + "applicationUrl": "https://0.0.0.0:5001;http://0.0.0.0:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/server/src/Services/ForgotPasswordService.cs b/server/src/Services/ForgotPasswordService.cs new file mode 100644 index 0000000..de38b29 --- /dev/null +++ b/server/src/Services/ForgotPasswordService.cs @@ -0,0 +1,115 @@ +namespace IOL.GreatOffice.Api.Services; + +public class ForgotPasswordService +{ + private readonly AppDbContext _context; + private readonly MailService _mailService; + private readonly IConfiguration _configuration; + private readonly ILogger<ForgotPasswordService> _logger; + + + public ForgotPasswordService( + AppDbContext context, + IConfiguration configuration, + ILogger<ForgotPasswordService> logger, + MailService mailService + ) { + _context = context; + _configuration = configuration; + _logger = logger; + _mailService = mailService; + } + + public async Task<ForgotPasswordRequest> GetRequestAsync(Guid id, CancellationToken cancellationToken = default) { + var request = await _context.ForgotPasswordRequests + .Include(c => c.User) + .SingleOrDefaultAsync(c => c.Id == id, cancellationToken); + if (request == default) { + return default; + } + + _logger.LogInformation($"Found forgot password request for user: {request.User.Username}, expires at {request.ExpirationDate} (in {request.ExpirationDate.Subtract(DateTime.UtcNow).Minutes} minutes)."); + return request; + } + + public async Task<bool> FullFillRequestAsync(Guid id, string newPassword, CancellationToken cancellationToken = default) { + var request = await GetRequestAsync(id, cancellationToken); + if (request == default) { + throw new ForgotPasswordRequestNotFoundException("Request with id: " + id + " was not found"); + } + + var user = _context.Users.SingleOrDefault(c => c.Id == request.User.Id); + if (user == default) { + throw new UserNotFoundException("User with id: " + request.User.Id + " was not found"); + } + + user.HashAndSetPassword(newPassword); + _context.Users.Update(user); + await _context.SaveChangesAsync(cancellationToken); + _logger.LogInformation($"Fullfilled forgot password request for user: {request.User.Username}"); + await DeleteRequestsForUserAsync(user.Id, cancellationToken); + return true; + } + + + public async Task AddRequestAsync(User user, TimeZoneInfo requestTz, CancellationToken cancellationToken = default) { + await DeleteRequestsForUserAsync(user.Id, cancellationToken); + var request = new ForgotPasswordRequest(user); + _context.ForgotPasswordRequests.Add(request); + await _context.SaveChangesAsync(cancellationToken); + var accountsUrl = _configuration.GetValue<string>(AppEnvironmentVariables.ACCOUNTS_URL); + var emailFromAddress = _configuration.GetValue<string>(AppEnvironmentVariables.EMAIL_FROM_ADDRESS); + var emailFromDisplayName = _configuration.GetValue<string>(AppEnvironmentVariables.EMAIL_FROM_DISPLAY_NAME); + var zonedExpirationDate = TimeZoneInfo.ConvertTimeBySystemTimeZoneId(request.ExpirationDate, requestTz.Id); + var message = new MailMessage { + From = new MailAddress(emailFromAddress, emailFromDisplayName), + To = { + new MailAddress(user.Username) + }, + Subject = "Time Tracker - Forgot password request", + Body = @$" +Hi {user.Username} + +Go to the following link to set a new password. + +{accountsUrl}/#/reset-password?id={request.Id} + +The link expires at {zonedExpirationDate:yyyy-MM-dd hh:mm}. +If you did not request a password reset, no action is required. +" + }; + +#pragma warning disable 4014 + Task.Run(() => { +#pragma warning restore 4014 + _mailService.SendMail(message); + _logger.LogInformation($"Added forgot password request for user: {request.User.Username}, expires in {request.ExpirationDate.Subtract(DateTime.UtcNow)}."); + }, + cancellationToken); + } + + public async Task DeleteRequestsForUserAsync(Guid userId, CancellationToken cancellationToken = default) { + var requestsToRemove = _context.ForgotPasswordRequests.Where(c => c.UserId == userId).ToList(); + if (!requestsToRemove.Any()) return; + _context.ForgotPasswordRequests.RemoveRange(requestsToRemove); + await _context.SaveChangesAsync(cancellationToken); + _logger.LogInformation($"Deleted {requestsToRemove.Count} forgot password requests for user: {userId}."); + } + + + public async Task DeleteStaleRequestsAsync(CancellationToken cancellationToken = default) { + var deleteCount = 0; + foreach (var request in _context.ForgotPasswordRequests) { + if (!request.IsExpired) { + continue; + } + + _context.ForgotPasswordRequests.Remove(request); + deleteCount++; + _logger.LogInformation($"Marking forgot password request with id: {request.Id} for deletion, expiration date was {request.ExpirationDate}."); + } + + await _context.SaveChangesAsync(cancellationToken); + _logger.LogInformation($"Deleted {deleteCount} stale forgot password requests."); + } +} diff --git a/server/src/Services/MailService.cs b/server/src/Services/MailService.cs new file mode 100644 index 0000000..b271de4 --- /dev/null +++ b/server/src/Services/MailService.cs @@ -0,0 +1,52 @@ +namespace IOL.GreatOffice.Api.Services; + +public class MailService +{ + private readonly ILogger<MailService> _logger; + private static string _emailHost; + private static int _emailPort; + private static string _emailUser; + private static string _emailPassword; + + /// <summary> + /// Provides methods to send email. + /// </summary> + /// <param name="configuration"></param> + /// <param name="logger"></param> + public MailService(IConfiguration configuration, ILogger<MailService> logger) { + _logger = logger; + _emailHost = configuration.GetValue<string>(AppEnvironmentVariables.SMTP_HOST); + _emailPort = configuration.GetValue<int>(AppEnvironmentVariables.SMTP_PORT); + _emailUser = configuration.GetValue<string>(AppEnvironmentVariables.SMTP_USER); + _emailPassword = configuration.GetValue<string>(AppEnvironmentVariables.SMTP_PASSWORD); + } + + /// <summary> + /// Send an email. + /// </summary> + /// <param name="message"></param> + public void SendMail(MailMessage message) { + using var smtpClient = new SmtpClient { + Host = _emailHost, + EnableSsl = _emailPort == 587, + Port = _emailPort, + Credentials = new NetworkCredential { + UserName = _emailUser, + Password = _emailPassword, + } + }; + var configurationString = JsonSerializer.Serialize(new { + Host = smtpClient.Host, + EnableSsl = smtpClient.EnableSsl, + Port = smtpClient.Port, + UserName = _emailUser.HasValue() ? "**REDACTED**" : "**MISSING**", + Password = _emailPassword.HasValue() ? "**REDACTED**" : "**MISSING**", + }, + new JsonSerializerOptions { + WriteIndented = true + }); + _logger.LogDebug("SmtpClient was instansiated with the following configuration\n" + configurationString); + + smtpClient.Send(message); + } +} diff --git a/server/src/Services/UserService.cs b/server/src/Services/UserService.cs new file mode 100644 index 0000000..9b531de --- /dev/null +++ b/server/src/Services/UserService.cs @@ -0,0 +1,50 @@ +namespace IOL.GreatOffice.Api.Services; + +public class UserService +{ + private readonly ForgotPasswordService _forgotPasswordService; + + /// <summary> + /// Provides methods to perform common operations on user data. + /// </summary> + /// <param name="forgotPasswordService"></param> + public UserService(ForgotPasswordService forgotPasswordService) { + _forgotPasswordService = forgotPasswordService; + } + + /// <summary> + /// Log in a user. + /// </summary> + /// <param name="httpContext"></param> + /// <param name="user"></param> + /// <param name="persist"></param> + public async Task LogInUser(HttpContext httpContext, User user, bool persist = false) { + var claims = new List<Claim> { + new(AppClaims.USER_ID, user.Id.ToString()), + new(AppClaims.NAME, user.Username), + }; + + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + var principal = new ClaimsPrincipal(identity); + var authenticationProperties = new AuthenticationProperties { + AllowRefresh = true, + IssuedUtc = DateTimeOffset.UtcNow, + }; + + if (persist) { + authenticationProperties.ExpiresUtc = DateTimeOffset.UtcNow.AddMonths(6); + authenticationProperties.IsPersistent = true; + } + + await httpContext.SignInAsync(principal, authenticationProperties); + await _forgotPasswordService.DeleteRequestsForUserAsync(user.Id); + } + + /// <summary> + /// Log out a user. + /// </summary> + /// <param name="httpContext"></param> + public async Task LogOutUser(HttpContext httpContext) { + await httpContext.SignOutAsync(); + } +} diff --git a/server/src/Utilities/BasicAuthenticationAttribute.cs b/server/src/Utilities/BasicAuthenticationAttribute.cs new file mode 100644 index 0000000..0bfd007 --- /dev/null +++ b/server/src/Utilities/BasicAuthenticationAttribute.cs @@ -0,0 +1,39 @@ +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace IOL.GreatOffice.Api.Utilities; + +public class BasicAuthenticationAttribute : TypeFilterAttribute +{ + public BasicAuthenticationAttribute(string claimPermission) : base(typeof(BasicAuthenticationFilter)) { + Arguments = new object[] { + new Claim(claimPermission, "True") + }; + } +} + +public class BasicAuthenticationFilter : IAuthorizationFilter +{ + private readonly Claim _claim; + + public BasicAuthenticationFilter(Claim claim) { + _claim = claim; + } + + public void OnAuthorization(AuthorizationFilterContext context) { + if (!context.HttpContext.Request.Headers.ContainsKey("Authorization")) return; + try { + var authHeader = AuthenticationHeaderValue.Parse(context.HttpContext.Request.Headers["Authorization"]); + if (authHeader.Parameter is null) { + context.Result = new ForbidResult(AppConstants.BASIC_AUTH_SCHEME); + } + + var hasClaim = context.HttpContext.User.Claims.Any(c => c.Type == _claim.Type && c.Value == _claim.Value); + if (!hasClaim) { + context.Result = new ForbidResult(AppConstants.BASIC_AUTH_SCHEME); + } + } catch { + // ignore + } + } +} diff --git a/server/src/Utilities/BasicAuthenticationHandler.cs b/server/src/Utilities/BasicAuthenticationHandler.cs new file mode 100644 index 0000000..2b9d9ef --- /dev/null +++ b/server/src/Utilities/BasicAuthenticationHandler.cs @@ -0,0 +1,79 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.Extensions.Options; + +namespace IOL.GreatOffice.Api.Utilities; + +public class BasicAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions> +{ + private readonly AppDbContext _context; + private readonly IConfiguration _configuration; + private readonly ILogger<BasicAuthenticationHandler> _logger; + + public BasicAuthenticationHandler( + IOptionsMonitor<AuthenticationSchemeOptions> options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock, + AppDbContext context, + IConfiguration configuration + ) : + base(options, logger, encoder, clock) { + _context = context; + _configuration = configuration; + _logger = logger.CreateLogger<BasicAuthenticationHandler>(); + } + + protected override Task<AuthenticateResult> HandleAuthenticateAsync() { + var endpoint = Context.GetEndpoint(); + if (endpoint?.Metadata.GetMetadata<IAllowAnonymous>() != null) + return Task.FromResult(AuthenticateResult.NoResult()); + + if (!Request.Headers.ContainsKey("Authorization")) + return Task.FromResult(AuthenticateResult.Fail("Missing Authorization Header")); + + try { + var token_entropy = _configuration.GetValue<string>("TOKEN_ENTROPY"); + if (token_entropy.IsNullOrWhiteSpace()) { + _logger.LogWarning("No token entropy is available in env:TOKEN_ENTROPY, Basic auth is disabled"); + return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header")); + } + + var authHeader = AuthenticationHeaderValue.Parse(Request.Headers["Authorization"]); + if (authHeader.Parameter == null) return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header")); + var credentialBytes = Convert.FromBase64String(authHeader.Parameter); + var decrypted_string = Encoding.UTF8.GetString(credentialBytes).DecryptWithAes(token_entropy); + var token_is_guid = Guid.TryParse(decrypted_string, out var token_id); + + if (!token_is_guid) { + return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header")); + } + + var token = _context.AccessTokens.Include(c => c.User).SingleOrDefault(c => c.Id == token_id); + if (token == default) { + return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header: Not Found")); + } + + if (token.HasExpired) { + return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header: Expired")); + } + + var permissions = new List<Claim>() { + new(AppConstants.TOKEN_ALLOW_READ, token.AllowRead.ToString()), + new(AppConstants.TOKEN_ALLOW_UPDATE, token.AllowUpdate.ToString()), + new(AppConstants.TOKEN_ALLOW_CREATE, token.AllowCreate.ToString()), + new(AppConstants.TOKEN_ALLOW_DELETE, token.AllowDelete.ToString()), + }; + var claims = token.User.DefaultClaims().Concat(permissions); + var identity = new ClaimsIdentity(claims, AppConstants.BASIC_AUTH_SCHEME); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, AppConstants.BASIC_AUTH_SCHEME); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } catch (Exception e) { + _logger.LogError(e, $"An exception occured when challenging {AppConstants.BASIC_AUTH_SCHEME}"); + return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header")); + } + } +} diff --git a/server/src/Utilities/ConfigurationExtensions.cs b/server/src/Utilities/ConfigurationExtensions.cs new file mode 100644 index 0000000..772059a --- /dev/null +++ b/server/src/Utilities/ConfigurationExtensions.cs @@ -0,0 +1,37 @@ +namespace IOL.GreatOffice.Api.Utilities; + +public static class ConfigurationExtensions +{ + public static string GetAppDatabaseConnectionString(this IConfiguration configuration) { + var host = configuration.GetValue<string>(AppEnvironmentVariables.DB_HOST); + var port = configuration.GetValue<string>(AppEnvironmentVariables.DB_PORT); + var database = configuration.GetValue<string>(AppEnvironmentVariables.DB_NAME); + var user = configuration.GetValue<string>(AppEnvironmentVariables.DB_USER); + var password = configuration.GetValue<string>(AppEnvironmentVariables.DB_PASSWORD); + + if (configuration.GetValue<string>("ASPNETCORE_ENVIRONMENT") == "Development") { + return $"Server={host};Port={port};Database={database};User Id={user};Password={password};Include Error Detail=true"; + } + + return $"Server={host};Port={port};Database={database};User Id={user};Password={password}"; + } + + public static string GetQuartzDatabaseConnectionString(this IConfiguration Configuration) { + var host = Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_HOST); + var port = Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_PORT); + var database = Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_NAME); + var user = Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_USER); + var password = Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_PASSWORD); + return $"Server={host};Port={port};Database={database};User Id={user};Password={password}"; + } + + public static string GetVersion(this IConfiguration configuration) { + var versionFilePath = Path.Combine(AppPaths.AppData.HostPath, "version.txt"); + if (File.Exists(versionFilePath)) { + var versionText = File.ReadAllText(versionFilePath); + return versionText + "-" + configuration.GetValue<string>("ASPNETCORE_ENVIRONMENT"); + } + + return "unknown-" + configuration.GetValue<string>("ASPNETCORE_ENVIRONMENT"); + } +} diff --git a/server/src/Utilities/GithubAuthenticationHelpers.cs b/server/src/Utilities/GithubAuthenticationHelpers.cs new file mode 100644 index 0000000..cf0cabb --- /dev/null +++ b/server/src/Utilities/GithubAuthenticationHelpers.cs @@ -0,0 +1,84 @@ +using Microsoft.AspNetCore.Authentication.OAuth; +using Npgsql; + +namespace IOL.GreatOffice.Api.Utilities; + +public static class GithubAuthenticationHelpers +{ + public static async Task HandleGithubTicketCreation(OAuthCreatingTicketContext context, IConfiguration configuration) { + var githubId = context.Identity?.FindFirst(p => p.Type == ClaimTypes.NameIdentifier)?.Value; + var githubUsername = context.Identity?.FindFirst(p => p.Type == ClaimTypes.Name)?.Value; + var githubEmail = context.Identity?.FindFirst(p => p.Type == ClaimTypes.Email)?.Value; + + if (githubId.IsNullOrWhiteSpace() || githubUsername.IsNullOrWhiteSpace() || context.Identity == default) { + return; + } + + var claims = context.Identity.Claims.ToList(); + foreach (var claim in claims) { + context.Identity.RemoveClaim(claim); + } + + var connstring = configuration.GetAppDatabaseConnectionString(); + var connection = new NpgsqlConnection(connstring); + + Log.Information($"Getting user mappings for github user: {githubId}"); + var getMappedUserQuery = @$"SELECT u.id,u.username FROM github_user_mappings INNER JOIN users u on u.id = github_user_mappings.user_id WHERE github_id='{githubId}'"; + await connection.OpenAsync(); + await using var getMappedUserCommand = new NpgsqlCommand(getMappedUserQuery, connection); + await using var reader = await getMappedUserCommand.ExecuteReaderAsync(); + var handled = false; + while (await reader.ReadAsync()) { + try { + var userId = reader.GetGuid(0); + var username = reader.GetString(1); + context.Identity.AddClaim(new Claim(AppClaims.USER_ID, userId.ToString())); + context.Identity.AddClaim(new Claim(AppClaims.NAME, username)); + if (context.AccessToken != default) { + context.Identity.AddClaim(new Claim(AppClaims.GITHUB_ACCESS_TOKEN, context.AccessToken ?? "")); + } + + Log.Information($"Found mapping for github id {githubId} mapped to user id {userId}"); + handled = true; + } catch (Exception e) { + Log.Error(e, "An exception occured when handling github user mappings"); + handled = false; + } + } + + await connection.CloseAsync(); + + if (!handled) { + var userId = Guid.NewGuid(); + + var insertUserQuery = $@"INSERT INTO users VALUES ('{userId}', '{githubUsername}', '', '{DateTime.UtcNow}')"; + await connection.OpenAsync(); + await using var insertUserCommand = new NpgsqlCommand(insertUserQuery, connection); + await insertUserCommand.ExecuteNonQueryAsync(); + await connection.CloseAsync(); + + var refreshTokenEncryptionKey = configuration.GetValue<string>(AppEnvironmentVariables.APP_AES_KEY); + string insertMappingQuery; + + if (context.RefreshToken.HasValue() && refreshTokenEncryptionKey.HasValue()) { + var encryptedRefreshToken = context.RefreshToken.EncryptWithAes(refreshTokenEncryptionKey); + insertMappingQuery = $@"INSERT INTO github_user_mappings VALUES ('{githubId}', '{userId}', '{githubEmail}', '{encryptedRefreshToken}')"; + } else { + insertMappingQuery = $@"INSERT INTO github_user_mappings VALUES ('{githubId}', '{userId}', '{githubEmail}', '')"; + } + + await connection.OpenAsync(); + await using var insertMappingCommand = new NpgsqlCommand(insertMappingQuery, connection); + await insertMappingCommand.ExecuteNonQueryAsync(); + await connection.CloseAsync(); + + context.Identity.AddClaim(new Claim(AppClaims.USER_ID, userId.ToString())); + context.Identity.AddClaim(new Claim(AppClaims.NAME, githubUsername)); + if (context.AccessToken != default) { + context.Identity.AddClaim(new Claim(AppClaims.GITHUB_ACCESS_TOKEN, context.AccessToken ?? "")); + } + + Log.Information($"Created mapping for github id {githubId} mapped to user id {userId}"); + } + } +} diff --git a/server/src/Utilities/QuartzJsonSerializer.cs b/server/src/Utilities/QuartzJsonSerializer.cs new file mode 100644 index 0000000..164a189 --- /dev/null +++ b/server/src/Utilities/QuartzJsonSerializer.cs @@ -0,0 +1,16 @@ +using Quartz.Spi; + +namespace IOL.GreatOffice.Api.Utilities; + +public class QuartzJsonSerializer : IObjectSerializer +{ + public void Initialize() { } + + public byte[] Serialize<T>(T obj) where T : class { + return JsonSerializer.SerializeToUtf8Bytes(obj); + } + + public T DeSerialize<T>(byte[] data) where T : class { + return JsonSerializer.Deserialize<T>(data); + } +} diff --git a/server/src/Utilities/SwaggerDefaultValues.cs b/server/src/Utilities/SwaggerDefaultValues.cs new file mode 100644 index 0000000..4b5c764 --- /dev/null +++ b/server/src/Utilities/SwaggerDefaultValues.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace IOL.GreatOffice.Api.Utilities; + +/// <summary> +/// Represents the Swagger/Swashbuckle operation filter used to document the implicit API version parameter. +/// </summary> +/// <remarks>This <see cref="IOperationFilter"/> is only required due to bugs in the <see cref="SwaggerGenerator"/>. +/// Once they are fixed and published, this class can be removed.</remarks> +public class SwaggerDefaultValues : IOperationFilter +{ + /// <summary> + /// Applies the filter to the specified operation using the given context. + /// </summary> + /// <param name="operation">The operation to apply the filter to.</param> + /// <param name="context">The current operation filter context.</param> + public void Apply(OpenApiOperation operation, OperationFilterContext context) { + var apiDescription = context.ApiDescription; + + operation.Deprecated |= apiDescription.IsDeprecated(); + + // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/1752#issue-663991077 + foreach (var responseType in context.ApiDescription.SupportedResponseTypes) { + // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/b7cf75e7905050305b115dd96640ddd6e74c7ac9/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs#L383-L387 + var responseKey = responseType.IsDefaultResponse ? "default" : responseType.StatusCode.ToString(); + var response = operation.Responses[responseKey]; + + foreach (var contentType in response.Content.Keys) { + if (!responseType.ApiResponseFormats.Any(x => x.MediaType == contentType)) { + response.Content.Remove(contentType); + } + } + } + + if (operation.Parameters == null) { + return; + } + + // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/412 + // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/pull/413 + foreach (var parameter in operation.Parameters) { + var description = apiDescription.ParameterDescriptions.First(p => p.Name == parameter.Name); + + if (parameter.Description == null) { + parameter.Description = description.ModelMetadata.Description; + } + + if (parameter.Schema.Default == null && description.DefaultValue != null) { + // REF: https://github.com/Microsoft/aspnet-api-versioning/issues/429#issuecomment-605402330 + var json = JsonSerializer.Serialize(description.DefaultValue, description.ModelMetadata.ModelType); + parameter.Schema.Default = OpenApiAnyFactory.CreateFromJson(json); + } + + parameter.Required |= description.IsRequired; + } + } +} diff --git a/server/src/Utilities/SwaggerGenOptionsExtensions.cs b/server/src/Utilities/SwaggerGenOptionsExtensions.cs new file mode 100644 index 0000000..a2dcf7a --- /dev/null +++ b/server/src/Utilities/SwaggerGenOptionsExtensions.cs @@ -0,0 +1,43 @@ +#nullable enable +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Controllers; +using Swashbuckle.AspNetCore.SwaggerGen; +using BaseRoute = IOL.GreatOffice.Api.Endpoints.V1.BaseRoute; + +namespace IOL.GreatOffice.Api.Utilities; + +public static class SwaggerGenOptionsExtensions +{ + /// <summary> + /// Updates Swagger document to support ApiEndpoints.<br/><br/> + /// For controllers inherited from <see cref="BaseRoute"/>:<br/> + /// - Replaces action Tag with <c>[namespace]</c><br/> + /// </summary> + public static void UseApiEndpoints(this SwaggerGenOptions options) { + options.TagActionsBy(EndpointNamespaceOrDefault); + } + + private static IList<string?> EndpointNamespaceOrDefault(ApiDescription api) { + if (api.ActionDescriptor is not ControllerActionDescriptor actionDescriptor) { + throw new InvalidOperationException($"Unable to determine tag for endpoint: {api.ActionDescriptor.DisplayName}"); + } + + if (actionDescriptor.ControllerTypeInfo.GetBaseTypesAndThis().Any(t => t == typeof(BaseRoute))) { + return new[] { + actionDescriptor.ControllerTypeInfo.Namespace?.Split('.').Last() + }; + } + + return new[] { + actionDescriptor.ControllerName + }; + } + + public static IEnumerable<Type> GetBaseTypesAndThis(this Type type) { + Type? current = type; + while (current != null) { + yield return current; + current = current.BaseType; + } + } +} diff --git a/server/src/appsettings.json b/server/src/appsettings.json new file mode 100644 index 0000000..8727fd7 --- /dev/null +++ b/server/src/appsettings.json @@ -0,0 +1,22 @@ +{ + "AllowedHosts": "*", + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.EntityFrameworkCore": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Filter": [ + { + "Name": "ByExcluding", + "Args": { + "expression": "@mt = 'An unhandled exception has occurred while executing the request.'" + } + } + ] + } +} diff --git a/server/src/wwwroot/version.txt b/server/src/wwwroot/version.txt new file mode 100644 index 0000000..863c2f5 --- /dev/null +++ b/server/src/wwwroot/version.txt @@ -0,0 +1 @@ +v35-server-dev diff --git a/sql/quartz-create.sql b/sql/quartz-create.sql new file mode 100644 index 0000000..d0dc298 --- /dev/null +++ b/sql/quartz-create.sql @@ -0,0 +1,156 @@ +CREATE TABLE IF NOT EXISTS qrtz_job_details +( + sched_name TEXT NOT NULL, + job_name TEXT NOT NULL, + job_group TEXT NOT NULL, + description TEXT NULL, + job_class_name TEXT NOT NULL, + is_durable BOOL NOT NULL, + is_nonconcurrent BOOL NOT NULL, + is_update_data BOOL NOT NULL, + requests_recovery BOOL NOT NULL, + job_data BYTEA NULL, + PRIMARY KEY (sched_name, job_name, job_group) +); + +CREATE TABLE IF NOT EXISTS qrtz_triggers +( + sched_name TEXT NOT NULL, + trigger_name TEXT NOT NULL, + trigger_group TEXT NOT NULL, + job_name TEXT NOT NULL, + job_group TEXT NOT NULL, + description TEXT NULL, + next_fire_time BIGINT NULL, + prev_fire_time BIGINT NULL, + priority INTEGER NULL, + trigger_state TEXT NOT NULL, + trigger_type TEXT NOT NULL, + start_time BIGINT NOT NULL, + end_time BIGINT NULL, + calendar_name TEXT NULL, + misfire_instr SMALLINT NULL, + job_data BYTEA NULL, + PRIMARY KEY (sched_name, trigger_name, trigger_group), + FOREIGN KEY (sched_name, job_name, job_group) + REFERENCES qrtz_job_details (sched_name, job_name, job_group) +); + +CREATE TABLE IF NOT EXISTS qrtz_simple_triggers +( + sched_name TEXT NOT NULL, + trigger_name TEXT NOT NULL, + trigger_group TEXT NOT NULL, + repeat_count BIGINT NOT NULL, + repeat_interval BIGINT NOT NULL, + times_triggered BIGINT NOT NULL, + PRIMARY KEY (sched_name, trigger_name, trigger_group), + FOREIGN KEY (sched_name, trigger_name, trigger_group) + REFERENCES qrtz_triggers (sched_name, trigger_name, trigger_group) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS QRTZ_SIMPROP_TRIGGERS +( + sched_name TEXT NOT NULL, + trigger_name TEXT NOT NULL, + trigger_group TEXT NOT NULL, + str_prop_1 TEXT NULL, + str_prop_2 TEXT NULL, + str_prop_3 TEXT NULL, + int_prop_1 INTEGER NULL, + int_prop_2 INTEGER NULL, + long_prop_1 BIGINT NULL, + long_prop_2 BIGINT NULL, + dec_prop_1 NUMERIC NULL, + dec_prop_2 NUMERIC NULL, + bool_prop_1 BOOL NULL, + bool_prop_2 BOOL NULL, + time_zone_id TEXT NULL, + PRIMARY KEY (sched_name, trigger_name, trigger_group), + FOREIGN KEY (sched_name, trigger_name, trigger_group) + REFERENCES qrtz_triggers (sched_name, trigger_name, trigger_group) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS qrtz_cron_triggers +( + sched_name TEXT NOT NULL, + trigger_name TEXT NOT NULL, + trigger_group TEXT NOT NULL, + cron_expression TEXT NOT NULL, + time_zone_id TEXT, + PRIMARY KEY (sched_name, trigger_name, trigger_group), + FOREIGN KEY (sched_name, trigger_name, trigger_group) + REFERENCES qrtz_triggers (sched_name, trigger_name, trigger_group) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS qrtz_blob_triggers +( + sched_name TEXT NOT NULL, + trigger_name TEXT NOT NULL, + trigger_group TEXT NOT NULL, + blob_data BYTEA NULL, + PRIMARY KEY (sched_name, trigger_name, trigger_group), + FOREIGN KEY (sched_name, trigger_name, trigger_group) + REFERENCES qrtz_triggers (sched_name, trigger_name, trigger_group) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS qrtz_calendars +( + sched_name TEXT NOT NULL, + calendar_name TEXT NOT NULL, + calendar BYTEA NOT NULL, + PRIMARY KEY (sched_name, calendar_name) +); + +CREATE TABLE IF NOT EXISTS qrtz_paused_trigger_grps +( + sched_name TEXT NOT NULL, + trigger_group TEXT NOT NULL, + PRIMARY KEY (sched_name, trigger_group) +); + +CREATE TABLE IF NOT EXISTS qrtz_fired_triggers +( + sched_name TEXT NOT NULL, + entry_id TEXT NOT NULL, + trigger_name TEXT NOT NULL, + trigger_group TEXT NOT NULL, + instance_name TEXT NOT NULL, + fired_time BIGINT NOT NULL, + sched_time BIGINT NOT NULL, + priority INTEGER NOT NULL, + state TEXT NOT NULL, + job_name TEXT NULL, + job_group TEXT NULL, + is_nonconcurrent BOOL NOT NULL, + requests_recovery BOOL NULL, + PRIMARY KEY (sched_name, entry_id) +); + +CREATE TABLE IF NOT EXISTS qrtz_scheduler_state +( + sched_name TEXT NOT NULL, + instance_name TEXT NOT NULL, + last_checkin_time BIGINT NOT NULL, + checkin_interval BIGINT NOT NULL, + PRIMARY KEY (sched_name, instance_name) +); + +CREATE TABLE IF NOT EXISTS qrtz_locks +( + sched_name TEXT NOT NULL, + lock_name TEXT NOT NULL, + PRIMARY KEY (sched_name, lock_name) +); + +CREATE INDEX IF NOT EXISTS idx_qrtz_j_req_recovery on qrtz_job_details (requests_recovery); +CREATE INDEX IF NOT EXISTS idx_qrtz_t_next_fire_time on qrtz_triggers (next_fire_time); +CREATE INDEX IF NOT EXISTS idx_qrtz_t_state on qrtz_triggers (trigger_state); +CREATE INDEX IF NOT EXISTS idx_qrtz_t_nft_st on qrtz_triggers (next_fire_time, trigger_state); +CREATE INDEX IF NOT EXISTS idx_qrtz_ft_trig_name on qrtz_fired_triggers (trigger_name); +CREATE INDEX IF NOT EXISTS idx_qrtz_ft_trig_group on qrtz_fired_triggers (trigger_group); +CREATE INDEX IF NOT EXISTS idx_qrtz_ft_trig_nm_gp on qrtz_fired_triggers (sched_name, trigger_name, trigger_group); +CREATE INDEX IF NOT EXISTS idx_qrtz_ft_trig_inst_name on qrtz_fired_triggers (instance_name); +CREATE INDEX IF NOT EXISTS idx_qrtz_ft_job_name on qrtz_fired_triggers (job_name); +CREATE INDEX IF NOT EXISTS idx_qrtz_ft_job_group on qrtz_fired_triggers (job_group); +CREATE INDEX IF NOT EXISTS idx_qrtz_ft_job_req_recovery on qrtz_fired_triggers (requests_recovery); diff --git a/sql/quartz-drop.sql b/sql/quartz-drop.sql new file mode 100644 index 0000000..87b0797 --- /dev/null +++ b/sql/quartz-drop.sql @@ -0,0 +1,23 @@ +DROP TABLE IF EXISTS qrtz_fired_triggers; +DROP TABLE IF EXISTS qrtz_paused_trigger_grps; +DROP TABLE IF EXISTS qrtz_scheduler_state; +DROP TABLE IF EXISTS qrtz_locks; +DROP TABLE IF EXISTS qrtz_simprop_triggers; +DROP TABLE IF EXISTS qrtz_simple_triggers; +DROP TABLE IF EXISTS qrtz_cron_triggers; +DROP TABLE IF EXISTS qrtz_blob_triggers; +DROP TABLE IF EXISTS qrtz_triggers; +DROP TABLE IF EXISTS qrtz_job_details; +DROP TABLE IF EXISTS qrtz_calendars; + +DROP INDEX IF EXISTS idx_qrtz_j_req_recovery; +DROP INDEX IF EXISTS idx_qrtz_t_next_fire_time; +DROP INDEX IF EXISTS idx_qrtz_t_state; +DROP INDEX IF EXISTS idx_qrtz_t_nft_st; +DROP INDEX IF EXISTS idx_qrtz_ft_trig_name; +DROP INDEX IF EXISTS idx_qrtz_ft_trig_group; +DROP INDEX IF EXISTS idx_qrtz_ft_trig_nm_gp; +DROP INDEX IF EXISTS idx_qrtz_ft_trig_inst_name; +DROP INDEX IF EXISTS idx_qrtz_ft_job_name; +DROP INDEX IF EXISTS idx_qrtz_ft_job_group; +DROP INDEX IF EXISTS idx_qrtz_ft_job_req_recovery; diff --git a/tests/IOL.GreatOffice.IntegrationTests/ApplicationTests/LoginPageTests.cs b/tests/IOL.GreatOffice.IntegrationTests/ApplicationTests/LoginPageTests.cs new file mode 100644 index 0000000..10525fd --- /dev/null +++ b/tests/IOL.GreatOffice.IntegrationTests/ApplicationTests/LoginPageTests.cs @@ -0,0 +1,23 @@ +using IOL.GreatOffice.IntegrationTests.Helpers; +using Xunit; + +namespace IOL.GreatOffice.IntegrationTests.ApplicationTests; + +public class LoginPageTests : IClassFixture<WebServerFixture> +{ + private readonly WebServerFixture _fixture; + + public LoginPageTests(WebServerFixture fixture) { + _fixture = fixture; + } + + [Fact] + public async Task LoginPageTestsRenders() { + var page = await _fixture.Browser.NewPageAsync(); + await page.GotoAsync(_fixture.BaseUrl); + + var actual = await page.TextContentAsync(Element.ByName("Page Title")); + + Assert.Equal("Welcome", actual); + } +} diff --git a/tests/IOL.GreatOffice.IntegrationTests/Helpers/Element.cs b/tests/IOL.GreatOffice.IntegrationTests/Helpers/Element.cs new file mode 100644 index 0000000..da83cc3 --- /dev/null +++ b/tests/IOL.GreatOffice.IntegrationTests/Helpers/Element.cs @@ -0,0 +1,6 @@ +namespace IOL.GreatOffice.IntegrationTests.Helpers; + +public static class Element +{ + public static string ByName(string name) => $"[pw-name='{name}']"; +} diff --git a/tests/IOL.GreatOffice.IntegrationTests/Helpers/WebServerFixture.cs b/tests/IOL.GreatOffice.IntegrationTests/Helpers/WebServerFixture.cs new file mode 100644 index 0000000..080fa9f --- /dev/null +++ b/tests/IOL.GreatOffice.IntegrationTests/Helpers/WebServerFixture.cs @@ -0,0 +1,48 @@ +using System.Net; +using System.Net.Sockets; +using Microsoft.AspNetCore.Builder; +using Microsoft.Playwright; +using Xunit; +using Program = IOL.GreatOffice.Api.Program; + +namespace IOL.GreatOffice.IntegrationTests.Helpers; + +// ReSharper disable once ClassNeverInstantiated.Global +public class WebServerFixture : IAsyncLifetime, IDisposable +{ + private readonly WebApplication Host; + private IPlaywright Playwright { get; set; } + public IBrowser Browser { get; private set; } + public string BaseUrl { get; } = $"https://localhost:{GetRandomUnusedPort()}"; + + public WebServerFixture() { + Host = Program.CreateWebApplication(Program.CreateAppBuilder(default)); + } + + public async Task InitializeAsync() { + Playwright = await Microsoft.Playwright.Playwright.CreateAsync(); + Browser = await Playwright.Chromium.LaunchAsync(); + await Host.StartAsync(); + } + + public async Task DisposeAsync() { + await Host.StopAsync(); + await Host.DisposeAsync(); + Playwright?.Dispose(); + } + + public void Dispose() { + Host.StopAsync(); + Host.DisposeAsync(); + Playwright?.Dispose(); + GC.SuppressFinalize(this); + } + + private static int GetRandomUnusedPort() { + var listener = new TcpListener(IPAddress.Any, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } +} diff --git a/tests/IOL.GreatOffice.IntegrationTests/IOL.GreatOffice.IntegrationTests.csproj b/tests/IOL.GreatOffice.IntegrationTests/IOL.GreatOffice.IntegrationTests.csproj new file mode 100644 index 0000000..92ed6b2 --- /dev/null +++ b/tests/IOL.GreatOffice.IntegrationTests/IOL.GreatOffice.IntegrationTests.csproj @@ -0,0 +1,25 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <OutputType>Exe</OutputType> + <ImplicitUsings>true</ImplicitUsings> + <TargetFramework>net6.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>disable</Nullable> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" /> + <PackageReference Include="Microsoft.Playwright" Version="1.22.0" /> + <PackageReference Include="xunit" Version="2.4.1" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\server\IOL.GreatOffice.Api.csproj" /> + <ProjectReference Include="..\..\server\src\IOL.GreatOffice.Api.csproj" /> + </ItemGroup> + + <ItemGroup> + <Folder Include="ServerTests" /> + </ItemGroup> +</Project> |
