diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh new file mode 100755 index 0000000..f7bc939 --- /dev/null +++ b/.devcontainer/setup.sh @@ -0,0 +1,24 @@ +WILCO_ID="`cat .wilco`" +CODESPACE_BACKEND_HOST=$(curl -s "${ENGINE_BASE_URL}/api/v1/codespace/backendHost?codespaceName=${CODESPACE_NAME}&portForwarding=${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}" | jq -r '.codespaceBackendHost') +CODESPACE_BACKEND_URL="https://${CODESPACE_BACKEND_HOST}" +export ENGINE_EVENT_ENDPOINT="${ENGINE_BASE_URL}/users/${WILCO_ID}/event" + +# Update engine that codespace started for user +curl -L -X POST "${ENGINE_EVENT_ENDPOINT}" -H "Content-Type: application/json" --data-raw "{ \"event\": \"github_codespace_started\" }" + +# Export backend envs when in codespaces +echo "export CODESPACE_BACKEND_HOST=\"${CODESPACE_BACKEND_HOST}\"" >> ~/.bashrc +echo "export CODESPACE_BACKEND_URL=\"${CODESPACE_BACKEND_URL}\"" >> ~/.bashrc +echo "export CODESPACE_WDS_SOCKET_PORT=443" >> ~/.bashrc + +# Export welcome prompt in bash: +echo "printf \"\n\n☁️☁️☁️️ Anythink: Develop in the Cloud ☁️☁️☁️\n\"" >> ~/.bashrc +echo "printf \"\n\x1b[31m \x1b[1m👉 Type: \\\`docker compose up\\\` to run the project. 👈\n\n\"" >> ~/.bashrc + +nohup bash -c "cd /wilco-agent && node agent.js &" >> /tmp/agent.log 2>&1 + +# Check if docker is installed +if command -v docker &> /dev/null +then + docker compose pull +fi diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..8ebf549 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,3 @@ +# Description + +Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. diff --git a/.github/workflows/k8s.yml b/.github/workflows/k8s.yml new file mode 100644 index 0000000..9a56d6a --- /dev/null +++ b/.github/workflows/k8s.yml @@ -0,0 +1,157 @@ +name: Build and deploy to Kubernetes +on: + push: + branches: + - main + +concurrency: + group: k8s + cancel-in-progress: true + +jobs: + check-kubernetes-enabled: + runs-on: ubuntu-20.04 + outputs: + kubernetes-enabled: ${{ steps.kubernetes-flag-defined.outputs.DEFINED }} + steps: + - id: kubernetes-flag-defined + if: "${{ env.ENABLE_KUBERNETES != '' }}" + run: echo "DEFINED=true" >> $GITHUB_OUTPUT + env: + ENABLE_KUBERNETES: ${{ secrets.ENABLE_KUBERNETES }} + + check-secret: + runs-on: ubuntu-20.04 + needs: [check-kubernetes-enabled] + outputs: + aws-creds-defined: ${{ steps.aws-creds-defined.outputs.DEFINED }} + kubeconfig-defined: ${{ steps.kubeconfig-defined.outputs.DEFINED }} + if: needs.check-kubernetes-enabled.outputs.kubernetes-enabled == 'true' + steps: + - id: aws-creds-defined + if: "${{ env.AWS_ACCESS_KEY_ID != '' && env.AWS_SECRET_ACCESS_KEY != '' }}" + run: echo "DEFINED=true" >> $GITHUB_OUTPUT + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + - id: kubeconfig-defined + if: "${{ env.KUBECONFIG != '' }}" + run: echo "DEFINED=true" >> $GITHUB_OUTPUT + env: + KUBECONFIG: ${{ secrets.KUBECONFIG }} + + build-backend: + name: Build backend image + runs-on: ubuntu-20.04 + needs: [check-secret] + if: needs.check-secret.outputs.aws-creds-defined == 'true' + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1-node16 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-2 + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + + - name: Set the image tag + run: echo IMAGE_TAG=${GITHUB_REPOSITORY/\//-}-latest >> $GITHUB_ENV + + - name: Set repository name + run: | + if [ ${{ secrets.CLUSTER_ENV }} == 'staging' ]; then + echo "REPO_NAME=staging-anythink-backend" >> $GITHUB_ENV + else + echo "REPO_NAME=anythink-backend" >> $GITHUB_ENV + fi + + - name: Build, tag, and push backend image to Amazon ECR + id: build-image-backend + run: | + docker build \ + -t ${{ steps.login-ecr.outputs.registry }}/${{ env.REPO_NAME }}:${{ env.IMAGE_TAG }} \ + -f backend/Dockerfile.aws \ + . + docker push ${{ steps.login-ecr.outputs.registry }}/${{ env.REPO_NAME }}:${{ env.IMAGE_TAG }} + + build-frontend: + name: Build frontend images + runs-on: ubuntu-20.04 + needs: [check-secret] + if: needs.check-secret.outputs.aws-creds-defined == 'true' + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1-node16 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-2 + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + + - name: Set the image tag + run: echo IMAGE_TAG=${GITHUB_REPOSITORY/\//-}-latest >> $GITHUB_ENV + + - name: Set repository name + run: | + if [ ${{ secrets.CLUSTER_ENV }} == 'staging' ]; then + echo "REPO_NAME=staging-anythink-frontend" >> $GITHUB_ENV + else + echo "REPO_NAME=anythink-frontend" >> $GITHUB_ENV + fi + + - name: Build, tag, and push frontend image to Amazon ECR + id: build-image-frontend + run: | + docker build \ + -t ${{ steps.login-ecr.outputs.registry }}/${{ env.REPO_NAME }}:${{ env.IMAGE_TAG }} \ + -f frontend/Dockerfile.aws \ + . + docker push ${{ steps.login-ecr.outputs.registry }}/${{ env.REPO_NAME }}:${{ env.IMAGE_TAG }} + + deploy: + name: Deploy latest tag using helm + runs-on: ubuntu-20.04 + if: needs.check-secret.outputs.kubeconfig-defined == 'true' + needs: + - build-frontend + - build-backend + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Create kube config + run: | + mkdir -p $HOME/.kube/ + echo "${{ secrets.KUBECONFIG }}" > $HOME/.kube/config + chmod 600 $HOME/.kube/config + - name: Install helm + run: | + curl -LO https://get.helm.sh/helm-v3.8.0-linux-amd64.tar.gz + tar -zxvf helm-v3.8.0-linux-amd64.tar.gz + mv linux-amd64/helm /usr/local/bin/helm + helm version + - name: Lint helm charts + run: helm lint ./charts/ + + - name: Set the image tag + run: echo IMAGE_TAG=${GITHUB_REPOSITORY/\//-}-latest >> $GITHUB_ENV + + - name: Deploy + run: | + helm upgrade --install --timeout 10m anythink-market ./charts/ \ + --set clusterEnv=${{ secrets.CLUSTER_ENV }} \ + --set frontend.image.tag=${{ env.IMAGE_TAG }} \ + --set backend.image.tag=${{ env.IMAGE_TAG }} diff --git a/.github/workflows/wilco-actions.yml b/.github/workflows/wilco-actions.yml deleted file mode 100644 index b732c86..0000000 --- a/.github/workflows/wilco-actions.yml +++ /dev/null @@ -1,24 +0,0 @@ -on: - pull_request: - branches: - - main - -jobs: - wilco: - runs-on: ubuntu-20.04 - timeout-minutes: 10 - name: Pr checks - - steps: - - name: Check out project - uses: actions/checkout@v2 - - - uses: oNaiPs/secrets-to-env-action@v1 - with: - secrets: ${{ toJSON(secrets) }} - - - name: Wilco checks - id: Wilco - uses: trywilco/actions@main - with: - engine: ${{ secrets.WILCO_ENGINE_URL }} diff --git a/.gitignore b/.gitignore index 2bc83f0..10d146d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,332 +1,37 @@ -## 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 -*.suo -*.user -*.userosscache -*.sln.docstates -.vscode +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/backend/node_modules +/frontend/node_modules +/.wilco-helpers/node_modules +/tests/e2e/node_modules +/tests/frontend/node_modules/ +/tests/frontend/test-results/ +/tests/frontend/playwright-report/ +/tests/frontend/playwright/.cache/ + +/.pnp +.pnp.js + +# testing +/coverage + +# production +/backend/build +/frontend/build + +# misc .DS_Store - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ - -# 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 - -# 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/ -**/Properties/launchSettings.json - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_i.h -*.ilk -*.meta -*.obj -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*.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 - -# JustCode is a .NET coding add-in -.JustCode - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# 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 -# 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 - -# 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 - -# 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/ - -# JetBrains Rider -.idea/ -*.sln.iml - -# CodeRush -.cr/ - -# 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/ +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +#IDEs +/.idea/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0c878f1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "workbench.startupEditor": "none" +} diff --git a/LICENSE b/LICENSE deleted file mode 100644 index f9b0011..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2018 Autodesk Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md deleted file mode 100644 index 518a2f1..0000000 --- a/README.md +++ /dev/null @@ -1,131 +0,0 @@ -# Basic skeleton for 3-legged OAuth and Webhooks - -![Platforms](https://img.shields.io/badge/platform-Windows|MacOS-lightgray.svg) -![.NET](https://img.shields.io/badge/.NET%20Core-3.1-blue.svg) -[![License](http://img.shields.io/:license-MIT-blue.svg)](http://opensource.org/licenses/MIT) - -[![oAuth2](https://img.shields.io/badge/oAuth2-v1-green.svg)](http://developer.autodesk.com/) -[![Data-Management](https://img.shields.io/badge/Data%20Management-v1-green.svg)](http://developer.autodesk.com/) -[![Webhook](https://img.shields.io/badge/Webhook-v1-green.svg)](http://developer.autodesk.com/) - - -![Advanced](https://img.shields.io/badge/Level-Advanced-red.svg) - -# Description - -Show BIM 360 Hubs, Projects and Files, based on [this tutorial](http://learnforge.autodesk.io). When select a folder on the tree view, the option to "Start watching folder" allow to create a `dm.version.added` webhook for that folder. When a new file is uploaded (e.g. via BIM 360 Docs UI) the webhook notifies the app, which queues the job to, later when ready, access the metadata of the file. - -## Thumbnail - -![thumbnail](thumbnail.png) - -## Demonstration - -There a few moving parts on this sample, [this video](https://www.youtube.com/watch?v=S35g6ZMHDXs) demonstrates the sample. - -# Setup - -## Prerequisites - -1. **Forge Account**: Learn how to create a Forge Account, activate subscription and create an app at [this tutorial](http://learnforge.autodesk.io/#/account/). -2. **Visual Studio**: Either Community (Windows) or Code (Windows, MacOS). -3. **.NET Core** basic knowledge with C# -4. **ngrok**: Routing tool, [download here](https://ngrok.com/) -5. **MongoDB**: noSQL database, [learn more](https://www.mongodb.com/). Or use a online version via [Mongo Altas](https://www.mongodb.com/cloud/atlas) (this is used on this sample) - -## Running locally - -Clone this project or download it. It's recommended to install [GitHub desktop](https://desktop.github.com/). To clone it via command line, use the following (**Terminal** on MacOSX/Linux, **Git Shell** on Windows): - - git clone https://github.com/autodesk-forge/forge-createwebhooks-skeleton - -**Visual Studio** (Windows): - -Right-click on the project, then go to **Debug**. Adjust the settings as shown below. - -![](webhook/wwwroot/img/readme/visual_studio_settings.png) - -**Visual Code** (Windows, MacOS): - -Open the folder, at the bottom-right, select **Yes** and **Restore**. This restores the packages (e.g. Autodesk.Forge) and creates the launch.json file. See *Tips & Tricks* for .NET Core on MacOS. - -![](webhook/wwwroot/img/readme/visual_code_restore.png) - -**MongoDB** - -[MongoDB](https://www.mongodb.com) is a no-SQL database based on "documents", which stores JSON-like data. For testing purpouses, you can either use local or live. For cloud environment, try [MongoDB Atlas](https://www.mongodb.com/cloud/atlas) (offers a free tier). With MongoDB Atlas you can set up an account for free and create clustered instances, intructions: - -1. Create a account on MongoDB Atlas. -2. Under "Collections", create a new database (e.g. named `webhooks`) with a collection (e.g. named `users`). -3. Under "Command Line Tools", whitelist the IP address to access the database, [see this tutorial](https://docs.atlas.mongodb.com/security-whitelist/). If the sample is running on Heroku, you'll need to open to all (IP `0.0.0.0/0`). Create a new user to access the database. - -At this point the connection string should be in the form of `mongodb+srv://:@clusterX-a1b2c4.mongodb.net/webhook?retryWrites=true`. [Learn more here](https://docs.mongodb.com/manual/reference/connection-string/) - -There are several tools to view your database, [Robo 3T](https://robomongo.org/) (formerly Robomongo) is a free lightweight GUI that can be used. When it opens, follow instructions [here](https://www.datduh.com/blog/2017/7/26/how-to-connect-to-mongodb-atlas-using-robo-3t-robomongo) to connect to MongoDB Atlas. - -**ngrok** - -Run `ngrok http 3000 -host-header="localhost:3000"` to create a tunnel to your local machine, then copy the address into the `FORGE_WEBHOOK_URL` environment variable. - -**Environment variables** - -At the `.vscode\launch.json`, find the env vars and add your Forge Client ID, Secret and callback URL. Also define the `ASPNETCORE_URLS` variable. The end result should be as shown below: - -```json -"env": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ASPNETCORE_URLS" : "http://localhost:3000", - "FORGE_CLIENT_ID": "your id here", - "FORGE_CLIENT_SECRET": "your secret here", - "FORGE_CALLBACK_URL": "http://localhost:3000/api/forge/callback/oauth", - "FORGE_WEBHOOK_URL": "your ngrok address here: e.g. http://abcd1234.ngrok.io", - "OAUTH_DATABASE": "mongodb+srv://:@clusterX-a1b2c4.mongodb.net/webhook?retryWrites=true", -}, -``` - -Open `http://localhost:3000` to start the app. - -Open `http://localhost:3000/hangfire` for jobs dashboard. - -## Deployment - -To deploy this application to Heroku, the **Callback URL** for Forge must use your `.herokuapp.com` address. After clicking on the button below, at the Heroku Create New App page, set your Client ID, Secret and Callback URL for Forge. - -[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) - - -# Further Reading - -Documentation: - -- [BIM 360 API](https://developer.autodesk.com/en/docs/bim360/v1/overview/) and [App Provisioning](https://forge.autodesk.com/blog/bim-360-docs-provisioning-forge-apps) -- [Data Management API](https://developer.autodesk.com/en/docs/data/v2/overview/) -- [Webhook](https://forge.autodesk.com/en/docs/webhooks/v1) - -Other APIs: - -- [Hangfire](https://www.hangfire.io/) queueing library for .NET -- [MongoDB for C#](https://docs.mongodb.com/ecosystem/drivers/csharp/) driver -- [Mongo Atlas](https://mongodb.com/) Database-as-a-Service for MongoDB - -### Known Issues - -- **No webhook for translation**: this sample tries `GET Manifest` every interval as, as of now, there is no webhook for Model Derivative translations on BIM 360 files. There is support for OSS Buckets translations, [learn more here](https://forge.autodesk.com/en/docs/webhooks/v1/tutorials/create-a-hook-model-derivative/). - -### Tips & Tricks - -This sample uses .NET Core and works fine on both Windows and MacOS, see [this tutorial for MacOS](https://github.com/augustogoncalves/dotnetcoreheroku). - -### Troubleshooting - -1. **Cannot see my BIM 360 projects**: Make sure to provision the Forge App Client ID within the BIM 360 Account, [learn more here](https://forge.autodesk.com/blog/bim-360-docs-provisioning-forge-apps). This requires the Account Admin permission. - -2. **error setting certificate verify locations** error: may happen on Windows, use the following: `git config --global http.sslverify "false"` - -## License - -This sample is licensed under the terms of the [MIT License](http://opensource.org/licenses/MIT). Please see the [LICENSE](LICENSE) file for full details. - -## Written by - -Augusto Goncalves [@augustomaia](https://twitter.com/augustomaia), [Forge Partner Development](http://forge.autodesk.com) diff --git a/app.json b/app.json deleted file mode 100644 index fa21de1..0000000 --- a/app.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "data.management-csharp-webhook", - "description": "Webhooks for Data Management hubs", - "repository": "https://github.com/autodesk-forge/data.management-csharp-webhook", - "logo": "https://avatars0.githubusercontent.com/u/8017462?v=3&s=200", - "keywords": [ - "autodesk", - "forge", - "bim360", - "webhook" - ], - "env": { - "FORGE_CLIENT_ID": { - "description": "Forge Client ID" - }, - "FORGE_CLIENT_SECRET": { - "description": "Forge Client Secret" - }, - "FORGE_CALLBACK_URL": { - "description": "Callback URL of your Forge app, required for 3-legged OAuth", - "value": "https://<>.herokuapp.com/api/forge/callback/oauth" - }, - "FORGE_WEBHOOK_CALLBACK_URL": { - "description": "Callback URL of your webhook", - "value": "http://<>/api/forge/callback/webhook" - }, - "OAUTH_DATABASE": { - "description": "MongoDB connection string, e.g frmm mLab", - "value": "mongodb://user:password@ds1234.mlab.com:6789/databaseName" - } - }, - "website": "https://developer.autodesk.com/", - "success_url": "/", - "buildpacks": [ - { - "url": "https://github.com/jincod/dotnetcore-buildpack.git" - } - ] -} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 41c2d03..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,26 +0,0 @@ -services: - mongo: - image: mongo:latest - container_name: mongo - ports: - - "27017:27017" - environment: - MONGO_INITDB_ROOT_USERNAME: wilco_user - MONGO_INITDB_ROOT_PASSWORD: wilco_password - - webhook: - container_name: webhook - build: - context: ./webhook - dockerfile: Dockerfile - ports: - - "5000:80" - environment: - ASPNETCORE_ENVIRONMENT: Development - FORGE_CLIENT_ID: client_id - FORGE_CLIENT_SECRET: client_secret - FORGE_CALLBACK_URL: http://${CODESPACE_NAME}-5000.preview.app.github.dev/api/forge/callback/oauth - FORGE_WEBHOOK_URL: http://${CODESPACE_NAME}-5000.preview.app.github.dev/api/forge/callback/webhook - OAUTH_DATABASE: mongodb://wilco_user:wilco_password@mongo:27017/webhook?authSource=admin - depends_on: - - mongo diff --git a/frontend/.eslintignore b/frontend/.eslintignore new file mode 100644 index 0000000..dd87e2d --- /dev/null +++ b/frontend/.eslintignore @@ -0,0 +1,2 @@ +node_modules +build diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..edfc119 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,16 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# dependencies +node_modules + +# testing +coverage + +# production +build + +# misc +.DS_Store +.env +npm-debug.log +.idea \ No newline at end of file diff --git a/frontend/Dockerfile.aws b/frontend/Dockerfile.aws new file mode 100644 index 0000000..9485ac1 --- /dev/null +++ b/frontend/Dockerfile.aws @@ -0,0 +1,9 @@ +FROM node:16 +WORKDIR /usr/src + +COPY frontend ./frontend +COPY .wilco ./.wilco + +# Pre-install npm packages +WORKDIR /usr/src/frontend +RUN yarn install diff --git a/frontend/jest.config.js b/frontend/jest.config.js new file mode 100644 index 0000000..582fe82 --- /dev/null +++ b/frontend/jest.config.js @@ -0,0 +1,8 @@ +const config = { + verbose: true, + jest: { + setupFilesAfterEnv: ["src/setupTests.js"], + }, +}; + +module.exports = config; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..45a643d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,74 @@ +{ + "name": "anythink-market-front", + "version": "0.1.0", + "engines": { + "node": "^16" + }, + "private": true, + "devDependencies": { + "@wojtekmaj/enzyme-adapter-react-17": "^0.6.7", + "core-js": "^3.25.1", + "enzyme": "^3.11.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-react": "^7.26.1", + "prettier": "2.4.1", + "react-test-renderer": "^17.0.2", + "redux-mock-store": "^1.5.4" + }, + "dependencies": { + "@babel/core": "^7.18.13", + "@babel/plugin-proposal-private-property-in-object": "^7.18.6", + "@babel/plugin-syntax-flow": "^7.18.6", + "@babel/plugin-transform-react-jsx": "^7.18.10", + "bootstrap": "^4.6.0", + "bootstrap-icons": "^1.7.1", + "history": "^4.6.3", + "jquery": "^3.6.1", + "marked": "^0.3.6", + "postcss": "^8.4.16", + "prop-types": "^15.5.10", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-redux": "^5.0.7", + "react-router-dom": "^6.9.0", + "react-scripts": "^5.0.1", + "redux": "^3.6.0", + "redux-devtools-extension": "^2.13.2", + "sass": "^1.45.0", + "superagent": "^3.8.2", + "superagent-promise": "^1.1.0", + "typescript": "^4.8.2" + }, + "scripts": { + "start": "REACT_APP_WILCO_ID=${WILCO_ID:-\"$(cat ../.wilco)\"} react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject", + "format": "yarn prettier --write .", + "lint": "yarn eslint . && yarn prettier --check ." + }, + "eslintConfig": { + "extends": [ + "react-app", + "eslint:recommended" + ], + "rules": { + "no-var": "error" + } + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "resolutions": { + "autoprefixer": "10.4.5" + } +} diff --git a/frontend/public/50precentoff.png b/frontend/public/50precentoff.png new file mode 100644 index 0000000..4c835a1 Binary files /dev/null and b/frontend/public/50precentoff.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..201ae78 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 0000000..f0441fc --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + Anythink Market + + +
+
+ + diff --git a/frontend/public/placeholder.png b/frontend/public/placeholder.png new file mode 100644 index 0000000..bf1d310 Binary files /dev/null and b/frontend/public/placeholder.png differ diff --git a/frontend/public/style.css b/frontend/public/style.css new file mode 100644 index 0000000..2bb7fe2 --- /dev/null +++ b/frontend/public/style.css @@ -0,0 +1,54 @@ +* { + -webkit-font-smoothing: antialiased; +} +body { + font-family: "Inter", sans-serif; + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + text-rendering: optimizeLegibility; + -moz-osx-font-smoothing: grayscale; + -webkit-font-feature-settings: "kern" 1, "liga" 1; + font-feature-settings: "kern" 1, "liga" 1; + scroll-behavior: smooth; +} +.top-announcement { + background-color: #59ca00; + padding: 15px; + font-size: 18px; + color: white; +} +.logo-text { + color: #59ca00 !important; + font-weight: 600; +} +.minegeek-navbar { + background-color: #393939; +} +.sunray { + background-image: url("sunray.jpeg"); + background-size: cover; +} +.text-white { + color: white; +} +.row-eq-height { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; +} +.minegear-btn { + background-color: #59ca00; + border: 0px; + border-radius: 0px; +} +.dramaticPerson { + opacity: 0; + position: fixed; + right: -500px; + bottom: 0px; + height: 590px; + width: 490px; + z-index: 1041; + background-size: cover; +} diff --git a/frontend/public/sunray.jpeg b/frontend/public/sunray.jpeg new file mode 100644 index 0000000..f133496 Binary files /dev/null and b/frontend/public/sunray.jpeg differ diff --git a/frontend/public/verified_seller.svg b/frontend/public/verified_seller.svg new file mode 100644 index 0000000..2e1b353 --- /dev/null +++ b/frontend/public/verified_seller.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + diff --git a/frontend/readme.md b/frontend/readme.md new file mode 100644 index 0000000..2f5bc17 --- /dev/null +++ b/frontend/readme.md @@ -0,0 +1,26 @@ +# Anythink Frontend + +The Anythink Frontend is an SPA written with [React](https://reactjs.org/) and [Redux](https://redux.js.org/) + +## Getting started + +Make sure your server is up and running to serve requests. + +## Pages overview + +- Home page (URL: /#/ ) + - List of tags + - List of items pulled from either Feed, Global, or by Tag + - Pagination for list of items +- Sign in/Sign up pages (URL: /#/login, /#/register ) + - Use JWT (store the token in localStorage) +- Settings page (URL: /#/settings ) +- Editor page to create/edit articles (URL: /#/editor, /#/editor/slug ) +- Item page (URL: /#/item/slug ) + - Delete item button (only shown to item's author) + - Render markdown from server client side + - Comments section at bottom of page + - Delete comment button (only shown to comment's author) +- Profile page (URL: /#/@username, /#/@username/favorites ) + - Show basic user info + - List of items populated from seller's items or user favorite items diff --git a/frontend/src/agent.js b/frontend/src/agent.js new file mode 100644 index 0000000..972f3e3 --- /dev/null +++ b/frontend/src/agent.js @@ -0,0 +1,98 @@ +import superagentPromise from "superagent-promise"; +import _superagent from "superagent"; + +const superagent = superagentPromise(_superagent, global.Promise); + +const BACKEND_URL = + process.env.NODE_ENV !== "production" + ? process.env.REACT_APP_BACKEND_URL + : "https://api.anythink.market"; + +const API_ROOT = `${BACKEND_URL}/api`; + +const encode = encodeURIComponent; +const responseBody = (res) => res.body; + +let token = null; +const tokenPlugin = (req) => { + if (token) { + req.set("authorization", `Token ${token}`); + } +}; + +const requests = { + del: (url) => + superagent.del(`${API_ROOT}${url}`).use(tokenPlugin).then(responseBody), + get: (url) => + superagent.get(`${API_ROOT}${url}`).use(tokenPlugin).then(responseBody), + put: (url, body) => + superagent + .put(`${API_ROOT}${url}`, body) + .use(tokenPlugin) + .then(responseBody), + post: (url, body) => + superagent + .post(`${API_ROOT}${url}`, body) + .use(tokenPlugin) + .then(responseBody), +}; + +const Auth = { + current: () => requests.get("/user"), + login: (email, password) => + requests.post("/users/login", { user: { email, password } }), + register: (username, email, password) => + requests.post("/users", { user: { username, email, password } }), + save: (user) => requests.put("/user", { user }), +}; + +const Tags = { + getAll: () => requests.get("/tags"), +}; + +const limit = (count, p) => `limit=${count}&offset=${p ? p * count : 0}`; +const omitSlug = (item) => Object.assign({}, item, { slug: undefined }); +const Items = { + all: (page) => requests.get(`/items?${limit(1000, page)}`), + bySeller: (seller, page) => + requests.get(`/items?seller=${encode(seller)}&${limit(500, page)}`), + byTag: (tag, page) => + requests.get(`/items?tag=${encode(tag)}&${limit(1000, page)}`), + del: (slug) => requests.del(`/items/${slug}`), + favorite: (slug) => requests.post(`/items/${slug}/favorite`), + favoritedBy: (seller, page) => + requests.get(`/items?favorited=${encode(seller)}&${limit(500, page)}`), + feed: () => requests.get("/items/feed?limit=10&offset=0"), + get: (slug) => requests.get(`/items/${slug}`), + unfavorite: (slug) => requests.del(`/items/${slug}/favorite`), + update: (item) => + requests.put(`/items/${item.slug}`, { item: omitSlug(item) }), + create: (item) => requests.post("/items", { item }), +}; + +const Comments = { + create: (slug, comment) => + requests.post(`/items/${slug}/comments`, { comment }), + delete: (slug, commentId) => + requests.del(`/items/${slug}/comments/${commentId}`), + forItem: (slug) => requests.get(`/items/${slug}/comments`), +}; + +const Profile = { + follow: (username) => requests.post(`/profiles/${username}/follow`), + get: (username) => requests.get(`/profiles/${username}`), + unfollow: (username) => requests.del(`/profiles/${username}/follow`), +}; + +const agentObj = { + Items, + Auth, + Comments, + Profile, + Tags, + setToken: (_token) => { + token = _token; + }, +}; + +export default agentObj; diff --git a/frontend/src/components/App.js b/frontend/src/components/App.js new file mode 100644 index 0000000..8b23812 --- /dev/null +++ b/frontend/src/components/App.js @@ -0,0 +1,81 @@ +import agent from "../agent"; +import Header from "./Header"; +import React, { useEffect } from "react"; +import { connect } from "react-redux"; +import { APP_LOAD, REDIRECT } from "../constants/actionTypes"; +import Item from "./Item"; +import Editor from "./Editor"; +import Home from "./Home"; +import Login from "./Login"; +import Profile from "./Profile"; +import ProfileFavorites from "./ProfileFavorites"; +import Register from "./Register"; +import Settings from "./Settings"; +import { Route, Routes, useNavigate } from "react-router-dom"; + +const mapStateToProps = (state) => { + return { + appLoaded: state.common.appLoaded, + appName: state.common.appName, + currentUser: state.common.currentUser, + redirectTo: state.common.redirectTo, + }; +}; + +const mapDispatchToProps = (dispatch) => ({ + onLoad: (payload, token) => + dispatch({ type: APP_LOAD, payload, token, skipTracking: true }), + onRedirect: () => dispatch({ type: REDIRECT }), +}); + +const App = (props) => { + const { redirectTo, onRedirect, onLoad } = props; + const navigate = useNavigate(); + + useEffect(() => { + if (redirectTo) { + navigate(redirectTo); + onRedirect(); + } + }, [redirectTo, onRedirect, navigate]); + + useEffect(() => { + const token = window.localStorage.getItem("jwt"); + if (token) { + agent.setToken(token); + } + onLoad(token ? agent.Auth.current() : null, token); + }, [onLoad]); + + if (props.appLoaded) { + return ( +
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+ ); + } + return ( +
+
+
+ ); +} + +export default connect(mapStateToProps, mapDispatchToProps)(App); \ No newline at end of file diff --git a/frontend/src/components/Editor.js b/frontend/src/components/Editor.js new file mode 100644 index 0000000..700a401 --- /dev/null +++ b/frontend/src/components/Editor.js @@ -0,0 +1,176 @@ +import ListErrors from "./ListErrors"; +import React from "react"; +import agent from "../agent"; +import { connect } from "react-redux"; +import { + ADD_TAG, + EDITOR_PAGE_LOADED, + REMOVE_TAG, + ITEM_SUBMITTED, + EDITOR_PAGE_UNLOADED, + UPDATE_FIELD_EDITOR, +} from "../constants/actionTypes"; +import { withRouterParams } from "./commons"; + +const mapStateToProps = (state) => ({ + ...state.editor, +}); + +const mapDispatchToProps = (dispatch) => ({ + onAddTag: () => dispatch({ type: ADD_TAG }), + onLoad: (payload) => dispatch({ type: EDITOR_PAGE_LOADED, payload }), + onRemoveTag: (tag) => dispatch({ type: REMOVE_TAG, tag }), + onSubmit: (payload) => dispatch({ type: ITEM_SUBMITTED, payload }), + onUnload: (payload) => dispatch({ type: EDITOR_PAGE_UNLOADED }), + onUpdateField: (key, value) => + dispatch({ type: UPDATE_FIELD_EDITOR, key, value }), +}); + +class Editor extends React.Component { + constructor() { + super(); + + const updateFieldEvent = (key) => (ev) => + this.props.onUpdateField(key, ev.target.value); + this.changeTitle = updateFieldEvent("title"); + this.changeDescription = updateFieldEvent("description"); + this.changeImage = updateFieldEvent("image"); + this.changeTagInput = updateFieldEvent("tagInput"); + + this.watchForEnter = (ev) => { + if (ev.keyCode === 13) { + ev.preventDefault(); + this.props.onAddTag(); + } + }; + + this.removeTagHandler = (tag) => () => { + this.props.onRemoveTag(tag); + }; + + this.submitForm = (ev) => { + ev.preventDefault(); + const item = { + title: this.props.title, + description: this.props.description, + image: this.props.image, + tagList: this.props.tagList, + }; + + const slug = { slug: this.props.itemSlug }; + const promise = this.props.itemSlug + ? agent.Items.update(Object.assign(item, slug)) + : agent.Items.create(item); + + this.props.onSubmit(promise); + }; + } + + componentDidUpdate(prevProps) { + if (this.props.params.slug !== prevProps.params.slug) { + if (this.props.params.slug) { + this.props.onUnload(); + return this.props.onLoad(agent.Items.get(this.props.params.slug)); + } + this.props.onLoad(null); + } + } + + componentDidMount() { + if (this.props.params.slug) { + return this.props.onLoad(agent.Items.get(this.props.params.slug)); + } + this.props.onLoad(null); + } + + componentWillUnmount() { + this.props.onUnload(); + } + + render() { + return ( +
+
+
+
+ + +
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ {(this.props.tagList || []).map((tag) => { + return ( + + + {tag} + + ); + })} +
+
+ + +
+
+
+
+
+
+ ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(withRouterParams(Editor)); diff --git a/frontend/src/components/Header.js b/frontend/src/components/Header.js new file mode 100644 index 0000000..be37dfc --- /dev/null +++ b/frontend/src/components/Header.js @@ -0,0 +1,73 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import logo from "../imgs/topbar_logo.png"; + +const LoggedOutView = () => { + return ( +
    +
  • + + Sign in + +
  • + +
  • + + Sign up + +
  • +
+ ); +}; + +const LoggedInView = (props) => { + return ( +
    +
  • + +  New Item + +
  • + +
  • + +  Settings + +
  • + +
  • + + {props.currentUser.username} + {props.currentUser.username} + +
  • +
+ ); +}; + +class Header extends React.Component { + render() { + return ( + + ); + } +} + +export default Header; diff --git a/frontend/src/components/Home/Banner.js b/frontend/src/components/Home/Banner.js new file mode 100644 index 0000000..60eed20 --- /dev/null +++ b/frontend/src/components/Home/Banner.js @@ -0,0 +1,19 @@ +import React from "react"; +import logo from "../../imgs/logo.png"; + +const Banner = () => { + return ( +
+
+ +
+ A place to + get + the cool stuff. +
+
+
+ ); +}; + +export default Banner; diff --git a/frontend/src/components/Home/MainView.js b/frontend/src/components/Home/MainView.js new file mode 100644 index 0000000..bf92549 --- /dev/null +++ b/frontend/src/components/Home/MainView.js @@ -0,0 +1,100 @@ +import ItemList from "../ItemList"; +import React from "react"; +import agent from "../../agent"; +import { connect } from "react-redux"; +import { CHANGE_TAB } from "../../constants/actionTypes"; + +const YourFeedTab = (props) => { + if (props.token) { + const clickHandler = (ev) => { + ev.preventDefault(); + props.onTabClick("feed", agent.Items.feed, agent.Items.feed()); + }; + + return ( +
  • + +
  • + ); + } + return null; +}; + +const GlobalFeedTab = (props) => { + const clickHandler = (ev) => { + ev.preventDefault(); + props.onTabClick("all", agent.Items.all, agent.Items.all()); + }; + return ( +
  • + +
  • + ); +}; + +const TagFilterTab = (props) => { + if (!props.tag) { + return null; + } + + return ( +
  • + +
  • + ); +}; + +const mapStateToProps = (state) => ({ + ...state.itemList, + tags: state.home.tags, + token: state.common.token, +}); + +const mapDispatchToProps = (dispatch) => ({ + onTabClick: (tab, pager, payload) => + dispatch({ type: CHANGE_TAB, tab, pager, payload }), +}); + +const MainView = (props) => { + return ( +
    +
    +
      + + + + + +
    +
    + + +
    + ); +}; + +export default connect(mapStateToProps, mapDispatchToProps)(MainView); diff --git a/frontend/src/components/Home/Tags.js b/frontend/src/components/Home/Tags.js new file mode 100644 index 0000000..01cba5d --- /dev/null +++ b/frontend/src/components/Home/Tags.js @@ -0,0 +1,40 @@ +import React from "react"; +import agent from "../../agent"; + +const Tags = (props) => { + const tags = props.tags; + if (tags) { + return ( +
    + Popular tags: + + {tags.map((tag) => { + const handleClick = (ev) => { + ev.preventDefault(); + props.onClickTag( + tag, + (page) => agent.Items.byTag(tag, page), + agent.Items.byTag(tag) + ); + }; + + return ( + + ); + })} + +
    + ); + } else { + return
    Loading Tags...
    ; + } +}; + +export default Tags; diff --git a/frontend/src/components/Home/index.js b/frontend/src/components/Home/index.js new file mode 100644 index 0000000..34e09ae --- /dev/null +++ b/frontend/src/components/Home/index.js @@ -0,0 +1,54 @@ +import Banner from "./Banner"; +import MainView from "./MainView"; +import React, { useEffect } from "react"; +import Tags from "./Tags"; +import agent from "../../agent"; +import { connect } from "react-redux"; +import { + HOME_PAGE_LOADED, + HOME_PAGE_UNLOADED, + APPLY_TAG_FILTER, +} from "../../constants/actionTypes"; + +const Promise = global.Promise; + +const mapStateToProps = (state) => ({ + ...state.home, + appName: state.common.appName, + token: state.common.token, +}); + +const mapDispatchToProps = (dispatch) => ({ + onClickTag: (tag, pager, payload) => + dispatch({ type: APPLY_TAG_FILTER, tag, pager, payload }), + onLoad: (tab, pager, payload) => + dispatch({ type: HOME_PAGE_LOADED, tab, pager, payload }), + onUnload: () => dispatch({ type: HOME_PAGE_UNLOADED }), +}); + +const Home = ({onLoad, onUnload, tags, onClickTag}) => { + const tab = "all"; + const itemsPromise = agent.Items.all; + + useEffect(() => { + onLoad( + tab, + itemsPromise, + Promise.all([agent.Tags.getAll(), itemsPromise()]) + ); + return onUnload; + }, [onLoad, onUnload, tab, itemsPromise]); + + return ( +
    + + +
    + + +
    +
    + ); +} + +export default connect(mapStateToProps, mapDispatchToProps)(Home); \ No newline at end of file diff --git a/frontend/src/components/Item/Comment.js b/frontend/src/components/Item/Comment.js new file mode 100644 index 0000000..f852408 --- /dev/null +++ b/frontend/src/components/Item/Comment.js @@ -0,0 +1,42 @@ +import DeleteButton from "./DeleteButton"; +import { Link } from "react-router-dom"; +import React from "react"; + +const Comment = (props) => { + const comment = props.comment; + const show = + props.currentUser && props.currentUser.username === comment.seller.username; + return ( +
    +
    +
    +

    {comment.body}

    +
    + + {comment.seller.username} + +   + + {comment.seller.username} + + | + + {new Date(comment.createdAt).toDateString()} + + +
    +
    +
    +
    + ); +}; + +export default Comment; diff --git a/frontend/src/components/Item/CommentContainer.js b/frontend/src/components/Item/CommentContainer.js new file mode 100644 index 0000000..88562b4 --- /dev/null +++ b/frontend/src/components/Item/CommentContainer.js @@ -0,0 +1,46 @@ +import CommentInput from "./CommentInput"; +import CommentList from "./CommentList"; +import { Link } from "react-router-dom"; +import React from "react"; + +const CommentContainer = (props) => { + if (props.currentUser) { + return ( +
    + +
    +
    + + +
    +
    +
    + ); + } else { + return ( +
    + +

    + + Sign in + +  or  + + sign up + +  to add comments on this item. +

    +
    + ); + } +}; + +export default CommentContainer; diff --git a/frontend/src/components/Item/CommentInput.js b/frontend/src/components/Item/CommentInput.js new file mode 100644 index 0000000..250a241 --- /dev/null +++ b/frontend/src/components/Item/CommentInput.js @@ -0,0 +1,59 @@ +import React from "react"; +import agent from "../../agent"; +import { connect } from "react-redux"; +import { ADD_COMMENT } from "../../constants/actionTypes"; + +const mapDispatchToProps = (dispatch) => ({ + onSubmit: (payload) => dispatch({ type: ADD_COMMENT, payload }), +}); + +class CommentInput extends React.Component { + constructor() { + super(); + this.state = { + body: "", + }; + + this.setBody = (ev) => { + this.setState({ body: ev.target.value }); + }; + + this.createComment = async (ev) => { + ev.preventDefault(); + agent.Comments.create(this.props.slug, { + body: this.state.body, + }).then((payload) => { + this.props.onSubmit(payload); + }); + this.setState({ body: "" }); + }; + } + + render() { + return ( +
    +
    + +
    +
    + {this.props.currentUser.username} + +
    +
    + ); + } +} + +export default connect(() => ({}), mapDispatchToProps)(CommentInput); diff --git a/frontend/src/components/Item/CommentList.js b/frontend/src/components/Item/CommentList.js new file mode 100644 index 0000000..b1bcb35 --- /dev/null +++ b/frontend/src/components/Item/CommentList.js @@ -0,0 +1,21 @@ +import Comment from "./Comment"; +import React from "react"; + +const CommentList = (props) => { + return ( +
    + {props.comments.map((comment) => { + return ( + + ); + })} +
    + ); +}; + +export default CommentList; diff --git a/frontend/src/components/Item/DeleteButton.js b/frontend/src/components/Item/DeleteButton.js new file mode 100644 index 0000000..b78b1b2 --- /dev/null +++ b/frontend/src/components/Item/DeleteButton.js @@ -0,0 +1,27 @@ +import React from "react"; +import agent from "../../agent"; +import { connect } from "react-redux"; +import { DELETE_COMMENT } from "../../constants/actionTypes"; + +const mapDispatchToProps = (dispatch) => ({ + onClick: (payload, commentId) => + dispatch({ type: DELETE_COMMENT, payload, commentId }), +}); + +const DeleteButton = (props) => { + const del = () => { + const payload = agent.Comments.delete(props.slug, props.commentId); + props.onClick(payload, props.commentId); + }; + + if (props.show) { + return ( + + + + ); + } + return null; +}; + +export default connect(() => ({}), mapDispatchToProps)(DeleteButton); diff --git a/frontend/src/components/Item/ItemActions.js b/frontend/src/components/Item/ItemActions.js new file mode 100644 index 0000000..b6d8615 --- /dev/null +++ b/frontend/src/components/Item/ItemActions.js @@ -0,0 +1,36 @@ +import { Link } from "react-router-dom"; +import React from "react"; +import agent from "../../agent"; +import { connect } from "react-redux"; +import { DELETE_ITEM } from "../../constants/actionTypes"; + +const mapDispatchToProps = (dispatch) => ({ + onClickDelete: (payload) => dispatch({ type: DELETE_ITEM, payload }), +}); + +const ItemActions = (props) => { + const item = props.item; + const del = () => { + props.onClickDelete(agent.Items.del(item.slug)); + }; + if (props.canModify) { + return ( + + + Edit Item + + + + + ); + } + + return ; +}; + +export default connect(() => ({}), mapDispatchToProps)(ItemActions); diff --git a/frontend/src/components/Item/ItemMeta.js b/frontend/src/components/Item/ItemMeta.js new file mode 100644 index 0000000..c3bdb6e --- /dev/null +++ b/frontend/src/components/Item/ItemMeta.js @@ -0,0 +1,30 @@ +import ItemActions from "./ItemActions"; +import { Link } from "react-router-dom"; +import React from "react"; + +const ItemMeta = (props) => { + const item = props.item; + return ( +
    + + {item.seller.username} + + +
    + + {item.seller.username} + + {new Date(item.createdAt).toDateString()} +
    + + +
    + ); +}; + +export default ItemMeta; diff --git a/frontend/src/components/Item/index.js b/frontend/src/components/Item/index.js new file mode 100644 index 0000000..7d1bf58 --- /dev/null +++ b/frontend/src/components/Item/index.js @@ -0,0 +1,85 @@ +import ItemMeta from "./ItemMeta"; +import CommentContainer from "./CommentContainer"; +import React, { useEffect } from "react"; +import { connect } from "react-redux"; +import marked from "marked"; +import { + ITEM_PAGE_LOADED, + ITEM_PAGE_UNLOADED, +} from "../../constants/actionTypes"; +import { getItemAndComments } from "./utils/ItemFetcher"; +import { useParams } from "react-router-dom"; + +const mapStateToProps = (state) => ({ + ...state.item, + currentUser: state.common.currentUser, +}); + +const mapDispatchToProps = (dispatch) => ({ + onLoad: (payload) => dispatch({ type: ITEM_PAGE_LOADED, payload }), + onUnload: () => dispatch({ type: ITEM_PAGE_UNLOADED }), +}); + +const Item = (props) => { + const params = useParams(); + const {onLoad, onUnload} = props; + useEffect(() => { + getItemAndComments( + params.id + ).then(([item, comments]) => { + onLoad([item, comments]); + }); + return onUnload; + }, [onLoad, onUnload, params]); + + if (!props.item) { + return null; + } + + const markup = { + __html: marked(props.item.description, { sanitize: true }), + }; + const canModify = + props.currentUser && + props.currentUser.username === props.item.seller.username; + return ( +
    +
    +
    +
    + {props.item.title} +
    + +
    +

    {props.item.title}

    + +
    + {props.item.tagList.map((tag) => { + return ( + + {tag} + + ); + })} +
    +
    + +
    + +
    +
    +
    + ); +} + +export default connect(mapStateToProps, mapDispatchToProps)(Item); diff --git a/frontend/src/components/Item/utils/ItemFetcher.js b/frontend/src/components/Item/utils/ItemFetcher.js new file mode 100644 index 0000000..6ebd6c8 --- /dev/null +++ b/frontend/src/components/Item/utils/ItemFetcher.js @@ -0,0 +1,8 @@ +import agent from "../../../agent"; + +export async function getItemAndComments(id) { + const item = await agent.Items.get(id); + const comments = await agent.Comments.forItem(id); + + return [item, comments]; +} diff --git a/frontend/src/components/ItemList.js b/frontend/src/components/ItemList.js new file mode 100644 index 0000000..268714e --- /dev/null +++ b/frontend/src/components/ItemList.js @@ -0,0 +1,35 @@ +import ItemPreview from "./ItemPreview"; +import ListPagination from "./ListPagination"; +import React from "react"; + +const ItemList = (props) => { + if (!props.items) { + return
    Loading...
    ; + } + + if (props.items.length === 0) { + return
    No items are here... yet.
    ; + } + + return ( +
    +
    + {props.items.map((item) => { + return ( +
    + +
    + ); + })} +
    + + +
    + ); +}; + +export default ItemList; diff --git a/frontend/src/components/ItemPreview.js b/frontend/src/components/ItemPreview.js new file mode 100644 index 0000000..ce89b00 --- /dev/null +++ b/frontend/src/components/ItemPreview.js @@ -0,0 +1,66 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import agent from "../agent"; +import { connect } from "react-redux"; +import { ITEM_FAVORITED, ITEM_UNFAVORITED } from "../constants/actionTypes"; + +const mapDispatchToProps = (dispatch) => ({ + favorite: (slug) => + dispatch({ + type: ITEM_FAVORITED, + payload: agent.Items.favorite(slug), + }), + unfavorite: (slug) => + dispatch({ + type: ITEM_UNFAVORITED, + payload: agent.Items.unfavorite(slug), + }), +}); + +const ItemPreview = (props) => { + const item = props.item; + + const handleClick = (ev) => { + ev.preventDefault(); + if (item.favorited) { + props.unfavorite(item.slug); + } else { + props.favorite(item.slug); + } + }; + + return ( +
    + item +
    + +

    {item.title}

    +

    {item.description}

    + +
    + + {item.seller.username} + + +
    +
    +
    + ); +}; + +export default connect(() => ({}), mapDispatchToProps)(ItemPreview); diff --git a/frontend/src/components/ListErrors.js b/frontend/src/components/ListErrors.js new file mode 100644 index 0000000..33c97e8 --- /dev/null +++ b/frontend/src/components/ListErrors.js @@ -0,0 +1,24 @@ +import React from "react"; + +class ListErrors extends React.Component { + render() { + const errors = this.props.errors; + if (errors) { + return ( +
      + {Object.keys(errors).map((key) => { + return ( +
    • + {key} {errors[key]} +
    • + ); + })} +
    + ); + } else { + return null; + } + } +} + +export default ListErrors; diff --git a/frontend/src/components/ListPagination.js b/frontend/src/components/ListPagination.js new file mode 100644 index 0000000..fcefbcc --- /dev/null +++ b/frontend/src/components/ListPagination.js @@ -0,0 +1,52 @@ +import React from "react"; +import agent from "../agent"; +import { connect } from "react-redux"; +import { SET_PAGE } from "../constants/actionTypes"; + +const mapDispatchToProps = (dispatch) => ({ + onSetPage: (page, payload) => dispatch({ type: SET_PAGE, page, payload }), +}); + +const ListPagination = (props) => { + if (props.itemsCount <= 10) { + return null; + } + + const range = []; + for (let i = 0; i < Math.ceil(props.itemsCount / 10); ++i) { + range.push(i); + } + + const setPage = (page) => { + if (props.pager) { + props.onSetPage(page, props.pager(page)); + } else { + props.onSetPage(page, agent.Items.all(page)); + } + }; + + return ( + + ); +}; + +export default connect(() => ({}), mapDispatchToProps)(ListPagination); diff --git a/frontend/src/components/Login.js b/frontend/src/components/Login.js new file mode 100644 index 0000000..af4f12d --- /dev/null +++ b/frontend/src/components/Login.js @@ -0,0 +1,121 @@ +import { Link } from "react-router-dom"; +import ListErrors from "./ListErrors"; +import React from "react"; +import agent from "../agent"; +import { connect } from "react-redux"; +import { + UPDATE_FIELD_AUTH, + LOGIN, + LOGIN_PAGE_UNLOADED, +} from "../constants/actionTypes"; + +const mapStateToProps = (state) => ({ ...state.auth }); + +const mapDispatchToProps = (dispatch) => ({ + onChangeEmail: (value) => + dispatch({ type: UPDATE_FIELD_AUTH, key: "email", value }), + onChangePassword: (value) => + dispatch({ type: UPDATE_FIELD_AUTH, key: "password", value }), + onSubmit: (email, password) => + dispatch({ type: LOGIN, payload: agent.Auth.login(email, password) }), + onUnload: () => dispatch({ type: LOGIN_PAGE_UNLOADED }), +}); + +class Login extends React.Component { + constructor() { + super(); + this.changeEmail = (ev) => this.props.onChangeEmail(ev.target.value); + this.changePassword = (ev) => this.props.onChangePassword(ev.target.value); + this.submitForm = (email, password) => (ev) => { + ev.preventDefault(); + this.props.onSubmit(email, password); + }; + } + + componentWillUnmount() { + this.props.onUnload(); + } + + render() { + const email = this.props.email; + const password = this.props.password; + return ( +
    +
    +
    +
    +

    Sign In

    + + + +
    +
    +
    +
    +
    + + + +
    + +
    +
    + +
    +
    +
    + + + +
    + +
    +
    + + +
    +
    +

    + + Need an account? + +

    +
    +
    +
    +
    + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(Login); diff --git a/frontend/src/components/Profile.js b/frontend/src/components/Profile.js new file mode 100644 index 0000000..b2e0f0b --- /dev/null +++ b/frontend/src/components/Profile.js @@ -0,0 +1,172 @@ +import ItemList from "./ItemList"; +import React from "react"; +import { Link } from "react-router-dom"; +import agent from "../agent"; +import { connect } from "react-redux"; +import { + FOLLOW_USER, + UNFOLLOW_USER, + PROFILE_PAGE_LOADED, + PROFILE_PAGE_UNLOADED, +} from "../constants/actionTypes"; +import { withRouterParams } from "./commons"; + +const EditProfileSettings = (props) => { + if (props.isUser) { + return ( + + Edit Profile Settings + + ); + } + return null; +}; + +const FollowUserButton = (props) => { + if (props.isUser) { + return null; + } + + let classes = "btn btn-sm action-btn"; + if (props.user.following) { + classes += " btn-secondary"; + } else { + classes += " btn-outline-secondary"; + } + + const handleClick = (ev) => { + ev.preventDefault(); + if (props.user.following) { + props.unfollow(props.user.username); + } else { + props.follow(props.user.username); + } + }; + + return ( + + ); +}; + +const mapStateToProps = (state) => ({ + ...state.itemList, + currentUser: state.common.currentUser, + profile: state.profile, +}); + +const mapDispatchToProps = (dispatch) => ({ + onFollow: (username) => + dispatch({ + type: FOLLOW_USER, + payload: agent.Profile.follow(username), + }), + onLoad: (payload) => dispatch({ type: PROFILE_PAGE_LOADED, payload }), + onUnfollow: (username) => + dispatch({ + type: UNFOLLOW_USER, + payload: agent.Profile.unfollow(username), + }), + onUnload: () => dispatch({ type: PROFILE_PAGE_UNLOADED }), +}); + +class Profile extends React.Component { + componentDidMount() { + const username = this.props.params.username?.substring(1); + this.props.onLoad( + Promise.all([ + agent.Profile.get(username), + agent.Items.bySeller(username), + ]) + ); + } + + componentWillUnmount() { + this.props.onUnload(); + } + + renderTabs() { + return ( +
      +
    • + + My Items + +
    • + +
    • + + Favorited Items + +
    • +
    + ); + } + + render() { + const profile = this.props.profile; + if (!profile) { + return null; + } + + const isUser = + this.props.currentUser && + this.props.profile.username === this.props.currentUser.username; + + return ( +
    +
    +
    +
    + {profile.username} +

    {profile.username}

    +

    {profile.bio}

    + + + +
    +
    +
    + +
    +
    +
    +
    {this.renderTabs()}
    + + +
    +
    +
    +
    + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(withRouterParams(Profile)); +export { Profile, mapStateToProps }; diff --git a/frontend/src/components/ProfileFavorites.js b/frontend/src/components/ProfileFavorites.js new file mode 100644 index 0000000..5fd3fba --- /dev/null +++ b/frontend/src/components/ProfileFavorites.js @@ -0,0 +1,56 @@ +import { Profile, mapStateToProps } from "./Profile"; +import React from "react"; +import { Link } from "react-router-dom"; +import agent from "../agent"; +import { connect } from "react-redux"; +import { + PROFILE_PAGE_LOADED, + PROFILE_PAGE_UNLOADED, +} from "../constants/actionTypes"; +import { withRouterParams } from "./commons"; + +const mapDispatchToProps = (dispatch) => ({ + onLoad: (pager, payload) => + dispatch({ type: PROFILE_PAGE_LOADED, pager, payload }), + onUnload: () => dispatch({ type: PROFILE_PAGE_UNLOADED }), +}); + +class ProfileFavorites extends Profile { + componentDidMount() { + const username = this.props.params.username?.substring(1); + this.props.onLoad( + (page) => agent.Items.favoritedBy(username, page), + Promise.all([ + agent.Profile.get(username), + agent.Items.favoritedBy(username), + ]) + ); + } + + componentWillUnmount() { + this.props.onUnload(); + } + + renderTabs() { + return ( +
      +
    • + + My Items + +
    • + +
    • + + Favorited Items + +
    • +
    + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(withRouterParams(ProfileFavorites)); diff --git a/frontend/src/components/Register.js b/frontend/src/components/Register.js new file mode 100644 index 0000000..195a25a --- /dev/null +++ b/frontend/src/components/Register.js @@ -0,0 +1,148 @@ +import { Link } from "react-router-dom"; +import ListErrors from "./ListErrors"; +import React from "react"; +import agent from "../agent"; +import { connect } from "react-redux"; +import { + UPDATE_FIELD_AUTH, + REGISTER, + REGISTER_PAGE_UNLOADED, +} from "../constants/actionTypes"; + +const mapStateToProps = (state) => ({ ...state.auth }); + +const mapDispatchToProps = (dispatch) => ({ + onChangeEmail: (value) => + dispatch({ type: UPDATE_FIELD_AUTH, key: "email", value }), + onChangePassword: (value) => + dispatch({ type: UPDATE_FIELD_AUTH, key: "password", value }), + onChangeUsername: (value) => + dispatch({ type: UPDATE_FIELD_AUTH, key: "username", value }), + onSubmit: (username, email, password) => { + const payload = agent.Auth.register(username, email, password); + dispatch({ type: REGISTER, payload }); + }, + onUnload: () => dispatch({ type: REGISTER_PAGE_UNLOADED }), +}); + +class Register extends React.Component { + constructor() { + super(); + this.changeEmail = (ev) => this.props.onChangeEmail(ev.target.value); + this.changePassword = (ev) => this.props.onChangePassword(ev.target.value); + this.changeUsername = (ev) => this.props.onChangeUsername(ev.target.value); + this.submitForm = (username, email, password) => (ev) => { + ev.preventDefault(); + this.props.onSubmit(username, email, password); + }; + } + + componentWillUnmount() { + this.props.onUnload(); + } + + render() { + const email = this.props.email; + const password = this.props.password; + const username = this.props.username; + + return ( +
    +
    +
    +
    +

    Sign Up

    + + + +
    +
    +
    +
    +
    + + + +
    + +
    +
    + +
    +
    +
    + + + +
    + +
    +
    + +
    +
    +
    + + + +
    + +
    +
    + + +
    +
    +

    + + Have an account? + +

    +
    +
    +
    +
    + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(Register); diff --git a/frontend/src/components/Settings.js b/frontend/src/components/Settings.js new file mode 100644 index 0000000..e4791bc --- /dev/null +++ b/frontend/src/components/Settings.js @@ -0,0 +1,142 @@ +import ListErrors from "./ListErrors"; +import React, { useCallback, useEffect, useState } from "react"; +import agent from "../agent"; +import { connect } from "react-redux"; +import { + SETTINGS_SAVED, + SETTINGS_PAGE_UNLOADED, + LOGOUT, +} from "../constants/actionTypes"; + +const SettingsForm = ({ currentUser, onSubmitForm }) => { + const [user, setUser] = useState({}); + + useEffect(() => { + if (currentUser) { + setUser(currentUser); + } + }, [currentUser]); + + const updateState = useCallback((field) => (ev) => { + const newState = Object.assign({}, user, { [field]: ev.target.value }); + setUser(newState); + }, [user]); + + const submitForm = useCallback((ev) => { + ev.preventDefault(); + const userToSubmit = { ...user }; + if (!userToSubmit.password) { + delete userToSubmit.password; + } + onSubmitForm(userToSubmit); + }, [user, onSubmitForm]); + + return ( +
    +
    +
    + +
    + +
    + +
    + +
    + +
    + +
    + +
    + +
    + +
    + + +
    +
    + ); +} + +const mapStateToProps = (state) => ({ + ...state.settings, + currentUser: state.common.currentUser, +}); + +const mapDispatchToProps = (dispatch) => ({ + onClickLogout: () => dispatch({ type: LOGOUT }), + onSubmitForm: (user) => + dispatch({ type: SETTINGS_SAVED, payload: agent.Auth.save(user) }), + onUnload: () => dispatch({ type: SETTINGS_PAGE_UNLOADED }), +}); + +class Settings extends React.Component { + render() { + return ( +
    +
    +
    +
    +

    Your Settings

    + + + + + +
    + + +
    +
    +
    +
    + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(Settings); diff --git a/frontend/src/components/commons.js b/frontend/src/components/commons.js new file mode 100644 index 0000000..8f1fd1c --- /dev/null +++ b/frontend/src/components/commons.js @@ -0,0 +1,5 @@ +import { useParams } from "react-router-dom"; + +export function withRouterParams(Component) { + return props => ; +} diff --git a/frontend/src/constants/actionTypes.js b/frontend/src/constants/actionTypes.js new file mode 100644 index 0000000..bb5380b --- /dev/null +++ b/frontend/src/constants/actionTypes.js @@ -0,0 +1,37 @@ +export const APP_LOAD = "APP_LOAD"; +export const REDIRECT = "REDIRECT"; +export const ITEM_SUBMITTED = "ITEM_SUBMITTED"; +export const SETTINGS_SAVED = "SETTINGS_SAVED"; +export const DELETE_ITEM = "DELETE_ITEM"; +export const SETTINGS_PAGE_UNLOADED = "SETTINGS_PAGE_UNLOADED"; +export const HOME_PAGE_LOADED = "HOME_PAGE_LOADED"; +export const HOME_PAGE_UNLOADED = "HOME_PAGE_UNLOADED"; +export const ITEM_PAGE_LOADED = "ITEM_PAGE_LOADED"; +export const ITEM_PAGE_UNLOADED = "ITEM_PAGE_UNLOADED"; +export const ADD_COMMENT = "ADD_COMMENT"; +export const DELETE_COMMENT = "DELETE_COMMENT"; +export const ITEM_FAVORITED = "ITEM_FAVORITED"; +export const ITEM_UNFAVORITED = "ITEM_UNFAVORITED"; +export const SET_PAGE = "SET_PAGE"; +export const APPLY_TAG_FILTER = "APPLY_TAG_FILTER"; +export const CHANGE_TAB = "CHANGE_TAB"; +export const PROFILE_PAGE_LOADED = "PROFILE_PAGE_LOADED"; +export const PROFILE_PAGE_UNLOADED = "PROFILE_PAGE_UNLOADED"; +export const LOGIN = "LOGIN"; +export const LOGOUT = "LOGOUT"; +export const REGISTER = "REGISTER"; +export const LOGIN_PAGE_UNLOADED = "LOGIN_PAGE_UNLOADED"; +export const REGISTER_PAGE_UNLOADED = "REGISTER_PAGE_UNLOADED"; +export const ASYNC_START = "ASYNC_START"; +export const ASYNC_END = "ASYNC_END"; +export const EDITOR_PAGE_LOADED = "EDITOR_PAGE_LOADED"; +export const EDITOR_PAGE_UNLOADED = "EDITOR_PAGE_UNLOADED"; +export const ADD_TAG = "ADD_TAG"; +export const REMOVE_TAG = "REMOVE_TAG"; +export const UPDATE_FIELD_AUTH = "UPDATE_FIELD_AUTH"; +export const UPDATE_FIELD_EDITOR = "UPDATE_FIELD_EDITOR"; +export const FOLLOW_USER = "FOLLOW_USER"; +export const UNFOLLOW_USER = "UNFOLLOW_USER"; +export const PROFILE_FAVORITES_PAGE_UNLOADED = + "PROFILE_FAVORITES_PAGE_UNLOADED"; +export const PROFILE_FAVORITES_PAGE_LOADED = "PROFILE_FAVORITES_PAGE_LOADED"; diff --git a/frontend/src/custom.scss b/frontend/src/custom.scss new file mode 100644 index 0000000..0b4d757 --- /dev/null +++ b/frontend/src/custom.scss @@ -0,0 +1,61 @@ +@import url("https://fonts.googleapis.com/css?family=Poppins:300,400,500,600,700,800"); + +// Override default variables before the import +$primary: #2b1456; +$secondary: #ff2b98; + +$body-color: white; //this is the text color +$body-bg: $primary; + +$dark: #170539; +$light: #af93f2; + +$input-border-color: #d0d0d0; + +$font-family-base: "Poppins", sans-serif !important; + +$theme-colors: ( + "light-gray": #f2f2f2, +); + +// Import Bootstrap and its default variables +@import "~bootstrap/scss/bootstrap.scss"; +@import "~bootstrap-icons/font/bootstrap-icons.css"; + +body { + background-image: url("./imgs/background.png"); + background-position: top; + background-repeat: no-repeat; +} +.page { + margin-top: 2 * $spacer; + margin-bottom: 2 * $spacer; +} + +.user-pic { + height: 40px; + width: 40px; +} + +.user-img { + width: 100px; + height: 100px; + border-radius: 100px; +} + +.item-img { + height: 150px; + object-fit: cover; +} + +.crop-text-3 { + -webkit-line-clamp: 3; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-box-orient: vertical; +} + +.user-info { + min-width: 800px; +} diff --git a/frontend/src/imgs/background.png b/frontend/src/imgs/background.png new file mode 100644 index 0000000..6c2e0f9 Binary files /dev/null and b/frontend/src/imgs/background.png differ diff --git a/frontend/src/imgs/logo.png b/frontend/src/imgs/logo.png new file mode 100644 index 0000000..89757c2 Binary files /dev/null and b/frontend/src/imgs/logo.png differ diff --git a/frontend/src/imgs/topbar_logo.png b/frontend/src/imgs/topbar_logo.png new file mode 100644 index 0000000..e7fefd3 Binary files /dev/null and b/frontend/src/imgs/topbar_logo.png differ diff --git a/frontend/src/index.js b/frontend/src/index.js new file mode 100644 index 0000000..a6ecd9b --- /dev/null +++ b/frontend/src/index.js @@ -0,0 +1,18 @@ +import "./custom.scss"; +import ReactDOM from "react-dom"; +import { Provider } from "react-redux"; +import React from "react"; +import { store } from "./store"; + +import App from "./components/App"; +import { BrowserRouter } from "react-router-dom"; + +ReactDOM.render( + + + + + , + + document.getElementById("root") +); diff --git a/frontend/src/middleware.js b/frontend/src/middleware.js new file mode 100644 index 0000000..4f82efb --- /dev/null +++ b/frontend/src/middleware.js @@ -0,0 +1,65 @@ +import agent from "./agent"; +import { + ASYNC_START, + ASYNC_END, + LOGIN, + LOGOUT, + REGISTER, +} from "./constants/actionTypes"; + +const promiseMiddleware = (store) => (next) => (action) => { + if (isPromise(action.payload)) { + store.dispatch({ type: ASYNC_START, subtype: action.type }); + + const currentView = store.getState().viewChangeCounter; + const skipTracking = action.skipTracking; + + action.payload.then( + (res) => { + const currentState = store.getState(); + if (!skipTracking && currentState.viewChangeCounter !== currentView) { + return; + } + action.payload = res; + store.dispatch({ type: ASYNC_END, promise: action.payload }); + store.dispatch(action); + }, + (error) => { + const currentState = store.getState(); + if (!skipTracking && currentState.viewChangeCounter !== currentView) { + return; + } + action.error = true; + action.payload = error.response.body; + if (!action.skipTracking) { + store.dispatch({ type: ASYNC_END, promise: action.payload }); + } + store.dispatch(action); + } + ); + + return; + } + + next(action); +}; + +const localStorageMiddleware = (store) => (next) => (action) => { + if (action.type === REGISTER || action.type === LOGIN) { + if (!action.error) { + window.localStorage.setItem("jwt", action.payload.user.token); + agent.setToken(action.payload.user.token); + } + } else if (action.type === LOGOUT) { + window.localStorage.setItem("jwt", ""); + agent.setToken(null); + } + + next(action); +}; + +function isPromise(v) { + return v && typeof v.then === "function"; +} + +export { promiseMiddleware, localStorageMiddleware }; diff --git a/frontend/src/reducer.js b/frontend/src/reducer.js new file mode 100644 index 0000000..65173ca --- /dev/null +++ b/frontend/src/reducer.js @@ -0,0 +1,20 @@ +import item from "./reducers/item"; +import itemList from "./reducers/itemList"; +import auth from "./reducers/auth"; +import { combineReducers } from "redux"; +import common from "./reducers/common"; +import editor from "./reducers/editor"; +import home from "./reducers/home"; +import profile from "./reducers/profile"; +import settings from "./reducers/settings"; + +export default combineReducers({ + item, + itemList, + auth, + common, + editor, + home, + profile, + settings, +}); diff --git a/frontend/src/reducers/auth.js b/frontend/src/reducers/auth.js new file mode 100644 index 0000000..6128b11 --- /dev/null +++ b/frontend/src/reducers/auth.js @@ -0,0 +1,36 @@ +import { + LOGIN, + REGISTER, + LOGIN_PAGE_UNLOADED, + REGISTER_PAGE_UNLOADED, + ASYNC_START, + UPDATE_FIELD_AUTH, +} from "../constants/actionTypes"; + +const reducer = (state = {}, action) => { + switch (action.type) { + case LOGIN: + case REGISTER: + return { + ...state, + inProgress: false, + errors: action.error ? action.payload.errors : null, + }; + case LOGIN_PAGE_UNLOADED: + case REGISTER_PAGE_UNLOADED: + return {}; + case ASYNC_START: + if (action.subtype === LOGIN || action.subtype === REGISTER) { + return { ...state, inProgress: true }; + } + break; + case UPDATE_FIELD_AUTH: + return { ...state, [action.key]: action.value }; + default: + return state; + } + + return state; +}; + +export default reducer; diff --git a/frontend/src/reducers/common.js b/frontend/src/reducers/common.js new file mode 100644 index 0000000..3ef54b4 --- /dev/null +++ b/frontend/src/reducers/common.js @@ -0,0 +1,79 @@ +import { + APP_LOAD, + REDIRECT, + LOGOUT, + ITEM_SUBMITTED, + SETTINGS_SAVED, + LOGIN, + REGISTER, + DELETE_ITEM, + ITEM_PAGE_UNLOADED, + EDITOR_PAGE_UNLOADED, + HOME_PAGE_UNLOADED, + PROFILE_PAGE_UNLOADED, + PROFILE_FAVORITES_PAGE_UNLOADED, + SETTINGS_PAGE_UNLOADED, + LOGIN_PAGE_UNLOADED, + REGISTER_PAGE_UNLOADED, +} from "../constants/actionTypes"; + +const defaultState = { + appName: "Anythink Market", + token: null, + viewChangeCounter: 0, +}; + +const reducer = (state = defaultState, action) => { + switch (action.type) { + case APP_LOAD: + return { + ...state, + token: action.token || null, + appLoaded: true, + currentUser: action.payload ? action.payload.user : null, + }; + case REDIRECT: + return { ...state, redirectTo: null }; + case LOGOUT: + return { ...state, redirectTo: "/", token: null, currentUser: null }; + case ITEM_SUBMITTED: { + const redirectUrl = `/item/${action.payload.item.slug}`; + return { ...state, redirectTo: redirectUrl }; + } + case SETTINGS_SAVED: + return { + ...state, + redirectTo: action.error ? null : "/", + currentUser: action.error ? null : action.payload.user, + }; + case LOGIN: + return { + ...state, + redirectTo: action.error ? null : "/", + token: action.error ? null : action.payload.user.token, + currentUser: action.error ? null : action.payload.user, + }; + case REGISTER: + return { + ...state, + redirectTo: action.error ? null : `/@${action.payload.user.username}`, + token: action.error ? null : action.payload.user.token, + currentUser: action.error ? null : action.payload.user, + }; + case DELETE_ITEM: + return { ...state, redirectTo: "/" }; + case ITEM_PAGE_UNLOADED: + case EDITOR_PAGE_UNLOADED: + case HOME_PAGE_UNLOADED: + case PROFILE_PAGE_UNLOADED: + case PROFILE_FAVORITES_PAGE_UNLOADED: + case SETTINGS_PAGE_UNLOADED: + case LOGIN_PAGE_UNLOADED: + case REGISTER_PAGE_UNLOADED: + return { ...state, viewChangeCounter: state.viewChangeCounter + 1 }; + default: + return state; + } +}; + +export default reducer; diff --git a/frontend/src/reducers/editor.js b/frontend/src/reducers/editor.js new file mode 100644 index 0000000..4320e70 --- /dev/null +++ b/frontend/src/reducers/editor.js @@ -0,0 +1,56 @@ +import { + EDITOR_PAGE_LOADED, + EDITOR_PAGE_UNLOADED, + ITEM_SUBMITTED, + ASYNC_START, + ADD_TAG, + REMOVE_TAG, + UPDATE_FIELD_EDITOR, +} from "../constants/actionTypes"; + +const reducer = (state = {}, action) => { + switch (action.type) { + case EDITOR_PAGE_LOADED: + return { + ...state, + itemSlug: action.payload ? action.payload.item.slug : "", + title: action.payload ? action.payload.item.title : "", + description: action.payload ? action.payload.item.description : "", + image: action.payload ? action.payload.item.image : "", + tagInput: "", + tagList: action.payload ? action.payload.item.tagList : [], + }; + case EDITOR_PAGE_UNLOADED: + return {}; + case ITEM_SUBMITTED: + return { + ...state, + inProgress: null, + errors: action.error ? action.payload.errors : null, + }; + case ASYNC_START: + if (action.subtype === ITEM_SUBMITTED) { + return { ...state, inProgress: true }; + } + break; + case ADD_TAG: + return { + ...state, + tagList: state.tagList.concat([state.tagInput]), + tagInput: "", + }; + case REMOVE_TAG: + return { + ...state, + tagList: state.tagList.filter((tag) => tag !== action.tag), + }; + case UPDATE_FIELD_EDITOR: + return { ...state, [action.key]: action.value }; + default: + return state; + } + + return state; +}; + +export default reducer; diff --git a/frontend/src/reducers/home.js b/frontend/src/reducers/home.js new file mode 100644 index 0000000..b9bc097 --- /dev/null +++ b/frontend/src/reducers/home.js @@ -0,0 +1,17 @@ +import { HOME_PAGE_LOADED, HOME_PAGE_UNLOADED } from "../constants/actionTypes"; + +const reducer = (state = {}, action) => { + switch (action.type) { + case HOME_PAGE_LOADED: + return { + ...state, + tags: action.payload[0].tags, + }; + case HOME_PAGE_UNLOADED: + return {}; + default: + return state; + } +}; + +export default reducer; diff --git a/frontend/src/reducers/item.js b/frontend/src/reducers/item.js new file mode 100644 index 0000000..918201c --- /dev/null +++ b/frontend/src/reducers/item.js @@ -0,0 +1,38 @@ +import { + ITEM_PAGE_LOADED, + ITEM_PAGE_UNLOADED, + ADD_COMMENT, + DELETE_COMMENT, +} from "../constants/actionTypes"; + +const reducer = (state = {}, action) => { + switch (action.type) { + case ITEM_PAGE_LOADED: + return { + ...state, + item: action.payload[0].item, + comments: action.payload[1].comments, + }; + case ITEM_PAGE_UNLOADED: + return {}; + case ADD_COMMENT: + return { + ...state, + commentErrors: action.error ? action.payload.errors : null, + comments: action.error + ? null + : (state.comments || []).concat([action.payload.comment]), + }; + case DELETE_COMMENT: { + const commentId = action.commentId; + return { + ...state, + comments: state.comments.filter((comment) => comment.id !== commentId), + }; + } + default: + return state; + } +}; + +export default reducer; diff --git a/frontend/src/reducers/itemList.js b/frontend/src/reducers/itemList.js new file mode 100644 index 0000000..016a996 --- /dev/null +++ b/frontend/src/reducers/itemList.js @@ -0,0 +1,88 @@ +import { + ITEM_FAVORITED, + ITEM_UNFAVORITED, + SET_PAGE, + APPLY_TAG_FILTER, + HOME_PAGE_LOADED, + HOME_PAGE_UNLOADED, + CHANGE_TAB, + PROFILE_PAGE_LOADED, + PROFILE_PAGE_UNLOADED, + PROFILE_FAVORITES_PAGE_LOADED, + PROFILE_FAVORITES_PAGE_UNLOADED, +} from "../constants/actionTypes"; + +const reducer = (state = {}, action) => { + switch (action.type) { + case ITEM_FAVORITED: + case ITEM_UNFAVORITED: + return { + ...state, + items: state.items.map((item) => { + if (item.slug === action.payload.item.slug) { + return { + ...item, + favorited: action.payload.item.favorited, + favoritesCount: action.payload.item.favoritesCount, + }; + } + return item; + }), + }; + case SET_PAGE: + return { + ...state, + items: action.payload.items, + itemsCount: action.payload.itemsCount, + currentPage: action.page, + }; + case APPLY_TAG_FILTER: + return { + ...state, + pager: action.pager, + items: action.payload.items, + itemsCount: action.payload.itemsCount, + tab: null, + tag: action.tag, + currentPage: 0, + }; + case HOME_PAGE_LOADED: + return { + ...state, + pager: action.pager, + tags: action.payload[0].tags, + items: action.payload[1].items, + itemsCount: action.payload[1].itemsCount, + currentPage: 0, + tab: action.tab, + }; + case HOME_PAGE_UNLOADED: + return {}; + case CHANGE_TAB: + return { + ...state, + pager: action.pager, + items: action.payload.items, + itemsCount: action.payload.itemsCount, + tab: action.tab, + currentPage: 0, + tag: null, + }; + case PROFILE_PAGE_LOADED: + case PROFILE_FAVORITES_PAGE_LOADED: + return { + ...state, + pager: action.pager, + items: action.payload?.[1]?.items, + itemsCount: action.payload?.[1]?.itemsCount, + currentPage: 0, + }; + case PROFILE_PAGE_UNLOADED: + case PROFILE_FAVORITES_PAGE_UNLOADED: + return {}; + default: + return state; + } +}; + +export default reducer; diff --git a/frontend/src/reducers/profile.js b/frontend/src/reducers/profile.js new file mode 100644 index 0000000..5d4fe85 --- /dev/null +++ b/frontend/src/reducers/profile.js @@ -0,0 +1,26 @@ +import { + PROFILE_PAGE_LOADED, + PROFILE_PAGE_UNLOADED, + FOLLOW_USER, + UNFOLLOW_USER, +} from "../constants/actionTypes"; + +const reducer = (state = {}, action) => { + switch (action.type) { + case PROFILE_PAGE_LOADED: + return { + ...action.payload?.[0]?.profile, + }; + case PROFILE_PAGE_UNLOADED: + return {}; + case FOLLOW_USER: + case UNFOLLOW_USER: + return { + ...action.payload.profile, + }; + default: + return state; + } +}; + +export default reducer; diff --git a/frontend/src/reducers/settings.js b/frontend/src/reducers/settings.js new file mode 100644 index 0000000..2cf4da0 --- /dev/null +++ b/frontend/src/reducers/settings.js @@ -0,0 +1,27 @@ +import { + SETTINGS_SAVED, + SETTINGS_PAGE_UNLOADED, + ASYNC_START, +} from "../constants/actionTypes"; + +const reducer = (state = {}, action) => { + switch (action.type) { + case SETTINGS_SAVED: + return { + ...state, + inProgress: false, + errors: action.error ? action.payload.errors : null, + }; + case SETTINGS_PAGE_UNLOADED: + return {}; + case ASYNC_START: + return { + ...state, + inProgress: true, + }; + default: + return state; + } +}; + +export default reducer; diff --git a/frontend/src/setupTests.js b/frontend/src/setupTests.js new file mode 100644 index 0000000..0772595 --- /dev/null +++ b/frontend/src/setupTests.js @@ -0,0 +1,5 @@ +import "core-js"; +import { configure } from "enzyme"; +import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; + +configure({ adapter: new Adapter() }); diff --git a/frontend/src/store.js b/frontend/src/store.js new file mode 100644 index 0000000..5ac25dc --- /dev/null +++ b/frontend/src/store.js @@ -0,0 +1,25 @@ +import { applyMiddleware, createStore } from "redux"; +import { composeWithDevTools } from "redux-devtools-extension/developmentOnly"; +import { promiseMiddleware, localStorageMiddleware } from "./middleware"; +import reducer from "./reducer"; + +import { createBrowserHistory } from "history"; + +export const history = createBrowserHistory(); + +const getMiddleware = () => { + if (process.env.NODE_ENV === "production") { + return applyMiddleware( + promiseMiddleware, + localStorageMiddleware + ); + } else { + // Enable additional logging in non-production environments. + return applyMiddleware( + promiseMiddleware, + localStorageMiddleware, + ); + } +}; + +export const store = createStore(reducer, composeWithDevTools(getMiddleware())); diff --git a/frontend/src/tests/components/Header.test.js b/frontend/src/tests/components/Header.test.js new file mode 100644 index 0000000..592b742 --- /dev/null +++ b/frontend/src/tests/components/Header.test.js @@ -0,0 +1,54 @@ +import { create } from "react-test-renderer"; +import { mount } from "enzyme"; +import { BrowserRouter as Router } from "react-router-dom"; +import Header from "../../components/Header"; + +describe("Header component", () => { + it("Snapshot testing with no user", () => { + const component = create( + +
    + + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it("Snapshot testing with user", () => { + const component = create( + +
    + + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it("Check link to main page", () => { + const header = mount( + +
    + + ); + expect(header.find("Link").first().prop("to")).toEqual("/"); + }); + + it("Render register button when there's no user", () => { + const header = mount( + +
    + + ); + expect(header.find("li > Link").first().text()).toEqual("Sign in"); + }); + + it("Render user name when there's a user", () => { + const user = { username: "user name", image: "image.png" }; + const header = mount( + +
    + + ); + expect(header.find("li > Link").last().text()).toEqual(user.username); + }); +}); diff --git a/frontend/src/tests/components/__snapshots__/Header.test.js.snap b/frontend/src/tests/components/__snapshots__/Header.test.js.snap new file mode 100644 index 0000000..e2509e5 --- /dev/null +++ b/frontend/src/tests/components/__snapshots__/Header.test.js.snap @@ -0,0 +1,119 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Header component Snapshot testing with no user 1`] = ` + +`; + +exports[`Header component Snapshot testing with user 1`] = ` + +`; diff --git a/frontend/src/tests/item/CommentInput.test.js b/frontend/src/tests/item/CommentInput.test.js new file mode 100644 index 0000000..ab7fcf5 --- /dev/null +++ b/frontend/src/tests/item/CommentInput.test.js @@ -0,0 +1,64 @@ +import { create } from "react-test-renderer"; +import { mount } from "enzyme"; +import configureMockStore from "redux-mock-store"; +import CommentInput from "../../components/Item/CommentInput"; +import agent from "../../agent"; +import { ADD_COMMENT } from "../../constants/actionTypes"; + +const mockStore = configureMockStore(); +agent.Comments.create = jest.fn(); + +describe("CommentInput component", () => { + let store; + + beforeEach(() => { + store = mockStore({}); + }); + + it("Snapshot testing with no user", () => { + const component = create( + + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it("Submit text", () => { + const user = { username: "name", image: "" }; + const component = mount( + + ); + const comment = { id: 1 }; + agent.Comments.create.mockResolvedValue(comment); + + const event = { target: { value: "sometext" } }; + component.find("textarea").simulate("change", event); + component.find("form").simulate("submit"); + + setImmediate(async () => { + expect(store.getActions()).toHaveLength(1); + expect(store.getActions()[0].type).toEqual(ADD_COMMENT); + expect(await store.getActions()[0].payload).toEqual(comment); + }); + }); + + it("Clear text after submit", async () => { + const user = { username: "name", image: "" }; + + const component = mount( + + ); + + const comment = { id: 1 }; + agent.Comments.create.mockResolvedValue(comment); + + const event = { target: { value: "sometext" } }; + component.find("textarea").simulate("change", event); + component.find("form").simulate("submit"); + expect(component.find("textarea").text()).toHaveLength(0); + }); +}); diff --git a/frontend/src/tests/item/__snapshots__/CommentInput.test.js.snap b/frontend/src/tests/item/__snapshots__/CommentInput.test.js.snap new file mode 100644 index 0000000..f739b2c --- /dev/null +++ b/frontend/src/tests/item/__snapshots__/CommentInput.test.js.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CommentInput component Snapshot testing with no user 1`] = ` +
    +
    +