Compare commits
19 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
4be72056d2 | ||
|
f0ab450af6 | ||
|
0c56fad0b8 | ||
|
cdef84f7ca | ||
|
37371cf298 | ||
|
1646553973 | ||
|
7f8622f48d | ||
|
e11a99000d | ||
|
ccca5549ac | ||
|
0a1b6dc9ce | ||
|
7a6a4c097d | ||
|
9b97290df1 | ||
|
a96d961142 | ||
|
07d7fbe1d2 | ||
|
96d8ab52a1 | ||
|
10c7a6b151 | ||
|
64b3d11de2 | ||
|
cfccc4e4d5 | ||
|
9cb2670fb2 |
@ -1,3 +0,0 @@
|
|||||||
api/.venv
|
|
||||||
web/node_modules
|
|
||||||
*.pyc
|
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,2 +0,0 @@
|
|||||||
*.swp
|
|
||||||
.DS_Store
|
|
BIN
.nova/Artwork
Normal file
BIN
.nova/Artwork
Normal file
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
5
.nova/Configuration.json
Normal file
5
.nova/Configuration.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"workspace.art_style" : 1,
|
||||||
|
"workspace.color" : 10,
|
||||||
|
"workspace.name" : "Treadl"
|
||||||
|
}
|
@ -1,35 +1,38 @@
|
|||||||
steps:
|
pipeline:
|
||||||
buildweb:
|
buildweb:
|
||||||
group: build
|
group: build
|
||||||
image: node
|
image: node
|
||||||
when:
|
when:
|
||||||
path: "web/**/*"
|
path: "web/*"
|
||||||
environment:
|
environment:
|
||||||
- VITE_API_URL=https://api.treadl.com
|
- VITE_API_URL=https://api.treadl.com
|
||||||
- VITE_IMAGINARY_URL=https://images.treadl.com
|
- VITE_IMAGINARY_URL=https://images.treadl.com
|
||||||
- VITE_SENTRY_DSN=https://7c88f77dd19c57bfb92bb9eb53e33c4b@o4508066290532352.ingest.de.sentry.io/4508075022090320
|
- VITE_SOURCE_REPO_URL=https://git.wilw.dev/wilw/treadl
|
||||||
|
- VITE_PATREON_URL=https://www.patreon.com/treadl
|
||||||
|
- VITE_KOFI_URL=https://ko-fi.com/wilw88
|
||||||
|
- VITE_CONTACT_EMAIL=hello@treadl.com
|
||||||
|
- VITE_APP_NAME=Treadl
|
||||||
commands:
|
commands:
|
||||||
- cd web
|
- cd web
|
||||||
- npm install
|
- yarn install
|
||||||
- npx vite build
|
- yarn build
|
||||||
|
|
||||||
buildapi:
|
buildapi:
|
||||||
group: build
|
group: build
|
||||||
image: woodpeckerci/plugin-docker-buildx
|
image: woodpeckerci/plugin-docker-buildx
|
||||||
secrets: [docker_username, docker_password]
|
secrets: [docker_username, docker_password]
|
||||||
when:
|
when:
|
||||||
path: "api/**/*"
|
path: "api/*"
|
||||||
settings:
|
settings:
|
||||||
repo: wilw/treadl-api
|
repo: wilw/treadl-api
|
||||||
dockerfile: api/Dockerfile
|
dockerfile: api/Dockerfile
|
||||||
context: api
|
context: api
|
||||||
platforms: linux/amd64
|
|
||||||
|
|
||||||
deployweb:
|
deployweb:
|
||||||
image: alpine
|
image: alpine
|
||||||
secrets: [ LINODE_ACCESS_KEY, LINODE_SECRET_ACCESS_KEY, BUNNY_KEY ]
|
secrets: [ LINODE_ACCESS_KEY, LINODE_SECRET_ACCESS_KEY, BUNNY_KEY ]
|
||||||
when:
|
when:
|
||||||
path: "web/**/*"
|
path: "web/*"
|
||||||
commands:
|
commands:
|
||||||
- cd web
|
- cd web
|
||||||
- apk update
|
- apk update
|
||||||
@ -38,5 +41,4 @@ steps:
|
|||||||
- s3cmd -c /root/.s3cfg sync --no-mime-magic --guess-mime-type dist/* s3://treadl.com
|
- s3cmd -c /root/.s3cfg sync --no-mime-magic --guess-mime-type dist/* s3://treadl.com
|
||||||
- 'curl -X POST -H "AccessKey: $BUNNY_KEY" https://api.bunny.net/pullzone/782753/purgeCache'
|
- 'curl -X POST -H "AccessKey: $BUNNY_KEY" https://api.bunny.net/pullzone/782753/purgeCache'
|
||||||
|
|
||||||
when:
|
branches: main
|
||||||
branch: main
|
|
104
README.md
104
README.md
@ -2,43 +2,54 @@
|
|||||||
|
|
||||||
This is a monorepo containing the code for the web and mobile front-ends and web API for the Treadl platform.
|
This is a monorepo containing the code for the web and mobile front-ends and web API for the Treadl platform.
|
||||||
|
|
||||||
|
## Running and developing Treadl locally
|
||||||
|
|
||||||
|
To run Treadl locally, we recommend taking the following steps:
|
||||||
|
|
||||||
|
1. Check out this repository locally.
|
||||||
|
1. Follow the instructions in the `api/` directory to launch a MongoDB instance and to run the Treadl API.
|
||||||
|
1. Follow the instructions in the `web/` directory to install the local dependencies and run the web UI.
|
||||||
|
|
||||||
## Deploying your own version of Treadl
|
## Deploying your own version of Treadl
|
||||||
|
|
||||||
### Run with Docker (recommended)
|
If you'd like to launch your own version of Treadl in a web production environment, follow the steps below. These instructions set-up a basic version of Treadl, and you may want or need to take additional steps for more advanced options.
|
||||||
|
|
||||||
We publish and maintain a [Docker image](https://hub.docker.com/r/wilw/treadl) for Treadl, which is the easiest way to get started.
|
We recommend forking this repository. That way you can make adjustments to the code to suit your needs, and pull in upstream updates as we continue to develop them.
|
||||||
|
|
||||||
We recommend using Docker Compose and our [template `docker-compose.yml`](https://git.wilw.dev/wilw/treadl/src/branch/main/docker/docker-compose.yml) to configure the app and the MongoDB database. Download this file to your computer and then run `docker compose up` to start Treadl.
|
### 1. Launch a MongoDB cluster/instance
|
||||||
|
|
||||||
In production, it is very important to change the values in the file's `environment` block to suit your own setup. We also strongly recommend the use of a reverse-proxy to handle TLS connections to the app.
|
Treadl uses MongoDB as its data store, and this should be setup first. You can either use a commercial hosted offering, or host the database yourself.
|
||||||
|
|
||||||
|
Hosted options:
|
||||||
|
|
||||||
### Alternative deployment
|
* [MongoDB Atlas](https://www.mongodb.com)
|
||||||
|
* [DigitalOcean managed MongoDB](https://www.digitalocean.com/products/managed-databases-mongodb)
|
||||||
|
|
||||||
In scenarios where you want more control over the deployment, or you are more concerned with scalability, you may wish to use a more manual approach.
|
Self-hosted guides:
|
||||||
|
|
||||||
In this case you'll need to:
|
* [Creating a MongoDB Replica Set](https://www.linode.com/docs/guides/create-a-mongodb-replica-set)
|
||||||
- Launch (or re-use) a MongoDB cluster/instance
|
* [MongoDB official Docker Image](https://hub.docker.com/_/mongo)
|
||||||
- Provision a server or service for running the Flask app (in the `api/` directory), ensuring all dependencies are installed and that it runs with the needed [environment variables](https://git.wilw.dev/wilw/treadl/src/branch/main/api/envfile.template)
|
|
||||||
- Build the web front-end (with `npx vite build` using your needed [environment variables](https://git.wilw.dev/wilw/treadl/src/branch/main/web/.env), having installed dependencies with `npm install`) and host the resulting `dist/` directory on a server or object store.
|
|
||||||
|
|
||||||
|
Either way, once launched, make a note of the cluster/instance's:
|
||||||
|
|
||||||
### S3-compatible object storage
|
* URI: The database's URI, probably in a format like `mongodb+srv://USERNAME:PASSWORD@host.com/AUTHDATABASE?retryWrites=true&w=majority`
|
||||||
|
* Database: The name of the database, within your cluster/instance, where you want Treadl to store the data.
|
||||||
|
|
||||||
Treadl uses S3-compatible object storage for storing user uploads. If you want to allow file uploads (apart from WIF files, which are processed directly), you should create and configure a bucket for Treadl to use.
|
### 2. Provision an S3-compatible bucket
|
||||||
|
|
||||||
|
Treadl uses S3-compatible object storage for storing assets (e.g. uploaded files). You should create and configure a bucket for Treadl to use.
|
||||||
|
|
||||||
Hosted options:
|
Hosted options:
|
||||||
|
|
||||||
* [Amazon S3](https://aws.amazon.com/s3)
|
* [Amazon S3](https://aws.amazon.com/s3)
|
||||||
* [Linode Object Storage](https://www.linode.com/products/object-storage)
|
* [Linode Object Storage](https://www.linode.com/products/object-storage) - Recommended option.
|
||||||
* [DigitalOcean Spaces](https://www.digitalocean.com/products/spaces)
|
* [DigitalOcean Spaces](https://www.digitalocean.com/products/spaces)
|
||||||
|
|
||||||
Self-hosted options:
|
Self-hosted options:
|
||||||
|
|
||||||
* [MinIO](https://min.io/download)
|
* [MinIO](https://min.io/download)
|
||||||
|
|
||||||
Once you have a bucket, generate some access keys for the bucket that will enable Treadl to read from and write to it. Ensure you make a record of the following for inclusion in your environment file/variables:
|
Once you have a bucket, generate some access keys for the bucket that will enable Treadl to read from and write to it. Ensure you make a record of the following for later:
|
||||||
|
|
||||||
* Bucket name: The name of the S3-compatible bucket you created
|
* Bucket name: The name of the S3-compatible bucket you created
|
||||||
* Endpoint URL: The endpoint for your bucket. This helps Treadl understand which provider you are using.
|
* Endpoint URL: The endpoint for your bucket. This helps Treadl understand which provider you are using.
|
||||||
@ -47,49 +58,64 @@ Once you have a bucket, generate some access keys for the bucket that will enabl
|
|||||||
|
|
||||||
_Note: assets in your bucket should be public. Treadl does not currently used signed requests to access uploaded files._
|
_Note: assets in your bucket should be public. Treadl does not currently used signed requests to access uploaded files._
|
||||||
|
|
||||||
|
### 3. Provision the API
|
||||||
|
|
||||||
## Running Treadl locally in development mode
|
The best way to run the web API is to do so via Docker. A `Dockerfile` is provided in the `api/` directory.
|
||||||
|
|
||||||
To run Treadl locally, first ensure you have the needed software installed:
|
Simply build the image and transfer it to your server (or just build it directly on the server, if easier).
|
||||||
|
|
||||||
- Python ^3.12
|
Make a copy of the `envfile.template` file included in the `api/` directory into a new file named `envfile` and make changes to this file to suit your needs. For example, you will likely need to:
|
||||||
- Node.js (we recommend v22.x)
|
|
||||||
- Docker (we use this for the Mongo database)
|
|
||||||
- It can be installed via the Docker website or your package manager
|
|
||||||
- Ensure the Docker service is running
|
|
||||||
- [Taskfile](https://taskfile.dev) (convenience tool for running tasks)
|
|
||||||
- This can be installed using `brew install go-task`
|
|
||||||
|
|
||||||
To begin, clone this repository to your computer:
|
* Add in the Mongo URI and database into the relevant parts
|
||||||
|
* Add the S3 detais into the relevant parts
|
||||||
|
* Add Mailgun connection details (for sending outbound mail)
|
||||||
|
* Change the app's URL and email addresses
|
||||||
|
|
||||||
```bash
|
Once ready, you can launch the API by passing in this envfile (assuming you built the image with a name of `treadl-api`):
|
||||||
git clone https://git.wilw.dev/wilw/treadl.git
|
|
||||||
|
```shell
|
||||||
|
$ docker run --env-file envfile -d treadl-api
|
||||||
```
|
```
|
||||||
|
|
||||||
Next, initialise the project by installing dependencies and creating an environment file for the API:
|
_Note: a reverse proxy (such as Nginx or Traefik) should be running on your server to proxy traffic through to port 8000 on your running Treadl API container._
|
||||||
|
|
||||||
```bash
|
### 4. Host the front-end
|
||||||
task init
|
|
||||||
|
The front-end is formed from static files that can be simply served from a webserver, from a CDN-fronted object store, or anything else.
|
||||||
|
|
||||||
|
Before building or hosting the front-end, please copy the `.env.development` file into a new file called `.env.production` and make changes to it as required. For example, you will need to:
|
||||||
|
|
||||||
|
* Include the URL of the web API you deployed earlier in the relevant field.
|
||||||
|
* Include a contact email address.
|
||||||
|
|
||||||
|
**Vercel**
|
||||||
|
|
||||||
|
We use [Vercel](https://vercel.com) to host the web UI. Once you have an account to which you are logged-in to locally, the front-end can be deployed by simply running:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ vercel --prod
|
||||||
```
|
```
|
||||||
|
|
||||||
This generates a 'envfile' in your 'api' directory. You can edit this as needed (though the defaults should allow you to at least launch the app). Note: if you run this command again then any changes you made to your `envfile` will be overwritten.
|
_Note: You will need to configure Vercel to use your own domain, and set-up a project, etc. first._
|
||||||
|
|
||||||
Finally, you can start the API and web UI by running:
|
**Manual**
|
||||||
|
|
||||||
```bash
|
Simply build the app and then deploy the resulting `build/` directory to a server or storage of your choice:
|
||||||
task
|
|
||||||
|
```shell
|
||||||
|
$ yarn build
|
||||||
|
$ s3cmd cp build/ s3://my-treadl-ui # Example
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: this command also starts the MongoDB database on port 27017. If the DB is already running, you'll see errors reported, but the API and web will still be launched.
|
### 5. Optional extras
|
||||||
|
|
||||||
You can now navigate to [http://localhost:8002](http://localhost:8002) to start using the app.
|
**Imaginary server**
|
||||||
|
|
||||||
If you pull updates from the repository in the future (e.g. with `git pull`) you may need to ensure your dependencies are up-to-date before starting the app again. This can be done with:
|
To help improve the performance of the app, you may wish to make use of [Imaginary](https://github.com/h2non/imaginary) to crop/resize large images. The web UI is already equipped to handle Imaginary if a server is configured.
|
||||||
|
|
||||||
```bash
|
To use this feature, simply rebuild the app ensuring that an environment entry is made into `.env.production` that includes `"VITE_IMAGINARY_URL=https://your.imaginaryserver.com"`.
|
||||||
task install-deps
|
|
||||||
```
|
|
||||||
|
|
||||||
|
_Note: If this is not set, Treadl will by default fetch the full size images straight from the S3 source._
|
||||||
|
|
||||||
## Contributions
|
## Contributions
|
||||||
|
|
||||||
|
121
Taskfile.yml
121
Taskfile.yml
@ -1,121 +0,0 @@
|
|||||||
version: '3'
|
|
||||||
|
|
||||||
vars:
|
|
||||||
VENV: ".venv/bin/activate"
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
default:
|
|
||||||
desc: Run web bundler and API
|
|
||||||
deps:
|
|
||||||
- start-db
|
|
||||||
- run-api
|
|
||||||
- run-web
|
|
||||||
|
|
||||||
run-web:
|
|
||||||
desc: Run web frontend
|
|
||||||
dir: 'web'
|
|
||||||
cmds:
|
|
||||||
- echo "[Web] Starting React app..."
|
|
||||||
- npx vite --port 8002
|
|
||||||
|
|
||||||
run-api:
|
|
||||||
desc: Run API server
|
|
||||||
dir: 'api'
|
|
||||||
dotenv: ['envfile']
|
|
||||||
cmds:
|
|
||||||
- echo "[FLASK] Starting Flask app..."
|
|
||||||
- bash -c "source {{.VENV}} && flask run --debug"
|
|
||||||
|
|
||||||
start-db:
|
|
||||||
desc: Start database
|
|
||||||
ignore_error: true
|
|
||||||
cmds:
|
|
||||||
- echo "[DB] Starting database..."
|
|
||||||
- docker run --rm -d --name mongo -v ~/.mongo:/data/db -p 27017:27017 mongo:6
|
|
||||||
|
|
||||||
init:
|
|
||||||
desc: Initialize project
|
|
||||||
cmds:
|
|
||||||
- task: install-deps
|
|
||||||
- cp api/envfile.template api/envfile
|
|
||||||
|
|
||||||
install-deps:
|
|
||||||
desc: Install all dependencies
|
|
||||||
deps:
|
|
||||||
- install-deps-web
|
|
||||||
- install-deps-api
|
|
||||||
|
|
||||||
install-deps-web:
|
|
||||||
desc: Install web dependencies
|
|
||||||
dir: 'web'
|
|
||||||
cmds:
|
|
||||||
- echo "[Web] Installing dependencies..."
|
|
||||||
- npm install
|
|
||||||
|
|
||||||
install-deps-api:
|
|
||||||
desc: Install API dependencies
|
|
||||||
dir: 'api'
|
|
||||||
cmds:
|
|
||||||
- echo "[FLASK] Installing dependencies..."
|
|
||||||
- cmd: python3.12 -m venv .venv
|
|
||||||
ignore_error: true
|
|
||||||
- bash -c "source {{.VENV}} && pip install poetry"
|
|
||||||
- bash -c "source {{.VENV}} && poetry install"
|
|
||||||
|
|
||||||
lint:
|
|
||||||
desc: Lint all
|
|
||||||
deps:
|
|
||||||
- lint-web
|
|
||||||
- lint-api
|
|
||||||
|
|
||||||
lint-web:
|
|
||||||
desc: Lint web frontend
|
|
||||||
dir: 'web'
|
|
||||||
cmds:
|
|
||||||
- echo "[Web] Linting React app..."
|
|
||||||
- npx standard --fix
|
|
||||||
|
|
||||||
lint-api:
|
|
||||||
desc: Lint API server
|
|
||||||
dir: 'api'
|
|
||||||
cmds:
|
|
||||||
- echo "[FLASK] Linting Flask app..."
|
|
||||||
- bash -c "source {{.VENV}} && ruff format ."
|
|
||||||
- bash -c "source {{.VENV}} && ruff check --fix ."
|
|
||||||
|
|
||||||
clean:
|
|
||||||
desc: Remove all dependencies
|
|
||||||
cmds:
|
|
||||||
- rm -rf web/node_modules
|
|
||||||
- rm -rf api/.venv
|
|
||||||
|
|
||||||
build-docker:
|
|
||||||
desc: Build all-in-one Docker image
|
|
||||||
cmds:
|
|
||||||
- echo "Building Docker image..."
|
|
||||||
- docker build -f docker/Dockerfile -t wilw/treadl --platform linux/amd64,linux/arm64 .
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
desc: Deploy all
|
|
||||||
deps:
|
|
||||||
- deploy-web
|
|
||||||
- deploy-api
|
|
||||||
|
|
||||||
deploy-web:
|
|
||||||
desc: Deploy web front-end
|
|
||||||
dir: 'web'
|
|
||||||
env:
|
|
||||||
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
|
|
||||||
AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
|
|
||||||
cmds:
|
|
||||||
- npm install
|
|
||||||
- npx vite build
|
|
||||||
- aws --profile personal s3 sync dist s3://treadl.com
|
|
||||||
- 'curl -X POST -H "AccessKey: $BUNNY_PERSONAL" https://api.bunny.net/pullzone/782753/purgeCache'
|
|
||||||
|
|
||||||
deploy-api:
|
|
||||||
desc: Deploy API
|
|
||||||
dir: 'api'
|
|
||||||
cmds:
|
|
||||||
- docker build -t wilw/treadl-api --platform linux/amd64 .
|
|
||||||
- docker push wilw/treadl-api
|
|
BIN
api/.DS_Store
vendored
Normal file
BIN
api/.DS_Store
vendored
Normal file
Binary file not shown.
2
api/.gitignore
vendored
2
api/.gitignore
vendored
@ -7,5 +7,3 @@ __pycache__/
|
|||||||
config-prod.yml
|
config-prod.yml
|
||||||
envfile
|
envfile
|
||||||
firebase.json
|
firebase.json
|
||||||
.DS_Store
|
|
||||||
migration_projects/
|
|
@ -1,16 +1,19 @@
|
|||||||
FROM amd64/python:3.12-slim
|
FROM python:3.9-slim-buster
|
||||||
|
|
||||||
# set work directory
|
# set work directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN pip install poetry
|
RUN pip install poetry
|
||||||
|
|
||||||
COPY poetry.lock .
|
COPY poetry.lock .
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
RUN poetry config virtualenvs.create false --local
|
|
||||||
RUN poetry install
|
RUN poetry export --without-hashes -f requirements.txt | pip install -r /dev/stdin
|
||||||
|
|
||||||
# Add remaining files
|
# Add remaining files
|
||||||
COPY . /app/
|
COPY app.py .
|
||||||
|
COPY api/ .
|
||||||
|
COPY util/ .
|
||||||
|
|
||||||
CMD ["gunicorn" , "-b", "0.0.0.0:8000", "app:app"]
|
CMD ["gunicorn" , "-b", "0.0.0.0:8000", "app:app"]
|
||||||
|
@ -1,3 +1,67 @@
|
|||||||
# Treadl web API
|
# Treadl web API
|
||||||
|
|
||||||
This directory contains the code for the back-end Treadl API.
|
This directory contains the code for the back-end Treadl API.
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
To run this web API locally, follow the steps below.
|
||||||
|
|
||||||
|
### 1. Run a local MongoDB instance
|
||||||
|
|
||||||
|
Install MongoDB for your operating system and then launch a local version in the background. For example:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ mongod --fork --dbpath=/path/to/.mongo --logpath /dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
(Remember to restart the database upon system restart or if the instance stops for another reason.)
|
||||||
|
|
||||||
|
### 2. Create and activate a virtual environment
|
||||||
|
|
||||||
|
Install and activate the environment using `virtualenv`:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ virtualenv -p python3 .venv # You only need to run this the first time
|
||||||
|
$ source .venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Install dependencies
|
||||||
|
|
||||||
|
We use Poetry to manage dependencies. If you don't have this yet, please refer to [the Poetry documentation](https://python-poetry.org) to install it. Once done, install the dependencies (ensuring you have `source`d your virtualenv first):
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ poetry install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Create an `envfile`
|
||||||
|
|
||||||
|
Copy the template file into a new `envfile`:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ cp envfile.template envfile
|
||||||
|
```
|
||||||
|
|
||||||
|
If you need to, make any changes to your new `envfile`. Note that changes are probably not required if you are running this locally. When happy, you can `source` this file too:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ source envfile
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Run the API
|
||||||
|
|
||||||
|
Ensure that both the virtualenv and `envfile` have been loaded into the environment:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ source .venv/bin/activate
|
||||||
|
$ source envfile
|
||||||
|
```
|
||||||
|
|
||||||
|
Now you can run the API:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ flask run
|
||||||
|
```
|
||||||
|
|
||||||
|
The API will now be available on port 2001.
|
||||||
|
|
||||||
|
Remember that you will need a local instance of [MongoDB](https://www.mongodb.com) running for the API to connect to.
|
@ -1,73 +1,37 @@
|
|||||||
import datetime
|
import datetime, jwt, bcrypt, re, os
|
||||||
import jwt
|
|
||||||
import bcrypt
|
|
||||||
import re
|
|
||||||
import os
|
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
from util import database, mail, util
|
from util import database, mail, util
|
||||||
from api import projects, uploads
|
from api import uploads
|
||||||
|
|
||||||
jwt_secret = os.environ["JWT_SECRET"]
|
jwt_secret = os.environ['JWT_SECRET']
|
||||||
MIN_PASSWORD_LENGTH = 8
|
MIN_PASSWORD_LENGTH = 8
|
||||||
|
|
||||||
|
def register(username, email, password):
|
||||||
def register(username, email, password, how_find_us):
|
|
||||||
if not username or len(username) < 4 or not email or len(email) < 6:
|
if not username or len(username) < 4 or not email or len(email) < 6:
|
||||||
raise util.errors.BadRequest("Your username or email is too short or invalid.")
|
raise util.errors.BadRequest('Your username or email is too short or invalid.')
|
||||||
username = username.lower()
|
username = username.lower()
|
||||||
email = email.lower()
|
email = email.lower()
|
||||||
if not re.match("^[a-z0-9_]+$", username):
|
if not re.match("^[a-z0-9_]+$", username):
|
||||||
raise util.errors.BadRequest(
|
raise util.errors.BadRequest('Usernames can only contain letters, numbers, and underscores')
|
||||||
"Usernames can only contain letters, numbers, and underscores"
|
|
||||||
)
|
|
||||||
if not password or len(password) < MIN_PASSWORD_LENGTH:
|
if not password or len(password) < MIN_PASSWORD_LENGTH:
|
||||||
raise util.errors.BadRequest(
|
raise util.errors.BadRequest('Your password should be at least {0} characters.'.format(MIN_PASSWORD_LENGTH))
|
||||||
"Your password should be at least {0} characters.".format(
|
|
||||||
MIN_PASSWORD_LENGTH
|
|
||||||
)
|
|
||||||
)
|
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
existingUser = db.users.find_one(
|
existingUser = db.users.find_one({'$or': [{'username': username}, {'email': email}]})
|
||||||
{"$or": [{"username": username}, {"email": email}]}
|
|
||||||
)
|
|
||||||
if existingUser:
|
if existingUser:
|
||||||
raise util.errors.BadRequest(
|
raise util.errors.BadRequest('An account with this username or email already exists.')
|
||||||
"An account with this username or email already exists."
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
|
hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
|
||||||
result = db.users.insert_one(
|
result = db.users.insert_one({ 'username': username, 'email': email, 'password': hashed_password, 'createdAt': datetime.datetime.now(), 'subscriptions': {'email': ['groups.invited', 'groups.joinRequested', 'groups.joined', 'messages.replied', 'projects.commented']}})
|
||||||
{
|
mail.send({
|
||||||
"username": username,
|
'to': os.environ.get('ADMIN_EMAIL'),
|
||||||
"email": email,
|
'subject': '{} signup'.format(os.environ.get('APP_NAME')),
|
||||||
"password": hashed_password,
|
'text': 'A new user signed up with username {0} and email {1}'.format(username, email)
|
||||||
"createdAt": datetime.datetime.now(),
|
})
|
||||||
"subscriptions": {
|
mail.send({
|
||||||
"email": [
|
'to': email,
|
||||||
"groups.invited",
|
'subject': 'Welcome to {}!'.format(os.environ.get('APP_NAME')),
|
||||||
"groups.joinRequested",
|
'text': '''Dear {0},
|
||||||
"groups.joined",
|
|
||||||
"messages.replied",
|
|
||||||
"projects.commented",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
mail.send(
|
|
||||||
{
|
|
||||||
"to": os.environ.get("ADMIN_EMAIL"),
|
|
||||||
"subject": "{} signup".format(os.environ.get("APP_NAME")),
|
|
||||||
"text": "A new user signed up with username {0} and email {1}, discovered from {2}".format(
|
|
||||||
username, email, how_find_us
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
mail.send(
|
|
||||||
{
|
|
||||||
"to": email,
|
|
||||||
"subject": "Welcome to {}!".format(os.environ.get("APP_NAME")),
|
|
||||||
"text": """Dear {0},
|
|
||||||
|
|
||||||
Welcome to {3}! We won't send you many emails but we just want to introduce ourselves and to give you some tips to help you get started.
|
Welcome to {3}! We won't send you many emails but we just want to introduce ourselves and to give you some tips to help you get started.
|
||||||
|
|
||||||
@ -97,226 +61,155 @@ We hope you enjoy using {3} and if you have any comments or feedback please tell
|
|||||||
Best wishes,
|
Best wishes,
|
||||||
|
|
||||||
The {3} Team
|
The {3} Team
|
||||||
""".format(
|
'''.format(
|
||||||
username,
|
username,
|
||||||
os.environ.get("APP_URL"),
|
os.environ.get('APP_URL'),
|
||||||
os.environ.get("CONTACT_EMAIL"),
|
os.environ.get('CONTACT_EMAIL'),
|
||||||
os.environ.get("APP_NAME"),
|
os.environ.get('APP_NAME'),
|
||||||
),
|
)})
|
||||||
}
|
return {'token': generate_access_token(result.inserted_id)}
|
||||||
)
|
|
||||||
return {"token": generate_access_token(result.inserted_id)}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
print(e)
|
||||||
raise util.errors.BadRequest(
|
raise util.errors.BadRequest('Unable to register your account. Please try again later')
|
||||||
"Unable to register your account. Please try again later"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def login(email, password):
|
def login(email, password):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
user = db.users.find_one(
|
user = db.users.find_one({'$or': [{'username': email.lower()}, {'email': email.lower()}]})
|
||||||
{"$or": [{"username": email.lower()}, {"email": email.lower()}]}
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
if user and bcrypt.checkpw(password.encode("utf-8"), user["password"]):
|
if user and bcrypt.checkpw(password.encode("utf-8"), user['password']):
|
||||||
return {"token": generate_access_token(user["_id"])}
|
return {'token': generate_access_token(user['_id'])}
|
||||||
else:
|
else:
|
||||||
raise util.errors.BadRequest("Your username or password is incorrect.")
|
raise util.errors.BadRequest('Your username or password is incorrect.')
|
||||||
except Exception:
|
except Exception as e:
|
||||||
raise util.errors.BadRequest("Your username or password is incorrect.")
|
raise util.errors.BadRequest('Your username or password is incorrect.')
|
||||||
|
|
||||||
|
|
||||||
def logout(user):
|
def logout(user):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
db.users.update_one(
|
db.users.update({'_id': user['_id']}, {'$pull': {'tokens.login': user['currentToken']}})
|
||||||
{"_id": user["_id"]}, {"$pull": {"tokens.login": user["currentToken"]}}
|
return {'loggedOut': True}
|
||||||
)
|
|
||||||
return {"loggedOut": True}
|
|
||||||
|
|
||||||
|
|
||||||
def update_email(user, data):
|
def update_email(user, data):
|
||||||
if not data:
|
if not data: raise util.errors.BadRequest('Invalid request')
|
||||||
raise util.errors.BadRequest("Invalid request")
|
if 'email' not in data: raise util.errors.BadRequest('Invalid request')
|
||||||
if "email" not in data:
|
if len(data['email']) < 4: raise util.errors.BadRequest('New email is too short')
|
||||||
raise util.errors.BadRequest("Invalid request")
|
|
||||||
if len(data["email"]) < 4:
|
|
||||||
raise util.errors.BadRequest("New email is too short")
|
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
db.users.update_one({"_id": user["_id"]}, {"$set": {"email": data["email"]}})
|
db.users.update_one({'_id': user['_id']}, {'$set': {'email': data['email']}})
|
||||||
mail.send(
|
mail.send({
|
||||||
{
|
'to': user['email'],
|
||||||
"to": user["email"],
|
'subject': 'Your email address has changed on {}'.format(os.environ.get('APP_NAME')),
|
||||||
"subject": "Your email address has changed on {}".format(
|
'text': 'Dear {0},\n\nThis email is to let you know that we recently received a request to change your account email address on {2}. We have now made this change.\n\nThe new email address for your account is {1}.\n\nIf you think this is a mistake then please get in touch with us as soon as possible.'.format(
|
||||||
os.environ.get("APP_NAME")
|
user['username'],
|
||||||
),
|
data['email'],
|
||||||
"text": "Dear {0},\n\nThis email is to let you know that we recently received a request to change your account email address on {2}. We have now made this change.\n\nThe new email address for your account is {1}.\n\nIf you think this is a mistake then please get in touch with us as soon as possible.".format(
|
os.environ.get('APP_NAME'),
|
||||||
user["username"],
|
|
||||||
data["email"],
|
|
||||||
os.environ.get("APP_NAME"),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
mail.send(
|
})
|
||||||
{
|
mail.send({
|
||||||
"to": data["email"],
|
'to': data['email'],
|
||||||
"subject": "Your email address has changed on {}".format(
|
'subject': 'Your email address has changed on {}'.format(os.environ.get('APP_NAME')),
|
||||||
os.environ.get("APP_NAME")
|
'text': 'Dear {0},\n\nThis email is to let you know that we recently received a request to change your account email address on {2}. We have now made this change.\n\nThe new email address for your account is {1}.\n\nIf you think this is a mistake then please get in touch with us as soon as possible.'.format(
|
||||||
),
|
user['username'],
|
||||||
"text": "Dear {0},\n\nThis email is to let you know that we recently received a request to change your account email address on {2}. We have now made this change.\n\nThe new email address for your account is {1}.\n\nIf you think this is a mistake then please get in touch with us as soon as possible.".format(
|
data['email'],
|
||||||
user["username"],
|
os.environ.get('APP_NAME'),
|
||||||
data["email"],
|
|
||||||
os.environ.get("APP_NAME"),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
return {"email": data["email"]}
|
})
|
||||||
|
return {'email': data['email']}
|
||||||
|
|
||||||
def update_password(user, data):
|
def update_password(user, data):
|
||||||
if not data:
|
if not data: raise util.errors.BadRequest('Invalid request')
|
||||||
raise util.errors.BadRequest("Invalid request")
|
if 'newPassword' not in data: raise util.errors.BadRequest('Invalid request')
|
||||||
if "newPassword" not in data:
|
if len(data['newPassword']) < MIN_PASSWORD_LENGTH: raise util.errors.BadRequest('New password should be at least {0} characters long'.format(MIN_PASSWORD_LENGTH))
|
||||||
raise util.errors.BadRequest("Invalid request")
|
|
||||||
if len(data["newPassword"]) < MIN_PASSWORD_LENGTH:
|
|
||||||
raise util.errors.BadRequest(
|
|
||||||
"New password should be at least {0} characters long".format(
|
|
||||||
MIN_PASSWORD_LENGTH
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
if "currentPassword" in data:
|
if 'currentPassword' in data:
|
||||||
if not user:
|
if not user: raise util.errors.BadRequest('User context is required')
|
||||||
raise util.errors.BadRequest("User context is required")
|
if not bcrypt.checkpw(data['currentPassword'].encode('utf-8'), user['password']):
|
||||||
if not bcrypt.checkpw(
|
raise util.errors.BadRequest('Incorrect password')
|
||||||
data["currentPassword"].encode("utf-8"), user["password"]
|
elif 'token' in data:
|
||||||
):
|
|
||||||
raise util.errors.BadRequest("Incorrect password")
|
|
||||||
elif "token" in data:
|
|
||||||
try:
|
try:
|
||||||
id = jwt.decode(data["token"], jwt_secret, algorithms="HS256")["sub"]
|
id = jwt.decode(data['token'], jwt_secret)['sub']
|
||||||
user = db.users.find_one(
|
user = db.users.find_one({'_id': ObjectId(id), 'tokens.passwordReset': data['token']})
|
||||||
{"_id": ObjectId(id), "tokens.passwordReset": data["token"]}
|
if not user: raise Exception
|
||||||
)
|
except Exception as e:
|
||||||
if not user:
|
raise util.errors.BadRequest('There was a problem updating your password. Your token may be invalid or out of date')
|
||||||
raise Exception
|
|
||||||
except Exception:
|
|
||||||
raise util.errors.BadRequest(
|
|
||||||
"There was a problem updating your password. Your token may be invalid or out of date"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
raise util.errors.BadRequest("Current password or reset token is required")
|
raise util.errors.BadRequest('Current password or reset token is required')
|
||||||
if not user:
|
if not user: raise util.errors.BadRequest('Unable to change your password')
|
||||||
raise util.errors.BadRequest("Unable to change your password")
|
|
||||||
|
|
||||||
hashed_password = bcrypt.hashpw(
|
hashed_password = bcrypt.hashpw(data['newPassword'].encode("utf-8"), bcrypt.gensalt())
|
||||||
data["newPassword"].encode("utf-8"), bcrypt.gensalt()
|
db.users.update({'_id': user['_id']}, {'$set': {'password': hashed_password}, '$unset': {'tokens.passwordReset': ''}})
|
||||||
)
|
|
||||||
db.users.update_one(
|
|
||||||
{"_id": user["_id"]},
|
|
||||||
{"$set": {"password": hashed_password}, "$unset": {"tokens.passwordReset": ""}},
|
|
||||||
)
|
|
||||||
|
|
||||||
mail.send(
|
mail.send({
|
||||||
{
|
'to_user': user,
|
||||||
"to_user": user,
|
'subject': 'Your {} password has changed'.format(os.environ.get('APP_NAME')),
|
||||||
"subject": "Your {} password has changed".format(
|
'text': 'Dear {0},\n\nThis email is to let you know that we recently received a request to change your account password on {1}. We have now made this change.\n\nIf you think this is a mistake then please login to change your password as soon as possible.'.format(
|
||||||
os.environ.get("APP_NAME")
|
user['username'],
|
||||||
),
|
os.environ.get('APP_NAME'),
|
||||||
"text": "Dear {0},\n\nThis email is to let you know that we recently received a request to change your account password on {1}. We have now made this change.\n\nIf you think this is a mistake then please login to change your password as soon as possible.".format(
|
|
||||||
user["username"],
|
|
||||||
os.environ.get("APP_NAME"),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
return {"passwordUpdated": True}
|
})
|
||||||
|
return {'passwordUpdated': True}
|
||||||
|
|
||||||
def delete(user, password):
|
def delete(user, password):
|
||||||
if not password or not bcrypt.checkpw(password.encode("utf-8"), user["password"]):
|
if not password or not bcrypt.checkpw(password.encode('utf-8'), user['password']):
|
||||||
raise util.errors.BadRequest("Incorrect password")
|
raise util.errors.BadRequest('Incorrect password')
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
for project in db.projects.find({"user": user["_id"]}):
|
for project in db.projects.find({'user': user['_id']}):
|
||||||
projects.delete(user, user["username"], project.get("path"))
|
db.objects.remove({'project': project['_id']})
|
||||||
db.comments.delete_many({"user": user["_id"]})
|
db.projects.remove({'_id': project['_id']})
|
||||||
db.users.update_many(
|
db.users.remove({'_id': user['_id']})
|
||||||
{"following.user": user["_id"]}, {"$pull": {"following": {"user": user["_id"]}}}
|
return {'deletedUser': user['_id']}
|
||||||
)
|
|
||||||
db.users.delete_one({"_id": user["_id"]})
|
|
||||||
uploads.delete_folder("users/" + str(user["_id"]))
|
|
||||||
return {"deletedUser": user["_id"]}
|
|
||||||
|
|
||||||
|
|
||||||
def generate_access_token(user_id):
|
def generate_access_token(user_id):
|
||||||
payload = {
|
payload = {
|
||||||
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=30),
|
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=30),
|
||||||
"iat": datetime.datetime.utcnow(),
|
'iat': datetime.datetime.utcnow(),
|
||||||
"sub": str(user_id),
|
'sub': str(user_id)
|
||||||
}
|
}
|
||||||
token = jwt.encode(payload, jwt_secret, algorithm="HS256")
|
token = jwt.encode(payload, jwt_secret, algorithm='HS256').decode("utf-8")
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
db.users.update_one({"_id": user_id}, {"$addToSet": {"tokens.login": token}})
|
db.users.update({'_id': user_id}, {'$addToSet': {'tokens.login': token}})
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
def get_user_context(token):
|
def get_user_context(token):
|
||||||
if not token:
|
if not token: return None
|
||||||
return None
|
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, jwt_secret, algorithms="HS256")
|
payload = jwt.decode(token, jwt_secret)
|
||||||
id = payload["sub"]
|
id = payload['sub']
|
||||||
if id:
|
if id:
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
user = db.users.find_one({"_id": ObjectId(id), "tokens.login": token})
|
user = db.users.find_one({'_id': ObjectId(id), 'tokens.login': token})
|
||||||
db.users.update_one(
|
db.users.update({'_id': user['_id']}, {'$set': {'lastSeenAt': datetime.datetime.now()}})
|
||||||
{"_id": user["_id"]}, {"$set": {"lastSeenAt": datetime.datetime.now()}}
|
user['currentToken'] = token
|
||||||
)
|
|
||||||
user["currentToken"] = token
|
|
||||||
return user
|
return user
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
print(e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def reset_password(data):
|
def reset_password(data):
|
||||||
if not data or "email" not in data:
|
if not data or not 'email' in data: raise util.errors.BadRequest('Invalid request')
|
||||||
raise util.errors.BadRequest("Invalid request")
|
if len(data['email']) < 5: raise util.errors.BadRequest('Your email is too short')
|
||||||
if len(data["email"]) < 5:
|
|
||||||
raise util.errors.BadRequest("Your email is too short")
|
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
user = db.users.find_one({"email": data["email"].lower()})
|
user = db.users.find_one({'email': data['email'].lower()})
|
||||||
if user:
|
if user:
|
||||||
payload = {
|
payload = {
|
||||||
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=1),
|
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=1),
|
||||||
"iat": datetime.datetime.utcnow(),
|
'iat': datetime.datetime.utcnow(),
|
||||||
"sub": str(user["_id"]),
|
'sub': str(user['_id'])
|
||||||
}
|
|
||||||
token = jwt.encode(payload, jwt_secret, algorithm="HS256")
|
|
||||||
mail.send(
|
|
||||||
{
|
|
||||||
"to_user": user,
|
|
||||||
"subject": "Reset your password",
|
|
||||||
"text": "Dear {0},\n\nA password reset email was recently requested for your {2} account. If this was you and you want to continue, please follow the link below:\n\n{1}\n\nThis link will expire after 24 hours.\n\nIf this was not you, then someone may be trying to gain access to your account. We recommend using a strong and unique password for your account.".format(
|
|
||||||
user["username"],
|
|
||||||
"{}/password/reset?token={}".format(
|
|
||||||
os.environ.get("APP_URL"), token
|
|
||||||
),
|
|
||||||
os.environ.get("APP_NAME"),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
token = jwt.encode(payload, jwt_secret, algorithm='HS256').decode('utf-8')
|
||||||
|
mail.send({
|
||||||
|
'to_user': user,
|
||||||
|
'subject': 'Reset your password',
|
||||||
|
'text': 'Dear {0},\n\nA password reset email was recently requested for your {2} account. If this was you and you want to continue, please follow the link below:\n\n{1}\n\nThis link will expire after 24 hours.\n\nIf this was not you, then someone may be trying to gain access to your account. We recommend using a strong and unique password for your account.'.format(
|
||||||
|
user['username'],
|
||||||
|
'{}/password/reset?token={}'.format(os.environ.get('APP_URL'), token),
|
||||||
|
os.environ.get('APP_NAME'),
|
||||||
)
|
)
|
||||||
db.users.update_one(
|
})
|
||||||
{"_id": user["_id"]}, {"$set": {"tokens.passwordReset": token}}
|
db.users.update({'_id': user['_id']}, {'$set': {'tokens.passwordReset': token}})
|
||||||
)
|
return {'passwordResetEmailSent': True}
|
||||||
return {"passwordResetEmailSent": True}
|
|
||||||
|
|
||||||
|
|
||||||
def update_push_token(user, data):
|
def update_push_token(user, data):
|
||||||
if not data or "pushToken" not in data:
|
if not data or 'pushToken' not in data: raise util.errors.BadRequest('Push token is required')
|
||||||
raise util.errors.BadRequest("Push token is required")
|
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
db.users.update_one(
|
db.users.update_one({'_id': user['_id']}, {'$set': {'pushToken': data['pushToken']}})
|
||||||
{"_id": user["_id"]}, {"$set": {"pushToken": data["pushToken"]}}
|
return {'addedPushToken': data['pushToken']}
|
||||||
)
|
|
||||||
return {"addedPushToken": data["pushToken"]}
|
|
||||||
|
@ -1,82 +1,64 @@
|
|||||||
import os
|
import os, re
|
||||||
import re
|
|
||||||
from util import database, util
|
from util import database, util
|
||||||
from api import uploads
|
from api import uploads
|
||||||
|
|
||||||
DOMAIN = os.environ.get("APP_DOMAIN")
|
DOMAIN = os.environ.get('APP_DOMAIN')
|
||||||
|
|
||||||
|
|
||||||
def webfinger(resource):
|
def webfinger(resource):
|
||||||
if not resource:
|
if not resource: raise util.errors.BadRequest('Resource required')
|
||||||
raise util.errors.BadRequest("Resource required")
|
|
||||||
resource = resource.lower()
|
resource = resource.lower()
|
||||||
exp = re.compile("acct:([a-z0-9_-]+)@([a-z0-9_\-\.]+)", re.IGNORECASE)
|
exp = re.compile('acct:([a-z0-9_-]+)@([a-z0-9_\-\.]+)', re.IGNORECASE)
|
||||||
matches = exp.findall(resource)
|
matches = exp.findall(resource)
|
||||||
if not matches or not matches[0]:
|
if not matches or not matches[0]: raise util.errors.BadRequest('Resource invalid')
|
||||||
raise util.errors.BadRequest("Resource invalid")
|
|
||||||
username, host = matches[0]
|
username, host = matches[0]
|
||||||
if not username or not host:
|
if not username or not host: raise util.errors.BadRequest('Resource invalid')
|
||||||
raise util.errors.BadRequest("Resource invalid")
|
if host != DOMAIN: raise util.errors.NotFound('Host unknown')
|
||||||
if host != DOMAIN:
|
|
||||||
raise util.errors.NotFound("Host unknown")
|
|
||||||
|
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
user = db.users.find_one({"username": username})
|
user = db.users.find_one({'username': username})
|
||||||
if not user:
|
if not user: raise util.errors.NotFound('User unknown')
|
||||||
raise util.errors.NotFound("User unknown")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"subject": resource,
|
"subject": resource,
|
||||||
"aliases": [
|
"aliases": [
|
||||||
"https://{}/{}".format(DOMAIN, username),
|
"https://{}/{}".format(DOMAIN, username),
|
||||||
"https://{}/u/{}".format(DOMAIN, username),
|
"https://{}/u/{}".format(DOMAIN, username)
|
||||||
],
|
],
|
||||||
"links": [
|
"links": [
|
||||||
{
|
{
|
||||||
"rel": "http://webfinger.net/rel/profile-page",
|
"rel": "http://webfinger.net/rel/profile-page",
|
||||||
"type": "text/html",
|
"type": "text/html",
|
||||||
"href": "https://{}/{}".format(DOMAIN, username),
|
"href": "https://{}/{}".format(DOMAIN, username)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"rel": "self",
|
"rel": "self",
|
||||||
"type": "application/activity+json",
|
"type": "application/activity+json",
|
||||||
"href": "https://{}/u/{}".format(DOMAIN, username),
|
"href": "https://{}/u/{}".format(DOMAIN, username)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"rel": "http://ostatus.org/schema/1.0/subscribe",
|
"rel": "http://ostatus.org/schema/1.0/subscribe",
|
||||||
"template": "https://{}/authorize_interaction".format(DOMAIN)
|
"template": "https://{}/authorize_interaction".format(DOMAIN) + "?uri={uri}"
|
||||||
+ "?uri={uri}",
|
}
|
||||||
},
|
]
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def user(username):
|
def user(username):
|
||||||
if not username:
|
if not username: raise util.errors.BadRequest('Username required')
|
||||||
raise util.errors.BadRequest("Username required")
|
|
||||||
username = username.lower()
|
username = username.lower()
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
user = db.users.find_one({"username": username})
|
user = db.users.find_one({'username': username})
|
||||||
if not user:
|
if not user: raise util.errors.NotFound('User unknown')
|
||||||
raise util.errors.NotFound("User unknown")
|
avatar_url = user.get('avatar') and uploads.get_presigned_url('users/{0}/{1}'.format(user['_id'], user['avatar']))
|
||||||
avatar_url = user.get("avatar") and uploads.get_presigned_url(
|
|
||||||
"users/{0}/{1}".format(user["_id"], user["avatar"])
|
|
||||||
)
|
|
||||||
|
|
||||||
pub_key = None
|
pub_key = None
|
||||||
if user.get("services", {}).get("activityPub", {}).get("publicKey"):
|
if user.get('services', {}).get('activityPub', {}).get('publicKey'):
|
||||||
pub_key = user["services"]["activityPub"]["publicKey"]
|
pub_key = user['services']['activityPub']['publicKey']
|
||||||
else:
|
else:
|
||||||
priv_key, pub_key = util.generate_rsa_keypair()
|
priv_key, pub_key = util.generate_rsa_keypair()
|
||||||
db.users.update_one(
|
db.users.update_one({'_id': user['_id']}, {'$set': {
|
||||||
{"_id": user["_id"]},
|
'services.activityPub.publicKey': pub_key,
|
||||||
{
|
'services.activityPub.privateKey': priv_key,
|
||||||
"$set": {
|
}})
|
||||||
"services.activityPub.publicKey": pub_key,
|
|
||||||
"services.activityPub.privateKey": priv_key,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
resp = {
|
resp = {
|
||||||
"@context": [
|
"@context": [
|
||||||
@ -85,106 +67,99 @@ def user(username):
|
|||||||
],
|
],
|
||||||
"id": "https://{}/u/{}".format(DOMAIN, username),
|
"id": "https://{}/u/{}".format(DOMAIN, username),
|
||||||
"type": "Person",
|
"type": "Person",
|
||||||
# "following": "https://fosstodon.org/users/wilw/following",
|
#"following": "https://fosstodon.org/users/wilw/following",
|
||||||
# "followers": "https://fosstodon.org/users/wilw/followers",
|
#"followers": "https://fosstodon.org/users/wilw/followers",
|
||||||
"inbox": "https://{}/inbox".format(DOMAIN),
|
"inbox": "https://{}/inbox".format(DOMAIN),
|
||||||
"outbox": "https://{}/u/{}/outbox".format(DOMAIN, username),
|
"outbox": "https://{}/u/{}/outbox".format(DOMAIN, username),
|
||||||
"preferredUsername": username,
|
"preferredUsername": username,
|
||||||
"name": username,
|
"name": username,
|
||||||
"summary": user.get("bio", ""),
|
"summary": user.get('bio', ''),
|
||||||
"url": "https://{}/{}".format(DOMAIN, username),
|
"url": "https://{}/{}".format(DOMAIN, username),
|
||||||
"discoverable": True,
|
"discoverable": True,
|
||||||
"published": "2021-01-27T00:00:00Z",
|
"published": "2021-01-27T00:00:00Z",
|
||||||
"publicKey": {
|
"publicKey": {
|
||||||
"id": "https://{}/u/{}#main-key".format(DOMAIN, username),
|
"id": "https://{}/u/{}#main-key".format(DOMAIN, username),
|
||||||
"owner": "https://{}/u/{}".format(DOMAIN, username),
|
"owner": "https://{}/u/{}".format(DOMAIN, username),
|
||||||
"publicKeyPem": pub_key.decode("utf-8"),
|
"publicKeyPem": pub_key.decode('utf-8')
|
||||||
},
|
},
|
||||||
"attachment": [],
|
"attachment": [],
|
||||||
"endpoints": {"sharedInbox": "https://{}/inbox".format(DOMAIN)},
|
"endpoints": {
|
||||||
"icon": {"type": "Image", "mediaType": "image/jpeg", "url": avatar_url},
|
"sharedInbox": "https://{}/inbox".format(DOMAIN)
|
||||||
"image": {"type": "Image", "mediaType": "image/jpeg", "url": avatar_url},
|
},
|
||||||
|
"icon": {
|
||||||
|
"type": "Image",
|
||||||
|
"mediaType": "image/jpeg",
|
||||||
|
"url": avatar_url
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"type": "Image",
|
||||||
|
"mediaType": "image/jpeg",
|
||||||
|
"url": avatar_url
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.get("website"):
|
if user.get('website'):
|
||||||
resp["attachment"].append(
|
resp['attachment'].append({
|
||||||
{
|
|
||||||
"type": "PropertyValue",
|
"type": "PropertyValue",
|
||||||
"name": "Website",
|
"name": "Website",
|
||||||
"value": '<a href="https://{}" target="_blank" rel="nofollow noopener noreferrer me"><span class="invisible">https://</span><span class="">{}</span><span class="invisible"></span></a>'.format(
|
"value": "<a href=\"https://{}\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\"><span class=\"invisible\">https://</span><span class=\"\">{}</span><span class=\"invisible\"></span></a>".format(user['website'], user['website'])
|
||||||
user["website"], user["website"]
|
})
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
def outbox(username, page, min_id, max_id):
|
def outbox(username, page, min_id, max_id):
|
||||||
if not username:
|
if not username: raise util.errors.BadRequest('Username required')
|
||||||
raise util.errors.BadRequest("Username required")
|
|
||||||
username = username.lower()
|
username = username.lower()
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
user = db.users.find_one({"username": username})
|
user = db.users.find_one({'username': username})
|
||||||
if not user:
|
if not user: raise util.errors.NotFound('User unknown')
|
||||||
raise util.errors.NotFound("User unknown")
|
|
||||||
|
|
||||||
if not page or page != "true":
|
if not page or page != 'true':
|
||||||
return {
|
return {
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
"id": "https://{}/u/{}/outbox".format(DOMAIN, username),
|
"id": "https://{}/u/{}/outbox".format(DOMAIN, username),
|
||||||
"type": "OrderedCollection",
|
"type": "OrderedCollection",
|
||||||
"first": "https://{}/u/{}/outbox?page=true".format(DOMAIN, username),
|
"first": "https://{}/u/{}/outbox?page=true".format(DOMAIN, username)
|
||||||
}
|
}
|
||||||
if page == "true":
|
if page == 'true':
|
||||||
min_string = "&min_id={}".format(min_id) if min_id else ""
|
min_string = '&min_id={}'.format(min_id) if min_id else ''
|
||||||
max_string = "&max_id={}".format(max_id) if max_id else ""
|
max_string = '&max_id={}'.format(max_id) if max_id else ''
|
||||||
ret = {
|
ret = {
|
||||||
"id": "https://{}/u/{}/outbox?page=true{}{}".format(
|
"id": "https://{}/u/{}/outbox?page=true{}{}".format(DOMAIN, username, min_string, max_string),
|
||||||
DOMAIN, username, min_string, max_string
|
|
||||||
),
|
|
||||||
"type": "OrderedCollectionPage",
|
"type": "OrderedCollectionPage",
|
||||||
# "next": "https://example.org/users/whatever/outbox?max_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true",
|
#"next": "https://example.org/users/whatever/outbox?max_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true",
|
||||||
# "prev": "https://example.org/users/whatever/outbox?min_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true",
|
#"prev": "https://example.org/users/whatever/outbox?min_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true",
|
||||||
"partOf": "https://{}/u/{}/outbox".format(DOMAIN, username),
|
"partOf": "https://{}/u/{}/outbox".format(DOMAIN, username),
|
||||||
"orderedItems": [],
|
"orderedItems": []
|
||||||
}
|
}
|
||||||
|
|
||||||
project_list = list(
|
project_list = list(db.projects.find({'user': user['_id'], 'visibility': 'public'}))
|
||||||
db.projects.find({"user": user["_id"], "visibility": "public"})
|
|
||||||
)
|
|
||||||
for p in project_list:
|
for p in project_list:
|
||||||
ret["orderedItems"].append(
|
ret['orderedItems'].append({
|
||||||
{
|
"id": "https://{}/{}/{}/activity".format(DOMAIN, username, p['path']),
|
||||||
"id": "https://{}/{}/{}/activity".format(
|
|
||||||
DOMAIN, username, p["path"]
|
|
||||||
),
|
|
||||||
"type": "Create",
|
"type": "Create",
|
||||||
"actor": "https://{}/u/{}".format(DOMAIN, username),
|
"actor": "https://{}/u/{}".format(DOMAIN, username),
|
||||||
"published": p["createdAt"].strftime(
|
"published": p['createdAt'].strftime("%Y-%m-%dT%H:%M:%SZ"),#"2021-10-18T20:06:18Z",
|
||||||
"%Y-%m-%dT%H:%M:%SZ"
|
"to": [
|
||||||
), # "2021-10-18T20:06:18Z",
|
"https://www.w3.org/ns/activitystreams#Public"
|
||||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
],
|
||||||
"object": {
|
"object": {
|
||||||
"id": "https://{}/{}/{}".format(DOMAIN, username, p["path"]),
|
"id": "https://{}/{}/{}".format(DOMAIN, username, p['path']),
|
||||||
"type": "Note",
|
"type": "Note",
|
||||||
"summary": None,
|
"summary": None,
|
||||||
# "inReplyTo": "https://mastodon.lhin.space/users/0xvms/statuses/108759565436297722",
|
#"inReplyTo": "https://mastodon.lhin.space/users/0xvms/statuses/108759565436297722",
|
||||||
"published": p["createdAt"].strftime(
|
"published": p['createdAt'].strftime("%Y-%m-%dT%H:%M:%SZ"),#"2022-08-03T15:43:30Z",
|
||||||
"%Y-%m-%dT%H:%M:%SZ"
|
"url": "https://{}/{}/{}".format(DOMAIN, username, p['path']),
|
||||||
), # "2022-08-03T15:43:30Z",
|
|
||||||
"url": "https://{}/{}/{}".format(DOMAIN, username, p["path"]),
|
|
||||||
"attributedTo": "https://{}/u/{}".format(DOMAIN, username),
|
"attributedTo": "https://{}/u/{}".format(DOMAIN, username),
|
||||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
"to": [
|
||||||
|
"https://www.w3.org/ns/activitystreams#Public"
|
||||||
|
],
|
||||||
"cc": [
|
"cc": [
|
||||||
"https://{}/u/{}/followers".format(DOMAIN, username),
|
"https://{}/u/{}/followers".format(DOMAIN, username),
|
||||||
],
|
],
|
||||||
"sensitive": False,
|
"sensitive": False,
|
||||||
"content": "{} created a project: {}".format(
|
"content": "{} created a project: {}".format(username, p['name']),
|
||||||
username, p["name"]
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
)
|
})
|
||||||
|
|
||||||
return ret
|
return ret
|
@ -1,804 +1,268 @@
|
|||||||
import datetime
|
import datetime, re, os
|
||||||
import re
|
|
||||||
import os
|
|
||||||
import math
|
|
||||||
import pymongo
|
import pymongo
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
from util import database, util, mail, push
|
from util import database, util, mail, push
|
||||||
from api import uploads
|
from api import uploads
|
||||||
|
|
||||||
APP_NAME = os.environ.get("APP_NAME")
|
APP_NAME = os.environ.get('APP_NAME')
|
||||||
APP_URL = os.environ.get("APP_URL")
|
APP_URL = os.environ.get('APP_URL')
|
||||||
|
|
||||||
|
|
||||||
def has_group_permission(user, group, permission=None):
|
|
||||||
if not user or not group:
|
|
||||||
return False
|
|
||||||
if user["_id"] in group.get("admins", []):
|
|
||||||
return True
|
|
||||||
if group["_id"] not in user.get("groups", []):
|
|
||||||
return False
|
|
||||||
if permission:
|
|
||||||
return permission in group.get("memberPermissions", [])
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def create(user, data):
|
def create(user, data):
|
||||||
if not data:
|
if not data: raise util.errors.BadRequest('Invalid request')
|
||||||
raise util.errors.BadRequest("Invalid request")
|
if len(data.get('name')) < 3: raise util.errors.BadRequest('A longer name is required')
|
||||||
if len(data.get("name")) < 3:
|
|
||||||
raise util.errors.BadRequest("A longer name is required")
|
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
|
|
||||||
group = {
|
group = {
|
||||||
"createdAt": datetime.datetime.now(),
|
'createdAt': datetime.datetime.now(),
|
||||||
"user": user["_id"],
|
'user': user['_id'],
|
||||||
"admins": [user["_id"]],
|
'admins': [user['_id']],
|
||||||
"name": data["name"],
|
'name': data['name'],
|
||||||
"description": data.get("description", ""),
|
'description': data.get('description', ''),
|
||||||
"closed": data.get("closed", False),
|
'closed': data.get('closed', False),
|
||||||
"advertised": data.get("advertised", False),
|
|
||||||
"memberPermissions": [
|
|
||||||
"viewMembers",
|
|
||||||
"viewNoticeboard",
|
|
||||||
"postNoticeboard",
|
|
||||||
"viewProjects",
|
|
||||||
"postProjects",
|
|
||||||
"viewForumTopics",
|
|
||||||
"postForumTopics",
|
|
||||||
"postForumTopicReplies",
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
result = db.groups.insert_one(group)
|
result = db.groups.insert_one(group)
|
||||||
group["_id"] = result.inserted_id
|
group['_id'] = result.inserted_id
|
||||||
create_member(user, group["_id"], user["_id"])
|
create_member(user, group['_id'], user['_id'])
|
||||||
return group
|
return group
|
||||||
|
|
||||||
|
|
||||||
def get(user):
|
def get(user):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
groups = list(db.groups.find({"_id": {"$in": user.get("groups", [])}}))
|
groups = list(db.groups.find({'_id': {'$in': user.get('groups', [])}}))
|
||||||
return {"groups": groups}
|
return {'groups': groups}
|
||||||
|
|
||||||
|
|
||||||
def get_one(user, id):
|
def get_one(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
group = db.groups.find_one({"_id": id})
|
group = db.groups.find_one({'_id': id})
|
||||||
if not group:
|
if not group: raise util.errors.NotFound('Group not found')
|
||||||
raise util.errors.NotFound("Group not found")
|
group['adminUsers'] = list(db.users.find({'_id': {'$in': group.get('admins', [])}}, {'username': 1, 'avatar': 1}))
|
||||||
if group.get("image"):
|
for u in group['adminUsers']:
|
||||||
group["imageUrl"] = uploads.get_presigned_url(
|
if 'avatar' in u:
|
||||||
"groups/{0}/{1}".format(id, group["image"])
|
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
||||||
)
|
|
||||||
group["adminUsers"] = list(
|
|
||||||
db.users.find(
|
|
||||||
{"_id": {"$in": group.get("admins", [])}}, {"username": 1, "avatar": 1}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for u in group["adminUsers"]:
|
|
||||||
if "avatar" in u:
|
|
||||||
u["avatarUrl"] = uploads.get_presigned_url(
|
|
||||||
"users/{0}/{1}".format(u["_id"], u["avatar"])
|
|
||||||
)
|
|
||||||
return group
|
return group
|
||||||
|
|
||||||
|
|
||||||
def update(user, id, update):
|
def update(user, id, update):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
||||||
if not group:
|
if not group: raise util.errors.NotFound('Group not found')
|
||||||
raise util.errors.NotFound("Group not found")
|
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You\'re not a group admin')
|
||||||
if user["_id"] not in group.get("admins", []):
|
allowed_keys = ['name', 'description', 'closed']
|
||||||
raise util.errors.Forbidden("You're not a group admin")
|
|
||||||
allowed_keys = [
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"closed",
|
|
||||||
"advertised",
|
|
||||||
"memberPermissions",
|
|
||||||
"image",
|
|
||||||
]
|
|
||||||
updater = util.build_updater(update, allowed_keys)
|
updater = util.build_updater(update, allowed_keys)
|
||||||
if updater:
|
if updater: db.groups.update({'_id': id}, updater)
|
||||||
if "$set" in updater and (
|
|
||||||
"name" in update or "description" in update or "image" in update
|
|
||||||
):
|
|
||||||
updater["$set"]["moderationRequired"] = True
|
|
||||||
util.send_moderation_request(user, "groups", group)
|
|
||||||
db.groups.update_one({"_id": id}, updater)
|
|
||||||
return get_one(user, id)
|
return get_one(user, id)
|
||||||
|
|
||||||
|
|
||||||
def delete(user, id):
|
def delete(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
||||||
if not group:
|
if not group: raise util.errors.NotFound('Group not found')
|
||||||
raise util.errors.NotFound("Group not found")
|
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You\'re not a group admin')
|
||||||
if user["_id"] not in group.get("admins", []):
|
db.groups.remove({'_id': id})
|
||||||
raise util.errors.Forbidden("You're not a group admin")
|
db.groupEntries.remove({'group': id})
|
||||||
db.groups.delete_one({"_id": id})
|
db.users.update({'groups': id}, {'$pull': {'groups': id}}, multi = True)
|
||||||
db.groupEntries.delete_many({"group": id})
|
return {'deletedGroup': id}
|
||||||
db.users.update_many({"groups": id}, {"$pull": {"groups": id}})
|
|
||||||
return {"deletedGroup": id}
|
|
||||||
|
|
||||||
|
|
||||||
def create_entry(user, id, data):
|
def create_entry(user, id, data):
|
||||||
if not data or "content" not in data:
|
if not data or 'content' not in data: raise util.errors.BadRequest('Invalid request')
|
||||||
raise util.errors.BadRequest("Invalid request")
|
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
group = db.groups.find_one({"_id": id})
|
group = db.groups.find_one({'_id': id}, {'admins': 1, 'name': 1})
|
||||||
if not group:
|
if not group: raise util.errors.NotFound('Group not found')
|
||||||
raise util.errors.NotFound("Group not found")
|
if group['_id'] not in user.get('groups', []): raise util.errors.Forbidden('You must be a member to write in the feed')
|
||||||
if group["_id"] not in user.get("groups", []):
|
|
||||||
raise util.errors.Forbidden("You must be a member to write in the feed")
|
|
||||||
if not has_group_permission(user, group, "postNoticeboard"):
|
|
||||||
raise util.errors.Forbidden("You don't have permission to post in the feed")
|
|
||||||
entry = {
|
entry = {
|
||||||
"createdAt": datetime.datetime.now(),
|
'createdAt': datetime.datetime.now(),
|
||||||
"group": id,
|
'group': id,
|
||||||
"user": user["_id"],
|
'user': user['_id'],
|
||||||
"content": data["content"],
|
'content': data['content'],
|
||||||
"moderationRequired": True,
|
|
||||||
}
|
}
|
||||||
if "attachments" in data:
|
if 'attachments' in data:
|
||||||
entry["attachments"] = data["attachments"]
|
entry['attachments'] = data['attachments']
|
||||||
for attachment in entry["attachments"]:
|
for attachment in entry['attachments']:
|
||||||
if re.search(
|
if re.search(r'(.jpg)|(.png)|(.jpeg)|(.gif)$', attachment['storedName'].lower()):
|
||||||
r"(.jpg)|(.png)|(.jpeg)|(.gif)$", attachment["storedName"].lower()
|
attachment['isImage'] = True
|
||||||
):
|
if attachment['type'] == 'file':
|
||||||
attachment["isImage"] = True
|
attachment['url'] = uploads.get_presigned_url('groups/{0}/{1}'.format(id, attachment['storedName']))
|
||||||
if attachment["type"] == "file":
|
|
||||||
attachment["url"] = uploads.get_presigned_url(
|
|
||||||
"groups/{0}/{1}".format(id, attachment["storedName"])
|
|
||||||
)
|
|
||||||
|
|
||||||
result = db.groupEntries.insert_one(entry)
|
result = db.groupEntries.insert_one(entry)
|
||||||
entry["_id"] = result.inserted_id
|
entry['_id'] = result.inserted_id
|
||||||
entry["authorUser"] = {
|
entry['authorUser'] = {'_id': user['_id'], 'username': user['username'], 'avatar': user.get('avatar')}
|
||||||
"_id": user["_id"],
|
if 'avatar' in user:
|
||||||
"username": user["username"],
|
entry['authorUser']['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(user['_id'], user['avatar']))
|
||||||
"avatar": user.get("avatar"),
|
|
||||||
}
|
|
||||||
if "avatar" in user:
|
|
||||||
entry["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
|
|
||||||
"users/{0}/{1}".format(user["_id"], user["avatar"])
|
|
||||||
)
|
|
||||||
util.send_moderation_request(user, "groupEntries", entry)
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
for u in db.users.find({'_id': {'$ne': user['_id']}, 'groups': id, 'subscriptions.email': 'groupFeed-' + str(id)}, {'email': 1, 'username': 1}):
|
||||||
def send_entry_notification(id):
|
mail.send({
|
||||||
db = database.get_db()
|
'to_user': u,
|
||||||
entry = db.groupEntries.find_one({"_id": ObjectId(id)})
|
'subject': 'New message in ' + group['name'],
|
||||||
# If this is a reply, then send the reply email instead
|
'text': 'Dear {0},\n\n{1} posted a message in the Notice Board of {2} on {5}:\n\n{3}\n\nFollow the link below to visit the group:\n\n{4}'.format(
|
||||||
if entry.get("inReplyTo"):
|
u['username'],
|
||||||
return send_entry_reply_notification(id)
|
user['username'],
|
||||||
group = db.groups.find_one({"_id": entry["group"]})
|
group['name'],
|
||||||
user = db.users.find_one({"_id": entry["user"]})
|
data['content'],
|
||||||
|
'{}/groups/{}'.format(APP_URL, str(id)),
|
||||||
for u in db.users.find(
|
|
||||||
{
|
|
||||||
"_id": {"$ne": user["_id"]},
|
|
||||||
"groups": group["_id"],
|
|
||||||
"subscriptions.email": "groupFeed-" + str(group["_id"]),
|
|
||||||
},
|
|
||||||
{"email": 1, "username": 1},
|
|
||||||
):
|
|
||||||
mail.send(
|
|
||||||
{
|
|
||||||
"to_user": u,
|
|
||||||
"subject": "New message in " + group["name"],
|
|
||||||
"text": "Dear {0},\n\n{1} posted a message in the Notice Board of {2} on {5}:\n\n{3}\n\nFollow the link below to visit the group:\n\n{4}".format(
|
|
||||||
u["username"],
|
|
||||||
user["username"],
|
|
||||||
group["name"],
|
|
||||||
entry["content"],
|
|
||||||
"{}/groups/{}".format(APP_URL, str(group["_id"])),
|
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
push.send_multiple(
|
})
|
||||||
list(db.users.find({"_id": {"$ne": user["_id"]}, "groups": group["_id"]})),
|
push.send_multiple(list(db.users.find({'_id': {'$ne': user['_id']}, 'groups': id})), '{} posted in {}'.format(user['username'], group['name']), data['content'][:30] + '...')
|
||||||
"{} posted in {}".format(user["username"], group["name"]),
|
return entry
|
||||||
entry["content"][:30] + "...",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_entries(user, id):
|
def get_entries(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
group = db.groups.find_one({"_id": id})
|
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
||||||
if not group:
|
if not group: raise util.errors.NotFound('Group not found')
|
||||||
raise util.errors.NotFound("Group not found")
|
if id not in user.get('groups', []): raise util.errors.BadRequest('You\'re not a member of this group')
|
||||||
if id not in user.get("groups", []):
|
entries = list(db.groupEntries.find({'group': id}).sort('createdAt', pymongo.DESCENDING))
|
||||||
raise util.errors.BadRequest("You're not a member of this group")
|
authors = list(db.users.find({'_id': {'$in': [e['user'] for e in entries]}}, {'username': 1, 'avatar': 1}))
|
||||||
if not has_group_permission(user, group, "viewNoticeboard"):
|
|
||||||
raise util.errors.Forbidden("You don't have permission to view the feed")
|
|
||||||
# Only return entries that have been moderated or are owned by the user
|
|
||||||
entries = list(
|
|
||||||
db.groupEntries.find(
|
|
||||||
{
|
|
||||||
"group": id,
|
|
||||||
"$or": [{"user": user["_id"]}, {"moderationRequired": {"$ne": True}}],
|
|
||||||
}
|
|
||||||
).sort("createdAt", pymongo.DESCENDING)
|
|
||||||
)
|
|
||||||
authors = list(
|
|
||||||
db.users.find(
|
|
||||||
{"_id": {"$in": [e["user"] for e in entries]}}, {"username": 1, "avatar": 1}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if "attachments" in entry:
|
if 'attachments' in entry:
|
||||||
for attachment in entry["attachments"]:
|
for attachment in entry['attachments']:
|
||||||
attachment["url"] = uploads.get_presigned_url(
|
attachment['url'] = uploads.get_presigned_url('groups/{0}/{1}'.format(id, attachment['storedName']))
|
||||||
"groups/{0}/{1}".format(id, attachment["storedName"])
|
|
||||||
)
|
|
||||||
for author in authors:
|
for author in authors:
|
||||||
if entry["user"] == author["_id"]:
|
if entry['user'] == author['_id']:
|
||||||
entry["authorUser"] = author
|
entry['authorUser'] = author
|
||||||
if "avatar" in author:
|
if 'avatar' in author:
|
||||||
entry["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
|
entry['authorUser']['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(author['_id'], author['avatar']))
|
||||||
"users/{0}/{1}".format(author["_id"], author["avatar"])
|
return {'entries': entries}
|
||||||
)
|
|
||||||
return {"entries": entries}
|
|
||||||
|
|
||||||
|
|
||||||
def delete_entry(user, id, entry_id):
|
def delete_entry(user, id, entry_id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
entry_id = ObjectId(entry_id)
|
entry_id = ObjectId(entry_id)
|
||||||
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
||||||
if not group:
|
if not group: raise util.errors.NotFound('Group not found')
|
||||||
raise util.errors.NotFound("Group not found")
|
entry = db.groupEntries.find_one(entry_id, {'user': 1, 'group': 1})
|
||||||
entry = db.groupEntries.find_one(entry_id, {"user": 1, "group": 1})
|
if not entry or entry['group'] != id: raise util.errors.NotFound('Entry not found')
|
||||||
if not entry or entry["group"] != id:
|
if entry['user'] != user['_id'] and user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You must own the entry or be an admin of the group')
|
||||||
raise util.errors.NotFound("Entry not found")
|
db.groupEntries.remove({'$or': [{'_id': entry_id}, {'inReplyTo': entry_id}]})
|
||||||
if entry["user"] != user["_id"] and user["_id"] not in group.get("admins", []):
|
return {'deletedEntry': entry_id}
|
||||||
raise util.errors.Forbidden(
|
|
||||||
"You must own the entry or be an admin of the group"
|
|
||||||
)
|
|
||||||
db.groupEntries.delete_one({"$or": [{"_id": entry_id}, {"inReplyTo": entry_id}]})
|
|
||||||
return {"deletedEntry": entry_id}
|
|
||||||
|
|
||||||
|
|
||||||
def create_entry_reply(user, id, entry_id, data):
|
def create_entry_reply(user, id, entry_id, data):
|
||||||
if not data or "content" not in data:
|
if not data or 'content' not in data: raise util.errors.BadRequest('Invalid request')
|
||||||
raise util.errors.BadRequest("Invalid request")
|
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
entry_id = ObjectId(entry_id)
|
entry_id = ObjectId(entry_id)
|
||||||
group = db.groups.find_one({"_id": id})
|
group = db.groups.find_one({'_id': id}, {'admins': 1, 'name': 1})
|
||||||
if not group:
|
if not group: raise util.errors.NotFound('Group not found')
|
||||||
raise util.errors.NotFound("Group not found")
|
entry = db.groupEntries.find_one({'_id': entry_id})
|
||||||
entry = db.groupEntries.find_one({"_id": entry_id})
|
if not entry or entry.get('group') != group['_id']: raise util.errors.NotFound('Entry to reply to not found')
|
||||||
if not entry or entry.get("group") != group["_id"]:
|
if group['_id'] not in user.get('groups', []): raise util.errors.Forbidden('You must be a member to write in the feed')
|
||||||
raise util.errors.NotFound("Entry to reply to not found")
|
|
||||||
if group["_id"] not in user.get("groups", []):
|
|
||||||
raise util.errors.Forbidden("You must be a member to write in the feed")
|
|
||||||
if not has_group_permission(user, group, "postNoticeboard"):
|
|
||||||
raise util.errors.Forbidden("You don't have permission to post in the feed")
|
|
||||||
reply = {
|
reply = {
|
||||||
"createdAt": datetime.datetime.now(),
|
'createdAt': datetime.datetime.now(),
|
||||||
"group": id,
|
'group': id,
|
||||||
"inReplyTo": entry_id,
|
'inReplyTo': entry_id,
|
||||||
"user": user["_id"],
|
'user': user['_id'],
|
||||||
"content": data["content"],
|
'content': data['content'],
|
||||||
"moderationRequired": True,
|
|
||||||
}
|
}
|
||||||
if "attachments" in data:
|
if 'attachments' in data:
|
||||||
reply["attachments"] = data["attachments"]
|
reply['attachments'] = data['attachments']
|
||||||
for attachment in reply["attachments"]:
|
for attachment in reply['attachments']:
|
||||||
if re.search(
|
if re.search(r'(.jpg)|(.png)|(.jpeg)|(.gif)$', attachment['storedName'].lower()):
|
||||||
r"(.jpg)|(.png)|(.jpeg)|(.gif)$", attachment["storedName"].lower()
|
attachment['isImage'] = True
|
||||||
):
|
if attachment['type'] == 'file':
|
||||||
attachment["isImage"] = True
|
attachment['url'] = uploads.get_presigned_url('groups/{0}/{1}'.format(id, attachment['storedName']))
|
||||||
if attachment["type"] == "file":
|
|
||||||
attachment["url"] = uploads.get_presigned_url(
|
|
||||||
"groups/{0}/{1}".format(id, attachment["storedName"])
|
|
||||||
)
|
|
||||||
|
|
||||||
result = db.groupEntries.insert_one(reply)
|
result = db.groupEntries.insert_one(reply)
|
||||||
reply["_id"] = result.inserted_id
|
reply['_id'] = result.inserted_id
|
||||||
reply["authorUser"] = {
|
reply['authorUser'] = {'_id': user['_id'], 'username': user['username'], 'avatar': user.get('avatar')}
|
||||||
"_id": user["_id"],
|
if 'avatar' in user:
|
||||||
"username": user["username"],
|
reply['authorUser']['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(user['_id'], user['avatar']))
|
||||||
"avatar": user.get("avatar"),
|
op = db.users.find_one({'$and': [{'_id': entry.get('user')}, {'_id': {'$ne': user['_id']}}], 'subscriptions.email': 'messages.replied'})
|
||||||
}
|
|
||||||
if "avatar" in user:
|
|
||||||
reply["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
|
|
||||||
"users/{0}/{1}".format(user["_id"], user["avatar"])
|
|
||||||
)
|
|
||||||
util.send_moderation_request(user, "groupEntries", entry)
|
|
||||||
return reply
|
|
||||||
|
|
||||||
|
|
||||||
def send_entry_reply_notification(id):
|
|
||||||
db = database.get_db()
|
|
||||||
reply = db.groupEntries.find_one({"_id": ObjectId(id)})
|
|
||||||
user = db.users.find_one({"_id": reply["user"]})
|
|
||||||
original_entry = db.groupEntries.find_one({"_id": reply["inReplyTo"]})
|
|
||||||
group = db.groups.find_one({"_id": original_entry["group"]})
|
|
||||||
op = db.users.find_one(
|
|
||||||
{
|
|
||||||
"$and": [
|
|
||||||
{"_id": original_entry.get("user")},
|
|
||||||
{"_id": {"$ne": user["_id"]}},
|
|
||||||
],
|
|
||||||
"subscriptions.email": "messages.replied",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if op:
|
if op:
|
||||||
mail.send(
|
mail.send({
|
||||||
{
|
'to_user': op,
|
||||||
"to_user": op,
|
'subject': user['username'] + ' replied to your post',
|
||||||
"subject": user["username"] + " replied to your post",
|
'text': 'Dear {0},\n\n{1} replied to your message in the Notice Board of {2} on {5}:\n\n{3}\n\nFollow the link below to visit the group:\n\n{4}'.format(
|
||||||
"text": "Dear {0},\n\n{1} replied to your message in the Notice Board of {2} on {5}:\n\n{3}\n\nFollow the link below to visit the group:\n\n{4}".format(
|
op['username'],
|
||||||
op["username"],
|
user['username'],
|
||||||
user["username"],
|
group['name'],
|
||||||
group["name"],
|
data['content'],
|
||||||
reply["content"],
|
'{}/groups/{}'.format(APP_URL, str(id)),
|
||||||
"{}/groups/{}".format(APP_URL, str(group["_id"])),
|
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
return reply
|
||||||
|
|
||||||
def delete_entry_reply(user, id, entry_id, reply_id):
|
def delete_entry_reply(user, id, entry_id, reply_id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
entry_id = ObjectId(entry_id)
|
entry_id = ObjectId(entry_id)
|
||||||
reply_id = ObjectId(reply_id)
|
reply_id = ObjectId(reply_id)
|
||||||
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
||||||
if not group:
|
if not group: raise util.errors.NotFound('Group not found')
|
||||||
raise util.errors.NotFound("Group not found")
|
entry = db.groupEntries.find_one(entry_id, {'user': 1, 'group': 1})
|
||||||
entry = db.groupEntries.find_one(entry_id, {"user": 1, "group": 1})
|
if not entry or entry['group'] != id: raise util.errors.NotFound('Entry not found')
|
||||||
if not entry or entry["group"] != id:
|
|
||||||
raise util.errors.NotFound("Entry not found")
|
|
||||||
reply = db.groupEntries.find_one(reply_id)
|
reply = db.groupEntries.find_one(reply_id)
|
||||||
if not reply or reply.get("inReplyTo") != entry_id:
|
if not reply or reply.get('inReplyTo') != entry_id: raise util.errors.NotFound('Reply not found')
|
||||||
raise util.errors.NotFound("Reply not found")
|
if entry['user'] != user['_id'] and reply['user'] != user['_id'] and user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You must own the reply or entry or be an admin of the group')
|
||||||
if (
|
db.groupEntries.remove({'_id': entry_id})
|
||||||
entry["user"] != user["_id"]
|
return {'deletedEntry': entry_id}
|
||||||
and reply["user"] != user["_id"]
|
|
||||||
and user["_id"] not in group.get("admins", [])
|
|
||||||
):
|
|
||||||
raise util.errors.Forbidden(
|
|
||||||
"You must own the reply or entry or be an admin of the group"
|
|
||||||
)
|
|
||||||
db.groupEntries.delete_one({"_id": entry_id})
|
|
||||||
return {"deletedEntry": entry_id}
|
|
||||||
|
|
||||||
|
def create_member(user, id, user_id, invited = False):
|
||||||
def create_member(user, id, user_id, invited=False):
|
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
user_id = ObjectId(user_id)
|
user_id = ObjectId(user_id)
|
||||||
group = db.groups.find_one({"_id": id}, {"admins": 1, "name": 1, "closed": 1})
|
group = db.groups.find_one({'_id': id}, {'admins': 1, 'name': 1, 'closed': 1})
|
||||||
if not group:
|
if not group: raise util.errors.NotFound('Group not found')
|
||||||
raise util.errors.NotFound("Group not found")
|
if user_id != user['_id']: raise util.errors.Forbidden('Not allowed to add someone else to the group')
|
||||||
if user_id != user["_id"]:
|
if group.get('closed') and not invited and user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('Not allowed to join a closed group')
|
||||||
raise util.errors.Forbidden("Not allowed to add someone else to the group")
|
db.users.update({'_id': user_id}, {'$addToSet': {'groups': id, 'subscriptions.email': 'groupFeed-' + str(id)}})
|
||||||
if (
|
db.invitations.remove({'type': 'group', 'typeId': id, 'recipient': user_id})
|
||||||
group.get("closed")
|
for admin in db.users.find({'_id': {'$in': group.get('admins', []), '$ne': user_id}, 'subscriptions.email': 'groups.joined'}, {'email': 1, 'username': 1}):
|
||||||
and not invited
|
mail.send({
|
||||||
and user["_id"] not in group.get("admins", [])
|
'to_user': admin,
|
||||||
):
|
'subject': 'Someone joined your group',
|
||||||
raise util.errors.Forbidden("Not allowed to join a closed group")
|
'text': 'Dear {0},\n\n{1} recently joined your group {2} on {4}!\n\nFollow the link below to manage your group:\n\n{3}'.format(
|
||||||
db.users.update_one(
|
admin['username'],
|
||||||
{"_id": user_id},
|
user['username'],
|
||||||
{"$addToSet": {"groups": id, "subscriptions.email": "groupFeed-" + str(id)}},
|
group['name'],
|
||||||
)
|
'{}/groups/{}'.format(APP_URL, str(id)),
|
||||||
db.invitations.delete_many({"type": "group", "typeId": id, "recipient": user_id})
|
|
||||||
for admin in db.users.find(
|
|
||||||
{
|
|
||||||
"_id": {"$in": group.get("admins", []), "$ne": user_id},
|
|
||||||
"subscriptions.email": "groups.joined",
|
|
||||||
},
|
|
||||||
{"email": 1, "username": 1},
|
|
||||||
):
|
|
||||||
mail.send(
|
|
||||||
{
|
|
||||||
"to_user": admin,
|
|
||||||
"subject": "Someone joined your group",
|
|
||||||
"text": "Dear {0},\n\n{1} recently joined your group {2} on {4}!\n\nFollow the link below to manage your group:\n\n{3}".format(
|
|
||||||
admin["username"],
|
|
||||||
user["username"],
|
|
||||||
group["name"],
|
|
||||||
"{}/groups/{}".format(APP_URL, str(id)),
|
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
|
||||||
return {"newMember": user_id}
|
return {'newMember': user_id}
|
||||||
|
|
||||||
|
|
||||||
def get_members(user, id):
|
def get_members(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
group = db.groups.find_one({"_id": id})
|
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
||||||
if not group:
|
if not group: raise util.errors.NotFound('Group not found')
|
||||||
raise util.errors.NotFound("Group not found")
|
if id not in user.get('groups', []) and not 'root' in user.get('roles', []): raise util.errors.Forbidden('You need to be a member to see the member list')
|
||||||
if id not in user.get("groups", []) and "root" not in user.get("roles", []):
|
members = list(db.users.find({'groups': id}, {'username': 1, 'avatar': 1, 'bio': 1, 'groups': 1}))
|
||||||
raise util.errors.Forbidden("You need to be a member to see the member list")
|
|
||||||
if not has_group_permission(user, group, "viewMembers"):
|
|
||||||
raise util.errors.Forbidden("You don't have permission to view the member list")
|
|
||||||
members = list(
|
|
||||||
db.users.find(
|
|
||||||
{"groups": id}, {"username": 1, "avatar": 1, "bio": 1, "groups": 1}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for m in members:
|
for m in members:
|
||||||
if "avatar" in m:
|
if 'avatar' in m:
|
||||||
m["avatarUrl"] = uploads.get_presigned_url(
|
m['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(m['_id'], m['avatar']))
|
||||||
"users/{0}/{1}".format(m["_id"], m["avatar"])
|
return {'members': members}
|
||||||
)
|
|
||||||
return {"members": members}
|
|
||||||
|
|
||||||
|
|
||||||
def delete_member(user, id, user_id):
|
def delete_member(user, id, user_id):
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
user_id = ObjectId(user_id)
|
user_id = ObjectId(user_id)
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
||||||
if not group:
|
if not group: raise util.errors.NotFound('Group not found')
|
||||||
raise util.errors.NotFound("Group not found")
|
if user_id != user['_id'] and user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You can\'t remove this user')
|
||||||
if user_id != user["_id"] and user["_id"] not in group.get("admins", []):
|
if user_id in group.get('admins', []) and len(group['admins']) == 1:
|
||||||
raise util.errors.Forbidden("You can't remove this user")
|
raise util.errors.Forbidden('There needs to be at least one admin in this group')
|
||||||
if user_id in group.get("admins", []) and len(group["admins"]) == 1:
|
db.users.update({'_id': user_id}, {'$pull': {'groups': id, 'subscriptions.email': 'groupFeed-' + str(id)}})
|
||||||
raise util.errors.Forbidden(
|
db.groups.update({'_id': id}, {'$pull': {'admins': user_id}})
|
||||||
"There needs to be at least one admin in this group"
|
return {'deletedMember': user_id}
|
||||||
)
|
|
||||||
db.users.update_one(
|
|
||||||
{"_id": user_id},
|
|
||||||
{"$pull": {"groups": id, "subscriptions.email": "groupFeed-" + str(id)}},
|
|
||||||
)
|
|
||||||
db.groups.update_one({"_id": id}, {"$pull": {"admins": user_id}})
|
|
||||||
return {"deletedMember": user_id}
|
|
||||||
|
|
||||||
|
|
||||||
def create_admin(user, id, user_id):
|
|
||||||
id = ObjectId(id)
|
|
||||||
user_id = ObjectId(user_id)
|
|
||||||
db = database.get_db()
|
|
||||||
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
|
||||||
if not group:
|
|
||||||
raise util.errors.NotFound("Group not found")
|
|
||||||
if user["_id"] not in group.get("admins", []):
|
|
||||||
raise util.errors.Forbidden("You can't add this admin")
|
|
||||||
if user_id in group.get("admins", []):
|
|
||||||
raise util.errors.Forbidden("This user is already an admin")
|
|
||||||
db.groups.update_one({"_id": id}, {"$addToSet": {"admins": user_id}})
|
|
||||||
return {"createdAdmin": user_id}
|
|
||||||
|
|
||||||
|
|
||||||
def delete_admin(user, id, user_id):
|
|
||||||
id = ObjectId(id)
|
|
||||||
user_id = ObjectId(user_id)
|
|
||||||
db = database.get_db()
|
|
||||||
group = db.groups.find_one({"_id": id}, {"admins": 1})
|
|
||||||
if not group:
|
|
||||||
raise util.errors.NotFound("Group not found")
|
|
||||||
if user_id != user["_id"] and user["_id"] not in group.get("admins", []):
|
|
||||||
raise util.errors.Forbidden("You can't remove this admin")
|
|
||||||
if user_id not in group.get("admins", []):
|
|
||||||
raise util.errors.Forbidden("This user is not an admin")
|
|
||||||
if len(group["admins"]) == 1:
|
|
||||||
raise util.errors.Forbidden(
|
|
||||||
"There needs to be at least one admin in this group"
|
|
||||||
)
|
|
||||||
db.groups.update_one({"_id": id}, {"$pull": {"admins": user_id}})
|
|
||||||
return {"deletedAdmin": user_id}
|
|
||||||
|
|
||||||
|
|
||||||
def get_projects(user, id):
|
def get_projects(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
group = db.groups.find_one({"_id": id})
|
group = db.groups.find_one({'_id': id}, {'admins': 1})
|
||||||
if not group:
|
if not group: raise util.errors.NotFound('Group not found')
|
||||||
raise util.errors.NotFound("Group not found")
|
if id not in user.get('groups', []): raise util.errors.Forbidden('You need to be a member to see the project list')
|
||||||
if id not in user.get("groups", []):
|
projects = list(db.projects.find({'groupVisibility': id}, {'name': 1, 'path': 1, 'user': 1, 'description': 1, 'visibility': 1}))
|
||||||
raise util.errors.Forbidden("You need to be a member to see the project list")
|
authors = list(db.users.find({'groups': id, '_id': {'$in': list(map(lambda p: p['user'], projects))}}, {'username': 1, 'avatar': 1, 'bio': 1}))
|
||||||
if not has_group_permission(user, group, "viewProjects"):
|
|
||||||
raise util.errors.Forbidden(
|
|
||||||
"You don't have permission to view the project list"
|
|
||||||
)
|
|
||||||
projects = list(
|
|
||||||
db.projects.find(
|
|
||||||
{"groupVisibility": id},
|
|
||||||
{"name": 1, "path": 1, "user": 1, "description": 1, "visibility": 1},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
authors = list(
|
|
||||||
db.users.find(
|
|
||||||
{"groups": id, "_id": {"$in": list(map(lambda p: p["user"], projects))}},
|
|
||||||
{"username": 1, "avatar": 1, "bio": 1},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for a in authors:
|
for a in authors:
|
||||||
if "avatar" in a:
|
if 'avatar' in a:
|
||||||
a["avatarUrl"] = uploads.get_presigned_url(
|
a['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(a['_id'], a['avatar']))
|
||||||
"users/{0}/{1}".format(a["_id"], a["avatar"])
|
|
||||||
)
|
|
||||||
for project in projects:
|
for project in projects:
|
||||||
for a in authors:
|
for a in authors:
|
||||||
if project["user"] == a["_id"]:
|
if project['user'] == a['_id']:
|
||||||
project["owner"] = a
|
project['owner'] = a
|
||||||
project["fullName"] = a["username"] + "/" + project["path"]
|
project['fullName'] = a['username'] + '/' + project['path']
|
||||||
break
|
break
|
||||||
return {"projects": projects}
|
return {'projects': projects}
|
||||||
|
|
||||||
|
|
||||||
def create_forum_topic(user, id, data):
|
|
||||||
db = database.get_db()
|
|
||||||
id = ObjectId(id)
|
|
||||||
group = db.groups.find_one({"_id": id})
|
|
||||||
if not group:
|
|
||||||
raise util.errors.NotFound("Group not found")
|
|
||||||
if not has_group_permission(user, group, "postForumTopics"):
|
|
||||||
raise util.errors.Forbidden("You don't have permission to create a topic")
|
|
||||||
topic = {
|
|
||||||
"createdAt": datetime.datetime.now(),
|
|
||||||
"group": id,
|
|
||||||
"user": user["_id"],
|
|
||||||
"title": data["title"],
|
|
||||||
"description": data.get("description", ""),
|
|
||||||
}
|
|
||||||
result = db.groupForumTopics.insert_one(topic)
|
|
||||||
topic["_id"] = result.inserted_id
|
|
||||||
return topic
|
|
||||||
|
|
||||||
|
|
||||||
def update_forum_topic(user, id, topic_id, data):
|
|
||||||
db = database.get_db()
|
|
||||||
id = ObjectId(id)
|
|
||||||
topic_id = ObjectId(topic_id)
|
|
||||||
group = db.groups.find_one({"_id": id})
|
|
||||||
if not group:
|
|
||||||
raise util.errors.NotFound("Group not found")
|
|
||||||
topic = db.groupForumTopics.find_one({"_id": topic_id})
|
|
||||||
if not topic or topic.get("group") != id:
|
|
||||||
raise util.errors.NotFound("Topic not found")
|
|
||||||
if not (user["_id"] in group.get("admins", []) or user["_id"] == topic.get("user")):
|
|
||||||
raise util.errors.Forbidden("You don't have permission to edit the topic")
|
|
||||||
allowed_keys = ["title", "description"]
|
|
||||||
updater = util.build_updater(data, allowed_keys)
|
|
||||||
if updater:
|
|
||||||
db.groupForumTopics.update_one({"_id": topic_id}, updater)
|
|
||||||
return db.groupForumTopics.find_one({"_id": topic_id})
|
|
||||||
|
|
||||||
|
|
||||||
def delete_forum_topic(user, id, topic_id):
|
|
||||||
db = database.get_db()
|
|
||||||
id = ObjectId(id)
|
|
||||||
topic_id = ObjectId(topic_id)
|
|
||||||
group = db.groups.find_one({"_id": id})
|
|
||||||
if not group:
|
|
||||||
raise util.errors.NotFound("Group not found")
|
|
||||||
topic = db.groupForumTopics.find_one({"_id": topic_id})
|
|
||||||
if not topic or topic.get("group") != id:
|
|
||||||
raise util.errors.NotFound("Topic not found")
|
|
||||||
if not (user["_id"] in group.get("admins", []) or user["_id"] == topic.get("user")):
|
|
||||||
raise util.errors.Forbidden("You don't have permission to delete the topic")
|
|
||||||
db.groupForumTopics.delete_one({"_id": topic_id})
|
|
||||||
db.groupForumTopicReplies.delete_many({"topic": topic_id})
|
|
||||||
return {"deletedTopic": topic_id}
|
|
||||||
|
|
||||||
|
|
||||||
def get_forum_topics(user, id):
|
|
||||||
db = database.get_db()
|
|
||||||
id = ObjectId(id)
|
|
||||||
group = db.groups.find_one({"_id": id})
|
|
||||||
if not group:
|
|
||||||
raise util.errors.NotFound("Group not found")
|
|
||||||
if not has_group_permission(user, group, "viewForumTopics"):
|
|
||||||
raise util.errors.Forbidden(
|
|
||||||
"You don't have permission to view the forum topics"
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"topics": list(
|
|
||||||
db.groupForumTopics.find({"group": id}).sort(
|
|
||||||
"createdAt", pymongo.DESCENDING
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def create_forum_topic_reply(user, id, topic_id, data):
|
|
||||||
db = database.get_db()
|
|
||||||
id = ObjectId(id)
|
|
||||||
topic_id = ObjectId(topic_id)
|
|
||||||
group = db.groups.find_one({"_id": id})
|
|
||||||
if not group:
|
|
||||||
raise util.errors.NotFound("Group not found")
|
|
||||||
topic = db.groupForumTopics.find_one({"_id": topic_id})
|
|
||||||
if not topic or topic.get("group") != id:
|
|
||||||
raise util.errors.NotFound("Topic not found")
|
|
||||||
if not has_group_permission(user, group, "postForumTopicReplies"):
|
|
||||||
raise util.errors.Forbidden("You don't have permission to create a reply")
|
|
||||||
reply = {
|
|
||||||
"createdAt": datetime.datetime.now(),
|
|
||||||
"group": id,
|
|
||||||
"topic": topic_id,
|
|
||||||
"user": user["_id"],
|
|
||||||
"content": data["content"],
|
|
||||||
"attachments": data.get("attachments", []),
|
|
||||||
"moderationRequired": True,
|
|
||||||
}
|
|
||||||
result = db.groupForumTopicReplies.insert_one(reply)
|
|
||||||
db.groupForumTopics.update_one(
|
|
||||||
{"_id": topic_id},
|
|
||||||
{
|
|
||||||
"$set": {
|
|
||||||
"lastReplyAt": reply["createdAt"],
|
|
||||||
"totalReplies": db.groupForumTopicReplies.count_documents(
|
|
||||||
{"topic": topic_id}
|
|
||||||
),
|
|
||||||
"lastReply": result.inserted_id,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
reply["_id"] = result.inserted_id
|
|
||||||
reply["author"] = {
|
|
||||||
"_id": user["_id"],
|
|
||||||
"username": user["username"],
|
|
||||||
"avatar": user.get("avatar"),
|
|
||||||
}
|
|
||||||
if "avatar" in user:
|
|
||||||
reply["author"]["avatarUrl"] = uploads.get_presigned_url(
|
|
||||||
"users/{0}/{1}".format(user["_id"], user["avatar"])
|
|
||||||
)
|
|
||||||
for attachment in reply["attachments"]:
|
|
||||||
if re.search(
|
|
||||||
r"(.jpg)|(.png)|(.jpeg)|(.gif)$", attachment["storedName"].lower()
|
|
||||||
):
|
|
||||||
attachment["isImage"] = True
|
|
||||||
if attachment["type"] == "file":
|
|
||||||
attachment["url"] = uploads.get_presigned_url(
|
|
||||||
"groups/{0}/topics/{1}/{2}".format(
|
|
||||||
id, topic_id, attachment["storedName"]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
util.send_moderation_request(user, "groupForumTopicReplies", reply)
|
|
||||||
return reply
|
|
||||||
|
|
||||||
|
|
||||||
def send_forum_topic_reply_notification(id):
|
|
||||||
db = database.get_db()
|
|
||||||
reply = db.groupForumTopicReplies.find_one({"_id": ObjectId(id)})
|
|
||||||
user = db.users.find_one({"_id": reply["user"]})
|
|
||||||
topic = db.groupForumTopics.find_one({"_id": reply["topic"]})
|
|
||||||
group = db.groups.find_one({"_id": topic["group"]})
|
|
||||||
for u in db.users.find(
|
|
||||||
{
|
|
||||||
"_id": {"$ne": reply["user"]},
|
|
||||||
"groups": topic["group"],
|
|
||||||
"subscriptions.email": "groupForumTopic-" + str(topic["_id"]),
|
|
||||||
},
|
|
||||||
{"email": 1, "username": 1},
|
|
||||||
):
|
|
||||||
mail.send(
|
|
||||||
{
|
|
||||||
"to_user": u,
|
|
||||||
"subject": "A new reply was posted to " + topic["title"],
|
|
||||||
"text": "Dear {0},\n\n{1} posted a new reply in {2} (in the group {3}) on {6}:\n\n{4}\n\nFollow the link below to visit the group:\n\n{5}".format(
|
|
||||||
u["username"],
|
|
||||||
user["username"],
|
|
||||||
topic["title"],
|
|
||||||
group["name"],
|
|
||||||
reply["content"],
|
|
||||||
"{}/groups/{}/forum/topics/{}".format(
|
|
||||||
APP_URL, str(group["_id"]), str(topic["_id"])
|
|
||||||
),
|
|
||||||
APP_NAME,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_forum_topic_replies(user, id, topic_id, data):
|
|
||||||
REPLIES_PER_PAGE = 20
|
|
||||||
page = int(data.get("page", 1))
|
|
||||||
db = database.get_db()
|
|
||||||
id = ObjectId(id)
|
|
||||||
topic_id = ObjectId(topic_id)
|
|
||||||
group = db.groups.find_one({"_id": id})
|
|
||||||
if not group:
|
|
||||||
raise util.errors.NotFound("Group not found")
|
|
||||||
topic = db.groupForumTopics.find_one({"_id": topic_id})
|
|
||||||
if not topic or topic.get("group") != id:
|
|
||||||
raise util.errors.NotFound("Topic not found")
|
|
||||||
if not has_group_permission(user, group, "viewForumTopics"):
|
|
||||||
raise util.errors.Forbidden(
|
|
||||||
"You don't have permission to view the forum topics"
|
|
||||||
)
|
|
||||||
total_replies = db.groupForumTopicReplies.count_documents({"topic": topic_id})
|
|
||||||
replies = list(
|
|
||||||
db.groupForumTopicReplies.find(
|
|
||||||
{
|
|
||||||
"topic": topic_id,
|
|
||||||
"$or": [{"moderationRequired": {"$ne": True}}, {"user": user["_id"]}],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.sort("createdAt", pymongo.ASCENDING)
|
|
||||||
.skip((page - 1) * REPLIES_PER_PAGE)
|
|
||||||
.limit(REPLIES_PER_PAGE)
|
|
||||||
)
|
|
||||||
authors = list(
|
|
||||||
db.users.find(
|
|
||||||
{"_id": {"$in": [r["user"] for r in replies]}}, {"username": 1, "avatar": 1}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for reply in replies:
|
|
||||||
author = next((a for a in authors if a["_id"] == reply["user"]), None)
|
|
||||||
if author:
|
|
||||||
reply["author"] = author
|
|
||||||
if "avatar" in author:
|
|
||||||
reply["author"]["avatarUrl"] = uploads.get_presigned_url(
|
|
||||||
"users/{0}/{1}".format(author["_id"], author["avatar"])
|
|
||||||
)
|
|
||||||
if "attachments" in reply:
|
|
||||||
for attachment in reply["attachments"]:
|
|
||||||
if attachment["type"] == "file":
|
|
||||||
attachment["isImage"] = False
|
|
||||||
if re.search(
|
|
||||||
r"(.jpg)|(.png)|(.jpeg)|(.gif)$",
|
|
||||||
attachment["storedName"].lower(),
|
|
||||||
):
|
|
||||||
attachment["isImage"] = True
|
|
||||||
attachment["url"] = uploads.get_presigned_url(
|
|
||||||
"groups/{0}/topics/{1}/{2}".format(
|
|
||||||
id, topic_id, attachment["storedName"]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"topic": topic,
|
|
||||||
"replies": replies,
|
|
||||||
"totalReplies": total_replies,
|
|
||||||
"page": page,
|
|
||||||
"totalPages": math.ceil(total_replies / REPLIES_PER_PAGE),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def delete_forum_topic_reply(user, id, topic_id, reply_id):
|
|
||||||
db = database.get_db()
|
|
||||||
id = ObjectId(id)
|
|
||||||
topic_id = ObjectId(topic_id)
|
|
||||||
reply_id = ObjectId(reply_id)
|
|
||||||
group = db.groups.find_one({"_id": id})
|
|
||||||
if not group:
|
|
||||||
raise util.errors.NotFound("Group not found")
|
|
||||||
topic = db.groupForumTopics.find_one({"_id": topic_id})
|
|
||||||
if not topic or topic.get("group") != id:
|
|
||||||
raise util.errors.NotFound("Topic not found")
|
|
||||||
reply = db.groupForumTopicReplies.find_one({"_id": reply_id})
|
|
||||||
if not reply or reply.get("topic") != topic_id:
|
|
||||||
raise util.errors.NotFound("Reply not found")
|
|
||||||
if not (user["_id"] in group.get("admins", []) or user["_id"] == reply.get("user")):
|
|
||||||
raise util.errors.Forbidden("You don't have permission to delete the reply")
|
|
||||||
db.groupForumTopicReplies.delete_one({"_id": reply_id})
|
|
||||||
last_reply = db.groupForumTopicReplies.find_one(
|
|
||||||
{"topic": topic_id}, sort=[("createdAt", pymongo.DESCENDING)]
|
|
||||||
)
|
|
||||||
db.groupForumTopics.update_one(
|
|
||||||
{"_id": topic_id},
|
|
||||||
{
|
|
||||||
"$set": {
|
|
||||||
"totalReplies": db.groupForumTopicReplies.count_documents(
|
|
||||||
{"topic": topic_id}
|
|
||||||
),
|
|
||||||
"lastReply": last_reply["_id"] if last_reply else None,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return {"deletedReply": reply_id}
|
|
||||||
|
@ -1,252 +1,171 @@
|
|||||||
import datetime
|
import re, datetime, os
|
||||||
import os
|
import pymongo
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
from util import database, util, mail
|
from util import database, util, mail
|
||||||
from api import uploads, groups
|
from api import uploads, groups
|
||||||
|
|
||||||
APP_NAME = os.environ.get("APP_NAME")
|
APP_NAME = os.environ.get('APP_NAME')
|
||||||
APP_URL = os.environ.get("APP_URL")
|
APP_URL = os.environ.get('APP_URL')
|
||||||
|
|
||||||
|
|
||||||
def get(user):
|
def get(user):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
admin_groups = list(db.groups.find({"admins": user["_id"]}))
|
admin_groups = list(db.groups.find({'admins': user['_id']}))
|
||||||
invites = list(
|
invites = list(db.invitations.find({'$or': [{'recipient': user['_id']}, {'recipientGroup': {'$in': list(map(lambda g: g['_id'], admin_groups))}}]}))
|
||||||
db.invitations.find(
|
inviters = list(db.users.find({'_id': {'$in': [i['user'] for i in invites]}}, {'username': 1, 'avatar': 1}))
|
||||||
{
|
|
||||||
"$or": [
|
|
||||||
{"recipient": user["_id"]},
|
|
||||||
{
|
|
||||||
"recipientGroup": {
|
|
||||||
"$in": list(map(lambda g: g["_id"], admin_groups))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
inviters = list(
|
|
||||||
db.users.find(
|
|
||||||
{"_id": {"$in": [i["user"] for i in invites]}}, {"username": 1, "avatar": 1}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for invite in invites:
|
for invite in invites:
|
||||||
invite["recipient"] = user["_id"]
|
invite['recipient'] = user['_id']
|
||||||
if invite["type"] in ["group", "groupJoinRequest"]:
|
if invite['type'] in ['group', 'groupJoinRequest']: invite['group'] = db.groups.find_one({'_id': invite['typeId']}, {'name': 1})
|
||||||
invite["group"] = db.groups.find_one({"_id": invite["typeId"]}, {"name": 1})
|
for u in inviters:
|
||||||
inviter = next((u for u in inviters if u["_id"] == invite["user"]), None)
|
if u['_id'] == invite['user']:
|
||||||
if inviter:
|
if 'avatar' in u:
|
||||||
if "avatar" in inviter:
|
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
||||||
inviter["avatarUrl"] = uploads.get_presigned_url(
|
invite['invitedBy'] = u
|
||||||
"users/{0}/{1}".format(inviter["_id"], inviter["avatar"])
|
break
|
||||||
)
|
sent_invites = list(db.invitations.find({'user': user['_id']}))
|
||||||
invite["invitedBy"] = inviter
|
recipients = list(db.users.find({'_id': {'$in': list(map(lambda i: i.get('recipient'), sent_invites))}}, {'username': 1, 'avatar': 1}))
|
||||||
sent_invites = list(db.invitations.find({"user": user["_id"]}))
|
|
||||||
recipients = list(
|
|
||||||
db.users.find(
|
|
||||||
{"_id": {"$in": list(map(lambda i: i.get("recipient"), sent_invites))}},
|
|
||||||
{"username": 1, "avatar": 1},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for invite in sent_invites:
|
for invite in sent_invites:
|
||||||
if invite["type"] in ["group", "groupJoinRequest"]:
|
if invite['type'] in ['group', 'groupJoinRequest']: invite['group'] = db.groups.find_one({'_id': invite['typeId']}, {'name': 1})
|
||||||
invite["group"] = db.groups.find_one({"_id": invite["typeId"]}, {"name": 1})
|
for u in recipients:
|
||||||
recipient = next(
|
if u['_id'] == invite.get('recipient'):
|
||||||
(u for u in recipients if u["_id"] == invite.get("recipient")), None
|
if 'avatar' in u:
|
||||||
)
|
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
||||||
if recipient:
|
invite['invitedBy'] = u
|
||||||
if "avatar" in recipient:
|
break
|
||||||
recipient["avatarUrl"] = uploads.get_presigned_url(
|
return {'invitations': invites, 'sentInvitations': sent_invites}
|
||||||
"users/{0}/{1}".format(recipient["_id"], recipient["avatar"])
|
|
||||||
)
|
|
||||||
invite["invitedBy"] = recipient
|
|
||||||
return {"invitations": invites, "sentInvitations": sent_invites}
|
|
||||||
|
|
||||||
|
|
||||||
def accept(user, id):
|
def accept(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
invite = db.invitations.find_one({"_id": id})
|
invite = db.invitations.find_one({'_id': id})
|
||||||
if not invite:
|
if not invite: raise util.errors.NotFound('Invitation not found')
|
||||||
raise util.errors.NotFound("Invitation not found")
|
if invite['type'] == 'group':
|
||||||
if invite["type"] == "group":
|
if invite['recipient'] != user['_id']: raise util.errors.Forbidden('This invitation is not yours to accept')
|
||||||
if invite["recipient"] != user["_id"]:
|
group = db.groups.find_one({'_id': invite['typeId']}, {'name': 1})
|
||||||
raise util.errors.Forbidden("This invitation is not yours to accept")
|
|
||||||
group = db.groups.find_one({"_id": invite["typeId"]}, {"name": 1})
|
|
||||||
if not group:
|
if not group:
|
||||||
db.invitations.delete_one({"_id": id})
|
db.invitations.remove({'_id': id})
|
||||||
return {"acceptedInvitation": id}
|
return {'acceptedInvitation': id}
|
||||||
groups.create_member(user, group["_id"], user["_id"], invited=True)
|
groups.create_member(user, group['_id'], user['_id'], invited = True)
|
||||||
db.invitations.delete_one({"_id": id})
|
db.invitations.remove({'_id': id})
|
||||||
return {"acceptedInvitation": id, "group": group}
|
return {'acceptedInvitation': id, 'group': group}
|
||||||
if invite["type"] == "groupJoinRequest":
|
if invite['type'] == 'groupJoinRequest':
|
||||||
group = db.groups.find_one({"_id": invite["typeId"]})
|
group = db.groups.find_one({'_id': invite['typeId']})
|
||||||
if user["_id"] not in group.get("admins", []):
|
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be an admin of this group to accept this request')
|
||||||
raise util.errors.Forbidden(
|
requester = db.users.find_one({'_id': invite['user']})
|
||||||
"You need to be an admin of this group to accept this request"
|
|
||||||
)
|
|
||||||
requester = db.users.find_one({"_id": invite["user"]})
|
|
||||||
if not group or not requester:
|
if not group or not requester:
|
||||||
db.invitations.delete_one({"_id": id})
|
db.invitations.remove({'_id': id})
|
||||||
return {"acceptedInvitation": id}
|
return {'acceptedInvitation': id}
|
||||||
groups.create_member(requester, group["_id"], requester["_id"], invited=True)
|
groups.create_member(requester, group['_id'], requester['_id'], invited = True)
|
||||||
db.invitations.delete_one({"_id": id})
|
db.invitations.remove({'_id': id})
|
||||||
return {"acceptedInvitation": id, "group": group}
|
return {'acceptedInvitation': id, 'group': group}
|
||||||
|
|
||||||
|
|
||||||
def delete(user, id):
|
def delete(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
id = ObjectId(id)
|
id = ObjectId(id)
|
||||||
invite = db.invitations.find_one({"_id": id})
|
invite = db.invitations.find_one({'_id': id})
|
||||||
if not invite:
|
if not invite: raise util.errors.NotFound('Invitation not found')
|
||||||
raise util.errors.NotFound("Invitation not found")
|
if invite['type'] == 'group':
|
||||||
if invite["type"] == "group":
|
if invite['recipient'] != user['_id']: raise util.errors.Forbidden('This invitation is not yours to decline')
|
||||||
if invite["recipient"] != user["_id"]:
|
if invite['type'] == 'groupJoinRequest':
|
||||||
raise util.errors.Forbidden("This invitation is not yours to decline")
|
group = db.groups.find_one({'_id': invite['typeId']})
|
||||||
if invite["type"] == "groupJoinRequest":
|
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be an admin of this group to manage this request')
|
||||||
group = db.groups.find_one({"_id": invite["typeId"]})
|
db.invitations.remove({'_id': id})
|
||||||
if user["_id"] not in group.get("admins", []):
|
return {'deletedInvitation': id}
|
||||||
raise util.errors.Forbidden(
|
|
||||||
"You need to be an admin of this group to manage this request"
|
|
||||||
)
|
|
||||||
db.invitations.delete_one({"_id": id})
|
|
||||||
return {"deletedInvitation": id}
|
|
||||||
|
|
||||||
|
|
||||||
def create_group_invitation(user, group_id, data):
|
def create_group_invitation(user, group_id, data):
|
||||||
if not data or "user" not in data:
|
if not data or 'user' not in data: raise util.errors.BadRequest('Invalid request')
|
||||||
raise util.errors.BadRequest("Invalid request")
|
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
recipient_id = ObjectId(data["user"])
|
recipient_id = ObjectId(data['user'])
|
||||||
group_id = ObjectId(group_id)
|
group_id = ObjectId(group_id)
|
||||||
group = db.groups.find_one({"_id": group_id}, {"admins": 1, "name": 1})
|
group = db.groups.find_one({'_id': group_id}, {'admins': 1, 'name': 1})
|
||||||
if not group:
|
if not group: raise util.errors.NotFound('Group not found')
|
||||||
raise util.errors.NotFound("Group not found")
|
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be a group admin to invite users')
|
||||||
if user["_id"] not in group.get("admins", []):
|
recipient = db.users.find_one({'_id': recipient_id}, {'groups': 1, 'username': 1, 'email': 1, 'subscriptions': 1})
|
||||||
raise util.errors.Forbidden("You need to be a group admin to invite users")
|
if not recipient: raise util.errors.NotFound('User not found')
|
||||||
recipient = db.users.find_one(
|
if group_id in recipient.get('groups', []): raise util.errors.BadRequest('This user is already in this group')
|
||||||
{"_id": recipient_id},
|
if db.invitations.find_one({'recipient': recipient_id, 'typeId': group_id, 'type': 'group'}):
|
||||||
{"groups": 1, "username": 1, "email": 1, "subscriptions": 1},
|
raise util.errors.BadRequest('This user has already been invited to this group')
|
||||||
)
|
|
||||||
if not recipient:
|
|
||||||
raise util.errors.NotFound("User not found")
|
|
||||||
if group_id in recipient.get("groups", []):
|
|
||||||
raise util.errors.BadRequest("This user is already in this group")
|
|
||||||
if db.invitations.find_one(
|
|
||||||
{"recipient": recipient_id, "typeId": group_id, "type": "group"}
|
|
||||||
):
|
|
||||||
raise util.errors.BadRequest("This user has already been invited to this group")
|
|
||||||
invite = {
|
invite = {
|
||||||
"createdAt": datetime.datetime.now(),
|
'createdAt': datetime.datetime.now(),
|
||||||
"user": user["_id"],
|
'user': user['_id'],
|
||||||
"recipient": recipient_id,
|
'recipient': recipient_id,
|
||||||
"type": "group",
|
'type': 'group',
|
||||||
"typeId": group_id,
|
'typeId': group_id
|
||||||
}
|
}
|
||||||
result = db.invitations.insert_one(invite)
|
result = db.invitations.insert_one(invite)
|
||||||
if "groups.invited" in recipient.get("subscriptions", {}).get("email", []):
|
if 'groups.invited' in recipient.get('subscriptions', {}).get('email', []):
|
||||||
mail.send(
|
mail.send({
|
||||||
{
|
'to_user': recipient,
|
||||||
"to_user": recipient,
|
'subject': 'You\'ve been invited to a group on {}!'.format(APP_NAME),
|
||||||
"subject": "You've been invited to a group on {}!".format(APP_NAME),
|
'text': 'Dear {0},\n\nYou have been invited to join the group {1} on {3}!\n\nLogin by visting {2} to find your invitation.'.format(
|
||||||
"text": "Dear {0},\n\nYou have been invited to join the group {1} on {3}!\n\nLogin by visting {2} to find your invitation.".format(
|
recipient['username'],
|
||||||
recipient["username"],
|
group['name'],
|
||||||
group["name"],
|
|
||||||
APP_URL,
|
APP_URL,
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
invite["_id"] = result.inserted_id
|
})
|
||||||
|
invite['_id'] = result.inserted_id
|
||||||
return invite
|
return invite
|
||||||
|
|
||||||
|
|
||||||
def create_group_request(user, group_id):
|
def create_group_request(user, group_id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
group_id = ObjectId(group_id)
|
group_id = ObjectId(group_id)
|
||||||
group = db.groups.find_one({"_id": group_id}, {"admins": 1, "name": 1})
|
group = db.groups.find_one({'_id': group_id}, {'admins': 1, 'name': 1})
|
||||||
if not group:
|
if not group: raise util.errors.NotFound('Group not found')
|
||||||
raise util.errors.NotFound("Group not found")
|
if group_id in user.get('groups'): raise util.errors.BadRequest('You are already a member of this group')
|
||||||
if group_id in user.get("groups", []):
|
admin = db.users.find_one({'_id': {'$in': group.get('admins', [])}}, {'groups': 1, 'username': 1, 'email': 1, 'subscriptions': 1})
|
||||||
raise util.errors.BadRequest("You are already a member of this group")
|
if not admin: raise util.errors.NotFound('No users can approve you to join this group')
|
||||||
admin = db.users.find_one(
|
if db.invitations.find_one({'recipient': user['_id'], 'typeId': group_id, 'type': 'group'}):
|
||||||
{"_id": {"$in": group.get("admins", [])}},
|
raise util.errors.BadRequest('You have already been invited to this group')
|
||||||
{"groups": 1, "username": 1, "email": 1, "subscriptions": 1},
|
if db.invitations.find_one({'user': user['_id'], 'typeId': group_id, 'type': 'groupJoinRequest'}):
|
||||||
)
|
raise util.errors.BadRequest('You have already requested access to this group')
|
||||||
if not admin:
|
|
||||||
raise util.errors.NotFound("No users can approve you to join this group")
|
|
||||||
if db.invitations.find_one(
|
|
||||||
{"recipient": user["_id"], "typeId": group_id, "type": "group"}
|
|
||||||
):
|
|
||||||
raise util.errors.BadRequest("You have already been invited to this group")
|
|
||||||
if db.invitations.find_one(
|
|
||||||
{"user": user["_id"], "typeId": group_id, "type": "groupJoinRequest"}
|
|
||||||
):
|
|
||||||
raise util.errors.BadRequest("You have already requested access to this group")
|
|
||||||
invite = {
|
invite = {
|
||||||
"createdAt": datetime.datetime.now(),
|
'createdAt': datetime.datetime.now(),
|
||||||
"user": user["_id"],
|
'user': user['_id'],
|
||||||
"recipientGroup": group["_id"],
|
'recipientGroup': group['_id'],
|
||||||
"type": "groupJoinRequest",
|
'type': 'groupJoinRequest',
|
||||||
"typeId": group_id,
|
'typeId': group_id
|
||||||
}
|
}
|
||||||
result = db.invitations.insert_one(invite)
|
result = db.invitations.insert_one(invite)
|
||||||
if "groups.joinRequested" in admin.get("subscriptions", {}).get("email", []):
|
if 'groups.joinRequested' in admin.get('subscriptions', {}).get('email', []):
|
||||||
mail.send(
|
mail.send({
|
||||||
{
|
'to_user': admin,
|
||||||
"to_user": admin,
|
'subject': 'Someone wants to join your group',
|
||||||
"subject": "Someone wants to join your group",
|
'text': 'Dear {0},\n\{1} has requested to join your group {2} on {4}!\n\nLogin by visting {3} to find and approve your requests.'.format(
|
||||||
"text": "Dear {0},\n\{1} has requested to join your group {2} on {4}!\n\nLogin by visting {3} to find and approve your requests.".format(
|
admin['username'],
|
||||||
admin["username"],
|
user['username'],
|
||||||
user["username"],
|
group['name'],
|
||||||
group["name"],
|
|
||||||
APP_URL,
|
APP_URL,
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
invite["_id"] = result.inserted_id
|
})
|
||||||
|
invite['_id'] = result.inserted_id
|
||||||
return invite
|
return invite
|
||||||
|
|
||||||
|
|
||||||
def get_group_invitations(user, id):
|
def get_group_invitations(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
group_id = ObjectId(id)
|
group_id = ObjectId(id)
|
||||||
group = db.groups.find_one({"_id": group_id}, {"admins": 1})
|
group = db.groups.find_one({'_id': group_id}, {'admins': 1})
|
||||||
if not group:
|
if not group: raise util.errors.NotFound('Group not found')
|
||||||
raise util.errors.NotFound("Group not found")
|
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be a group admin to see invitations')
|
||||||
if user["_id"] not in group.get("admins", []):
|
invites = list(db.invitations.find({'type': 'group', 'typeId': group_id}))
|
||||||
raise util.errors.Forbidden("You need to be a group admin to see invitations")
|
recipients = list(db.users.find({'_id': {'$in': [i['recipient'] for i in invites]}}, {'username': 1, 'avatar': 1}))
|
||||||
invites = list(db.invitations.find({"type": "group", "typeId": group_id}))
|
|
||||||
recipients = list(
|
|
||||||
db.users.find(
|
|
||||||
{"_id": {"$in": [i["recipient"] for i in invites]}},
|
|
||||||
{"username": 1, "avatar": 1},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for invite in invites:
|
for invite in invites:
|
||||||
for recipient in recipients:
|
for recipient in recipients:
|
||||||
if invite["recipient"] == recipient["_id"]:
|
if invite['recipient'] == recipient['_id']:
|
||||||
if "avatar" in recipient:
|
if 'avatar' in recipient:
|
||||||
recipient["avatarUrl"] = uploads.get_presigned_url(
|
recipient['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(recipient['_id'], recipient['avatar']))
|
||||||
"users/{0}/{1}".format(recipient["_id"], recipient["avatar"])
|
invite['recipientUser'] = recipient
|
||||||
)
|
|
||||||
invite["recipientUser"] = recipient
|
|
||||||
break
|
break
|
||||||
return {"invitations": invites}
|
return {'invitations': invites}
|
||||||
|
|
||||||
|
|
||||||
def delete_group_invitation(user, id, invite_id):
|
def delete_group_invitation(user, id, invite_id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
group_id = ObjectId(id)
|
group_id = ObjectId(id)
|
||||||
invite_id = ObjectId(invite_id)
|
invite_id = ObjectId(invite_id)
|
||||||
group = db.groups.find_one({"_id": group_id}, {"admins": 1})
|
group = db.groups.find_one({'_id': group_id}, {'admins': 1})
|
||||||
if not group:
|
if not group: raise util.errors.NotFound('Group not found')
|
||||||
raise util.errors.NotFound("Group not found")
|
if user['_id'] not in group.get('admins', []): raise util.errors.Forbidden('You need to be a group admin to see invitations')
|
||||||
if user["_id"] not in group.get("admins", []):
|
invite = db.invitations.find_one({'_id': invite_id})
|
||||||
raise util.errors.Forbidden("You need to be a group admin to see invitations")
|
if not invite or invite['typeId'] != group_id: raise util.errors.NotFound('This invite could not be found')
|
||||||
invite = db.invitations.find_one({"_id": invite_id})
|
db.invitations.remove({'_id': invite_id})
|
||||||
if not invite or invite["typeId"] != group_id:
|
return {'deletedInvite': invite_id}
|
||||||
raise util.errors.NotFound("This invite could not be found")
|
|
||||||
db.invitations.delete_one({"_id": invite_id})
|
|
||||||
return {"deletedInvite": invite_id}
|
|
||||||
|
@ -1,274 +1,154 @@
|
|||||||
import datetime
|
import datetime, base64, os
|
||||||
import base64
|
|
||||||
import os
|
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
import requests
|
import requests
|
||||||
from util import database, wif, util, mail
|
from util import database, wif, util, mail
|
||||||
from api import uploads
|
from api import uploads
|
||||||
|
|
||||||
APP_NAME = os.environ.get("APP_NAME")
|
APP_NAME = os.environ.get('APP_NAME')
|
||||||
APP_URL = os.environ.get("APP_URL")
|
|
||||||
|
|
||||||
|
|
||||||
def delete(user, id):
|
def delete(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
obj = db.objects.find_one(ObjectId(id), {"project": 1, "storedName": 1})
|
obj = db.objects.find_one(ObjectId(id), {'project': 1})
|
||||||
if not obj:
|
if not obj:
|
||||||
raise util.errors.NotFound("Object not found")
|
raise util.errors.NotFound('Object not found')
|
||||||
project = db.projects.find_one(obj.get("project"), {"user": 1})
|
project = db.projects.find_one(obj.get('project'), {'user': 1})
|
||||||
if not project:
|
if not project:
|
||||||
raise util.errors.NotFound("Project not found")
|
raise util.errors.NotFound('Project not found')
|
||||||
if not util.can_edit_project(user, project):
|
if project['user'] != user['_id']:
|
||||||
raise util.errors.Forbidden("Forbidden", 403)
|
raise util.errors.Forbidden('Forbidden', 403)
|
||||||
db.objects.delete_one({"_id": ObjectId(id)})
|
db.objects.remove(ObjectId(id))
|
||||||
db.comments.delete_many({"object": ObjectId(id)})
|
return {'deletedObject': id}
|
||||||
uploads.delete_file(f"projects/{project['_id']}/{obj.get('storedName')}")
|
|
||||||
return {"deletedObject": id}
|
|
||||||
|
|
||||||
|
|
||||||
def get(user, id):
|
def get(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
obj = db.objects.find_one({"_id": ObjectId(id)})
|
obj = db.objects.find_one(ObjectId(id))
|
||||||
if not obj:
|
if not obj:
|
||||||
raise util.errors.NotFound("Object not found")
|
raise util.errors.NotFound('Object not found')
|
||||||
proj = db.projects.find_one({"_id": obj["project"]})
|
|
||||||
if not proj:
|
|
||||||
raise util.errors.NotFound("Project not found")
|
|
||||||
is_owner = user and (user.get("_id") == proj["user"])
|
|
||||||
if not is_owner and proj["visibility"] != "public":
|
|
||||||
raise util.errors.Forbidden("Forbidden")
|
|
||||||
if not util.can_edit_project(user, proj) and obj.get("moderationRequired"):
|
|
||||||
raise util.errors.Forbidden("Awaiting moderation")
|
|
||||||
owner = db.users.find_one({"_id": proj["user"]}, {"username": 1, "avatar": 1})
|
|
||||||
if obj["type"] == "file" and "storedName" in obj:
|
|
||||||
obj["url"] = uploads.get_presigned_url(
|
|
||||||
"projects/{0}/{1}".format(proj["_id"], obj["storedName"])
|
|
||||||
)
|
|
||||||
if obj["type"] == "pattern" and "preview" in obj and ".png" in obj["preview"]:
|
|
||||||
obj["previewUrl"] = uploads.get_presigned_url(
|
|
||||||
"projects/{0}/{1}".format(proj["_id"], obj["preview"])
|
|
||||||
)
|
|
||||||
del obj["preview"]
|
|
||||||
if obj.get("fullPreview"):
|
|
||||||
obj["fullPreviewUrl"] = uploads.get_presigned_url(
|
|
||||||
"projects/{0}/{1}".format(proj["_id"], obj["fullPreview"])
|
|
||||||
)
|
|
||||||
obj["projectObject"] = proj
|
|
||||||
if owner:
|
|
||||||
if "avatar" in owner:
|
|
||||||
owner["avatarUrl"] = uploads.get_presigned_url(
|
|
||||||
"users/{0}/{1}".format(str(owner["_id"]), owner["avatar"])
|
|
||||||
)
|
|
||||||
obj["projectObject"]["owner"] = owner
|
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
def copy_to_project(user, id, project_id):
|
def copy_to_project(user, id, project_id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
obj = db.objects.find_one(ObjectId(id))
|
obj = db.objects.find_one(ObjectId(id))
|
||||||
if not obj:
|
if not obj: raise util.errors.NotFound('This object could not be found')
|
||||||
raise util.errors.NotFound("This object could not be found")
|
original_project = db.projects.find_one(obj['project'])
|
||||||
original_project = db.projects.find_one(obj["project"])
|
|
||||||
if not original_project:
|
if not original_project:
|
||||||
raise util.errors.NotFound("Project not found")
|
raise util.errors.NotFound('Project not found')
|
||||||
if not original_project.get("openSource") and not util.can_edit_project(
|
if not original_project.get('openSource') and not (user and user['_id'] == original_project['user']):
|
||||||
user, original_project
|
raise util.errors.Forbidden('This project is not open-source')
|
||||||
):
|
|
||||||
raise util.errors.Forbidden("This project is not open-source")
|
|
||||||
if original_project.get("visibility") != "public" and not util.can_edit_project(
|
|
||||||
user, original_project
|
|
||||||
):
|
|
||||||
raise util.errors.Forbidden("This project is not public")
|
|
||||||
target_project = db.projects.find_one(ObjectId(project_id))
|
target_project = db.projects.find_one(ObjectId(project_id))
|
||||||
if not target_project or not util.can_edit_project(user, target_project):
|
if not target_project or target_project['user'] != user['_id']:
|
||||||
raise util.errors.Forbidden("You don't own the target project")
|
raise util.errors.Forbidden('You don\'t own the target project')
|
||||||
|
|
||||||
obj["_id"] = ObjectId()
|
obj['_id'] = ObjectId()
|
||||||
obj["project"] = target_project["_id"]
|
obj['project'] = target_project['_id']
|
||||||
obj["createdAt"] = datetime.datetime.now()
|
obj['createdAt'] = datetime.datetime.now()
|
||||||
obj["commentCount"] = 0
|
obj['commentCount'] = 0
|
||||||
if "preview" in obj:
|
|
||||||
del obj["preview"]
|
|
||||||
if obj.get("pattern"):
|
|
||||||
images = wif.generate_images(obj)
|
|
||||||
if images:
|
|
||||||
obj.update(images)
|
|
||||||
db.objects.insert_one(obj)
|
db.objects.insert_one(obj)
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
def get_wif(user, id):
|
def get_wif(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
obj = db.objects.find_one(ObjectId(id))
|
obj = db.objects.find_one(ObjectId(id))
|
||||||
if not obj:
|
if not obj: raise util.errors.NotFound('Object not found')
|
||||||
raise util.errors.NotFound("Object not found")
|
project = db.projects.find_one(obj['project'])
|
||||||
project = db.projects.find_one(obj["project"])
|
if not project.get('openSource') and not (user and user['_id'] == project['user']):
|
||||||
if not project.get("openSource") and not util.can_edit_project(user, project):
|
raise util.errors.Forbidden('This project is not open-source')
|
||||||
raise util.errors.Forbidden("This project is not open-source")
|
|
||||||
if project.get("visibility") != "public" and not util.can_edit_project(
|
|
||||||
user, project
|
|
||||||
):
|
|
||||||
raise util.errors.Forbidden("This project is not public")
|
|
||||||
try:
|
try:
|
||||||
output = wif.dumps(obj).replace("\n", "\\n")
|
output = wif.dumps(obj).replace('\n', '\\n')
|
||||||
return {"wif": output}
|
return {'wif': output}
|
||||||
except Exception:
|
except Exception as e:
|
||||||
raise util.errors.BadRequest("Unable to create WIF file")
|
raise util.errors.BadRequest('Unable to create WIF file')
|
||||||
|
|
||||||
|
|
||||||
def get_pdf(user, id):
|
def get_pdf(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
obj = db.objects.find_one(ObjectId(id))
|
obj = db.objects.find_one(ObjectId(id))
|
||||||
if not obj:
|
if not obj: raise util.errors.NotFound('Object not found')
|
||||||
raise util.errors.NotFound("Object not found")
|
project = db.projects.find_one(obj['project'])
|
||||||
project = db.projects.find_one(obj["project"])
|
if not project.get('openSource') and not (user and user['_id'] == project['user']):
|
||||||
if not project.get("openSource") and not util.can_edit_project(user, project):
|
raise util.errors.Forbidden('This project is not open-source')
|
||||||
raise util.errors.Forbidden("This project is not open-source")
|
|
||||||
if project.get("visibility") != "public" and not util.can_edit_project(
|
|
||||||
user, project
|
|
||||||
):
|
|
||||||
raise util.errors.Forbidden("This project is not public")
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get('https://h2io6k3ovg.execute-api.eu-west-1.amazonaws.com/prod/pdf?object=' + id + '&landscape=true&paperWidth=23.39&paperHeight=33.11')
|
||||||
"https://h2io6k3ovg.execute-api.eu-west-1.amazonaws.com/prod/pdf?object="
|
|
||||||
+ id
|
|
||||||
+ "&landscape=true&paperWidth=23.39&paperHeight=33.11"
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
pdf = uploads.get_file("objects/" + id + "/export.pdf")
|
pdf = uploads.get_file('objects/' + id + '/export.pdf')
|
||||||
body64 = base64.b64encode(pdf["Body"].read())
|
body64 = base64.b64encode(pdf['Body'].read())
|
||||||
return {"pdf": body64.decode("ascii")}
|
bytes_str = str(body64).replace("b'", '')[:-1]
|
||||||
|
return {'pdf': body64.decode('ascii')}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
print(e)
|
||||||
raise util.errors.BadRequest("Unable to export PDF")
|
raise util.errors.BadRequest('Unable to export PDF')
|
||||||
|
|
||||||
|
|
||||||
def update(user, id, data):
|
def update(user, id, data):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
obj = db.objects.find_one(ObjectId(id), {"project": 1})
|
obj = db.objects.find_one(ObjectId(id), {'project': 1})
|
||||||
if not obj:
|
if not obj: raise util.errors.NotFound('Object not found')
|
||||||
raise util.errors.NotFound("Object not found")
|
project = db.projects.find_one(obj.get('project'), {'user': 1})
|
||||||
project = db.projects.find_one(obj.get("project"), {"user": 1})
|
if not project: raise util.errors.NotFound('Project not found')
|
||||||
if not project:
|
if project['user'] != user['_id']: raise util.errors.Forbidden('Forbidden')
|
||||||
raise util.errors.NotFound("Project not found")
|
allowed_keys = ['name', 'description', 'pattern', 'preview']
|
||||||
if not util.can_edit_project(user, project):
|
|
||||||
raise util.errors.Forbidden("Forbidden")
|
|
||||||
allowed_keys = ["name", "description", "pattern"]
|
|
||||||
|
|
||||||
updater = util.build_updater(data, allowed_keys)
|
updater = util.build_updater(data, allowed_keys)
|
||||||
if updater:
|
if updater:
|
||||||
db.objects.update_one({"_id": ObjectId(id)}, updater)
|
db.objects.update({'_id': ObjectId(id)}, updater)
|
||||||
|
|
||||||
if data.get("pattern"):
|
|
||||||
obj.update(data)
|
|
||||||
wif.generate_images(obj)
|
|
||||||
|
|
||||||
return get(user, id)
|
return get(user, id)
|
||||||
|
|
||||||
|
|
||||||
def create_comment(user, id, data):
|
def create_comment(user, id, data):
|
||||||
if not data or not data.get("content"):
|
if not data or not data.get('content'): raise util.errors.BadRequest('Comment data is required')
|
||||||
raise util.errors.BadRequest("Comment data is required")
|
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
obj = db.objects.find_one({"_id": ObjectId(id)})
|
obj = db.objects.find_one({'_id': ObjectId(id)})
|
||||||
if not obj:
|
if not obj: raise util.errors.NotFound('We could not find the specified object')
|
||||||
raise util.errors.NotFound("We could not find the specified object")
|
project = db.projects.find_one({'_id': obj['project']})
|
||||||
comment = {
|
comment = {
|
||||||
"content": data.get("content", ""),
|
'content': data.get('content', ''),
|
||||||
"object": ObjectId(id),
|
'object': ObjectId(id),
|
||||||
"user": user["_id"],
|
'user': user['_id'],
|
||||||
"createdAt": datetime.datetime.now(),
|
'createdAt': datetime.datetime.now()
|
||||||
"moderationRequired": True,
|
|
||||||
}
|
}
|
||||||
result = db.comments.insert_one(comment)
|
result = db.comments.insert_one(comment)
|
||||||
db.objects.update_one({"_id": ObjectId(id)}, {"$inc": {"commentCount": 1}})
|
db.objects.update_one({'_id': ObjectId(id)}, {'$inc': {'commentCount': 1}})
|
||||||
comment["_id"] = result.inserted_id
|
comment['_id'] = result.inserted_id
|
||||||
comment["authorUser"] = {
|
comment['authorUser'] = {
|
||||||
"username": user["username"],
|
'username': user['username'],
|
||||||
"avatar": user.get("avatar"),
|
'avatar': user.get('avatar'),
|
||||||
"avatarUrl": uploads.get_presigned_url(
|
'avatarUrl': uploads.get_presigned_url('users/{0}/{1}'.format(user['_id'], user.get('avatar')))
|
||||||
"users/{0}/{1}".format(user["_id"], user.get("avatar"))
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
util.send_moderation_request(user, "comments", comment)
|
project_owner = db.users.find_one({'_id': project['user'], 'subscriptions.email': 'projects.commented'})
|
||||||
return comment
|
if project_owner and project_owner['_id'] != user['_id']:
|
||||||
|
mail.send({
|
||||||
|
'to_user': project_owner,
|
||||||
def send_comment_notification(id):
|
'subject': '{} commented on {}'.format(user['username'], project['name']),
|
||||||
db = database.get_db()
|
'text': 'Dear {0},\n\n{1} commented on {2} in your project {3} on {6}:\n\n{4}\n\nFollow the link below to see the comment:\n\n{5}'.format(
|
||||||
comment = db.comments.find_one({"_id": ObjectId(id)})
|
project_owner['username'],
|
||||||
user = db.users.find_one({"_id": comment["user"]})
|
user['username'],
|
||||||
obj = db.objects.find_one({"_id": comment["object"]})
|
obj['name'],
|
||||||
project = db.projects.find_one({"_id": obj["project"]})
|
project['name'],
|
||||||
project_owner = db.users.find_one(
|
comment['content'],
|
||||||
{"_id": project["user"], "subscriptions.email": "projects.commented"}
|
'{}/{}/{}/{}'.format(
|
||||||
)
|
APP_URL, project_owner['username'], project['path'], str(id)
|
||||||
if project_owner and project_owner["_id"] != user["_id"]:
|
|
||||||
mail.send(
|
|
||||||
{
|
|
||||||
"to_user": project_owner,
|
|
||||||
"subject": "{} commented on {}".format(
|
|
||||||
user["username"], project["name"]
|
|
||||||
),
|
|
||||||
"text": "Dear {0},\n\n{1} commented on {2} in your project {3} on {6}:\n\n{4}\n\nFollow the link below to see the comment:\n\n{5}".format(
|
|
||||||
project_owner["username"],
|
|
||||||
user["username"],
|
|
||||||
obj["name"],
|
|
||||||
project["name"],
|
|
||||||
comment["content"],
|
|
||||||
"{}/{}/{}/{}".format(
|
|
||||||
APP_URL, project_owner["username"], project["path"], str(id)
|
|
||||||
),
|
),
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
return comment
|
||||||
|
|
||||||
def get_comments(user, id):
|
def get_comments(user, id):
|
||||||
id = ObjectId(id)
|
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
obj = db.objects.find_one({"_id": id}, {"project": 1})
|
comments = list(db.comments.find({'object': ObjectId(id)}))
|
||||||
if not obj:
|
user_ids = list(map(lambda c:c['user'], comments))
|
||||||
raise util.errors.NotFound("Object not found")
|
users = list(db.users.find({'_id': {'$in': user_ids}}, {'username': 1, 'avatar': 1}))
|
||||||
proj = db.projects.find_one({"_id": obj["project"]}, {"user": 1, "visibility": 1})
|
|
||||||
if not proj:
|
|
||||||
raise util.errors.NotFound("Project not found")
|
|
||||||
is_owner = user and (user.get("_id") == proj["user"])
|
|
||||||
if not is_owner and proj["visibility"] != "public":
|
|
||||||
raise util.errors.Forbidden("This project is private")
|
|
||||||
query = {
|
|
||||||
"object": id,
|
|
||||||
"$or": [
|
|
||||||
{"moderationRequired": {"$ne": True}},
|
|
||||||
{"user": user["_id"] if user else None},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
comments = list(db.comments.find(query))
|
|
||||||
user_ids = list(map(lambda c: c["user"], comments))
|
|
||||||
users = list(
|
|
||||||
db.users.find({"_id": {"$in": user_ids}}, {"username": 1, "avatar": 1})
|
|
||||||
)
|
|
||||||
for comment in comments:
|
for comment in comments:
|
||||||
for u in users:
|
for u in users:
|
||||||
if comment["user"] == u["_id"]:
|
if comment['user'] == u['_id']:
|
||||||
comment["authorUser"] = u
|
comment['authorUser'] = u
|
||||||
if "avatar" in u:
|
if 'avatar' in u:
|
||||||
comment["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
|
comment['authorUser']['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
||||||
"users/{0}/{1}".format(u["_id"], u["avatar"])
|
return {'comments': comments}
|
||||||
)
|
|
||||||
return {"comments": comments}
|
|
||||||
|
|
||||||
|
|
||||||
def delete_comment(user, id, comment_id):
|
def delete_comment(user, id, comment_id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
comment = db.comments.find_one({"_id": ObjectId(comment_id)})
|
comment = db.comments.find_one({'_id': ObjectId(comment_id)})
|
||||||
obj = db.objects.find_one({"_id": ObjectId(id)})
|
obj = db.objects.find_one({'_id': ObjectId(id)})
|
||||||
if not comment or not obj or obj["_id"] != comment["object"]:
|
if not comment or not obj or obj['_id'] != comment['object']: raise util.errors.NotFound('Comment not found')
|
||||||
raise util.errors.NotFound("Comment not found")
|
project = db.projects.find_one({'_id': obj['project']})
|
||||||
project = db.projects.find_one({"_id": obj["project"]})
|
if comment['user'] != user['_id'] and comment['user'] != project['user']: raise util.errors.Forbidden('You can\'t delete this comment')
|
||||||
if comment["user"] != user["_id"] and not util.can_edit_project(user, project):
|
db.comments.remove({'_id': comment['_id']})
|
||||||
raise util.errors.Forbidden("You can't delete this comment")
|
db.objects.update_one({'_id': ObjectId(id)}, {'$inc': {'commentCount': -1}})
|
||||||
db.comments.delete_one({"_id": comment["_id"]})
|
return {'deletedComment': comment['_id']}
|
||||||
db.objects.update_one({"_id": ObjectId(id)}, {"$inc": {"commentCount": -1}})
|
|
||||||
return {"deletedComment": comment["_id"]}
|
|
||||||
|
@ -1,366 +1,190 @@
|
|||||||
import datetime
|
import datetime, re
|
||||||
import re
|
|
||||||
import os
|
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
from util import database, wif, util, mail
|
from util import database, wif, util
|
||||||
from api import uploads, objects
|
from api import uploads
|
||||||
|
|
||||||
default_pattern = {
|
default_pattern = {
|
||||||
"warp": {
|
'warp': {
|
||||||
"shafts": 8,
|
'shafts': 8,
|
||||||
"threading": [{"shaft": 0}] * 100,
|
'threads': 100,
|
||||||
"defaultColour": "178,53,111",
|
'threading': [{'shaft': 0}] * 100,
|
||||||
"defaultSpacing": 1,
|
'defaultColour': '178,53,111',
|
||||||
"defaultThickness": 1,
|
'defaultSpacing': 1,
|
||||||
"guideFrequency": 8,
|
'defaultThickness': 1,
|
||||||
},
|
},
|
||||||
"weft": {
|
'weft': {
|
||||||
"treadles": 8,
|
'treadles': 8,
|
||||||
"treadling": [{"treadle": 0}] * 50,
|
'threads': 50,
|
||||||
"defaultColour": "53,69,178",
|
'treadling': [{'treadle': 0}] * 50,
|
||||||
"defaultSpacing": 1,
|
'defaultColour': '53,69,178',
|
||||||
"defaultThickness": 1,
|
'defaultSpacing': 1,
|
||||||
"guideFrequency": 8,
|
'defaultThickness': 1
|
||||||
},
|
},
|
||||||
"tieups": [[]] * 8,
|
'tieups': [[]] * 8,
|
||||||
"colours": [
|
'colours': ['256,256,256', '0,0,0', '50,0,256', '0,68,256', '0,256,256', '0,256,0', '119,256,0', '256,256,0', '256,136,0', '256,0,0', '256,0,153', '204,0,256', '132,102,256', '102,155,256', '102,256,256', '102,256,102', '201,256,102', '256,256,102', '256,173,102', '256,102,102', '256,102,194', '224,102,256', '31,0,153', '0,41,153', '0,153,153', '0,153,0', '71,153,0', '153,153,0', '153,82,0', '153,0,0', '153,0,92', '122,0,153', '94,68,204', '68,102,204', '68,204,204', '68,204,68', '153,204,68', '204,204,68', '204,136,68', '204,68,68', '204,68,153', '170,68,204', '37,0,204', '0,50,204', '0,204,204', '0,204,0', '89,204,0', '204,204,0', '204,102,0', '204,0,0', '204,0,115', '153,0,204', '168,136,256', '136,170,256', '136,256,256', '136,256,136', '230,256,136', '256,256,136', '256,178,136', '256,136,136', '256,136,204', '240,136,256', '49,34,238', '34,68,238', '34,238,238', '34,238,34', '71,238,34', '238,238,34', '238,82,34', '238,34,34', '238,34,92', '122,34,238', '128,102,238', '102,136,238', '102,238,238', '102,238,102', '187,238,102', '238,238,102', '238,170,102', '238,102,102', '238,102,187', '204,102,238', '178,53,111', '53,69,178'],
|
||||||
"256,256,256",
|
|
||||||
"0,0,0",
|
|
||||||
"50,0,256",
|
|
||||||
"0,68,256",
|
|
||||||
"0,256,256",
|
|
||||||
"0,256,0",
|
|
||||||
"119,256,0",
|
|
||||||
"256,256,0",
|
|
||||||
"256,136,0",
|
|
||||||
"256,0,0",
|
|
||||||
"256,0,153",
|
|
||||||
"204,0,256",
|
|
||||||
"132,102,256",
|
|
||||||
"102,155,256",
|
|
||||||
"102,256,256",
|
|
||||||
"102,256,102",
|
|
||||||
"201,256,102",
|
|
||||||
"256,256,102",
|
|
||||||
"256,173,102",
|
|
||||||
"256,102,102",
|
|
||||||
"256,102,194",
|
|
||||||
"224,102,256",
|
|
||||||
"31,0,153",
|
|
||||||
"0,41,153",
|
|
||||||
"0,153,153",
|
|
||||||
"0,153,0",
|
|
||||||
"71,153,0",
|
|
||||||
"153,153,0",
|
|
||||||
"153,82,0",
|
|
||||||
"153,0,0",
|
|
||||||
"153,0,92",
|
|
||||||
"122,0,153",
|
|
||||||
"94,68,204",
|
|
||||||
"68,102,204",
|
|
||||||
"68,204,204",
|
|
||||||
"68,204,68",
|
|
||||||
"153,204,68",
|
|
||||||
"204,204,68",
|
|
||||||
"204,136,68",
|
|
||||||
"204,68,68",
|
|
||||||
"204,68,153",
|
|
||||||
"170,68,204",
|
|
||||||
"37,0,204",
|
|
||||||
"0,50,204",
|
|
||||||
"0,204,204",
|
|
||||||
"0,204,0",
|
|
||||||
"89,204,0",
|
|
||||||
"204,204,0",
|
|
||||||
"204,102,0",
|
|
||||||
"204,0,0",
|
|
||||||
"204,0,115",
|
|
||||||
"153,0,204",
|
|
||||||
"168,136,256",
|
|
||||||
"136,170,256",
|
|
||||||
"136,256,256",
|
|
||||||
"136,256,136",
|
|
||||||
"230,256,136",
|
|
||||||
"256,256,136",
|
|
||||||
"256,178,136",
|
|
||||||
"256,136,136",
|
|
||||||
"256,136,204",
|
|
||||||
"240,136,256",
|
|
||||||
"49,34,238",
|
|
||||||
"34,68,238",
|
|
||||||
"34,238,238",
|
|
||||||
"34,238,34",
|
|
||||||
"71,238,34",
|
|
||||||
"238,238,34",
|
|
||||||
"238,82,34",
|
|
||||||
"238,34,34",
|
|
||||||
"238,34,92",
|
|
||||||
"122,34,238",
|
|
||||||
"128,102,238",
|
|
||||||
"102,136,238",
|
|
||||||
"102,238,238",
|
|
||||||
"102,238,102",
|
|
||||||
"187,238,102",
|
|
||||||
"238,238,102",
|
|
||||||
"238,170,102",
|
|
||||||
"238,102,102",
|
|
||||||
"238,102,187",
|
|
||||||
"204,102,238",
|
|
||||||
"178,53,111",
|
|
||||||
"53,69,178",
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def derive_path(name):
|
def derive_path(name):
|
||||||
path = name.replace(" ", "-").lower()
|
path = name.replace(' ', '-').lower()
|
||||||
return re.sub("[^0-9a-z\-]+", "", path)
|
return re.sub('[^0-9a-z\-]+', '', path)
|
||||||
|
|
||||||
|
|
||||||
def get_by_username(username, project_path):
|
def get_by_username(username, project_path):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
owner = db.users.find_one({"username": username}, {"_id": 1, "username": 1})
|
owner = db.users.find_one({'username': username}, {'_id': 1, 'username': 1})
|
||||||
if not owner:
|
if not owner:
|
||||||
raise util.errors.BadRequest("User not found")
|
raise util.errors.BadRequest('User not found')
|
||||||
project = db.projects.find_one({"user": owner["_id"], "path": project_path})
|
project = db.projects.find_one({'user': owner['_id'], 'path': project_path})
|
||||||
if not project:
|
if not project:
|
||||||
raise util.errors.NotFound("Project not found")
|
raise util.errors.NotFound('Project not found')
|
||||||
project["owner"] = owner
|
project['owner'] = owner
|
||||||
project["fullName"] = owner["username"] + "/" + project["path"]
|
project['fullName'] = owner['username'] + '/' + project['path']
|
||||||
return project
|
return project
|
||||||
|
|
||||||
|
|
||||||
def create(user, data):
|
def create(user, data):
|
||||||
if not data:
|
if not data: raise util.errors.BadRequest('Invalid request')
|
||||||
raise util.errors.BadRequest("Invalid request")
|
name = data.get('name', '')
|
||||||
name = data.get("name", "")
|
if len(name) < 3: raise util.errors.BadRequest('A longer name is required')
|
||||||
if len(name) < 3:
|
|
||||||
raise util.errors.BadRequest("A longer name is required")
|
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
|
|
||||||
path = derive_path(name)
|
path = derive_path(name)
|
||||||
if db.projects.find_one({"user": user["_id"], "path": path}, {"_id": 1}):
|
if db.projects.find_one({'user': user['_id'], 'path': path}, {'_id': 1}):
|
||||||
raise util.errors.BadRequest("Bad Name")
|
raise util.errors.BadRequest('Bad Name')
|
||||||
groups = data.get("groupVisibility", [])
|
groups = data.get('groupVisibility', [])
|
||||||
group_visibility = []
|
group_visibility = []
|
||||||
for group in groups:
|
for group in groups:
|
||||||
group_visibility.append(ObjectId(group))
|
group_visibility.append(ObjectId(group))
|
||||||
proj = {
|
proj = {
|
||||||
"name": name,
|
'name': name,
|
||||||
"description": data.get("description", ""),
|
'description': data.get('description', ''),
|
||||||
"visibility": data.get("visibility", "public"),
|
'visibility': data.get('visibility', 'public'),
|
||||||
"openSource": data.get("openSource", True),
|
'openSource': data.get('openSource', True),
|
||||||
"groupVisibility": group_visibility,
|
'groupVisibility': group_visibility,
|
||||||
"path": path,
|
'path': path,
|
||||||
"user": user["_id"],
|
'user': user['_id'],
|
||||||
"createdAt": datetime.datetime.now(),
|
'createdAt': datetime.datetime.now()
|
||||||
}
|
}
|
||||||
result = db.projects.insert_one(proj)
|
result = db.projects.insert_one(proj)
|
||||||
proj["_id"] = result.inserted_id
|
proj['_id'] = result.inserted_id
|
||||||
proj["owner"] = {"_id": user["_id"], "username": user["username"]}
|
proj['owner'] = {'_id': user['_id'], 'username': user['username']}
|
||||||
proj["fullName"] = user["username"] + "/" + proj["path"]
|
proj['fullName'] = user['username'] + '/' + proj['path']
|
||||||
return proj
|
return proj
|
||||||
|
|
||||||
|
|
||||||
def get(user, username, path):
|
def get(user, username, path):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
owner = db.users.find_one(
|
owner = db.users.find_one({'username': username}, {'_id': 1, 'username': 1, 'avatar': 1, 'isSilverSupporter': 1, 'isGoldSupporter': 1})
|
||||||
{"username": username},
|
if not owner: raise util.errors.NotFound('User not found')
|
||||||
{
|
project = db.projects.find_one({'user': owner['_id'], 'path': path})
|
||||||
"_id": 1,
|
if not project: raise util.errors.NotFound('Project not found')
|
||||||
"username": 1,
|
|
||||||
"avatar": 1,
|
|
||||||
"isSilverSupporter": 1,
|
|
||||||
"isGoldSupporter": 1,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if not owner:
|
|
||||||
raise util.errors.NotFound("User not found")
|
|
||||||
project = db.projects.find_one({"user": owner["_id"], "path": path})
|
|
||||||
if not project:
|
|
||||||
raise util.errors.NotFound("Project not found")
|
|
||||||
if not util.can_view_project(user, project):
|
if not util.can_view_project(user, project):
|
||||||
raise util.errors.Forbidden("This project is private")
|
raise util.errors.Forbidden('This project is private')
|
||||||
|
|
||||||
if "avatar" in owner:
|
if 'avatar' in owner:
|
||||||
owner["avatarUrl"] = uploads.get_presigned_url(
|
owner['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(owner['_id'], owner['avatar']))
|
||||||
"users/{0}/{1}".format(owner["_id"], owner["avatar"])
|
project['owner'] = owner
|
||||||
)
|
project['fullName'] = owner['username'] + '/' + project['path']
|
||||||
project["owner"] = owner
|
|
||||||
project["fullName"] = owner["username"] + "/" + project["path"]
|
|
||||||
return project
|
return project
|
||||||
|
|
||||||
|
|
||||||
def update(user, username, project_path, update):
|
def update(user, username, project_path, update):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
project = get_by_username(username, project_path)
|
project = get_by_username(username, project_path)
|
||||||
if not util.can_edit_project(user, project):
|
if project['user'] != user['_id']: raise util.errors.Forbidden('Forbidden')
|
||||||
raise util.errors.Forbidden("Forbidden")
|
|
||||||
|
|
||||||
current_path = project_path
|
current_path = project_path
|
||||||
if "name" in update:
|
if 'name' in update:
|
||||||
if len(update["name"]) < 3:
|
if len(update['name']) < 3: raise util.errors.BadRequest('The name is too short.')
|
||||||
raise util.errors.BadRequest("The name is too short.")
|
path = derive_path(update['name'])
|
||||||
path = derive_path(update["name"])
|
if db.projects.find_one({'user': user['_id'], 'path': path}, {'_id': 1}):
|
||||||
if db.projects.find_one({"user": user["_id"], "path": path}, {"_id": 1}):
|
raise util.errors.BadRequest('You already have a project with a similar name')
|
||||||
raise util.errors.BadRequest(
|
update['path'] = path
|
||||||
"You already have a project with a similar name"
|
|
||||||
)
|
|
||||||
update["path"] = path
|
|
||||||
current_path = path
|
current_path = path
|
||||||
update["groupVisibility"] = list(
|
update['groupVisibility'] = list(map(lambda g: ObjectId(g), update.get('groupVisibility', [])))
|
||||||
map(lambda g: ObjectId(g), update.get("groupVisibility", []))
|
allowed_keys = ['name', 'description', 'path', 'visibility', 'openSource', 'groupVisibility']
|
||||||
)
|
|
||||||
allowed_keys = [
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"path",
|
|
||||||
"visibility",
|
|
||||||
"openSource",
|
|
||||||
"groupVisibility",
|
|
||||||
]
|
|
||||||
updater = util.build_updater(update, allowed_keys)
|
updater = util.build_updater(update, allowed_keys)
|
||||||
if updater:
|
if updater:
|
||||||
db.projects.update_one({"_id": project["_id"]}, updater)
|
db.projects.update({'_id': project['_id']}, updater)
|
||||||
return get(user, username, current_path)
|
return get(user, username, current_path)
|
||||||
|
|
||||||
|
|
||||||
def delete(user, username, project_path):
|
def delete(user, username, project_path):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
project = get_by_username(username, project_path)
|
project = get_by_username(username, project_path)
|
||||||
if not util.can_edit_project(user, project):
|
if project['user'] != user['_id']:
|
||||||
raise util.errors.Forbidden("Forbidden")
|
raise util.errors.Forbidden('Forbidden')
|
||||||
|
db.projects.remove({'_id': project['_id']})
|
||||||
objects = list(db.objects.find({"project": project["_id"]}, {"_id": 1}))
|
db.objects.remove({'project': project['_id']})
|
||||||
db.projects.delete_one({"_id": project["_id"]})
|
return {'deletedProject': project['_id'] }
|
||||||
db.objects.delete_many({"project": project["_id"]})
|
|
||||||
db.comments.delete_many({"object": {"$in": [o["_id"] for o in objects]}})
|
|
||||||
uploads.delete_folder("projects/" + str(project["_id"]))
|
|
||||||
return {"deletedProject": project["_id"]}
|
|
||||||
|
|
||||||
|
|
||||||
def get_objects(user, username, path):
|
def get_objects(user, username, path):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
project = get_by_username(username, path)
|
project = get_by_username(username, path)
|
||||||
if not project:
|
if not project: raise util.errors.NotFound('Project not found')
|
||||||
raise util.errors.NotFound("Project not found")
|
|
||||||
if not util.can_view_project(user, project):
|
if not util.can_view_project(user, project):
|
||||||
raise util.errors.Forbidden("This project is private")
|
raise util.errors.Forbidden('This project is private')
|
||||||
|
|
||||||
query = {"project": project["_id"]}
|
objs = list(db.objects.find({'project': project['_id']}, {'createdAt': 1, 'name': 1, 'description': 1, 'project': 1, 'preview': 1, 'type': 1, 'storedName': 1, 'isImage': 1, 'imageBlurHash': 1, 'commentCount': 1}))
|
||||||
if not util.can_edit_project(user, project):
|
|
||||||
query["moderationRequired"] = {"$ne": True}
|
|
||||||
objs = list(
|
|
||||||
db.objects.find(
|
|
||||||
query,
|
|
||||||
{
|
|
||||||
"createdAt": 1,
|
|
||||||
"name": 1,
|
|
||||||
"description": 1,
|
|
||||||
"project": 1,
|
|
||||||
"preview": 1,
|
|
||||||
"fullPreview": 1,
|
|
||||||
"type": 1,
|
|
||||||
"storedName": 1,
|
|
||||||
"isImage": 1,
|
|
||||||
"imageBlurHash": 1,
|
|
||||||
"commentCount": 1,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for obj in objs:
|
for obj in objs:
|
||||||
if obj["type"] == "file" and "storedName" in obj:
|
if obj['type'] == 'file' and 'storedName' in obj:
|
||||||
obj["url"] = uploads.get_presigned_url(
|
obj['url'] = uploads.get_presigned_url('projects/{0}/{1}'.format(project['_id'], obj['storedName']))
|
||||||
"projects/{0}/{1}".format(project["_id"], obj["storedName"])
|
|
||||||
)
|
|
||||||
if obj["type"] == "pattern" and "preview" in obj and ".png" in obj["preview"]:
|
|
||||||
obj["previewUrl"] = uploads.get_presigned_url(
|
|
||||||
"projects/{0}/{1}".format(project["_id"], obj["preview"])
|
|
||||||
)
|
|
||||||
del obj["preview"]
|
|
||||||
if obj.get("fullPreview"):
|
|
||||||
obj["fullPreviewUrl"] = uploads.get_presigned_url(
|
|
||||||
"projects/{0}/{1}".format(project["_id"], obj["fullPreview"])
|
|
||||||
)
|
|
||||||
return objs
|
return objs
|
||||||
|
|
||||||
|
|
||||||
def create_object(user, username, path, data):
|
def create_object(user, username, path, data):
|
||||||
if not data and not data.get("type"):
|
if not data and not data.get('type'): raise util.errors.BadRequest('Invalid request')
|
||||||
raise util.errors.BadRequest("Invalid request")
|
if not data.get('type'): raise util.errors.BadRequest('Object type is required.')
|
||||||
if not data.get("type"):
|
|
||||||
raise util.errors.BadRequest("Object type is required.")
|
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
project = get_by_username(username, path)
|
project = get_by_username(username, path)
|
||||||
if not util.can_edit_project(user, project):
|
if project['user'] != user['_id']: raise util.errors.Forbidden('Forbidden')
|
||||||
raise util.errors.Forbidden("Forbidden")
|
file_count = db.objects.find({'project': project['_id']}).count()
|
||||||
|
|
||||||
if data["type"] == "file":
|
if data['type'] == 'file':
|
||||||
if "storedName" not in data:
|
if not 'storedName' in data:
|
||||||
raise util.errors.BadRequest("File stored name must be included")
|
raise util.errors.BadRequest('File stored name must be included')
|
||||||
obj = {
|
obj = {
|
||||||
"project": project["_id"],
|
'project': project['_id'],
|
||||||
"name": data.get("name", "Untitled file"),
|
'name': data.get('name', 'Untitled file'),
|
||||||
"storedName": data["storedName"],
|
'storedName': data['storedName'],
|
||||||
"createdAt": datetime.datetime.now(),
|
'createdAt': datetime.datetime.now(),
|
||||||
"type": "file",
|
'type': 'file',
|
||||||
"moderationRequired": True,
|
|
||||||
}
|
}
|
||||||
if re.search(r"(.jpg)|(.png)|(.jpeg)|(.gif)$", data["storedName"].lower()):
|
if re.search(r'(.jpg)|(.png)|(.jpeg)|(.gif)$', data['storedName'].lower()):
|
||||||
obj["isImage"] = True
|
obj['isImage'] = True
|
||||||
result = db.objects.insert_one(obj)
|
result = db.objects.insert_one(obj)
|
||||||
obj["_id"] = result.inserted_id
|
obj['_id'] = result.inserted_id
|
||||||
obj["url"] = uploads.get_presigned_url(
|
obj['url'] = uploads.get_presigned_url('projects/{0}/{1}'.format(project['_id'], obj['storedName']))
|
||||||
"projects/{0}/{1}".format(project["_id"], obj["storedName"])
|
if obj.get('isImage'):
|
||||||
)
|
|
||||||
if obj.get("isImage"):
|
|
||||||
|
|
||||||
def handle_cb(h):
|
def handle_cb(h):
|
||||||
db.objects.update_one(
|
db.objects.update_one({'_id': obj['_id']}, {'$set': {'imageBlurHash': h}})
|
||||||
{"_id": obj["_id"]}, {"$set": {"imageBlurHash": h}}
|
uploads.blur_image('projects/' + str(project['_id']) + '/' + data['storedName'], handle_cb)
|
||||||
)
|
|
||||||
|
|
||||||
uploads.blur_image(
|
|
||||||
"projects/" + str(project["_id"]) + "/" + data["storedName"], handle_cb
|
|
||||||
)
|
|
||||||
util.send_moderation_request(user, "object", obj)
|
|
||||||
return obj
|
return obj
|
||||||
if data["type"] == "pattern":
|
if data['type'] == 'pattern':
|
||||||
obj = {
|
if data.get('wif'):
|
||||||
"project": project["_id"],
|
|
||||||
"createdAt": datetime.datetime.now(),
|
|
||||||
"type": "pattern",
|
|
||||||
}
|
|
||||||
if data.get("wif"):
|
|
||||||
try:
|
try:
|
||||||
pattern = wif.loads(data["wif"])
|
pattern = wif.loads(data['wif'])
|
||||||
if pattern:
|
if pattern:
|
||||||
obj["name"] = pattern["name"]
|
obj = {
|
||||||
obj["pattern"] = pattern
|
'project': project['_id'],
|
||||||
except Exception as e:
|
'name': pattern['name'],
|
||||||
mail.send(
|
'createdAt': datetime.datetime.now(),
|
||||||
{
|
'type': 'pattern',
|
||||||
"to": os.environ.get("ADMIN_EMAIL"),
|
'pattern': pattern
|
||||||
"subject": "Error loading WIF file",
|
|
||||||
"text": "A WIF file failed to parse with error: {}. The contents are below:\n\n{}".format(
|
|
||||||
e, data["wif"]
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
raise util.errors.BadRequest(
|
|
||||||
"Unable to load WIF file. It is either invalid or in a format we cannot understand."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
pattern = default_pattern.copy()
|
|
||||||
pattern["warp"].update({"shafts": data.get("shafts", 8)})
|
|
||||||
pattern["weft"].update({"treadles": data.get("treadles", 8)})
|
|
||||||
obj["name"] = data.get("name") or "Untitled Pattern"
|
|
||||||
obj["pattern"] = pattern
|
|
||||||
result = db.objects.insert_one(obj)
|
result = db.objects.insert_one(obj)
|
||||||
obj["_id"] = result.inserted_id
|
obj['_id'] = result.inserted_id
|
||||||
images = wif.generate_images(obj)
|
return obj
|
||||||
if images:
|
except Exception as e:
|
||||||
db.objects.update_one({"_id": obj["_id"]}, {"$set": images})
|
raise util.errors.BadRequest('Unable to load WIF file. It is either invalid or in a format we cannot understand.')
|
||||||
|
elif data.get('name'):
|
||||||
|
pattern = default_pattern.copy()
|
||||||
|
pattern['warp'].update({'shafts': data.get('shafts', 8)})
|
||||||
|
pattern['weft'].update({'treadles': data.get('treadles', 8)})
|
||||||
|
obj = {
|
||||||
|
'project': project['_id'],
|
||||||
|
'name': data['name'],
|
||||||
|
'createdAt': datetime.datetime.now(),
|
||||||
|
'type': 'pattern',
|
||||||
|
'pattern': pattern
|
||||||
|
}
|
||||||
|
result = db.objects.insert_one(obj)
|
||||||
|
obj['_id'] = result.inserted_id
|
||||||
|
return obj
|
||||||
|
raise util.errors.BadRequest('Unable to create object')
|
||||||
|
|
||||||
|
|
||||||
return objects.get(user, obj["_id"])
|
|
||||||
raise util.errors.BadRequest("Unable to create object")
|
|
||||||
|
144
api/api/root.py
144
api/api/root.py
@ -1,135 +1,35 @@
|
|||||||
import datetime
|
import re, datetime
|
||||||
|
import pymongo
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
from util import database, util
|
from util import database, util, mail
|
||||||
from api import uploads, objects, groups
|
from api import uploads, groups
|
||||||
|
|
||||||
|
|
||||||
def get_users(user):
|
def get_users(user):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
if not util.is_root(user):
|
if 'root' not in user.get('roles', []): raise util.errors.Forbidden('Not allowed')
|
||||||
raise util.errors.Forbidden("Not allowed")
|
users = list(db.users.find({}, {'username': 1, 'avatar': 1, 'email': 1, 'createdAt': 1, 'lastSeenAt': 1, 'roles': 1, 'groups': 1}).sort('lastSeenAt', -1))
|
||||||
users = list(
|
|
||||||
db.users.find(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
"username": 1,
|
|
||||||
"avatar": 1,
|
|
||||||
"email": 1,
|
|
||||||
"createdAt": 1,
|
|
||||||
"lastSeenAt": 1,
|
|
||||||
"roles": 1,
|
|
||||||
"groups": 1,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.sort("lastSeenAt", -1)
|
|
||||||
.limit(200)
|
|
||||||
)
|
|
||||||
group_ids = []
|
group_ids = []
|
||||||
|
for u in users: group_ids += u.get('groups', [])
|
||||||
|
groups = list(db.groups.find({'_id': {'$in': group_ids}}))
|
||||||
|
projects = list(db.projects.find({}, {'name': 1, 'path': 1, 'user': 1}))
|
||||||
for u in users:
|
for u in users:
|
||||||
group_ids += u.get("groups", [])
|
if 'avatar' in u:
|
||||||
groups = list(db.groups.find({"_id": {"$in": group_ids}}, {"name": 1}))
|
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(str(u['_id']), u['avatar']))
|
||||||
projects = list(db.projects.find({}, {"name": 1, "path": 1, "user": 1}))
|
u['projects'] = []
|
||||||
for u in users:
|
|
||||||
if "avatar" in u:
|
|
||||||
u["avatarUrl"] = uploads.get_presigned_url(
|
|
||||||
"users/{0}/{1}".format(str(u["_id"]), u["avatar"])
|
|
||||||
)
|
|
||||||
u["projects"] = []
|
|
||||||
for p in projects:
|
for p in projects:
|
||||||
if p["user"] == u["_id"]:
|
if p['user'] == u['_id']:
|
||||||
u["projects"].append(p)
|
u['projects'].append(p)
|
||||||
u["groupMemberships"] = []
|
u['groupMemberships'] = []
|
||||||
if u.get("groups"):
|
if u.get('groups'):
|
||||||
for g in groups:
|
for g in groups:
|
||||||
if g["_id"] in u.get("groups", []):
|
if g['_id'] in u.get('groups', []):
|
||||||
u["groupMemberships"].append(g)
|
u['groupMemberships'].append(g)
|
||||||
return {"users": users}
|
return {'users': users}
|
||||||
|
|
||||||
|
|
||||||
def get_groups(user):
|
def get_groups(user):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
if not util.is_root(user):
|
if 'root' not in user.get('roles', []): raise util.errors.Forbidden('Not allowed')
|
||||||
raise util.errors.Forbidden("Not allowed")
|
|
||||||
groups = list(db.groups.find({}))
|
groups = list(db.groups.find({}))
|
||||||
for group in groups:
|
for group in groups:
|
||||||
group["memberCount"] = db.users.count_documents({"groups": group["_id"]})
|
group['memberCount'] = db.users.find({'groups': group['_id']}).count()
|
||||||
return {"groups": groups}
|
return {'groups': groups}
|
||||||
|
|
||||||
|
|
||||||
def get_moderation(user):
|
|
||||||
db = database.get_db()
|
|
||||||
if not util.is_root(user):
|
|
||||||
raise util.errors.Forbidden("Not allowed")
|
|
||||||
object_list = list(db.objects.find({"moderationRequired": True}))
|
|
||||||
for obj in object_list:
|
|
||||||
if obj["type"] == "file" and "storedName" in obj:
|
|
||||||
obj["url"] = uploads.get_presigned_url(
|
|
||||||
"projects/{0}/{1}".format(obj["project"], obj["storedName"])
|
|
||||||
)
|
|
||||||
comment_list = list(db.comments.find({"moderationRequired": True}))
|
|
||||||
user_list = list(db.users.find({"moderationRequired": True}, {"username": 1}))
|
|
||||||
group_list = list(db.groups.find({"moderationRequired": True}, {"name": 1}))
|
|
||||||
group_entry_list = list(db.groupEntries.find({"moderationRequired": True}))
|
|
||||||
for entry in group_entry_list:
|
|
||||||
for a in entry.get("attachments", []):
|
|
||||||
if a["type"] == "file" and "storedName" in a:
|
|
||||||
a["url"] = uploads.get_presigned_url(
|
|
||||||
"groups/{0}/{1}".format(entry["group"], a["storedName"])
|
|
||||||
)
|
|
||||||
group_topic_reply_list = list(
|
|
||||||
db.groupForumTopicReplies.find({"moderationRequired": True})
|
|
||||||
)
|
|
||||||
for reply in group_topic_reply_list:
|
|
||||||
for a in reply.get("attachments", []):
|
|
||||||
if a["type"] == "file" and "storedName" in a:
|
|
||||||
a["url"] = uploads.get_presigned_url(
|
|
||||||
"groups/{0}/topics/{1}/{2}".format(
|
|
||||||
reply["group"], reply["topic"], a["storedName"]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"objects": object_list,
|
|
||||||
"comments": comment_list,
|
|
||||||
"users": user_list,
|
|
||||||
"groups": group_list,
|
|
||||||
"groupEntries": group_entry_list,
|
|
||||||
"groupForumTopicReplies": group_topic_reply_list,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def moderate(user, item_type, item_id, allowed):
|
|
||||||
db = database.get_db()
|
|
||||||
if not util.is_root(user):
|
|
||||||
raise util.errors.Forbidden("Not allowed")
|
|
||||||
if item_type not in [
|
|
||||||
"objects",
|
|
||||||
"comments",
|
|
||||||
"users",
|
|
||||||
"groups",
|
|
||||||
"groupEntries",
|
|
||||||
"groupForumTopicReplies",
|
|
||||||
]:
|
|
||||||
raise util.errors.BadRequest("Invalid item type")
|
|
||||||
item_id = ObjectId(item_id)
|
|
||||||
item = db[item_type].find_one({"_id": item_id})
|
|
||||||
# For now, handle only allowed moderations.
|
|
||||||
# Disallowed will be manually managed.
|
|
||||||
if item and allowed:
|
|
||||||
db[item_type].update_one(
|
|
||||||
{"_id": item_id},
|
|
||||||
{
|
|
||||||
"$set": {
|
|
||||||
"moderationRequired": False,
|
|
||||||
"moderated": True,
|
|
||||||
"moderatedAt": datetime.datetime.now(),
|
|
||||||
"moderatedBy": user["_id"],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if item_type == "comments":
|
|
||||||
objects.send_comment_notification(item_id)
|
|
||||||
if item_type == "groupEntries":
|
|
||||||
groups.send_entry_notification(item_id)
|
|
||||||
if item_type == "groupForumTopicReplies":
|
|
||||||
groups.send_forum_topic_reply_notification(item_id)
|
|
||||||
return {"success": True}
|
|
||||||
|
@ -1,253 +1,74 @@
|
|||||||
import re
|
import re, random
|
||||||
import random
|
|
||||||
import pymongo
|
import pymongo
|
||||||
from util import database, util
|
from util import database, util
|
||||||
from api import uploads
|
from api import uploads
|
||||||
|
|
||||||
|
|
||||||
def all(user, params):
|
def all(user, params):
|
||||||
if not params or "query" not in params:
|
if not params or 'query' not in params: raise util.errors.BadRequest('Username parameter needed')
|
||||||
raise util.errors.BadRequest("Query parameter needed")
|
expression = re.compile(params['query'], re.IGNORECASE)
|
||||||
expression = re.compile(params["query"], re.IGNORECASE)
|
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
|
|
||||||
users = list(
|
users = list(db.users.find({'username': expression}, {'username': 1, 'avatar': 1, 'isSilverSupporter': 1, 'isGoldSupporter': 1}).limit(10).sort('username', pymongo.ASCENDING))
|
||||||
db.users.find(
|
|
||||||
{"username": expression},
|
|
||||||
{"username": 1, "avatar": 1, "isSilverSupporter": 1, "isGoldSupporter": 1},
|
|
||||||
)
|
|
||||||
.limit(10)
|
|
||||||
.sort("username", pymongo.ASCENDING)
|
|
||||||
)
|
|
||||||
for u in users:
|
for u in users:
|
||||||
if "avatar" in u:
|
if 'avatar' in u:
|
||||||
u["avatarUrl"] = uploads.get_presigned_url(
|
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
||||||
"users/{0}/{1}".format(u["_id"], u["avatar"])
|
|
||||||
)
|
|
||||||
|
|
||||||
my_projects = list(db.projects.find({"user": user["_id"]}, {"name": 1, "path": 1}))
|
projects = list(db.projects.find({'name': expression, '$or': [
|
||||||
objects = list(
|
{'user': user['_id']},
|
||||||
db.objects.find(
|
{'groupVisibility': {'$in': user.get('groups', [])}},
|
||||||
{
|
{'visibility': 'public'}
|
||||||
"project": {"$in": list(map(lambda p: p["_id"], my_projects))},
|
]}, {'name': 1, 'path': 1, 'user': 1}).limit(5))
|
||||||
"name": expression,
|
proj_users = list(db.users.find({'_id': {'$in': list(map(lambda p:p['user'], projects))}}, {'username': 1, 'avatar': 1}))
|
||||||
},
|
|
||||||
{"name": 1, "type": 1, "isImage": 1, "project": 1},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for o in objects:
|
|
||||||
proj = next(p for p in my_projects if p["_id"] == o["project"])
|
|
||||||
if proj:
|
|
||||||
o["path"] = user["username"] + "/" + proj["path"] + "/" + str(o["_id"])
|
|
||||||
|
|
||||||
projects = list(
|
|
||||||
db.projects.find(
|
|
||||||
{
|
|
||||||
"name": expression,
|
|
||||||
"$or": [
|
|
||||||
{"user": user["_id"]},
|
|
||||||
{"groupVisibility": {"$in": user.get("groups", [])}},
|
|
||||||
{"visibility": "public"},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{"name": 1, "path": 1, "user": 1},
|
|
||||||
).limit(10)
|
|
||||||
)
|
|
||||||
proj_users = list(
|
|
||||||
db.users.find(
|
|
||||||
{"_id": {"$in": list(map(lambda p: p["user"], projects))}},
|
|
||||||
{"username": 1, "avatar": 1},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for proj in projects:
|
for proj in projects:
|
||||||
for proj_user in proj_users:
|
for proj_user in proj_users:
|
||||||
if proj["user"] == proj_user["_id"]:
|
if proj['user'] == proj_user['_id']:
|
||||||
proj["owner"] = proj_user
|
proj['owner'] = proj_user
|
||||||
proj["fullName"] = proj_user["username"] + "/" + proj["path"]
|
proj['fullName'] = proj_user['username'] + '/' + proj['path']
|
||||||
if "avatar" in proj_user:
|
if 'avatar' in proj_user:
|
||||||
proj["owner"]["avatarUrl"] = uploads.get_presigned_url(
|
proj['owner']['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(proj_user['_id'], proj_user['avatar']))
|
||||||
"users/{0}/{1}".format(proj_user["_id"], proj_user["avatar"])
|
|
||||||
)
|
|
||||||
|
|
||||||
groups = list(
|
groups = list(db.groups.find({'name': expression, 'unlisted': {'$ne': True}}, {'name': 1, 'closed': 1}).limit(5))
|
||||||
db.groups.find(
|
|
||||||
{"name": expression, "unlisted": {"$ne": True}}, {"name": 1, "closed": 1}
|
|
||||||
).limit(5)
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"users": users, "projects": projects, "groups": groups, "objects": objects}
|
|
||||||
|
|
||||||
|
return {'users': users, 'projects': projects, 'groups': groups}
|
||||||
|
|
||||||
def users(user, params):
|
def users(user, params):
|
||||||
if not user:
|
if not user: raise util.errors.Forbidden('You need to be logged in')
|
||||||
raise util.errors.Forbidden("You need to be logged in")
|
if not params or 'username' not in params: raise util.errors.BadRequest('Username parameter needed')
|
||||||
if not params or "username" not in params:
|
expression = re.compile(params['username'], re.IGNORECASE)
|
||||||
raise util.errors.BadRequest("Username parameter needed")
|
|
||||||
expression = re.compile(params["username"], re.IGNORECASE)
|
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
users = list(
|
users = list(db.users.find({'username': expression}, {'username': 1, 'avatar': 1, 'isSilverSupporter': 1, 'isGoldSupporter': 1}).limit(5).sort('username', pymongo.ASCENDING))
|
||||||
db.users.find(
|
|
||||||
{"username": expression},
|
|
||||||
{"username": 1, "avatar": 1, "isSilverSupporter": 1, "isGoldSupporter": 1},
|
|
||||||
)
|
|
||||||
.limit(5)
|
|
||||||
.sort("username", pymongo.ASCENDING)
|
|
||||||
)
|
|
||||||
for u in users:
|
for u in users:
|
||||||
if "avatar" in u:
|
if 'avatar' in u:
|
||||||
u["avatarUrl"] = uploads.get_presigned_url(
|
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
||||||
"users/{0}/{1}".format(u["_id"], u["avatar"])
|
return {'users': users}
|
||||||
)
|
|
||||||
return {"users": users}
|
|
||||||
|
|
||||||
|
def discover(user):
|
||||||
|
if not user: raise util.errors.Forbidden('You need to be logged in')
|
||||||
|
|
||||||
def discover(user, count=3):
|
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
projects = []
|
projects = []
|
||||||
users = []
|
users = []
|
||||||
groups = []
|
count = 3
|
||||||
|
|
||||||
all_projects_query = {
|
all_projects = list(db.projects.find({'name': {'$not': re.compile('my new project', re.IGNORECASE)}, 'visibility': 'public', 'user': {'$ne': user['_id']}}, {'name': 1, 'path': 1, 'user': 1}))
|
||||||
"name": {"$not": re.compile("my new project", re.IGNORECASE)},
|
|
||||||
"visibility": "public",
|
|
||||||
}
|
|
||||||
if user and user.get("_id"):
|
|
||||||
all_projects_query["user"] = {"$ne": user["_id"]}
|
|
||||||
all_projects = list(
|
|
||||||
db.projects.find(all_projects_query, {"name": 1, "path": 1, "user": 1})
|
|
||||||
)
|
|
||||||
random.shuffle(all_projects)
|
random.shuffle(all_projects)
|
||||||
for p in all_projects:
|
for p in all_projects:
|
||||||
if db.objects.find_one(
|
if db.objects.find_one({'project': p['_id'], 'name': {'$ne': 'Untitled pattern'}}):
|
||||||
{"project": p["_id"], "name": {"$ne": "Untitled pattern"}}
|
owner = db.users.find_one({'_id': p['user']}, {'username': 1})
|
||||||
):
|
p['fullName'] = owner['username'] + '/' + p['path']
|
||||||
owner = db.users.find_one({"_id": p["user"]}, {"username": 1, "avatar": 1})
|
|
||||||
p["fullName"] = owner["username"] + "/" + p["path"]
|
|
||||||
p["owner"] = owner
|
|
||||||
if "avatar" in p["owner"]:
|
|
||||||
p["owner"]["avatarUrl"] = uploads.get_presigned_url(
|
|
||||||
"users/{0}/{1}".format(p["owner"]["_id"], p["owner"]["avatar"])
|
|
||||||
)
|
|
||||||
projects.append(p)
|
projects.append(p)
|
||||||
if len(projects) >= count:
|
if len(projects) >= count: break
|
||||||
break
|
|
||||||
|
|
||||||
interest_fields = [
|
interest_fields = ['bio', 'avatar', 'website', 'facebook', 'twitter', 'instagram', 'location']
|
||||||
"bio",
|
all_users = list(db.users.find({'_id': {'$ne': user['_id']}, '$or': list(map(lambda f: {f: {'$exists': True}}, interest_fields))}, {'username': 1, 'avatar': 1, 'isSilverSupporter': 1, 'isGoldSupporter': 1}))
|
||||||
"avatar",
|
|
||||||
"website",
|
|
||||||
"facebook",
|
|
||||||
"twitter",
|
|
||||||
"instagram",
|
|
||||||
"location",
|
|
||||||
]
|
|
||||||
all_users_query = {
|
|
||||||
"$or": list(map(lambda f: {f: {"$exists": True}}, interest_fields))
|
|
||||||
}
|
|
||||||
if user and user.get("_id"):
|
|
||||||
all_users_query["_id"] = {"$ne": user["_id"]}
|
|
||||||
all_users = list(
|
|
||||||
db.users.find(
|
|
||||||
all_users_query,
|
|
||||||
{"username": 1, "avatar": 1, "isSilverSupporter": 1, "isGoldSupporter": 1},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
random.shuffle(all_users)
|
random.shuffle(all_users)
|
||||||
for u in all_users:
|
for u in all_users:
|
||||||
if "avatar" in u:
|
if 'avatar' in u:
|
||||||
u["avatarUrl"] = uploads.get_presigned_url(
|
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
||||||
"users/{0}/{1}".format(u["_id"], u["avatar"])
|
|
||||||
)
|
|
||||||
if user:
|
|
||||||
u["following"] = u["_id"] in list(
|
|
||||||
map(lambda f: f["user"], user.get("following", []))
|
|
||||||
)
|
|
||||||
users.append(u)
|
users.append(u)
|
||||||
if len(users) >= count:
|
if len(users) >= count: break
|
||||||
break
|
|
||||||
|
|
||||||
all_groups = list(
|
|
||||||
db.groups.find(
|
|
||||||
{"advertised": True, "name": {"$ne": "My group"}}, {"name": 1, "image": 1}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
random.shuffle(all_groups)
|
|
||||||
for g in all_groups:
|
|
||||||
if "image" in g:
|
|
||||||
g["imageUrl"] = uploads.get_presigned_url(
|
|
||||||
"groups/{0}/{1}".format(g["_id"], g["image"])
|
|
||||||
)
|
|
||||||
groups.append(g)
|
|
||||||
if len(groups) >= count:
|
|
||||||
break
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"highlightProjects": projects,
|
'highlightProjects': projects,
|
||||||
"highlightUsers": users,
|
'highlightUsers': users,
|
||||||
"highlightGroups": groups,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def explore(page=1):
|
|
||||||
db = database.get_db()
|
|
||||||
per_page = 10
|
|
||||||
|
|
||||||
project_map = {}
|
|
||||||
user_map = {}
|
|
||||||
all_public_projects = list(
|
|
||||||
db.projects.find(
|
|
||||||
{
|
|
||||||
"name": {"$not": re.compile("my new project", re.IGNORECASE)},
|
|
||||||
"visibility": "public",
|
|
||||||
},
|
|
||||||
{"name": 1, "path": 1, "user": 1},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
all_public_project_ids = list(map(lambda p: p["_id"], all_public_projects))
|
|
||||||
for project in all_public_projects:
|
|
||||||
project_map[project["_id"]] = project
|
|
||||||
objects = list(
|
|
||||||
db.objects.find(
|
|
||||||
{
|
|
||||||
"project": {"$in": all_public_project_ids},
|
|
||||||
"name": {"$not": re.compile("untitled pattern", re.IGNORECASE)},
|
|
||||||
"preview": {"$exists": True},
|
|
||||||
},
|
|
||||||
{"project": 1, "name": 1, "createdAt": 1, "type": 1, "preview": 1},
|
|
||||||
)
|
|
||||||
.sort("createdAt", pymongo.DESCENDING)
|
|
||||||
.skip((page - 1) * per_page)
|
|
||||||
.limit(per_page)
|
|
||||||
)
|
|
||||||
for object in objects:
|
|
||||||
object["projectObject"] = project_map.get(object["project"])
|
|
||||||
if "preview" in object and ".png" in object["preview"]:
|
|
||||||
object["previewUrl"] = uploads.get_presigned_url(
|
|
||||||
"projects/{0}/{1}".format(object["project"], object["preview"])
|
|
||||||
)
|
|
||||||
del object["preview"]
|
|
||||||
authors = list(
|
|
||||||
db.users.find(
|
|
||||||
{
|
|
||||||
"_id": {
|
|
||||||
"$in": list(
|
|
||||||
map(lambda o: o.get("projectObject", {}).get("user"), objects)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{"username": 1, "avatar": 1, "isSilverSupporter": 1, "isGoldSupporter": 1},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for a in authors:
|
|
||||||
if "avatar" in a:
|
|
||||||
a["avatarUrl"] = uploads.get_presigned_url(
|
|
||||||
"users/{0}/{1}".format(a["_id"], a["avatar"])
|
|
||||||
)
|
|
||||||
user_map[a["_id"]] = a
|
|
||||||
for object in objects:
|
|
||||||
object["userObject"] = user_map.get(object.get("projectObject", {}).get("user"))
|
|
||||||
object["projectObject"]["owner"] = user_map.get(
|
|
||||||
object.get("projectObject", {}).get("user")
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"objects": objects}
|
|
||||||
|
@ -1,41 +0,0 @@
|
|||||||
import datetime
|
|
||||||
from bson.objectid import ObjectId
|
|
||||||
from util import database, util
|
|
||||||
|
|
||||||
|
|
||||||
def list_for_user(user):
|
|
||||||
db = database.get_db()
|
|
||||||
snippets = db.snippets.find({"user": user["_id"]}).sort("createdAt", -1)
|
|
||||||
return {"snippets": list(snippets)}
|
|
||||||
|
|
||||||
|
|
||||||
def create(user, data):
|
|
||||||
if not data:
|
|
||||||
raise util.errors.BadRequest("Invalid request")
|
|
||||||
name = data.get("name", "")
|
|
||||||
snippet_type = data.get("type", "")
|
|
||||||
if len(name) < 3:
|
|
||||||
raise util.errors.BadRequest("A longer name is required")
|
|
||||||
if snippet_type not in ["warp", "weft"]:
|
|
||||||
raise util.errors.BadRequest("Invalid snippet type")
|
|
||||||
db = database.get_db()
|
|
||||||
snippet = {
|
|
||||||
"name": name,
|
|
||||||
"user": user["_id"],
|
|
||||||
"createdAt": datetime.datetime.utcnow(),
|
|
||||||
"type": snippet_type,
|
|
||||||
"threading": data.get("threading", []),
|
|
||||||
"treadling": data.get("treadling", []),
|
|
||||||
}
|
|
||||||
result = db.snippets.insert_one(snippet)
|
|
||||||
snippet["_id"] = result.inserted_id
|
|
||||||
return snippet
|
|
||||||
|
|
||||||
|
|
||||||
def delete(user, id):
|
|
||||||
db = database.get_db()
|
|
||||||
snippet = db.snippets.find_one({"_id": ObjectId(id), "user": user["_id"]})
|
|
||||||
if not snippet:
|
|
||||||
raise util.errors.NotFound("Snippet not found")
|
|
||||||
db.snippets.delete_one({"_id": snippet["_id"]})
|
|
||||||
return {"deletedSnippet": snippet["_id"]}
|
|
@ -1,140 +1,83 @@
|
|||||||
import os
|
import os, time, re
|
||||||
import time
|
|
||||||
import re
|
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
import boto3
|
import boto3
|
||||||
|
from botocore.client import Config
|
||||||
import blurhash
|
import blurhash
|
||||||
from util import database, util
|
from util import database
|
||||||
from api.groups import has_group_permission
|
|
||||||
|
|
||||||
s3_client = None
|
|
||||||
|
|
||||||
|
|
||||||
def sanitise_filename(s):
|
def sanitise_filename(s):
|
||||||
bad_chars = re.compile("[^a-zA-Z0-9_.]")
|
bad_chars = re.compile('[^a-zA-Z0-9_.]')
|
||||||
s = bad_chars.sub("_", s)
|
s = bad_chars.sub('_', s)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
def get_s3():
|
def get_s3():
|
||||||
global s3_client
|
|
||||||
if s3_client:
|
|
||||||
return s3_client
|
|
||||||
session = boto3.session.Session()
|
session = boto3.session.Session()
|
||||||
|
|
||||||
s3_client = session.client(
|
s3_client = session.client(
|
||||||
service_name="s3",
|
service_name='s3',
|
||||||
aws_access_key_id=os.environ["AWS_ACCESS_KEY_ID"],
|
aws_access_key_id=os.environ['AWS_ACCESS_KEY_ID'],
|
||||||
aws_secret_access_key=os.environ["AWS_SECRET_ACCESS_KEY"],
|
aws_secret_access_key=os.environ['AWS_SECRET_ACCESS_KEY'],
|
||||||
endpoint_url=os.environ["AWS_S3_ENDPOINT"],
|
endpoint_url=os.environ['AWS_S3_ENDPOINT'],
|
||||||
)
|
)
|
||||||
return s3_client
|
return s3_client
|
||||||
|
|
||||||
|
|
||||||
def get_presigned_url(path):
|
def get_presigned_url(path):
|
||||||
|
return os.environ['AWS_S3_ENDPOINT'] + os.environ['AWS_S3_BUCKET'] + '/' + path
|
||||||
s3 = get_s3()
|
s3 = get_s3()
|
||||||
return s3.generate_presigned_url(
|
return s3.generate_presigned_url('get_object',
|
||||||
"get_object", Params={"Bucket": os.environ["AWS_S3_BUCKET"], "Key": path}
|
Params = {
|
||||||
|
'Bucket': os.environ['AWS_S3_BUCKET'],
|
||||||
|
'Key': path
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def upload_file(path, data):
|
|
||||||
s3 = get_s3()
|
|
||||||
s3.upload_fileobj(
|
|
||||||
data,
|
|
||||||
os.environ["AWS_S3_BUCKET"],
|
|
||||||
path,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_file(key):
|
def get_file(key):
|
||||||
if not key:
|
|
||||||
return None
|
|
||||||
s3 = get_s3()
|
s3 = get_s3()
|
||||||
return s3.get_object(Bucket=os.environ["AWS_S3_BUCKET"], Key=key)
|
return s3.get_object(
|
||||||
|
Bucket = os.environ['AWS_S3_BUCKET'],
|
||||||
|
Key = key
|
||||||
def delete_file(key):
|
|
||||||
if not key:
|
|
||||||
return
|
|
||||||
s3 = get_s3()
|
|
||||||
s3.delete_object(Bucket=os.environ["AWS_S3_BUCKET"], Key=key)
|
|
||||||
|
|
||||||
|
|
||||||
def delete_folder(path):
|
|
||||||
bucket_name = os.environ["AWS_S3_BUCKET"]
|
|
||||||
if not path:
|
|
||||||
return
|
|
||||||
s3 = get_s3()
|
|
||||||
|
|
||||||
response = s3.list_objects_v2(Bucket=bucket_name, Prefix=path)
|
|
||||||
if "Contents" in response:
|
|
||||||
files_in_folder = response["Contents"]
|
|
||||||
files_to_delete = []
|
|
||||||
|
|
||||||
for f in files_in_folder:
|
|
||||||
files_to_delete.append({"Key": f["Key"]})
|
|
||||||
|
|
||||||
response = s3.delete_objects(
|
|
||||||
Bucket=bucket_name, Delete={"Objects": files_to_delete}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def generate_file_upload_request(user, file_name, file_size, file_type, for_type, for_id):
|
||||||
def generate_file_upload_request(
|
|
||||||
user, file_name, file_size, file_type, for_type, for_id
|
|
||||||
):
|
|
||||||
if int(file_size) > (1024 * 1024 * 30): # 30MB
|
if int(file_size) > (1024 * 1024 * 30): # 30MB
|
||||||
raise util.errors.BadRequest("File size is too big")
|
raise util.errors.BadRequest('File size is too big')
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
allowed = False
|
allowed = False
|
||||||
path = ""
|
path = ''
|
||||||
if for_type == "project":
|
if for_type == 'project':
|
||||||
project = db.projects.find_one(ObjectId(for_id))
|
project = db.projects.find_one(ObjectId(for_id))
|
||||||
allowed = project and util.can_edit_project(user, project)
|
allowed = project and project.get('user') == user['_id']
|
||||||
path = "projects/" + for_id + "/"
|
path = 'projects/' + for_id + '/'
|
||||||
if for_type == "user":
|
if for_type == 'user':
|
||||||
allowed = for_id == str(user["_id"])
|
allowed = for_id == str(user['_id'])
|
||||||
path = "users/" + for_id + "/"
|
path = 'users/' + for_id + '/'
|
||||||
if for_type == "group":
|
if for_type == 'group':
|
||||||
allowed = ObjectId(for_id) in user.get("groups", [])
|
allowed = ObjectId(for_id) in user.get('groups', [])
|
||||||
path = "groups/" + for_id + "/"
|
path = 'groups/' + for_id + '/'
|
||||||
if for_type == "groupForum":
|
|
||||||
topic = db.groupForumTopics.find_one(ObjectId(for_id))
|
|
||||||
if not topic:
|
|
||||||
raise util.errors.NotFound("Topic not found")
|
|
||||||
group = db.groups.find_one(topic["group"])
|
|
||||||
if not group:
|
|
||||||
raise util.errors.NotFound("Group not found")
|
|
||||||
allowed = has_group_permission(user, group, "postForumTopicReplies")
|
|
||||||
path = "groups/" + str(group["_id"]) + "/topics/" + for_id + "/"
|
|
||||||
if not allowed:
|
if not allowed:
|
||||||
raise util.errors.Forbidden("You're not allowed to upload this file")
|
raise util.errors.Forbidden('You\'re not allowed to upload this file')
|
||||||
|
|
||||||
file_body, file_extension = os.path.splitext(file_name)
|
file_body, file_extension = os.path.splitext(file_name)
|
||||||
new_name = sanitise_filename(
|
new_name = sanitise_filename('{0}_{1}{2}'.format(file_body or file_name, int(time.time()), file_extension or ''))
|
||||||
"{0}_{1}{2}".format(
|
|
||||||
file_body or file_name, int(time.time()), file_extension or ""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
s3 = get_s3()
|
s3 = get_s3()
|
||||||
signed_url = s3.generate_presigned_url(
|
signed_url = s3.generate_presigned_url('put_object',
|
||||||
"put_object",
|
Params = {
|
||||||
Params={
|
'Bucket': os.environ['AWS_S3_BUCKET'],
|
||||||
"Bucket": os.environ["AWS_S3_BUCKET"],
|
'Key': path + new_name,
|
||||||
"Key": path + new_name,
|
'ContentType': file_type
|
||||||
"ContentType": file_type,
|
}
|
||||||
},
|
|
||||||
)
|
)
|
||||||
return {"signedRequest": signed_url, "fileName": new_name}
|
return {
|
||||||
|
'signedRequest': signed_url,
|
||||||
|
'fileName': new_name
|
||||||
|
}
|
||||||
|
|
||||||
def handle_blur_image(key, func):
|
def handle_blur_image(key, func):
|
||||||
f = get_file(key)["Body"]
|
f = get_file(key)['Body']
|
||||||
bhash = blurhash.encode(f, x_components=4, y_components=3)
|
bhash = blurhash.encode(f, x_components=4, y_components=3)
|
||||||
func(bhash)
|
func(bhash)
|
||||||
|
|
||||||
|
|
||||||
def blur_image(key, func):
|
def blur_image(key, func):
|
||||||
thr = Thread(target=handle_blur_image, args=[key, func])
|
thr = Thread(target=handle_blur_image, args=[key, func])
|
||||||
thr.start()
|
thr.start()
|
||||||
|
380
api/api/users.py
380
api/api/users.py
@ -1,357 +1,93 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import re
|
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
from util import database, util
|
from util import database, util
|
||||||
from api import uploads
|
from api import uploads
|
||||||
|
|
||||||
|
|
||||||
def me(user):
|
def me(user):
|
||||||
db = database.get_db()
|
|
||||||
return {
|
return {
|
||||||
"_id": user["_id"],
|
'_id': user['_id'],
|
||||||
"username": user["username"],
|
'username': user['username'],
|
||||||
"bio": user.get("bio"),
|
'bio': user.get('bio'),
|
||||||
"email": user.get("email"),
|
'email': user.get('email'),
|
||||||
"avatar": user.get("avatar"),
|
'avatar': user.get('avatar'),
|
||||||
"avatarUrl": user.get("avatar")
|
'avatarUrl': user.get('avatar') and uploads.get_presigned_url('users/{0}/{1}'.format(user['_id'], user['avatar'])),
|
||||||
and uploads.get_presigned_url(
|
'roles': user.get('roles', []),
|
||||||
"users/{0}/{1}".format(user["_id"], user["avatar"])
|
'groups': user.get('groups', []),
|
||||||
),
|
'subscriptions': user.get('subscriptions'),
|
||||||
"roles": user.get("roles", []),
|
'finishedTours': user.get('completedTours', []) + user.get('skippedTours', []),
|
||||||
"groups": user.get("groups", []),
|
'isSilverSupporter': user.get('isSilverSupporter'),
|
||||||
"subscriptions": user.get("subscriptions"),
|
'isGoldSupporter': user.get('isGoldSupporter'),
|
||||||
"finishedTours": user.get("completedTours", []) + user.get("skippedTours", []),
|
|
||||||
"isSilverSupporter": user.get("isSilverSupporter"),
|
|
||||||
"isGoldSupporter": user.get("isGoldSupporter"),
|
|
||||||
"followerCount": db.users.count_documents({"following.user": user["_id"]}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get(user, username):
|
def get(user, username):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
fetch_user = db.users.find_one(
|
fetch_user = db.users.find_one({'username': username}, {'username': 1, 'createdAt': 1, 'avatar': 1, 'avatarBlurHash': 1, 'bio': 1, 'location': 1, 'website': 1, 'twitter': 1, 'facebook': 1, 'linkedIn': 1, 'instagram': 1, 'isSilverSupporter': 1, 'isGoldSupporter': 1})
|
||||||
{"username": username},
|
|
||||||
{
|
|
||||||
"username": 1,
|
|
||||||
"createdAt": 1,
|
|
||||||
"avatar": 1,
|
|
||||||
"avatarBlurHash": 1,
|
|
||||||
"bio": 1,
|
|
||||||
"location": 1,
|
|
||||||
"website": 1,
|
|
||||||
"twitter": 1,
|
|
||||||
"facebook": 1,
|
|
||||||
"linkedIn": 1,
|
|
||||||
"instagram": 1,
|
|
||||||
"isSilverSupporter": 1,
|
|
||||||
"isGoldSupporter": 1,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if not fetch_user:
|
if not fetch_user:
|
||||||
raise util.errors.NotFound("User not found")
|
raise util.errors.NotFound('User not found')
|
||||||
project_query = {"user": fetch_user["_id"]}
|
project_query = {'user': fetch_user['_id']}
|
||||||
if not user or not user["_id"] == fetch_user["_id"]:
|
if not user or not user['_id'] == fetch_user['_id']:
|
||||||
project_query["visibility"] = "public"
|
project_query['visibility'] = 'public'
|
||||||
|
|
||||||
if "avatar" in fetch_user:
|
|
||||||
fetch_user["avatarUrl"] = uploads.get_presigned_url(
|
|
||||||
"users/{0}/{1}".format(str(fetch_user["_id"]), fetch_user["avatar"])
|
|
||||||
)
|
|
||||||
if user:
|
|
||||||
fetch_user["following"] = fetch_user["_id"] in list(
|
|
||||||
map(lambda f: f["user"], user.get("following", []))
|
|
||||||
)
|
|
||||||
|
|
||||||
user_projects = list(
|
|
||||||
db.projects.find(
|
|
||||||
project_query, {"name": 1, "path": 1, "description": 1, "visibility": 1}
|
|
||||||
).limit(15)
|
|
||||||
)
|
|
||||||
for project in user_projects:
|
|
||||||
project["fullName"] = fetch_user["username"] + "/" + project["path"]
|
|
||||||
project["owner"] = {
|
|
||||||
"_id": fetch_user["_id"],
|
|
||||||
"username": fetch_user["username"],
|
|
||||||
"avatar": fetch_user.get("avatar"),
|
|
||||||
"avatarUrl": fetch_user.get("avatarUrl"),
|
|
||||||
}
|
|
||||||
fetch_user["projects"] = user_projects
|
|
||||||
|
|
||||||
|
fetch_user['projects'] = list(db.projects.find(project_query, {'name': 1, 'path': 1, 'description': 1, 'visibility': 1}).limit(15))
|
||||||
|
for project in fetch_user['projects']:
|
||||||
|
project['fullName'] = fetch_user['username'] + '/' + project['path']
|
||||||
|
if 'avatar' in fetch_user:
|
||||||
|
fetch_user['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(str(fetch_user['_id']), fetch_user['avatar']))
|
||||||
return fetch_user
|
return fetch_user
|
||||||
|
|
||||||
|
|
||||||
def update(user, username, data):
|
def update(user, username, data):
|
||||||
if not data:
|
if not data: raise util.errors.BadRequest('Invalid request')
|
||||||
raise util.errors.BadRequest("Invalid request")
|
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
if user["username"] != username:
|
if user['username'] != username:
|
||||||
raise util.errors.Forbidden("Not allowed")
|
raise util.errors.Forbidden('Not allowed')
|
||||||
allowed_keys = [
|
allowed_keys = ['username', 'avatar', 'bio', 'location', 'website', 'twitter', 'facebook', 'linkedIn', 'instagram']
|
||||||
"username",
|
if 'username' in data:
|
||||||
"avatar",
|
if not data.get('username') or len(data['username']) < 3:
|
||||||
"bio",
|
raise util.errors.BadRequest('New username is not valid')
|
||||||
"location",
|
if db.users.find({'username': data['username'].lower()}).count():
|
||||||
"website",
|
raise util.errors.BadRequest('A user with this username already exists')
|
||||||
"twitter",
|
data['username'] = data['username'].lower()
|
||||||
"facebook",
|
if 'avatar' in data and len(data['avatar']) > 3: # Not a default avatar
|
||||||
"linkedIn",
|
|
||||||
"instagram",
|
|
||||||
]
|
|
||||||
if "username" in data:
|
|
||||||
if not data.get("username") or len(data["username"]) < 3:
|
|
||||||
raise util.errors.BadRequest("New username is not valid")
|
|
||||||
if not re.match("^[a-z0-9_]+$", data["username"]):
|
|
||||||
raise util.errors.BadRequest(
|
|
||||||
"Usernames can only contain letters, numbers, and underscores"
|
|
||||||
)
|
|
||||||
if db.users.count_documents({"username": data["username"].lower()}):
|
|
||||||
raise util.errors.BadRequest("A user with this username already exists")
|
|
||||||
data["username"] = data["username"].lower()
|
|
||||||
if data.get("avatar") and len(data["avatar"]) > 3: # Not a default avatar
|
|
||||||
|
|
||||||
def handle_cb(h):
|
def handle_cb(h):
|
||||||
db.users.update_one({"_id": user["_id"]}, {"$set": {"avatarBlurHash": h}})
|
db.users.update_one({'_id': user['_id']}, {'$set': {'avatarBlurHash': h}})
|
||||||
|
uploads.blur_image('users/' + str(user['_id']) + '/' + data['avatar'], handle_cb)
|
||||||
uploads.blur_image(
|
|
||||||
"users/" + str(user["_id"]) + "/" + data["avatar"], handle_cb
|
|
||||||
)
|
|
||||||
if "avatar" in data and user.get("avatar") and user["avatar"] != data["avatar"]:
|
|
||||||
uploads.delete_file("users/" + str(user["_id"]) + "/" + user["avatar"])
|
|
||||||
updater = util.build_updater(data, allowed_keys)
|
updater = util.build_updater(data, allowed_keys)
|
||||||
if updater:
|
if updater:
|
||||||
if "avatar" in updater.get(
|
db.users.update({'username': username}, updater)
|
||||||
"$unset", {}
|
return get(user, data.get('username', username))
|
||||||
): # Also unset blurhash if removing avatar
|
|
||||||
updater["$unset"]["avatarBlurHash"] = ""
|
|
||||||
if "$set" in updater and (
|
|
||||||
"avatar" in data or "bio" in data or "website" in data or "username" in data
|
|
||||||
):
|
|
||||||
updater["$set"]["moderationRequired"] = True
|
|
||||||
util.send_moderation_request(user, "users", user)
|
|
||||||
db.users.update_one({"username": username}, updater)
|
|
||||||
return get(user, data.get("username", username))
|
|
||||||
|
|
||||||
|
|
||||||
def finish_tour(user, username, tour, status):
|
def finish_tour(user, username, tour, status):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
if user["username"] != username:
|
if user['username'] != username:
|
||||||
raise util.errors.Forbidden("Not allowed")
|
raise util.errors.Forbidden('Not allowed')
|
||||||
key = "completedTours" if status == "completed" else "skippedTours"
|
key = 'completedTours' if status == 'completed' else 'skippedTours'
|
||||||
db.users.update_one({"_id": user["_id"]}, {"$addToSet": {key: tour}})
|
db.users.update_one({'_id': user['_id']}, {'$addToSet': {key: tour}})
|
||||||
return {"finishedTour": tour}
|
return {'finishedTour': tour}
|
||||||
|
|
||||||
|
|
||||||
def get_projects(user, id):
|
def get_projects(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
u = db.users.find_one(id, {"username": 1, "avatar": 1})
|
u = db.users.find_one(id, {'username': 1, 'avatar': 1})
|
||||||
if not u:
|
if not u: raise util.errors.NotFound('User not found')
|
||||||
raise util.errors.NotFound("User not found")
|
if 'avatar' in u: u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(str(u['_id']), u['avatar']))
|
||||||
if "avatar" in u:
|
|
||||||
u["avatarUrl"] = uploads.get_presigned_url(
|
|
||||||
"users/{0}/{1}".format(str(u["_id"]), u["avatar"])
|
|
||||||
)
|
|
||||||
projects = []
|
projects = []
|
||||||
project_query = {"user": ObjectId(id)}
|
for project in db.projects.find({'user': ObjectId(id)}):
|
||||||
if not user or not user["_id"] == ObjectId(id):
|
project['owner'] = u
|
||||||
project_query["visibility"] = "public"
|
project['fullName'] = u['username'] + '/' + project['path']
|
||||||
for project in db.projects.find(project_query):
|
|
||||||
project["owner"] = u
|
|
||||||
project["fullName"] = u["username"] + "/" + project["path"]
|
|
||||||
projects.append(project)
|
projects.append(project)
|
||||||
return projects
|
return projects
|
||||||
|
|
||||||
|
|
||||||
def create_email_subscription(user, username, subscription):
|
def create_email_subscription(user, username, subscription):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
if user["username"] != username:
|
if user['username'] != username: raise util.errors.Forbidden('Forbidden')
|
||||||
raise util.errors.Forbidden("Forbidden")
|
u = db.users.find_one({'username': username})
|
||||||
u = db.users.find_one({"username": username})
|
db.users.update({'_id': u['_id']}, {'$addToSet': {'subscriptions.email': subscription}})
|
||||||
db.users.update_one(
|
subs = db.users.find_one(u['_id'], {'subscriptions': 1})
|
||||||
{"_id": u["_id"]}, {"$addToSet": {"subscriptions.email": subscription}}
|
return {'subscriptions': subs.get('subscriptions', {})}
|
||||||
)
|
|
||||||
subs = db.users.find_one(u["_id"], {"subscriptions": 1})
|
|
||||||
return {"subscriptions": subs.get("subscriptions", {})}
|
|
||||||
|
|
||||||
|
|
||||||
def delete_email_subscription(user, username, subscription):
|
def delete_email_subscription(user, username, subscription):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
if user["username"] != username:
|
if user['username'] != username: raise util.errors.Forbidden('Forbidden')
|
||||||
raise util.errors.Forbidden("Forbidden")
|
u = db.users.find_one({'username': username})
|
||||||
u = db.users.find_one({"username": username})
|
db.users.update({'_id': u['_id']}, {'$pull': {'subscriptions.email': subscription}})
|
||||||
db.users.update_one(
|
subs = db.users.find_one(u['_id'], {'subscriptions': 1})
|
||||||
{"_id": u["_id"]}, {"$pull": {"subscriptions.email": subscription}}
|
return {'subscriptions': subs.get('subscriptions', {})}
|
||||||
)
|
|
||||||
subs = db.users.find_one(u["_id"], {"subscriptions": 1})
|
|
||||||
return {"subscriptions": subs.get("subscriptions", {})}
|
|
||||||
|
|
||||||
|
|
||||||
def create_follower(user, username):
|
|
||||||
db = database.get_db()
|
|
||||||
target_user = db.users.find_one({"username": username.lower()})
|
|
||||||
if not target_user:
|
|
||||||
raise util.errors.NotFound("User not found")
|
|
||||||
if target_user["_id"] == user["_id"]:
|
|
||||||
raise util.errors.BadRequest("Cannot follow yourself")
|
|
||||||
follow_object = {
|
|
||||||
"user": target_user["_id"],
|
|
||||||
"followedAt": datetime.datetime.utcnow(),
|
|
||||||
}
|
|
||||||
db.users.update_one(
|
|
||||||
{"_id": user["_id"]}, {"$addToSet": {"following": follow_object}}
|
|
||||||
)
|
|
||||||
return follow_object
|
|
||||||
|
|
||||||
|
|
||||||
def delete_follower(user, username):
|
|
||||||
db = database.get_db()
|
|
||||||
target_user = db.users.find_one({"username": username.lower()})
|
|
||||||
if not target_user:
|
|
||||||
raise util.errors.NotFound("User not found")
|
|
||||||
db.users.update_one(
|
|
||||||
{"_id": user["_id"]}, {"$pull": {"following": {"user": target_user["_id"]}}}
|
|
||||||
)
|
|
||||||
return {"unfollowed": True}
|
|
||||||
|
|
||||||
|
|
||||||
def get_feed(user, username):
|
|
||||||
db = database.get_db()
|
|
||||||
if user["username"] != username:
|
|
||||||
raise util.errors.Forbidden("Forbidden")
|
|
||||||
following_user_ids = list(map(lambda f: f["user"], user.get("following", [])))
|
|
||||||
following_project_ids = list(
|
|
||||||
map(
|
|
||||||
lambda p: p["_id"],
|
|
||||||
db.projects.find(
|
|
||||||
{"user": {"$in": following_user_ids}, "visibility": "public"},
|
|
||||||
{"_id": 1},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
one_year_ago = datetime.datetime.utcnow() - datetime.timedelta(days=365)
|
|
||||||
|
|
||||||
# Fetch the items for the feed
|
|
||||||
recent_projects = list(
|
|
||||||
db.projects.find(
|
|
||||||
{
|
|
||||||
"_id": {"$in": following_project_ids},
|
|
||||||
"createdAt": {"$gt": one_year_ago},
|
|
||||||
"visibility": "public",
|
|
||||||
},
|
|
||||||
{"user": 1, "createdAt": 1, "name": 1, "path": 1, "visibility": 1},
|
|
||||||
)
|
|
||||||
.sort("createdAt", -1)
|
|
||||||
.limit(20)
|
|
||||||
)
|
|
||||||
recent_objects = list(
|
|
||||||
db.objects.find(
|
|
||||||
{
|
|
||||||
"project": {"$in": following_project_ids},
|
|
||||||
"createdAt": {"$gt": one_year_ago},
|
|
||||||
},
|
|
||||||
{"project": 1, "createdAt": 1, "name": 1},
|
|
||||||
)
|
|
||||||
.sort("createdAt", -1)
|
|
||||||
.limit(30)
|
|
||||||
)
|
|
||||||
recent_comments = list(
|
|
||||||
db.comments.find(
|
|
||||||
{"user": {"$in": following_user_ids}, "createdAt": {"$gt": one_year_ago}},
|
|
||||||
{"user": 1, "createdAt": 1, "object": 1, "content": 1},
|
|
||||||
)
|
|
||||||
.sort("createdAt", -1)
|
|
||||||
.limit(30)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Process objects (as don't know the user)
|
|
||||||
object_project_ids = list(map(lambda o: o["project"], recent_objects))
|
|
||||||
object_projects = list(
|
|
||||||
db.projects.find(
|
|
||||||
{"_id": {"$in": object_project_ids}, "visibility": "public"}, {"user": 1}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for obj in recent_objects:
|
|
||||||
for proj in object_projects:
|
|
||||||
if obj["project"] == proj["_id"]:
|
|
||||||
obj["user"] = proj.get("user")
|
|
||||||
|
|
||||||
# Process comments as don't know the project
|
|
||||||
comment_object_ids = list(map(lambda c: c["object"], recent_comments))
|
|
||||||
comment_objects = list(
|
|
||||||
db.objects.find({"_id": {"$in": comment_object_ids}}, {"project": 1})
|
|
||||||
)
|
|
||||||
for com in recent_comments:
|
|
||||||
for obj in comment_objects:
|
|
||||||
if com["object"] == obj["_id"]:
|
|
||||||
com["project"] = obj.get("project")
|
|
||||||
|
|
||||||
# Prepare the feed items, and sort it
|
|
||||||
feed_items = []
|
|
||||||
for p in recent_projects:
|
|
||||||
p["feedType"] = "project"
|
|
||||||
feed_items.append(p)
|
|
||||||
for o in recent_objects:
|
|
||||||
o["feedType"] = "object"
|
|
||||||
feed_items.append(o)
|
|
||||||
for c in recent_comments:
|
|
||||||
c["feedType"] = "comment"
|
|
||||||
feed_items.append(c)
|
|
||||||
feed_items.sort(key=lambda d: d["createdAt"], reverse=True)
|
|
||||||
feed_items = feed_items[:20]
|
|
||||||
|
|
||||||
# Post-process the feed, adding user/project objects
|
|
||||||
feed_user_ids = set()
|
|
||||||
feed_project_ids = set()
|
|
||||||
for f in feed_items:
|
|
||||||
feed_user_ids.add(f.get("user"))
|
|
||||||
feed_project_ids.add(f.get("project"))
|
|
||||||
feed_projects = list(
|
|
||||||
db.projects.find(
|
|
||||||
{"_id": {"$in": list(feed_project_ids)}, "visibility": "public"},
|
|
||||||
{"name": 1, "path": 1, "user": 1, "visibility": 1},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
feed_users = list(
|
|
||||||
db.users.find(
|
|
||||||
{
|
|
||||||
"$or": [
|
|
||||||
{"_id": {"$in": list(feed_user_ids)}},
|
|
||||||
{"_id": {"$in": list(map(lambda p: p["user"], feed_projects))}},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{"username": 1, "avatar": 1, "isSilverSupporter": 1, "isGoldSupporter": 1},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for u in feed_users:
|
|
||||||
if "avatar" in u:
|
|
||||||
u["avatarUrl"] = uploads.get_presigned_url(
|
|
||||||
"users/{0}/{1}".format(str(u["_id"]), u["avatar"])
|
|
||||||
)
|
|
||||||
feed_user_map = {}
|
|
||||||
feed_project_map = {}
|
|
||||||
for u in feed_users:
|
|
||||||
feed_user_map[str(u["_id"])] = u
|
|
||||||
for p in feed_projects:
|
|
||||||
feed_project_map[str(p["_id"])] = p
|
|
||||||
for f in feed_items:
|
|
||||||
if f.get("user") and feed_user_map.get(str(f["user"])):
|
|
||||||
f["userObject"] = feed_user_map.get(str(f["user"]))
|
|
||||||
if f.get("project") and feed_project_map.get(str(f["project"])):
|
|
||||||
f["projectObject"] = feed_project_map.get(str(f["project"]))
|
|
||||||
if f.get("projectObject", {}).get("user") and feed_user_map.get(
|
|
||||||
str(f["projectObject"]["user"])
|
|
||||||
):
|
|
||||||
f["projectObject"]["userObject"] = feed_user_map.get(
|
|
||||||
str(f["projectObject"]["user"])
|
|
||||||
)
|
|
||||||
|
|
||||||
# Filter out orphaned or non-public comments/objects
|
|
||||||
def filter_func(f):
|
|
||||||
if f["feedType"] == "comment" and not f.get("projectObject"):
|
|
||||||
return False
|
|
||||||
if f["feedType"] == "object" and not f.get("projectObject"):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
feed_items = list(filter(filter_func, feed_items))
|
|
||||||
|
|
||||||
return {"feed": feed_items}
|
|
||||||
|
862
api/app.py
862
api/app.py
File diff suppressed because it is too large
Load Diff
@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"Statement": [
|
|
||||||
{
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Principal": {
|
|
||||||
"AWS": [
|
|
||||||
"*"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Action": [
|
|
||||||
"s3:GetObject"
|
|
||||||
],
|
|
||||||
"Resource": [
|
|
||||||
"arn:aws:s3:::treadl-dev/*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
@ -11,7 +11,7 @@
|
|||||||
"s3:GetObject"
|
"s3:GetObject"
|
||||||
],
|
],
|
||||||
"Resource": [
|
"Resource": [
|
||||||
"arn:aws:s3:::treadl/*"
|
"arn:aws:s3::treadl/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -1,34 +0,0 @@
|
|||||||
# Script to migrate from the old data: string URLs for images to image files directly on S3.
|
|
||||||
|
|
||||||
from pymongo import MongoClient
|
|
||||||
import base64
|
|
||||||
import os
|
|
||||||
|
|
||||||
db = MongoClient("mongodb://USER:PASS@db/admin")["treadl"]
|
|
||||||
|
|
||||||
os.makedirs("migration_projects/projects", exist_ok=True)
|
|
||||||
|
|
||||||
for obj in db.objects.find(
|
|
||||||
{"preview": {"$regex": "^data:"}}, {"preview": 1, "project": 1}
|
|
||||||
):
|
|
||||||
preview = obj["preview"]
|
|
||||||
preview = preview.replace("data:image/png;base64,", "")
|
|
||||||
|
|
||||||
imgdata = base64.b64decode(preview)
|
|
||||||
filename = "some_image.png"
|
|
||||||
|
|
||||||
os.makedirs("migration_projects/projects/" + str(obj["project"]), exist_ok=True)
|
|
||||||
with open(
|
|
||||||
"migration_projects/projects/"
|
|
||||||
+ str(obj["project"])
|
|
||||||
+ "/preview_"
|
|
||||||
+ str(obj["_id"])
|
|
||||||
+ ".png",
|
|
||||||
"wb",
|
|
||||||
) as f:
|
|
||||||
f.write(imgdata)
|
|
||||||
db.objects.update_one(
|
|
||||||
{"_id": obj["_id"]},
|
|
||||||
{"$set": {"previewNew": "preview_" + str(obj["_id"]) + ".png"}},
|
|
||||||
)
|
|
||||||
# exit()
|
|
2376
api/poetry.lock
generated
2376
api/poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,33 +1,30 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "api"
|
name = "api"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
package-mode = false
|
|
||||||
description = "Treadl API"
|
description = "Treadl API"
|
||||||
authors = ["Will <will@treadl.com>"]
|
authors = ["Will <will@treadl.com>"]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.12"
|
python = "^3.9"
|
||||||
flask = "^3.1.0"
|
flask = "^1.1.1"
|
||||||
bcrypt = "^4.3.0"
|
bcrypt = "^3.1.7"
|
||||||
pyjwt = "^2.10.0"
|
pyjwt = "^1.7.1"
|
||||||
boto3 = "^1.37.4"
|
boto3 = "^1.10.50"
|
||||||
flask-cors = "^5.0.1"
|
flask-cors = "^3.0.8"
|
||||||
dnspython = "^2.7.0"
|
dnspython = "^1.16.0"
|
||||||
requests = "^2.32.3"
|
requests = "^2.22.0"
|
||||||
pymongo = "^4.11.1"
|
botocore = "^1.13.50"
|
||||||
flask_limiter = "^3.10.1"
|
pymongo = "^3.10.1"
|
||||||
firebase-admin = "^6.6.0"
|
flask_limiter = "^1.3.1"
|
||||||
blurhash-python = "^1.2.2"
|
werkzeug = "^1.0.1"
|
||||||
gunicorn = "^23.0.0"
|
firebase-admin = "^4.3.0"
|
||||||
sentry-sdk = {extras = ["flask"], version = "^2.22.0"}
|
blurhash-python = "^1.0.2"
|
||||||
pyOpenSSL = "^25.0.0"
|
gunicorn = "^20.0.4"
|
||||||
webargs = "^8.6.0"
|
sentry-sdk = {extras = ["flask"], version = "^1.5.10"}
|
||||||
|
pyOpenSSL = "^22.0.0"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
|
||||||
ruff = "^0.9.9"
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry>=0.12"]
|
requires = ["poetry>=0.12"]
|
||||||
build-backend = "poetry.masonry.api"
|
build-backend = "poetry.masonry.api"
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import os
|
import os
|
||||||
from pymongo import MongoClient
|
from pymongo import MongoClient
|
||||||
|
from flask import g
|
||||||
|
|
||||||
db = None
|
db = None
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
global db
|
global db
|
||||||
|
|
||||||
if db is None:
|
if db is None:
|
||||||
db = MongoClient(os.environ["MONGO_URL"])[os.environ["MONGO_DATABASE"]]
|
db = MongoClient(os.environ['MONGO_URL'])[os.environ['MONGO_DATABASE']]
|
||||||
return db
|
return db
|
||||||
|
@ -2,39 +2,33 @@ import os
|
|||||||
from threading import Thread
|
from threading import Thread
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
||||||
def handle_send(data):
|
def handle_send(data):
|
||||||
if "from" not in data:
|
if 'from' not in data:
|
||||||
data["from"] = "{} <{}>".format(
|
data['from'] = '{} <{}>'.format(os.environ.get('APP_NAME'), os.environ.get('FROM_EMAIL'))
|
||||||
os.environ.get("APP_NAME"), os.environ.get("FROM_EMAIL")
|
if 'to_user' in data:
|
||||||
|
user = data['to_user']
|
||||||
|
data['to'] = user['username'] + ' <' + user['email'] + '>'
|
||||||
|
del data['to_user']
|
||||||
|
data['text'] += '\n\nFrom the team at {0}\n\n\n\n--\n\nDon\'t like this email? Choose which emails you receive from {0} by visiting {1}/settings/notifications\n\nReceived this email in error? Please let us know by contacting {2}'.format(
|
||||||
|
os.environ.get('APP_NAME'),
|
||||||
|
os.environ.get('APP_URL'),
|
||||||
|
os.environ.get('CONTACT_EMAIL')
|
||||||
)
|
)
|
||||||
if "to_user" in data:
|
data['reply-to'] = os.environ.get('CONTACT_EMAIL')
|
||||||
user = data["to_user"]
|
|
||||||
data["to"] = user["username"] + " <" + user["email"] + ">"
|
|
||||||
del data["to_user"]
|
|
||||||
data["text"] += (
|
|
||||||
"\n\nFrom the team at {0}\n\n\n\n--\n\nDon't like this email? Choose which emails you receive from {0} by visiting {1}/settings/notifications\n\nReceived this email in error? Please let us know by contacting {2}".format(
|
|
||||||
os.environ.get("APP_NAME"),
|
|
||||||
os.environ.get("APP_URL"),
|
|
||||||
os.environ.get("CONTACT_EMAIL"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
data["reply-to"] = os.environ.get("CONTACT_EMAIL")
|
|
||||||
|
|
||||||
base_url = os.environ.get("MAILGUN_URL")
|
base_url = os.environ.get('MAILGUN_URL')
|
||||||
api_key = os.environ.get("MAILGUN_KEY")
|
api_key = os.environ.get('MAILGUN_KEY')
|
||||||
if base_url and api_key:
|
if base_url and api_key:
|
||||||
auth = ("api", api_key)
|
auth = ('api', api_key)
|
||||||
try:
|
try:
|
||||||
response = requests.post(base_url, auth=auth, data=data)
|
response = requests.post(base_url, auth=auth, data=data)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except Exception:
|
except:
|
||||||
print("Unable to send email")
|
print('Unable to send email')
|
||||||
else:
|
else:
|
||||||
print("Not sending email. Message pasted below.")
|
print('Not sending email. Message pasted below.')
|
||||||
print(data)
|
print(data)
|
||||||
|
|
||||||
|
|
||||||
def send(data):
|
def send(data):
|
||||||
thr = Thread(target=handle_send, args=[data])
|
thr = Thread(target=handle_send, args=[data])
|
||||||
thr.start()
|
thr.start()
|
||||||
|
@ -4,63 +4,52 @@ from firebase_admin import messaging
|
|||||||
|
|
||||||
default_app = firebase_admin.initialize_app()
|
default_app = firebase_admin.initialize_app()
|
||||||
|
|
||||||
|
def handle_send_multiple(users, title, body, extra = {}):
|
||||||
def handle_send_multiple(users, title, body, extra={}):
|
|
||||||
tokens = []
|
tokens = []
|
||||||
for user in users:
|
for user in users:
|
||||||
if user.get("pushToken"):
|
if user.get('pushToken'): tokens.append(user['pushToken'])
|
||||||
tokens.append(user["pushToken"])
|
if not tokens: return
|
||||||
if not tokens:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Create a list containing up to 500 messages.
|
# Create a list containing up to 500 messages.
|
||||||
messages = list(
|
messages = list(map(lambda t: messaging.Message(
|
||||||
map(
|
|
||||||
lambda t: messaging.Message(
|
|
||||||
notification=messaging.Notification(title, body),
|
notification=messaging.Notification(title, body),
|
||||||
apns=messaging.APNSConfig(
|
apns=messaging.APNSConfig(
|
||||||
payload=messaging.APNSPayload(
|
payload=messaging.APNSPayload(
|
||||||
aps=messaging.Aps(badge=1, sound="default"),
|
aps=messaging.Aps(badge=1, sound='default'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
token=t,
|
token=t,
|
||||||
data=extra,
|
data=extra,
|
||||||
),
|
), tokens))
|
||||||
tokens,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
response = messaging.send_all(messages)
|
response = messaging.send_all(messages)
|
||||||
print("{0} messages were sent successfully".format(response.success_count))
|
print('{0} messages were sent successfully'.format(response.success_count))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("Error sending notification", str(e))
|
print('Error sending notification', str(e))
|
||||||
|
|
||||||
|
def send_multiple(users, title, body, extra = {}):
|
||||||
def send_multiple(users, title, body, extra={}):
|
|
||||||
thr = Thread(target=handle_send_multiple, args=[users, title, body, extra])
|
thr = Thread(target=handle_send_multiple, args=[users, title, body, extra])
|
||||||
thr.start()
|
thr.start()
|
||||||
|
|
||||||
|
def send_single(user, title, body, extra = {}):
|
||||||
def send_single(user, title, body, extra={}):
|
token = user.get('pushToken')
|
||||||
token = user.get("pushToken")
|
if not token: return
|
||||||
if not token:
|
|
||||||
return
|
|
||||||
message = messaging.Message(
|
message = messaging.Message(
|
||||||
notification=messaging.Notification(
|
notification=messaging.Notification(
|
||||||
title=title,
|
title = title,
|
||||||
body=body,
|
body = body,
|
||||||
),
|
),
|
||||||
apns=messaging.APNSConfig(
|
apns=messaging.APNSConfig(
|
||||||
payload=messaging.APNSPayload(
|
payload=messaging.APNSPayload(
|
||||||
aps=messaging.Aps(badge=1, sound="default"),
|
aps=messaging.Aps(badge=1, sound='default'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
data=extra,
|
data = extra,
|
||||||
token=token,
|
token = token,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
response = messaging.send(message)
|
response = messaging.send(message)
|
||||||
# Response is a message ID string.
|
# Response is a message ID string.
|
||||||
print("Successfully sent message:", response)
|
print('Successfully sent message:', response)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("Error sending notification", str(e))
|
print('Error sending notification', str(e))
|
||||||
|
117
api/util/util.py
117
api/util/util.py
@ -1,6 +1,4 @@
|
|||||||
import os
|
import json, datetime
|
||||||
import json
|
|
||||||
import datetime
|
|
||||||
from flask import request, Response
|
from flask import request, Response
|
||||||
import werkzeug
|
import werkzeug
|
||||||
from flask_limiter.util import get_remote_address
|
from flask_limiter.util import get_remote_address
|
||||||
@ -8,66 +6,44 @@ from cryptography.hazmat.primitives import serialization
|
|||||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
from api import accounts
|
from api import accounts
|
||||||
from util import util, mail
|
from util import util
|
||||||
|
|
||||||
errors = werkzeug.exceptions
|
errors = werkzeug.exceptions
|
||||||
|
|
||||||
|
def get_user(required = True):
|
||||||
def get_user(required=True):
|
|
||||||
headers = request.headers
|
headers = request.headers
|
||||||
if not headers.get("Authorization") and required:
|
if not headers.get('Authorization') and required:
|
||||||
raise util.errors.Unauthorized("This resource requires authentication")
|
raise util.errors.Unauthorized('This resource requires authentication')
|
||||||
if headers.get("Authorization"):
|
if headers.get('Authorization'):
|
||||||
user = accounts.get_user_context(
|
user = accounts.get_user_context(headers.get('Authorization').replace('Bearer ', ''))
|
||||||
headers.get("Authorization").replace("Bearer ", "")
|
|
||||||
)
|
|
||||||
if user is None and required:
|
if user is None and required:
|
||||||
raise util.errors.Unauthorized("Invalid token")
|
raise util.errors.Unauthorized('Invalid token')
|
||||||
return user
|
return user
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def limit_by_client():
|
def limit_by_client():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if data:
|
if data:
|
||||||
if data.get("email"):
|
if data.get('email'): return data.get('email')
|
||||||
return data.get("email")
|
if data.get('token'): return data.get('token')
|
||||||
if data.get("token"):
|
|
||||||
return data.get("token")
|
|
||||||
return get_remote_address()
|
return get_remote_address()
|
||||||
|
|
||||||
|
|
||||||
def limit_by_user():
|
def limit_by_user():
|
||||||
user = util.get_user(required=False)
|
user = util.get_user(required = False)
|
||||||
return user["_id"] if user else get_remote_address()
|
return user['_id'] if user else get_remote_address()
|
||||||
|
|
||||||
|
|
||||||
def is_root(user):
|
|
||||||
return user and "root" in user.get("roles", [])
|
|
||||||
|
|
||||||
|
|
||||||
def can_view_project(user, project):
|
def can_view_project(user, project):
|
||||||
if not project:
|
if not project: return False
|
||||||
return False
|
if project.get('visibility') == 'public':
|
||||||
if project.get("visibility") == "public":
|
|
||||||
return True
|
return True
|
||||||
if not user:
|
if not user: return False
|
||||||
return False
|
if project.get('visibility') == 'private' and user['_id'] == project['user']:
|
||||||
if project.get("visibility") == "private" and can_edit_project(user, project):
|
|
||||||
return True
|
return True
|
||||||
if set(user.get("groups", [])).intersection(project.get("groupVisibility", [])):
|
if set(user.get('groups', [])).intersection(project.get('groupVisibility', [])):
|
||||||
return True
|
|
||||||
if "root" in user.get("roles", []):
|
|
||||||
return True
|
return True
|
||||||
|
if 'root' in user.get('roles', []): return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def can_edit_project(user, project):
|
|
||||||
if not user or not project:
|
|
||||||
return False
|
|
||||||
return project.get("user") == user["_id"] or is_root(user)
|
|
||||||
|
|
||||||
|
|
||||||
def filter_keys(obj, allowed_keys):
|
def filter_keys(obj, allowed_keys):
|
||||||
filtered = {}
|
filtered = {}
|
||||||
for key in allowed_keys:
|
for key in allowed_keys:
|
||||||
@ -75,67 +51,35 @@ def filter_keys(obj, allowed_keys):
|
|||||||
filtered[key] = obj[key]
|
filtered[key] = obj[key]
|
||||||
return filtered
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
def build_updater(obj, allowed_keys):
|
def build_updater(obj, allowed_keys):
|
||||||
if not obj:
|
if not obj: return {}
|
||||||
return {}
|
|
||||||
allowed = filter_keys(obj, allowed_keys)
|
allowed = filter_keys(obj, allowed_keys)
|
||||||
updater = {}
|
updater = {}
|
||||||
for key in allowed:
|
for key in allowed:
|
||||||
if not allowed[key]:
|
if not allowed[key]:
|
||||||
if "$unset" not in updater:
|
if '$unset' not in updater: updater['$unset'] = {}
|
||||||
updater["$unset"] = {}
|
updater['$unset'][key] = ''
|
||||||
updater["$unset"][key] = ""
|
|
||||||
else:
|
else:
|
||||||
if "$set" not in updater:
|
if '$set' not in updater: updater['$set'] = {}
|
||||||
updater["$set"] = {}
|
updater['$set'][key] = allowed[key]
|
||||||
updater["$set"][key] = allowed[key]
|
|
||||||
return updater
|
return updater
|
||||||
|
|
||||||
|
|
||||||
def send_report_email(report):
|
|
||||||
if not report:
|
|
||||||
return
|
|
||||||
mail.send(
|
|
||||||
{
|
|
||||||
"to": os.environ.get("ADMIN_EMAIL"),
|
|
||||||
"subject": "{} report".format(os.environ.get("APP_NAME")),
|
|
||||||
"text": "A new report has been submitted: {0}".format(
|
|
||||||
json.dumps(report, indent=4)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def send_moderation_request(from_user, item_type, item):
|
|
||||||
if not from_user or not item_type or not item:
|
|
||||||
return
|
|
||||||
mail.send(
|
|
||||||
{
|
|
||||||
"to": os.environ.get("ADMIN_EMAIL"),
|
|
||||||
"subject": "{} moderation needed".format(os.environ.get("APP_NAME")),
|
|
||||||
"text": "New content has been added by {0} ({1}) and needs moderating: {2} ({3})".format(
|
|
||||||
from_user["username"], from_user["email"], item_type, item["_id"]
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_rsa_keypair():
|
def generate_rsa_keypair():
|
||||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
|
private_key = rsa.generate_private_key(
|
||||||
|
public_exponent=65537,
|
||||||
|
key_size=4096
|
||||||
|
)
|
||||||
private_pem = private_key.private_bytes(
|
private_pem = private_key.private_bytes(
|
||||||
encoding=serialization.Encoding.PEM,
|
encoding=serialization.Encoding.PEM,
|
||||||
format=serialization.PrivateFormat.PKCS8,
|
format=serialization.PrivateFormat.PKCS8,
|
||||||
encryption_algorithm=serialization.NoEncryption(),
|
encryption_algorithm=serialization.NoEncryption()
|
||||||
)
|
)
|
||||||
public_key = private_key.public_key()
|
public_key = private_key.public_key()
|
||||||
public_pem = public_key.public_bytes(
|
public_pem = public_key.public_bytes(
|
||||||
encoding=serialization.Encoding.PEM,
|
encoding=serialization.Encoding.PEM,
|
||||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||||
)
|
)
|
||||||
return private_pem, public_pem
|
return private_pem, public_pem
|
||||||
|
|
||||||
|
|
||||||
class MongoJsonEncoder(json.JSONEncoder):
|
class MongoJsonEncoder(json.JSONEncoder):
|
||||||
def default(self, obj):
|
def default(self, obj):
|
||||||
if isinstance(obj, (datetime.datetime, datetime.date)):
|
if isinstance(obj, (datetime.datetime, datetime.date)):
|
||||||
@ -144,9 +88,8 @@ class MongoJsonEncoder(json.JSONEncoder):
|
|||||||
return str(obj)
|
return str(obj)
|
||||||
return json.JSONEncoder.default(self, obj)
|
return json.JSONEncoder.default(self, obj)
|
||||||
|
|
||||||
|
|
||||||
def jsonify(*args, **kwargs):
|
def jsonify(*args, **kwargs):
|
||||||
resp_data = json.dumps(dict(*args, **kwargs), cls=MongoJsonEncoder)
|
resp_data = json.dumps(dict(*args, **kwargs), cls=MongoJsonEncoder)
|
||||||
resp = Response(resp_data)
|
resp = Response(resp_data)
|
||||||
resp.headers["Content-Type"] = "application/json"
|
resp.headers['Content-Type'] = 'application/json'
|
||||||
return resp
|
return resp
|
||||||
|
674
api/util/wif.py
674
api/util/wif.py
@ -1,593 +1,197 @@
|
|||||||
import io
|
|
||||||
import configparser
|
import configparser
|
||||||
import time
|
|
||||||
from threading import Thread
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
from api import uploads
|
|
||||||
from util import database
|
|
||||||
|
|
||||||
|
|
||||||
def normalise_colour(max_color, triplet):
|
def normalise_colour(max_color, triplet):
|
||||||
color_factor = 256 / max_color
|
color_factor = 256/max_color
|
||||||
components = triplet.split(",")
|
components = triplet.split(',')
|
||||||
new_components = []
|
|
||||||
for component in components:
|
|
||||||
new_components.append(str(int(float(color_factor) * int(float(component)))))
|
|
||||||
return ",".join(new_components)
|
|
||||||
|
|
||||||
|
|
||||||
def denormalise_colour(max_color, triplet):
|
|
||||||
color_factor = max_color / 256
|
|
||||||
components = triplet.split(",")
|
|
||||||
new_components = []
|
new_components = []
|
||||||
for component in components:
|
for component in components:
|
||||||
new_components.append(str(int(float(color_factor) * int(component))))
|
new_components.append(str(int(float(color_factor) * int(component))))
|
||||||
return ",".join(new_components)
|
return ','.join(new_components)
|
||||||
|
|
||||||
|
|
||||||
def colour_tuple(triplet):
|
|
||||||
if not triplet:
|
|
||||||
return None
|
|
||||||
components = triplet.split(",")
|
|
||||||
return tuple(map(lambda c: int(c), components))
|
|
||||||
|
|
||||||
|
|
||||||
def darken_colour(c_tuple, val):
|
|
||||||
def darken(c):
|
|
||||||
c = c * val
|
|
||||||
if c < 0:
|
|
||||||
c = 0
|
|
||||||
if c > 255:
|
|
||||||
c = 255
|
|
||||||
return int(c)
|
|
||||||
|
|
||||||
return tuple(map(darken, c_tuple))
|
|
||||||
|
|
||||||
|
def denormalise_colour(max_color, triplet):
|
||||||
|
color_factor = max_color/256
|
||||||
|
components = triplet.split(',')
|
||||||
|
new_components = []
|
||||||
|
for component in components:
|
||||||
|
new_components.append(str(int(float(color_factor) * int(component))))
|
||||||
|
return ','.join(new_components)
|
||||||
|
|
||||||
def get_colour_index(colours, colour):
|
def get_colour_index(colours, colour):
|
||||||
for index, c in enumerate(colours):
|
for (index, c) in enumerate(colours):
|
||||||
if c == colour:
|
if c == colour: return index + 1
|
||||||
return index + 1
|
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def dumps(obj):
|
def dumps(obj):
|
||||||
if not obj or not obj["pattern"]:
|
if not obj or not obj['pattern']: raise Exception('Invalid pattern')
|
||||||
raise Exception("Invalid pattern")
|
|
||||||
wif = []
|
wif = []
|
||||||
|
|
||||||
wif.append("[WIF]")
|
wif.append('[WIF]')
|
||||||
wif.append("Version=1.1")
|
wif.append('Version=1.1')
|
||||||
wif.append("Source Program=Treadl")
|
wif.append('Source Program=Treadl')
|
||||||
wif.append("Source Version=1")
|
wif.append('Source Version=1')
|
||||||
|
|
||||||
wif.append("\n[CONTENTS]")
|
wif.append('\n[CONTENTS]')
|
||||||
wif.append("COLOR PALETTE=true")
|
wif.append('COLOR PALETTE=true')
|
||||||
wif.append("TEXT=true")
|
wif.append('TEXT=true')
|
||||||
wif.append("WEAVING=true")
|
wif.append('WEAVING=true')
|
||||||
wif.append("WARP=true")
|
wif.append('WARP=true')
|
||||||
wif.append("WARP COLORS=true")
|
wif.append('WARP COLORS=true')
|
||||||
wif.append("WEFT COLORS=true")
|
wif.append('WEFT COLORS=true')
|
||||||
wif.append("WEFT=true")
|
wif.append('WEFT=true')
|
||||||
wif.append("COLOR TABLE=true")
|
wif.append('COLOR TABLE=true')
|
||||||
wif.append("THREADING=true")
|
wif.append('THREADING=true')
|
||||||
wif.append("TIEUP=true")
|
wif.append('TIEUP=true')
|
||||||
wif.append("TREADLING=true")
|
wif.append('TREADLING=true')
|
||||||
|
|
||||||
wif.append("\n[TEXT]")
|
wif.append('\n[TEXT]')
|
||||||
wif.append("Title={0}".format(obj["name"]))
|
wif.append('Title={0}'.format(obj['name']))
|
||||||
|
|
||||||
wif.append("\n[COLOR TABLE]")
|
wif.append('\n[COLOR TABLE]')
|
||||||
for index, colour in enumerate(obj["pattern"]["colours"]):
|
for (index, colour) in enumerate(obj['pattern']['colours']):
|
||||||
wif.append("{0}={1}".format(index + 1, denormalise_colour(999, colour)))
|
wif.append('{0}={1}'.format(index + 1, denormalise_colour(999, colour)))
|
||||||
|
|
||||||
wif.append("\n[COLOR PALETTE]")
|
wif.append('\n[COLOR PALETTE]')
|
||||||
wif.append("Range=0,999")
|
wif.append('Range=0,999')
|
||||||
wif.append("Entries={0}".format(len(obj["pattern"]["colours"])))
|
wif.append('Entries={0}'.format(len(obj['pattern']['colours'])))
|
||||||
|
|
||||||
wif.append("\n[WEAVING]")
|
wif.append('\n[WEAVING]')
|
||||||
wif.append("Rising Shed=true")
|
wif.append('Rising Shed=true')
|
||||||
wif.append("Treadles={0}".format(obj["pattern"]["weft"]["treadles"]))
|
wif.append('Treadles={0}'.format(obj['pattern']['weft']['treadles']))
|
||||||
wif.append("Shafts={0}".format(obj["pattern"]["warp"]["shafts"]))
|
wif.append('Shafts={0}'.format(obj['pattern']['warp']['shafts']))
|
||||||
|
|
||||||
wif.append("\n[WARP]")
|
wif.append('\n[WARP]')
|
||||||
wif.append("Units=centimeters")
|
wif.append('Units=centimeters')
|
||||||
wif.append(
|
wif.append('Color={0}'.format(get_colour_index(obj['pattern']['colours'], obj['pattern']['warp']['defaultColour'])))
|
||||||
"Color={0}".format(
|
wif.append('Threads={0}'.format(obj['pattern']['warp']['threads']))
|
||||||
get_colour_index(
|
wif.append('Spacing=0.212')
|
||||||
obj["pattern"]["colours"], obj["pattern"]["warp"]["defaultColour"]
|
wif.append('Thickness=0.212')
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
wif.append("Threads={0}".format(len(obj["pattern"]["warp"]["threading"])))
|
|
||||||
wif.append("Spacing=0.212")
|
|
||||||
wif.append("Thickness=0.212")
|
|
||||||
|
|
||||||
wif.append("\n[WARP COLORS]")
|
wif.append('\n[WARP COLORS]')
|
||||||
for index, thread in enumerate(obj["pattern"]["warp"]["threading"]):
|
for (index, thread) in enumerate(obj['pattern']['warp']['threading']):
|
||||||
if "colour" in thread:
|
if 'colour' in thread:
|
||||||
wif.append(
|
wif.append('{0}={1}'.format(index + 1, get_colour_index(obj['pattern']['colours'], thread['colour'])))
|
||||||
"{0}={1}".format(
|
|
||||||
index + 1,
|
|
||||||
get_colour_index(obj["pattern"]["colours"], thread["colour"]),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
wif.append("\n[THREADING]")
|
wif.append('\n[THREADING]')
|
||||||
for index, thread in enumerate(obj["pattern"]["warp"]["threading"]):
|
for (index, thread) in enumerate(obj['pattern']['warp']['threading']):
|
||||||
wif.append("{0}={1}".format(index + 1, thread["shaft"]))
|
wif.append('{0}={1}'.format(index + 1, thread['shaft']))
|
||||||
|
|
||||||
wif.append("\n[WEFT]")
|
wif.append('\n[WEFT]')
|
||||||
wif.append("Units=centimeters")
|
wif.append('Units=centimeters')
|
||||||
wif.append(
|
wif.append('Color={0}'.format(get_colour_index(obj['pattern']['colours'], obj['pattern']['weft']['defaultColour'])))
|
||||||
"Color={0}".format(
|
wif.append('Threads={0}'.format(obj['pattern']['weft']['threads']))
|
||||||
get_colour_index(
|
wif.append('Spacing=0.212')
|
||||||
obj["pattern"]["colours"], obj["pattern"]["weft"]["defaultColour"]
|
wif.append('Thickness=0.212')
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
wif.append("Threads={0}".format(len(obj["pattern"]["weft"]["treadling"])))
|
|
||||||
wif.append("Spacing=0.212")
|
|
||||||
wif.append("Thickness=0.212")
|
|
||||||
|
|
||||||
wif.append("\n[WEFT COLORS]")
|
wif.append('\n[WEFT COLORS]')
|
||||||
for index, thread in enumerate(obj["pattern"]["weft"]["treadling"]):
|
for (index, thread) in enumerate(obj['pattern']['weft']['treadling']):
|
||||||
if "colour" in thread:
|
if 'colour' in thread:
|
||||||
wif.append(
|
wif.append('{0}={1}'.format(index + 1, get_colour_index(obj['pattern']['colours'], thread['colour'])))
|
||||||
"{0}={1}".format(
|
|
||||||
index + 1,
|
|
||||||
get_colour_index(obj["pattern"]["colours"], thread["colour"]),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
wif.append("\n[TREADLING]")
|
wif.append('\n[TREADLING]')
|
||||||
for index, thread in enumerate(obj["pattern"]["weft"]["treadling"]):
|
for (index, thread) in enumerate(obj['pattern']['weft']['treadling']):
|
||||||
wif.append("{0}={1}".format(index + 1, thread["treadle"]))
|
wif.append('{0}={1}'.format(index + 1, thread['treadle']))
|
||||||
|
|
||||||
wif.append("\n[TIEUP]")
|
wif.append('\n[TIEUP]')
|
||||||
for index, tieup in enumerate(obj["pattern"]["tieups"]):
|
for (index, tieup) in enumerate(obj['pattern']['tieups']):
|
||||||
wif.append("{0}={1}".format(str(index + 1), ",".join(str(x) for x in tieup)))
|
wif.append('{0}={1}'.format(str(index + 1), ','.join(str(x) for x in tieup)))
|
||||||
|
|
||||||
return "\n".join(wif)
|
|
||||||
|
|
||||||
|
return '\n'.join(wif)
|
||||||
|
|
||||||
def loads(wif_file):
|
def loads(wif_file):
|
||||||
# Ensure file exists:
|
config = configparser.ConfigParser(allow_no_value=True, strict=False)
|
||||||
if not wif_file or type(wif_file) is not str:
|
config.read_string(wif_file.lower())
|
||||||
raise Exception("Invalid file: null or empty or not string")
|
|
||||||
|
|
||||||
# Some user-uploaded files (Quickdraw?) start with strange HTTP header info.
|
|
||||||
# Remove all preceding non-section lines:
|
|
||||||
wif_file = "[" + wif_file.split("[", 1)[1]
|
|
||||||
|
|
||||||
# Make all section names lowercase
|
|
||||||
normalized_lines = []
|
|
||||||
for line in wif_file.splitlines():
|
|
||||||
if line.strip().startswith("[") and line.strip().endswith("]"):
|
|
||||||
section_name = line.strip()[1:-1].lower()
|
|
||||||
normalized_lines.append(f"[{section_name}]")
|
|
||||||
else:
|
|
||||||
normalized_lines.append(line)
|
|
||||||
wif_file = "\n".join(normalized_lines)
|
|
||||||
|
|
||||||
# Load config
|
|
||||||
config = configparser.ConfigParser(
|
|
||||||
allow_no_value=True, strict=False, inline_comment_prefixes=("#", ";")
|
|
||||||
)
|
|
||||||
config.read_string(wif_file)
|
|
||||||
DEFAULT_TITLE = "Untitled Pattern"
|
|
||||||
draft = {}
|
draft = {}
|
||||||
|
|
||||||
if "wif" in config:
|
text = config['text']
|
||||||
draft["wifInfo"] = dict(config["wif"])
|
draft['name'] = text.get('title')
|
||||||
draft["wifInfo"]["importedFile"] = wif_file
|
|
||||||
if "text" in config:
|
|
||||||
text = config["text"]
|
|
||||||
draft["name"] = text.get("title") or DEFAULT_TITLE
|
|
||||||
if not draft.get("name"):
|
|
||||||
draft["name"] = DEFAULT_TITLE
|
|
||||||
|
|
||||||
|
min_color = 0
|
||||||
max_color = 255
|
max_color = 255
|
||||||
if "color palette" in config:
|
if 'color palette' in config:
|
||||||
color_palette = config["color palette"]
|
color_palette = config['color palette']
|
||||||
color_range = color_palette.get("range").split(",")
|
color_range = color_palette.get('range').split(',')
|
||||||
|
min_color = int(color_range[0])
|
||||||
max_color = int(color_range[1])
|
max_color = int(color_range[1])
|
||||||
|
|
||||||
if "color table" in config:
|
if 'color table' in config:
|
||||||
color_table = config["color table"]
|
color_table = config['color table']
|
||||||
draft["colours"] = [None] * len(color_table)
|
draft['colours'] = [None]*len(color_table)
|
||||||
for x in color_table:
|
for x in color_table:
|
||||||
draft["colours"][int(x) - 1] = normalise_colour(max_color, color_table[x])
|
draft['colours'][int(x)-1] = normalise_colour(max_color, color_table[x])
|
||||||
if not draft.get("colours"):
|
if not draft.get('colours'): draft['colours'] = []
|
||||||
draft["colours"] = []
|
if len(draft['colours']) < 2:
|
||||||
if len(draft["colours"]) < 2:
|
draft['colours'] += [normalise_colour(255, '255,255,255'), normalise_colour(255, '0,0,255')]
|
||||||
draft["colours"] += [
|
|
||||||
normalise_colour(255, "255,255,255"),
|
|
||||||
normalise_colour(255, "0,0,255"),
|
|
||||||
]
|
|
||||||
|
|
||||||
weaving = config["weaving"] if "weaving" in config else None
|
weaving = config['weaving']
|
||||||
|
|
||||||
threading = config["threading"] if "threading" in config else []
|
threading = config['threading']
|
||||||
warp = config["warp"] if "warp" in config else None
|
warp = config['warp']
|
||||||
draft["warp"] = {}
|
draft['warp'] = {}
|
||||||
draft["warp"]["shafts"] = weaving.getint("shafts") if weaving else 0
|
draft['warp']['shafts'] = weaving.getint('shafts')
|
||||||
draft["warp"]["threading"] = []
|
draft['warp']['threading'] = []
|
||||||
|
|
||||||
# Work out default warp colour
|
|
||||||
if warp and warp.get("color"):
|
if warp.get('color'):
|
||||||
warp_colour_index = warp.getint("color") - 1
|
warp_colour_index = warp.getint('color') - 1
|
||||||
if warp_colour_index < len(draft["colours"]):
|
draft['warp']['defaultColour'] = draft['colours'][warp_colour_index]
|
||||||
draft["warp"]["defaultColour"] = draft["colours"][warp_colour_index]
|
|
||||||
if not draft.get("warp").get("defaultColour"):
|
else:
|
||||||
# In case of no color table or colour index out of bounds
|
# In case of no color table or colour index out of bounds
|
||||||
draft["warp"]["defaultColour"] = draft["colours"][0]
|
draft['warp']['defaultColour'] = draft['colours'][0]
|
||||||
|
|
||||||
for x in threading:
|
for x in threading:
|
||||||
shaft = threading[x].strip()
|
shaft = threading[x]
|
||||||
if "," in shaft:
|
if ',' in shaft:
|
||||||
shaft = shaft.split(",")[0]
|
shaft = shaft.split(",")[0]
|
||||||
shaft = int(shaft) if shaft else 0
|
shaft = int(shaft)
|
||||||
while int(x) > len(
|
while int(x) >= len(draft['warp']['threading']) - 1:
|
||||||
draft["warp"]["threading"]
|
draft['warp']['threading'].append({'shaft': 0})
|
||||||
): # grow threading array to current x
|
draft['warp']['threading'][int(x) - 1] = {'shaft': shaft}
|
||||||
draft["warp"]["threading"].append({"shaft": 0})
|
draft['warp']['threads'] = len(draft['warp']['threading'])
|
||||||
draft["warp"]["threading"][int(x) - 1] = {"shaft": shaft}
|
|
||||||
if shaft > draft["warp"]["shafts"]:
|
|
||||||
draft["warp"]["shafts"] = shaft
|
|
||||||
draft["warp"]["guideFrequency"] = draft["warp"]["shafts"]
|
|
||||||
try:
|
try:
|
||||||
warp_colours = config["warp colors"]
|
warp_colours = config['warp colors']
|
||||||
for x in warp_colours:
|
for x in warp_colours:
|
||||||
draft["warp"]["threading"][int(x) - 1]["colour"] = draft["colours"][
|
draft['warp']['threading'][int(x) - 1]['colour'] = draft['colours'][warp_colours.getint(x)-1]
|
||||||
warp_colours.getint(x) - 1
|
except Exception as e:
|
||||||
]
|
|
||||||
except Exception:
|
|
||||||
pass
|
pass
|
||||||
if not draft["warp"]["threading"]: # Make a bunch of empty threads
|
|
||||||
draft["warp"]["threading"] = [{"shaft": 0} for i in range(20)]
|
|
||||||
|
|
||||||
treadling = config["treadling"] if "treadling" in config else []
|
treadling = config['treadling']
|
||||||
weft = config["weft"] if "weft" in config else None
|
weft = config['weft']
|
||||||
draft["weft"] = {}
|
draft['weft'] = {}
|
||||||
draft["weft"]["treadles"] = weaving.getint("treadles") if weaving else 0
|
draft['weft']['treadles'] = weaving.getint('treadles')
|
||||||
draft["weft"]["treadling"] = []
|
draft['weft']['treadling'] = []
|
||||||
|
|
||||||
# Work out default weft colour
|
if weft.get('color'):
|
||||||
if weft and weft.get("color"):
|
weft_colour_index = weft.getint('color') - 1
|
||||||
weft_colour_index = weft.getint("color") - 1
|
draft['weft']['defaultColour'] = draft['colours'][weft_colour_index]
|
||||||
if weft_colour_index < len(draft["colours"]):
|
else:
|
||||||
draft["weft"]["defaultColour"] = draft["colours"][weft_colour_index]
|
|
||||||
if not draft.get("weft").get("defaultColour"):
|
|
||||||
# In case of no color table or colour index out of bounds
|
# In case of no color table or colour index out of bounds
|
||||||
draft["weft"]["defaultColour"] = draft["colours"][1]
|
draft['weft']['defaultColour'] = draft['colours'][1]
|
||||||
|
|
||||||
for x in treadling:
|
for x in treadling:
|
||||||
treadle = treadling[x].strip()
|
shaft = treadling[x]
|
||||||
if "," in treadle:
|
if ',' in shaft:
|
||||||
treadle = treadle.split(",")[0]
|
shaft = shaft.split(",")[0]
|
||||||
treadle = int(treadle) if treadle else 0
|
shaft = int(shaft)
|
||||||
while int(x) > len(
|
while int(x) >= len(draft['weft']['treadling']) - 1:
|
||||||
draft["weft"]["treadling"]
|
draft['weft']['treadling'].append({'treadle': 0})
|
||||||
): # grow treadling array to current x
|
draft['weft']['treadling'][int(x) - 1] = {'treadle': shaft}
|
||||||
draft["weft"]["treadling"].append({"treadle": 0})
|
draft['weft']['threads'] = len(draft['weft']['treadling'])
|
||||||
draft["weft"]["treadling"][int(x) - 1] = {"treadle": treadle}
|
|
||||||
if treadle > draft["weft"]["treadles"]:
|
|
||||||
draft["weft"]["treadles"] = treadle
|
|
||||||
draft["weft"]["guideFrequency"] = draft["weft"]["treadles"]
|
|
||||||
try:
|
try:
|
||||||
weft_colours = config["weft colors"]
|
weft_colours = config['weft colors']
|
||||||
for x in weft_colours:
|
for x in weft_colours:
|
||||||
draft["weft"]["treadling"][int(x) - 1]["colour"] = draft["colours"][
|
draft['weft']['treadling'][int(x) - 1]['colour'] = draft['colours'][weft_colours.getint(x)-1]
|
||||||
weft_colours.getint(x) - 1
|
except: pass
|
||||||
]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if not draft["weft"]["treadling"]: # Make a bunch of empty threads
|
|
||||||
draft["weft"]["treadling"] = [{"treadle": 0} for i in range(20)]
|
|
||||||
|
|
||||||
tieup = config["tieup"] if "tieup" in config else None
|
tieup = config['tieup']
|
||||||
draft["tieups"] = []
|
draft['tieups'] = []#[0]*len(tieup)
|
||||||
if tieup:
|
|
||||||
for x in tieup:
|
for x in tieup:
|
||||||
while int(x) >= len(draft["tieups"]) - 1:
|
while int(x) >= len(draft['tieups']) - 1:
|
||||||
draft["tieups"].append([])
|
draft['tieups'].append([])
|
||||||
|
split = tieup[x].split(',')
|
||||||
try:
|
try:
|
||||||
split = tieup[x].split(",")
|
draft['tieups'][int(x)-1] = [int(i) for i in split]
|
||||||
draft["tieups"][int(x) - 1] = [int(i) for i in split]
|
except:
|
||||||
except Exception:
|
draft['tieups'][int(x)-1] = []
|
||||||
draft["tieups"][int(x) - 1] = []
|
|
||||||
|
|
||||||
return draft
|
return draft
|
||||||
|
|
||||||
|
|
||||||
def generate_images_thread(obj):
|
|
||||||
preview_image = draw_image(obj)
|
|
||||||
full_preview_image = draw_image(obj, with_plan=True)
|
|
||||||
if preview_image or full_preview_image:
|
|
||||||
db = database.get_db()
|
|
||||||
db.objects.update_one(
|
|
||||||
{"_id": obj["_id"]},
|
|
||||||
{
|
|
||||||
"$set": {
|
|
||||||
"preview": preview_image,
|
|
||||||
"fullPreview": full_preview_image,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_images(obj):
|
|
||||||
thr = Thread(target=generate_images_thread, args=[obj])
|
|
||||||
thr.start()
|
|
||||||
|
|
||||||
|
|
||||||
def draw_image(obj, with_plan=False):
|
|
||||||
if not obj or not obj["pattern"]:
|
|
||||||
raise Exception("Invalid pattern")
|
|
||||||
BASE_SIZE = 10
|
|
||||||
pattern = obj["pattern"]
|
|
||||||
warp = pattern["warp"]
|
|
||||||
weft = pattern["weft"]
|
|
||||||
tieups = pattern["tieups"]
|
|
||||||
|
|
||||||
full_width = (
|
|
||||||
len(warp["threading"]) * BASE_SIZE
|
|
||||||
+ BASE_SIZE
|
|
||||||
+ weft["treadles"] * BASE_SIZE
|
|
||||||
+ BASE_SIZE
|
|
||||||
if with_plan
|
|
||||||
else len(warp["threading"]) * BASE_SIZE
|
|
||||||
)
|
|
||||||
full_height = (
|
|
||||||
warp["shafts"] * BASE_SIZE + len(weft["treadling"]) * BASE_SIZE + BASE_SIZE * 2
|
|
||||||
if with_plan
|
|
||||||
else len(weft["treadling"]) * BASE_SIZE
|
|
||||||
)
|
|
||||||
|
|
||||||
warp_top = 0
|
|
||||||
warp_left = 0
|
|
||||||
warp_right = len(warp["threading"]) * BASE_SIZE
|
|
||||||
warp_bottom = warp["shafts"] * BASE_SIZE + BASE_SIZE
|
|
||||||
|
|
||||||
weft_left = warp_right + BASE_SIZE
|
|
||||||
weft_top = warp["shafts"] * BASE_SIZE + BASE_SIZE * 2
|
|
||||||
weft_right = warp_right + BASE_SIZE + weft["treadles"] * BASE_SIZE + BASE_SIZE
|
|
||||||
weft_bottom = weft_top + len(weft["treadling"]) * BASE_SIZE
|
|
||||||
|
|
||||||
tieup_left = warp_right + BASE_SIZE
|
|
||||||
tieup_top = BASE_SIZE
|
|
||||||
tieup_right = tieup_left + weft["treadles"] * BASE_SIZE
|
|
||||||
tieup_bottom = warp_bottom
|
|
||||||
|
|
||||||
drawdown_top = warp_bottom + BASE_SIZE if with_plan else 0
|
|
||||||
drawdown_right = warp_right if with_plan else full_width
|
|
||||||
drawdown_left = warp_left if with_plan else 0
|
|
||||||
drawdown_bottom = weft_bottom if with_plan else full_height
|
|
||||||
|
|
||||||
warp_guides = warp.get("guideFrequency") or 0
|
|
||||||
weft_guides = weft.get("guideFrequency") or 0
|
|
||||||
|
|
||||||
WHITE = (255, 255, 255)
|
|
||||||
GREY = (150, 150, 150)
|
|
||||||
BLACK = (0, 0, 0)
|
|
||||||
img = Image.new("RGBA", (full_width, full_height), WHITE)
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
|
|
||||||
# Draw warp
|
|
||||||
if with_plan:
|
|
||||||
draw.rectangle(
|
|
||||||
[(warp_left, warp_top), (warp_right, warp_bottom)],
|
|
||||||
fill=None,
|
|
||||||
outline=GREY,
|
|
||||||
width=1,
|
|
||||||
)
|
|
||||||
for y in range(1, warp["shafts"] + 1):
|
|
||||||
ycoord = y * BASE_SIZE
|
|
||||||
draw.line(
|
|
||||||
[
|
|
||||||
(warp_left, ycoord),
|
|
||||||
(warp_right, ycoord),
|
|
||||||
],
|
|
||||||
fill=GREY,
|
|
||||||
width=1,
|
|
||||||
joint=None,
|
|
||||||
)
|
|
||||||
col_index = 1
|
|
||||||
for i, x in enumerate(range(len(warp["threading"]) - 1, 0, -1)):
|
|
||||||
is_guide = warp_guides and col_index % warp_guides == 0
|
|
||||||
col_index += 1
|
|
||||||
thread = warp["threading"][i]
|
|
||||||
xcoord = x * BASE_SIZE
|
|
||||||
draw.line(
|
|
||||||
[
|
|
||||||
(xcoord, warp_top),
|
|
||||||
(xcoord, warp_bottom),
|
|
||||||
],
|
|
||||||
fill=BLACK if is_guide else GREY,
|
|
||||||
width=2 if is_guide else 1,
|
|
||||||
joint=None,
|
|
||||||
)
|
|
||||||
if thread.get("shaft", 0) > 0:
|
|
||||||
ycoord = warp_bottom - (thread["shaft"] * BASE_SIZE)
|
|
||||||
draw.rectangle(
|
|
||||||
[(xcoord, ycoord), (xcoord + BASE_SIZE, ycoord + BASE_SIZE)],
|
|
||||||
fill=BLACK,
|
|
||||||
outline=None,
|
|
||||||
width=1,
|
|
||||||
)
|
|
||||||
colour = warp["defaultColour"]
|
|
||||||
if thread and thread.get("colour"):
|
|
||||||
colour = thread["colour"]
|
|
||||||
draw.rectangle(
|
|
||||||
[
|
|
||||||
(xcoord, warp_top),
|
|
||||||
(xcoord + BASE_SIZE, warp_top + BASE_SIZE),
|
|
||||||
],
|
|
||||||
fill=colour_tuple(colour),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Draw weft
|
|
||||||
draw.rectangle(
|
|
||||||
[(weft_left, weft_top), (weft_right, weft_bottom)],
|
|
||||||
fill=None,
|
|
||||||
outline=GREY,
|
|
||||||
width=1,
|
|
||||||
)
|
|
||||||
for x in range(1, weft["treadles"] + 1):
|
|
||||||
xcoord = weft_left + x * BASE_SIZE
|
|
||||||
draw.line(
|
|
||||||
[
|
|
||||||
(xcoord, weft_top),
|
|
||||||
(xcoord, weft_bottom),
|
|
||||||
],
|
|
||||||
fill=GREY,
|
|
||||||
width=1,
|
|
||||||
joint=None,
|
|
||||||
)
|
|
||||||
row_index = 0
|
|
||||||
for i, y in enumerate(range(0, len(weft["treadling"]))):
|
|
||||||
is_guide = weft_guides and row_index % weft_guides == 0
|
|
||||||
row_index += 1
|
|
||||||
thread = weft["treadling"][i]
|
|
||||||
ycoord = weft_top + y * BASE_SIZE
|
|
||||||
draw.line(
|
|
||||||
[
|
|
||||||
(weft_left, ycoord),
|
|
||||||
(weft_right, ycoord),
|
|
||||||
],
|
|
||||||
fill=BLACK if is_guide else GREY,
|
|
||||||
width=2 if is_guide else 1,
|
|
||||||
joint=None,
|
|
||||||
)
|
|
||||||
if thread.get("treadle", 0) > 0:
|
|
||||||
xcoord = weft_left + (thread["treadle"] - 1) * BASE_SIZE
|
|
||||||
draw.rectangle(
|
|
||||||
[(xcoord, ycoord), (xcoord + BASE_SIZE, ycoord + BASE_SIZE)],
|
|
||||||
fill=BLACK,
|
|
||||||
outline=None,
|
|
||||||
width=1,
|
|
||||||
)
|
|
||||||
colour = weft["defaultColour"]
|
|
||||||
if thread and thread.get("colour"):
|
|
||||||
colour = thread["colour"]
|
|
||||||
draw.rectangle(
|
|
||||||
[
|
|
||||||
(weft_right - BASE_SIZE, ycoord),
|
|
||||||
(weft_right, ycoord + BASE_SIZE),
|
|
||||||
],
|
|
||||||
fill=colour_tuple(colour),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Draw tieups
|
|
||||||
draw.rectangle(
|
|
||||||
[(tieup_left, tieup_top), (tieup_right, tieup_bottom)],
|
|
||||||
fill=None,
|
|
||||||
outline=GREY,
|
|
||||||
width=1,
|
|
||||||
)
|
|
||||||
for y in range(1, warp["shafts"] + 1):
|
|
||||||
ycoord = y * BASE_SIZE
|
|
||||||
draw.line(
|
|
||||||
[
|
|
||||||
(tieup_left, ycoord),
|
|
||||||
(tieup_right, ycoord),
|
|
||||||
],
|
|
||||||
fill=GREY,
|
|
||||||
width=1,
|
|
||||||
joint=None,
|
|
||||||
)
|
|
||||||
for x, tieup in enumerate(tieups):
|
|
||||||
xcoord = tieup_left + x * BASE_SIZE
|
|
||||||
draw.line(
|
|
||||||
[
|
|
||||||
(xcoord, tieup_top),
|
|
||||||
(xcoord, tieup_bottom),
|
|
||||||
],
|
|
||||||
fill=GREY,
|
|
||||||
width=1,
|
|
||||||
joint=None,
|
|
||||||
)
|
|
||||||
for entry in tieup:
|
|
||||||
if entry > 0:
|
|
||||||
ycoord = tieup_bottom - (entry * BASE_SIZE)
|
|
||||||
draw.rectangle(
|
|
||||||
[(xcoord, ycoord), (xcoord + BASE_SIZE, ycoord + BASE_SIZE)],
|
|
||||||
fill=BLACK,
|
|
||||||
outline=None,
|
|
||||||
width=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Draw drawdown
|
|
||||||
draw.rectangle(
|
|
||||||
[(drawdown_left, drawdown_top), (drawdown_right, drawdown_bottom)],
|
|
||||||
fill=None,
|
|
||||||
outline=(0, 0, 0),
|
|
||||||
width=1,
|
|
||||||
)
|
|
||||||
for y, weft_thread in enumerate(weft["treadling"]):
|
|
||||||
for x, warp_thread in enumerate(warp["threading"]):
|
|
||||||
# Ensure selected treadle and shaft is within configured pattern range
|
|
||||||
treadle = (
|
|
||||||
0
|
|
||||||
if weft_thread["treadle"] > weft["treadles"]
|
|
||||||
else weft_thread["treadle"]
|
|
||||||
)
|
|
||||||
shaft = 0 if warp_thread["shaft"] > warp["shafts"] else warp_thread["shaft"]
|
|
||||||
|
|
||||||
# Work out if should be warp or weft in "front"
|
|
||||||
tieup = (
|
|
||||||
tieups[treadle - 1] if (treadle > 0 and treadle <= len(tieups)) else []
|
|
||||||
)
|
|
||||||
tieup = [t for t in tieup if t <= warp["shafts"]]
|
|
||||||
thread_type = "warp" if shaft in tieup else "weft"
|
|
||||||
|
|
||||||
# Calculate current colour
|
|
||||||
weft_colour = weft_thread.get("colour") or weft.get("defaultColour")
|
|
||||||
warp_colour = warp_thread.get("colour") or warp.get("defaultColour")
|
|
||||||
colour = colour_tuple(warp_colour if thread_type == "warp" else weft_colour)
|
|
||||||
|
|
||||||
# Calculate drawdown coordinates
|
|
||||||
x1 = drawdown_right - (x + 1) * BASE_SIZE
|
|
||||||
x2 = drawdown_right - x * BASE_SIZE
|
|
||||||
y1 = drawdown_top + y * BASE_SIZE
|
|
||||||
y2 = drawdown_top + (y + 1) * BASE_SIZE
|
|
||||||
|
|
||||||
# Draw the thread, with shadow
|
|
||||||
d = [0.6, 0.8, 0.9, 1.1, 1.3, 1.3, 1.1, 0.9, 0.8, 0.6, 0.5]
|
|
||||||
if thread_type == "warp":
|
|
||||||
for i, grad_x in enumerate(range(x1, x2)):
|
|
||||||
draw.line(
|
|
||||||
[
|
|
||||||
(grad_x, y1),
|
|
||||||
(grad_x, y2),
|
|
||||||
],
|
|
||||||
fill=(darken_colour(colour, d[i])),
|
|
||||||
width=1,
|
|
||||||
joint=None,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
for i, grad_y in enumerate(range(y1, y2)):
|
|
||||||
draw.line(
|
|
||||||
[
|
|
||||||
(x1, grad_y),
|
|
||||||
(x2, grad_y),
|
|
||||||
],
|
|
||||||
fill=(darken_colour(colour, d[i])),
|
|
||||||
width=1,
|
|
||||||
joint=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
in_mem_file = io.BytesIO()
|
|
||||||
img.save(in_mem_file, "PNG")
|
|
||||||
in_mem_file.seek(0)
|
|
||||||
file_name_prefix = "preview-{0}_{1}".format(
|
|
||||||
"full" if with_plan else "base", obj["_id"]
|
|
||||||
)
|
|
||||||
file_name = "{0}-{1}.png".format(file_name_prefix, int(time.time()))
|
|
||||||
folder = "projects/{}".format(obj["project"])
|
|
||||||
# Delete existing preview images of this type
|
|
||||||
uploads.delete_folder("{}/{}".format(folder, file_name_prefix))
|
|
||||||
# Upload the new preview image
|
|
||||||
uploads.upload_file("{}/{}".format(folder, file_name), in_mem_file)
|
|
||||||
return file_name
|
|
||||||
|
@ -1,40 +0,0 @@
|
|||||||
# Stage 1: Build React SPA
|
|
||||||
FROM node:20 AS react-build
|
|
||||||
WORKDIR /app
|
|
||||||
COPY web/package.json web/package-lock.json ./
|
|
||||||
RUN npm install
|
|
||||||
COPY web/ ./
|
|
||||||
RUN npx vite build
|
|
||||||
|
|
||||||
# Stage 2: Set up Nginx with React and Flask
|
|
||||||
FROM python:3.12-slim
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install Flask and dependencies
|
|
||||||
RUN pip install poetry
|
|
||||||
COPY api/poetry.lock .
|
|
||||||
COPY api/pyproject.toml .
|
|
||||||
RUN poetry config virtualenvs.create false --local
|
|
||||||
RUN poetry install
|
|
||||||
|
|
||||||
# Copy Flask app
|
|
||||||
COPY api/ ./
|
|
||||||
|
|
||||||
# Install Nginx
|
|
||||||
RUN apt-get update && apt-get install -y nginx && rm -rf /var/lib/apt/lists/*
|
|
||||||
RUN unlink /etc/nginx/sites-enabled/default # Ensure default Nginx configuration is not used
|
|
||||||
|
|
||||||
# Copy React build files into Nginx's static directory
|
|
||||||
COPY --from=react-build /app/dist /usr/share/nginx/html
|
|
||||||
|
|
||||||
# Copy custom Nginx configuration file
|
|
||||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
# Expose ports for Nginx
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
# Start both Flask and Nginx using a script
|
|
||||||
COPY docker/start.sh /start.sh
|
|
||||||
RUN chmod +x /start.sh
|
|
||||||
|
|
||||||
CMD ["/start.sh"]
|
|
@ -1,33 +0,0 @@
|
|||||||
services:
|
|
||||||
treadl:
|
|
||||||
image: wilw/treadl:latest
|
|
||||||
ports:
|
|
||||||
- "8080:80"
|
|
||||||
environment:
|
|
||||||
# App settings
|
|
||||||
- JWT_SECRET=secret # Change this to a secure secret
|
|
||||||
- APP_URL=http://example.com
|
|
||||||
- APP_DOMAIN=example.com
|
|
||||||
- APP_NAME=Treadl
|
|
||||||
|
|
||||||
# MongoDB connection
|
|
||||||
- MONGO_URL=mongodb://mongo:27017/treadl
|
|
||||||
- MONGO_DATABASE=treadl
|
|
||||||
|
|
||||||
# Mailgun email settings
|
|
||||||
- MAILGUN_URL=
|
|
||||||
- MAILGUN_KEY
|
|
||||||
- FROM_EMAIL= # An email address to send emails from
|
|
||||||
|
|
||||||
# Email addresses
|
|
||||||
- CONTACT_EMAIL= # An email address for people to contact you
|
|
||||||
- ADMIN_EMAIL= # An email address for admin notifications
|
|
||||||
|
|
||||||
# S3 storage settings
|
|
||||||
- AWS_S3_ENDPOINT=https://eu-central-1.linodeobjects.com/
|
|
||||||
- AWS_S3_BUCKET=treadl
|
|
||||||
- AWS_ACCESS_KEY_ID=
|
|
||||||
- AWS_SECRET_ACCESS_KEY=
|
|
||||||
|
|
||||||
mongo:
|
|
||||||
image: mongo:6
|
|
@ -1,22 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
|
|
||||||
# Serve React static files for all non-API routes
|
|
||||||
location / {
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
try_files $uri /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Proxy API requests to Flask backend
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://127.0.0.1:5000/;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
gzip on;
|
|
||||||
gzip_types text/plain application/json text/css application/javascript;
|
|
||||||
}
|
|
@ -1,8 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Start Flask app in the background
|
|
||||||
gunicorn -b 0.0.0.0:5000 app:app &
|
|
||||||
|
|
||||||
# Start Nginx in the foreground
|
|
||||||
nginx -g "daemon off;"
|
|
||||||
|
|
@ -4,33 +4,7 @@
|
|||||||
# This file should be version controlled and should not be manually edited.
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
version:
|
version:
|
||||||
revision: "d211f42860350d914a5ad8102f9ec32764dc6d06"
|
revision: e6b34c2b5c96bb95325269a29a84e83ed8909b5f
|
||||||
channel: "stable"
|
channel: stable
|
||||||
|
|
||||||
project_type: app
|
project_type: app
|
||||||
|
|
||||||
# Tracks metadata for the flutter migrate command
|
|
||||||
migration:
|
|
||||||
platforms:
|
|
||||||
- platform: root
|
|
||||||
create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
|
||||||
base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
|
||||||
- platform: linux
|
|
||||||
create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
|
||||||
base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
|
||||||
- platform: macos
|
|
||||||
create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
|
||||||
base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
|
||||||
- platform: windows
|
|
||||||
create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
|
||||||
base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06
|
|
||||||
|
|
||||||
# User provided section
|
|
||||||
|
|
||||||
# List of Local paths (relative to this file) that should be
|
|
||||||
# ignored by the migrate tool.
|
|
||||||
#
|
|
||||||
# Files that are not part of the templates will be ignored by default.
|
|
||||||
unmanaged_files:
|
|
||||||
- 'lib/main.dart'
|
|
||||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
|
||||||
|
@ -6,7 +6,7 @@ The source code for Treadl's iOS and Android application.
|
|||||||
|
|
||||||
The application is written in Dart using the Flutter framework.
|
The application is written in Dart using the Flutter framework.
|
||||||
|
|
||||||
The mobile app currently supports only a subset of the features of the web app, and is largely useful only for the groups functionality and for adding images to projects when out and about.
|
The mobile app currently supports only a subset of the features of the web app, and is largely useful only for the groups functionality.
|
||||||
|
|
||||||
## Start
|
## Start
|
||||||
|
|
||||||
|
@ -1,28 +0,0 @@
|
|||||||
# This file configures the analyzer, which statically analyzes Dart code to
|
|
||||||
# check for errors, warnings, and lints.
|
|
||||||
#
|
|
||||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
|
||||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
|
||||||
# invoked from the command line by running `flutter analyze`.
|
|
||||||
|
|
||||||
# The following line activates a set of recommended lints for Flutter apps,
|
|
||||||
# packages, and plugins designed to encourage good coding practices.
|
|
||||||
include: package:flutter_lints/flutter.yaml
|
|
||||||
|
|
||||||
linter:
|
|
||||||
# The lint rules applied to this project can be customized in the
|
|
||||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
|
||||||
# included above or to enable additional rules. A list of all available lints
|
|
||||||
# and their documentation is published at https://dart.dev/lints.
|
|
||||||
#
|
|
||||||
# Instead of disabling a lint rule for the entire project in the
|
|
||||||
# section below, it can also be suppressed for a single line of code
|
|
||||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
|
||||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
|
||||||
# producing the lint.
|
|
||||||
rules:
|
|
||||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
|
||||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
|
||||||
# https://dart.dev/guides/language/analysis-options
|
|
@ -32,7 +32,7 @@ if (keystorePropertiesFile.exists()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdkVersion 33
|
compileSdkVersion 31
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
main.java.srcDirs += 'src/main/kotlin'
|
main.java.srcDirs += 'src/main/kotlin'
|
||||||
@ -44,8 +44,8 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.treadl"
|
applicationId "com.treadl"
|
||||||
minSdkVersion 29
|
minSdkVersion 19
|
||||||
targetSdkVersion 34
|
targetSdkVersion 31
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
}
|
}
|
||||||
|
@ -43,14 +43,6 @@
|
|||||||
<action android:name="FLUTTER_NOTIFICATION_CLICK" />
|
<action android:name="FLUTTER_NOTIFICATION_CLICK" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
|
|
||||||
<intent-filter android:autoVerify="true">
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
<data android:scheme="http" android:host="treadl.com" />
|
|
||||||
<data android:scheme="https" android:host="treadl.com" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
@ -1,21 +1,16 @@
|
|||||||
// Generated file.
|
// Generated file.
|
||||||
//
|
|
||||||
// If you wish to remove Flutter's multidex support, delete this entire file.
|
// If you wish to remove Flutter's multidex support, delete this entire file.
|
||||||
//
|
|
||||||
// Modifications to this file should be done in a copy under a different name
|
|
||||||
// as this file may be regenerated.
|
|
||||||
|
|
||||||
package io.flutter.app;
|
package io.flutter.app;
|
||||||
|
|
||||||
import android.app.Application;
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import androidx.annotation.CallSuper;
|
import androidx.annotation.CallSuper;
|
||||||
import androidx.multidex.MultiDex;
|
import androidx.multidex.MultiDex;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extension of {@link android.app.Application}, adding multidex support.
|
* Extension of {@link io.flutter.app.FlutterApplication}, adding multidex support.
|
||||||
*/
|
*/
|
||||||
public class FlutterMultiDexApplication extends Application {
|
public class FlutterMultiDexApplication extends FlutterApplication {
|
||||||
@Override
|
@Override
|
||||||
@CallSuper
|
@CallSuper
|
||||||
protected void attachBaseContext(Context base) {
|
protected void attachBaseContext(Context base) {
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.8.20'
|
ext.kotlin_version = '1.6.10'
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
jcenter()
|
jcenter()
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:7.4.1'
|
classpath 'com.android.tools.build:gradle:3.5.0'
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
classpath 'com.google.gms:google-services:4.3.3'
|
classpath 'com.google.gms:google-services:4.3.3'
|
||||||
}
|
}
|
||||||
@ -27,6 +27,6 @@ subprojects {
|
|||||||
project.evaluationDependsOn(':app')
|
project.evaluationDependsOn(':app')
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register("clean", Delete) {
|
task clean(type: Delete) {
|
||||||
delete rootProject.buildDir
|
delete rootProject.buildDir
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
|
#Fri Jun 23 08:50:38 CEST 2017
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
|
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 56 KiB |
@ -21,6 +21,6 @@
|
|||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>MinimumOSVersion</key>
|
<key>MinimumOSVersion</key>
|
||||||
<string>12.0</string>
|
<string>9.0</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
# Uncomment this line to define a global platform for your project
|
# Uncomment this line to define a global platform for your project
|
||||||
# platform :ios, '12.0'
|
# platform :ios, '9.0'
|
||||||
|
|
||||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
|
@ -1,192 +1,153 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- DKImagePickerController/Core (4.3.9):
|
- Firebase/CoreOnly (8.11.0):
|
||||||
- DKImagePickerController/ImageDataManager
|
- FirebaseCore (= 8.11.0)
|
||||||
- DKImagePickerController/Resource
|
- Firebase/Messaging (8.11.0):
|
||||||
- DKImagePickerController/ImageDataManager (4.3.9)
|
|
||||||
- DKImagePickerController/PhotoGallery (4.3.9):
|
|
||||||
- DKImagePickerController/Core
|
|
||||||
- DKPhotoGallery
|
|
||||||
- DKImagePickerController/Resource (4.3.9)
|
|
||||||
- DKPhotoGallery (0.0.19):
|
|
||||||
- DKPhotoGallery/Core (= 0.0.19)
|
|
||||||
- DKPhotoGallery/Model (= 0.0.19)
|
|
||||||
- DKPhotoGallery/Preview (= 0.0.19)
|
|
||||||
- DKPhotoGallery/Resource (= 0.0.19)
|
|
||||||
- SDWebImage
|
|
||||||
- SwiftyGif
|
|
||||||
- DKPhotoGallery/Core (0.0.19):
|
|
||||||
- DKPhotoGallery/Model
|
|
||||||
- DKPhotoGallery/Preview
|
|
||||||
- SDWebImage
|
|
||||||
- SwiftyGif
|
|
||||||
- DKPhotoGallery/Model (0.0.19):
|
|
||||||
- SDWebImage
|
|
||||||
- SwiftyGif
|
|
||||||
- DKPhotoGallery/Preview (0.0.19):
|
|
||||||
- DKPhotoGallery/Model
|
|
||||||
- DKPhotoGallery/Resource
|
|
||||||
- SDWebImage
|
|
||||||
- SwiftyGif
|
|
||||||
- DKPhotoGallery/Resource (0.0.19):
|
|
||||||
- SDWebImage
|
|
||||||
- SwiftyGif
|
|
||||||
- file_picker (0.0.1):
|
|
||||||
- DKImagePickerController/PhotoGallery
|
|
||||||
- Flutter
|
|
||||||
- Firebase/CoreOnly (11.8.0):
|
|
||||||
- FirebaseCore (~> 11.8.0)
|
|
||||||
- Firebase/Messaging (11.8.0):
|
|
||||||
- Firebase/CoreOnly
|
- Firebase/CoreOnly
|
||||||
- FirebaseMessaging (~> 11.8.0)
|
- FirebaseMessaging (~> 8.11.0)
|
||||||
- firebase_core (3.12.1):
|
- firebase_core (1.13.1):
|
||||||
- Firebase/CoreOnly (= 11.8.0)
|
- Firebase/CoreOnly (= 8.11.0)
|
||||||
- Flutter
|
- Flutter
|
||||||
- firebase_messaging (15.2.4):
|
- firebase_messaging (11.2.8):
|
||||||
- Firebase/Messaging (= 11.8.0)
|
- Firebase/Messaging (= 8.11.0)
|
||||||
- firebase_core
|
- firebase_core
|
||||||
- Flutter
|
- Flutter
|
||||||
- FirebaseCore (11.8.1):
|
- FirebaseCore (8.11.0):
|
||||||
- FirebaseCoreInternal (~> 11.8.0)
|
- FirebaseCoreDiagnostics (~> 8.0)
|
||||||
- GoogleUtilities/Environment (~> 8.0)
|
- GoogleUtilities/Environment (~> 7.7)
|
||||||
- GoogleUtilities/Logger (~> 8.0)
|
- GoogleUtilities/Logger (~> 7.7)
|
||||||
- FirebaseCoreInternal (11.8.0):
|
- FirebaseCoreDiagnostics (8.12.0):
|
||||||
- "GoogleUtilities/NSData+zlib (~> 8.0)"
|
- GoogleDataTransport (~> 9.1)
|
||||||
- FirebaseInstallations (11.8.0):
|
- GoogleUtilities/Environment (~> 7.7)
|
||||||
- FirebaseCore (~> 11.8.0)
|
- GoogleUtilities/Logger (~> 7.7)
|
||||||
- GoogleUtilities/Environment (~> 8.0)
|
- nanopb (~> 2.30908.0)
|
||||||
- GoogleUtilities/UserDefaults (~> 8.0)
|
- FirebaseInstallations (8.12.0):
|
||||||
- PromisesObjC (~> 2.4)
|
- FirebaseCore (~> 8.0)
|
||||||
- FirebaseMessaging (11.8.0):
|
- GoogleUtilities/Environment (~> 7.7)
|
||||||
- FirebaseCore (~> 11.8.0)
|
- GoogleUtilities/UserDefaults (~> 7.7)
|
||||||
- FirebaseInstallations (~> 11.0)
|
- PromisesObjC (< 3.0, >= 1.2)
|
||||||
- GoogleDataTransport (~> 10.0)
|
- FirebaseMessaging (8.11.0):
|
||||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
|
- FirebaseCore (~> 8.0)
|
||||||
- GoogleUtilities/Environment (~> 8.0)
|
- FirebaseInstallations (~> 8.0)
|
||||||
- GoogleUtilities/Reachability (~> 8.0)
|
- GoogleDataTransport (~> 9.1)
|
||||||
- GoogleUtilities/UserDefaults (~> 8.0)
|
- GoogleUtilities/AppDelegateSwizzler (~> 7.7)
|
||||||
- nanopb (~> 3.30910.0)
|
- GoogleUtilities/Environment (~> 7.7)
|
||||||
|
- GoogleUtilities/Reachability (~> 7.7)
|
||||||
|
- GoogleUtilities/UserDefaults (~> 7.7)
|
||||||
|
- nanopb (~> 2.30908.0)
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
- GoogleDataTransport (10.1.0):
|
- fluttertoast (0.0.2):
|
||||||
- nanopb (~> 3.30910.0)
|
- Flutter
|
||||||
- PromisesObjC (~> 2.4)
|
- Toast
|
||||||
- GoogleUtilities/AppDelegateSwizzler (8.0.2):
|
- GoogleDataTransport (9.1.2):
|
||||||
|
- GoogleUtilities/Environment (~> 7.2)
|
||||||
|
- nanopb (~> 2.30908.0)
|
||||||
|
- PromisesObjC (< 3.0, >= 1.2)
|
||||||
|
- GoogleUtilities/AppDelegateSwizzler (7.7.0):
|
||||||
- GoogleUtilities/Environment
|
- GoogleUtilities/Environment
|
||||||
- GoogleUtilities/Logger
|
- GoogleUtilities/Logger
|
||||||
- GoogleUtilities/Network
|
- GoogleUtilities/Network
|
||||||
- GoogleUtilities/Privacy
|
- GoogleUtilities/Environment (7.7.0):
|
||||||
- GoogleUtilities/Environment (8.0.2):
|
- PromisesObjC (< 3.0, >= 1.2)
|
||||||
- GoogleUtilities/Privacy
|
- GoogleUtilities/Logger (7.7.0):
|
||||||
- GoogleUtilities/Logger (8.0.2):
|
|
||||||
- GoogleUtilities/Environment
|
- GoogleUtilities/Environment
|
||||||
- GoogleUtilities/Privacy
|
- GoogleUtilities/Network (7.7.0):
|
||||||
- GoogleUtilities/Network (8.0.2):
|
|
||||||
- GoogleUtilities/Logger
|
- GoogleUtilities/Logger
|
||||||
- "GoogleUtilities/NSData+zlib"
|
- "GoogleUtilities/NSData+zlib"
|
||||||
- GoogleUtilities/Privacy
|
|
||||||
- GoogleUtilities/Reachability
|
- GoogleUtilities/Reachability
|
||||||
- "GoogleUtilities/NSData+zlib (8.0.2)":
|
- "GoogleUtilities/NSData+zlib (7.7.0)"
|
||||||
- GoogleUtilities/Privacy
|
- GoogleUtilities/Reachability (7.7.0):
|
||||||
- GoogleUtilities/Privacy (8.0.2)
|
|
||||||
- GoogleUtilities/Reachability (8.0.2):
|
|
||||||
- GoogleUtilities/Logger
|
- GoogleUtilities/Logger
|
||||||
- GoogleUtilities/Privacy
|
- GoogleUtilities/UserDefaults (7.7.0):
|
||||||
- GoogleUtilities/UserDefaults (8.0.2):
|
|
||||||
- GoogleUtilities/Logger
|
- GoogleUtilities/Logger
|
||||||
- GoogleUtilities/Privacy
|
- image_picker (0.0.1):
|
||||||
- image_picker_ios (0.0.1):
|
|
||||||
- Flutter
|
- Flutter
|
||||||
- nanopb (3.30910.0):
|
- nanopb (2.30908.0):
|
||||||
- nanopb/decode (= 3.30910.0)
|
- nanopb/decode (= 2.30908.0)
|
||||||
- nanopb/encode (= 3.30910.0)
|
- nanopb/encode (= 2.30908.0)
|
||||||
- nanopb/decode (3.30910.0)
|
- nanopb/decode (2.30908.0)
|
||||||
- nanopb/encode (3.30910.0)
|
- nanopb/encode (2.30908.0)
|
||||||
- path_provider_foundation (0.0.1):
|
- PromisesObjC (2.0.0)
|
||||||
|
- shared_preferences_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- Toast (4.0.0)
|
||||||
- PromisesObjC (2.4.0)
|
|
||||||
- SDWebImage (5.21.0):
|
|
||||||
- SDWebImage/Core (= 5.21.0)
|
|
||||||
- SDWebImage/Core (5.21.0)
|
|
||||||
- share_plus (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- shared_preferences_foundation (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- FlutterMacOS
|
|
||||||
- SwiftyGif (5.4.5)
|
|
||||||
- url_launcher_ios (0.0.1):
|
- url_launcher_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- video_player_avfoundation (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- wakelock (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- webview_flutter_wkwebview (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
|
||||||
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
|
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
|
||||||
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
|
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
- image_picker (from `.symlinks/plugins/image_picker/ios`)
|
||||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
- shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`)
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
|
||||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||||
|
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`)
|
||||||
|
- wakelock (from `.symlinks/plugins/wakelock/ios`)
|
||||||
|
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
trunk:
|
trunk:
|
||||||
- DKImagePickerController
|
|
||||||
- DKPhotoGallery
|
|
||||||
- Firebase
|
- Firebase
|
||||||
- FirebaseCore
|
- FirebaseCore
|
||||||
- FirebaseCoreInternal
|
- FirebaseCoreDiagnostics
|
||||||
- FirebaseInstallations
|
- FirebaseInstallations
|
||||||
- FirebaseMessaging
|
- FirebaseMessaging
|
||||||
- GoogleDataTransport
|
- GoogleDataTransport
|
||||||
- GoogleUtilities
|
- GoogleUtilities
|
||||||
- nanopb
|
- nanopb
|
||||||
- PromisesObjC
|
- PromisesObjC
|
||||||
- SDWebImage
|
- Toast
|
||||||
- SwiftyGif
|
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
file_picker:
|
|
||||||
:path: ".symlinks/plugins/file_picker/ios"
|
|
||||||
firebase_core:
|
firebase_core:
|
||||||
:path: ".symlinks/plugins/firebase_core/ios"
|
:path: ".symlinks/plugins/firebase_core/ios"
|
||||||
firebase_messaging:
|
firebase_messaging:
|
||||||
:path: ".symlinks/plugins/firebase_messaging/ios"
|
:path: ".symlinks/plugins/firebase_messaging/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
image_picker_ios:
|
fluttertoast:
|
||||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
:path: ".symlinks/plugins/fluttertoast/ios"
|
||||||
path_provider_foundation:
|
image_picker:
|
||||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
:path: ".symlinks/plugins/image_picker/ios"
|
||||||
share_plus:
|
shared_preferences_ios:
|
||||||
:path: ".symlinks/plugins/share_plus/ios"
|
:path: ".symlinks/plugins/shared_preferences_ios/ios"
|
||||||
shared_preferences_foundation:
|
|
||||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||||
|
video_player_avfoundation:
|
||||||
|
:path: ".symlinks/plugins/video_player_avfoundation/ios"
|
||||||
|
wakelock:
|
||||||
|
:path: ".symlinks/plugins/wakelock/ios"
|
||||||
|
webview_flutter_wkwebview:
|
||||||
|
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
Firebase: 44dd9724c84df18b486639e874f31436eaa9a20c
|
||||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
firebase_core: 08f6a85f62060111de5e98d6a214810d11365de9
|
||||||
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
firebase_messaging: 36238f3d0b933af8c919aef608408aae06ba22e8
|
||||||
Firebase: d80354ed7f6df5f9aca55e9eb47cc4b634735eaf
|
FirebaseCore: 2f4f85b453cc8fea4bb2b37e370007d2bcafe3f0
|
||||||
firebase_core: 8d552814f6c01ccde5d88939fced4ec26f2f5510
|
FirebaseCoreDiagnostics: 3b40dfadef5b90433a60ae01f01e90fe87aa76aa
|
||||||
firebase_messaging: 8b96a4f09841c15a16b96973ef5c3dcfc1a064e4
|
FirebaseInstallations: 25764cf322e77f99449395870a65b2bef88e1545
|
||||||
FirebaseCore: 99fe0c4b44a39f37d99e6404e02009d2db5d718d
|
FirebaseMessaging: 02e248e8997f71fa8cc9d78e9d49ec1a701ba14a
|
||||||
FirebaseCoreInternal: df24ce5af28864660ecbd13596fc8dd3a8c34629
|
Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a
|
||||||
FirebaseInstallations: 6c963bd2a86aca0481eef4f48f5a4df783ae5917
|
fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037
|
||||||
FirebaseMessaging: 487b634ccdf6f7b7ff180fdcb2a9935490f764e8
|
GoogleDataTransport: 629c20a4d363167143f30ea78320d5a7eb8bd940
|
||||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
GoogleUtilities: e0913149f6b0625b553d70dae12b49fc62914fd1
|
||||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
image_picker: 541dcbb3b9cf32d87eacbd957845d8651d6c62c3
|
||||||
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
|
nanopb: a0ba3315591a9ae0a16a309ee504766e90db0c96
|
||||||
image_picker_ios: afb77645f1e1060a27edb6793996ff9b42256909
|
PromisesObjC: 68159ce6952d93e17b2dfe273b8c40907db5ba58
|
||||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad
|
||||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
||||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de
|
||||||
SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868
|
video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff
|
||||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
|
||||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
webview_flutter_wkwebview: 005fbd90c888a42c5690919a1527ecc6649e1162
|
||||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
|
||||||
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
|
||||||
|
|
||||||
PODFILE CHECKSUM: c4c93c5f6502fe2754f48404d3594bf779584011
|
PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.10.1
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 54;
|
objectVersion = 51;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@ -48,7 +48,6 @@
|
|||||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
9C430D344D81D00E4F8BC572 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
9C430D344D81D00E4F8BC572 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
BE18F7F22B54707500363B2E /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
|
||||||
BE6C8E7324CDE9B20018AD10 /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = "<group>"; };
|
BE6C8E7324CDE9B20018AD10 /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = "<group>"; };
|
||||||
BEA6727A24CCAF5600BBF836 /* RunnerRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerRelease.entitlements; sourceTree = "<group>"; };
|
BEA6727A24CCAF5600BBF836 /* RunnerRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerRelease.entitlements; sourceTree = "<group>"; };
|
||||||
BEA6727B24CCB04900BBF836 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
BEA6727B24CCB04900BBF836 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||||
@ -117,7 +116,6 @@
|
|||||||
97C146F01CF9000F007C117D /* Runner */ = {
|
97C146F01CF9000F007C117D /* Runner */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
BE18F7F22B54707500363B2E /* Runner.entitlements */,
|
|
||||||
BE6C8E7324CDE9B20018AD10 /* RunnerDebug.entitlements */,
|
BE6C8E7324CDE9B20018AD10 /* RunnerDebug.entitlements */,
|
||||||
BEA6727A24CCAF5600BBF836 /* RunnerRelease.entitlements */,
|
BEA6727A24CCAF5600BBF836 /* RunnerRelease.entitlements */,
|
||||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||||
@ -156,7 +154,6 @@
|
|||||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||||
2341743D762090318428F35C /* [CP] Embed Pods Frameworks */,
|
2341743D762090318428F35C /* [CP] Embed Pods Frameworks */,
|
||||||
4777130FB39D1044BC14FC9C /* [CP] Copy Pods Resources */,
|
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@ -173,7 +170,7 @@
|
|||||||
97C146E61CF9000F007C117D /* Project object */ = {
|
97C146E61CF9000F007C117D /* Project object */ = {
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
LastUpgradeCheck = 1510;
|
LastUpgradeCheck = 1300;
|
||||||
ORGANIZATIONNAME = "";
|
ORGANIZATIONNAME = "";
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
97C146ED1CF9000F007C117D = {
|
97C146ED1CF9000F007C117D = {
|
||||||
@ -235,12 +232,10 @@
|
|||||||
};
|
};
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
alwaysOutOfDate = 1;
|
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
);
|
);
|
||||||
inputPaths = (
|
inputPaths = (
|
||||||
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
|
||||||
);
|
);
|
||||||
name = "Thin Binary";
|
name = "Thin Binary";
|
||||||
outputPaths = (
|
outputPaths = (
|
||||||
@ -249,26 +244,8 @@
|
|||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||||
};
|
};
|
||||||
4777130FB39D1044BC14FC9C /* [CP] Copy Pods Resources */ = {
|
|
||||||
isa = PBXShellScriptBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
inputFileListPaths = (
|
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
|
||||||
);
|
|
||||||
name = "[CP] Copy Pods Resources";
|
|
||||||
outputFileListPaths = (
|
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
shellPath = /bin/sh;
|
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
|
||||||
showEnvVarsInLog = 0;
|
|
||||||
};
|
|
||||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
alwaysOutOfDate = 1;
|
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
);
|
);
|
||||||
@ -393,7 +370,6 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = 38T664W57F;
|
DEVELOPMENT_TEAM = 38T664W57F;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1510"
|
LastUpgradeVersion = "1300"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
@ -48,7 +48,6 @@
|
|||||||
ignoresPersistentStateOnLaunch = "NO"
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
debugServiceExtension = "internal"
|
debugServiceExtension = "internal"
|
||||||
enableGPUValidationMode = "1"
|
|
||||||
allowLocationSimulation = "YES">
|
allowLocationSimulation = "YES">
|
||||||
<BuildableProductRunnable
|
<BuildableProductRunnable
|
||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Flutter
|
import Flutter
|
||||||
|
|
||||||
@main
|
@UIApplicationMain
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
|
@ -2,8 +2,6 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
|
||||||
<false/>
|
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
@ -14,8 +12,6 @@
|
|||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>Treadl</string>
|
<string>Treadl</string>
|
||||||
<key>FlutterDeepLinkingEnabled</key>
|
|
||||||
<true/>
|
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
@ -50,9 +46,5 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
|
||||||
<true/>
|
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
@ -1,11 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>com.apple.developer.associated-domains</key>
|
|
||||||
<array>
|
|
||||||
<string>applinks:treadl.com</string>
|
|
||||||
<string>applinks:www.treadl.com</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
@ -4,10 +4,5 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>development</string>
|
<string>development</string>
|
||||||
<key>com.apple.developer.associated-domains</key>
|
|
||||||
<array>
|
|
||||||
<string>applinks:treadl.com</string>
|
|
||||||
<string>applinks:www.treadl.com</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
@ -4,10 +4,5 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>development</string>
|
<string>development</string>
|
||||||
<key>com.apple.developer.associated-domains</key>
|
|
||||||
<array>
|
|
||||||
<string>applinks:treadl.com</string>
|
|
||||||
<string>applinks:www.treadl.com</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
@ -1,35 +1,28 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'util.dart';
|
|
||||||
import 'model.dart';
|
|
||||||
|
|
||||||
class Api {
|
class Api {
|
||||||
|
|
||||||
String? _token;
|
String _token;
|
||||||
final String apiBase = 'https://api.treadl.com';
|
final String apiBase = 'https://api.treadl.com';
|
||||||
//final String apiBase = 'http://192.168.5.134:2001';
|
//final String apiBase = 'http://localhost:2001';
|
||||||
|
|
||||||
Api({token: null}) {
|
Future<String> loadToken() async {
|
||||||
if (token != null) _token = token;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String?> loadToken() async {
|
|
||||||
if (_token != null) {
|
if (_token != null) {
|
||||||
return _token!;
|
return _token;
|
||||||
}
|
}
|
||||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||||
String? token = prefs.getString('apiToken');
|
final String token = prefs.getString('apiToken');
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
Future<Map<String,String>> getHeaders(method) async {
|
Future<Map<String,String>> getHeaders(method) async {
|
||||||
Map<String,String> headers = {};
|
Map<String,String> headers = {};
|
||||||
String? token = await loadToken();
|
String token = await loadToken();
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
headers['Authorization'] = 'Bearer ' + token!;
|
headers['Authorization'] = 'Bearer ' + token;
|
||||||
}
|
}
|
||||||
if (method == 'POST' || method == 'DELETE') {
|
if (method == 'POST' || method == 'DELETE') {
|
||||||
headers['Content-Type'] = 'application/json';
|
headers['Content-Type'] = 'application/json';
|
||||||
@ -41,23 +34,17 @@ class Api {
|
|||||||
http.Client client = http.Client();
|
http.Client client = http.Client();
|
||||||
return await client.get(url, headers: await getHeaders('GET'));
|
return await client.get(url, headers: await getHeaders('GET'));
|
||||||
}
|
}
|
||||||
Future<http.Response> _post(Uri url, Map<String, dynamic>? data) async {
|
Future<http.Response> _post(Uri url, Map<String, dynamic> data) async {
|
||||||
String? json = null;
|
String json = jsonEncode(data);
|
||||||
if (data != null) {
|
|
||||||
json = jsonEncode(data!);
|
|
||||||
}
|
|
||||||
http.Client client = http.Client();
|
http.Client client = http.Client();
|
||||||
return await client.post(url, headers: await getHeaders('POST'), body: json);
|
return await client.post(url, headers: await getHeaders('POST'), body: json);
|
||||||
}
|
}
|
||||||
Future<http.Response> _put(Uri url, Map<String, dynamic>? data) async {
|
Future<http.Response> _put(Uri url, Map<String, dynamic> data) async {
|
||||||
String? json = null;
|
String json = jsonEncode(data);
|
||||||
if (data != null) {
|
|
||||||
json = jsonEncode(data!);
|
|
||||||
}
|
|
||||||
http.Client client = http.Client();
|
http.Client client = http.Client();
|
||||||
return await client.put(url, headers: await getHeaders('POST'), body: json);
|
return await client.put(url, headers: await getHeaders('POST'), body: json);
|
||||||
}
|
}
|
||||||
Future<http.Response> _delete(Uri url, [Map<String, dynamic>? data]) async {
|
Future<http.Response> _delete(Uri url, [Map<String, dynamic> data]) async {
|
||||||
http.Client client = http.Client();
|
http.Client client = http.Client();
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
String json = jsonEncode(data);
|
String json = jsonEncode(data);
|
||||||
@ -67,10 +54,10 @@ class Api {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> request(String method, String path, [Map<String, dynamic>? data]) async {
|
Future<Map<String, dynamic>> request(String method, String path, [Map<String, dynamic> data]) async {
|
||||||
String url = apiBase + path;
|
String url = apiBase + path;
|
||||||
Uri uri = Uri.parse(url);
|
Uri uri = Uri.parse(url);
|
||||||
http.Response? response;
|
http.Response response;
|
||||||
if (method == 'POST') {
|
if (method == 'POST') {
|
||||||
response = await _post(uri, data);
|
response = await _post(uri, data);
|
||||||
}
|
}
|
||||||
@ -83,19 +70,16 @@ class Api {
|
|||||||
if (method == 'DELETE') {
|
if (method == 'DELETE') {
|
||||||
response = await _delete(uri, data);
|
response = await _delete(uri, data);
|
||||||
}
|
}
|
||||||
if (response == null) {
|
int status = response.statusCode;
|
||||||
return {'success': false, 'message': 'No response for your request'};
|
|
||||||
}
|
|
||||||
int status = response!.statusCode;
|
|
||||||
if (status == 200) {
|
if (status == 200) {
|
||||||
print('SUCCESS');
|
print('SUCCESS');
|
||||||
Map<String, dynamic> respData = jsonDecode(response!.body);
|
Map<String, dynamic> respData = jsonDecode(response.body);
|
||||||
return {'success': true, 'payload': respData};
|
return {'success': true, 'payload': respData};
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
print('ERROR');
|
print('ERROR');
|
||||||
Map<String, dynamic> respData = jsonDecode(response!.body);
|
Map<String, dynamic> respData = jsonDecode(response.body);
|
||||||
return {'success': false, 'code': status, 'message': respData['message']};
|
return {'success': false, 'code': response.statusCode, 'message': respData['message']};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,17 +93,4 @@ class Api {
|
|||||||
int status = response.statusCode;
|
int status = response.statusCode;
|
||||||
return status == 200;
|
return status == 200;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<File?> downloadFile(String url, String fileName) async {
|
|
||||||
Uri uri = Uri.parse(url);
|
|
||||||
http.Client client = http.Client();
|
|
||||||
http.Response response = await client.get(uri);
|
|
||||||
if(response.statusCode == 200) {
|
|
||||||
final String dirPath = await Util.storagePath();
|
|
||||||
final file = File('$dirPath/$fileName');
|
|
||||||
await file.writeAsBytes(response.bodyBytes);
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
31
mobile/lib/data_image.dart
Normal file
31
mobile/lib/data_image.dart
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class DataImage extends StatefulWidget {
|
||||||
|
final String _data;
|
||||||
|
DataImage(this._data) {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
DataImageState createState() => new DataImageState(_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
class DataImageState extends State<MyHomePage> {
|
||||||
|
String _base64;
|
||||||
|
DataImageState(this._base64) {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_base64 == null)
|
||||||
|
return new Container();
|
||||||
|
Uint8List bytes = BASE64.decode(_base64);
|
||||||
|
return new Scaffold(
|
||||||
|
appBar: new AppBar(title: new Text('Example App')),
|
||||||
|
body: new ListTile(
|
||||||
|
leading: new Image.memory(bytes),
|
||||||
|
title: new Text(_base64),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,115 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'api.dart';
|
|
||||||
import 'util.dart';
|
|
||||||
import 'lib.dart';
|
|
||||||
|
|
||||||
class _ExploreTabState extends State<ExploreTab> {
|
|
||||||
List<dynamic> objects = [];
|
|
||||||
List<dynamic> projects = [];
|
|
||||||
bool loading = false;
|
|
||||||
int explorePage = 1;
|
|
||||||
final Api api = Api();
|
|
||||||
final Util util = Util();
|
|
||||||
|
|
||||||
@override
|
|
||||||
initState() {
|
|
||||||
super.initState();
|
|
||||||
getExploreData();
|
|
||||||
getData();
|
|
||||||
}
|
|
||||||
|
|
||||||
void getExploreData() async {
|
|
||||||
if (explorePage == -1) return;
|
|
||||||
var data = await api.request('GET', '/search/explore?page=${explorePage}');
|
|
||||||
if (data['success'] == true) {
|
|
||||||
setState(() {
|
|
||||||
loading = false;
|
|
||||||
objects = objects + data['payload']['objects'];
|
|
||||||
explorePage = data['payload']['objects'].length == 0 ? -1 : (explorePage + 1); // Set to -1 to disable 'load more'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void getData() async {
|
|
||||||
setState(() {
|
|
||||||
loading = true;
|
|
||||||
});
|
|
||||||
var data2 = await api.request('GET', '/search/discover');
|
|
||||||
if (data2['success'] == true) {
|
|
||||||
setState(() {
|
|
||||||
projects = data2['payload']['highlightProjects'];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
List<Widget> patternCards = objects.map<Widget>((object) =>
|
|
||||||
PatternCard(object)
|
|
||||||
).toList();
|
|
||||||
if (explorePage > -1) {
|
|
||||||
patternCards.add(Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.pink[50],
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(10)),
|
|
||||||
),
|
|
||||||
child:Center(
|
|
||||||
child: TextButton(
|
|
||||||
child: Text('Load more'),
|
|
||||||
onPressed: () => getExploreData(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text('Explore'),
|
|
||||||
),
|
|
||||||
body: loading ?
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.all(10.0),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: CircularProgressIndicator()
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
SizedBox(height: 10),
|
|
||||||
CustomText('Discover projects', 'h1', margin: 5),
|
|
||||||
SizedBox(height: 5),
|
|
||||||
Container(
|
|
||||||
height: 130,
|
|
||||||
child: ListView(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
children: projects.map((p) => ProjectCard(p)).toList()
|
|
||||||
)
|
|
||||||
),
|
|
||||||
SizedBox(height: 10),
|
|
||||||
CustomText('Recent patterns', 'h1', margin: 5),
|
|
||||||
SizedBox(height: 5),
|
|
||||||
Expanded(child: Container(
|
|
||||||
margin: EdgeInsets.only(left: 15, right: 15),
|
|
||||||
child: GridView.count(
|
|
||||||
crossAxisCount: 2,
|
|
||||||
mainAxisSpacing: 5,
|
|
||||||
crossAxisSpacing: 5,
|
|
||||||
childAspectRatio: 0.9,
|
|
||||||
children: patternCards,
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ExploreTab extends StatefulWidget {
|
|
||||||
@override
|
|
||||||
_ExploreTabState createState() => _ExploreTabState();
|
|
||||||
}
|
|
||||||
|
|
@ -6,41 +6,31 @@ import 'group_noticeboard.dart';
|
|||||||
import 'group_members.dart';
|
import 'group_members.dart';
|
||||||
|
|
||||||
class _GroupScreenState extends State<GroupScreen> {
|
class _GroupScreenState extends State<GroupScreen> {
|
||||||
final String id;
|
|
||||||
Map<String, dynamic>? _group;
|
|
||||||
int _selectedIndex = 0;
|
int _selectedIndex = 0;
|
||||||
|
List<Widget> _widgetOptions = <Widget> [];
|
||||||
|
final Map<String, dynamic> _group;
|
||||||
|
|
||||||
_GroupScreenState(this.id) { }
|
_GroupScreenState(this._group) {
|
||||||
|
_widgetOptions = <Widget> [
|
||||||
@override
|
GroupNoticeBoardTab(this._group),
|
||||||
void initState() {
|
GroupMembersTab(this._group)
|
||||||
fetchGroup();
|
];
|
||||||
super.initState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void fetchGroup() async {
|
void _onItemTapped(int index) {
|
||||||
Api api = Api();
|
|
||||||
var data = await api.request('GET', '/groups/' + id);
|
|
||||||
if (data['success'] == true) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_group = data['payload'];
|
_selectedIndex = index;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(_group?['name'] ?? 'Group')
|
title: Text(_group['name'])
|
||||||
),
|
),
|
||||||
body: Center(
|
body: Center(
|
||||||
child: _group != null ?
|
child: _widgetOptions.elementAt(_selectedIndex),
|
||||||
[
|
|
||||||
GroupNoticeBoardTab(_group!),
|
|
||||||
GroupMembersTab(_group!)
|
|
||||||
].elementAt(_selectedIndex)
|
|
||||||
: CircularProgressIndicator(),
|
|
||||||
),
|
),
|
||||||
bottomNavigationBar: BottomNavigationBar(
|
bottomNavigationBar: BottomNavigationBar(
|
||||||
items: const <BottomNavigationBarItem>[
|
items: const <BottomNavigationBarItem>[
|
||||||
@ -55,17 +45,15 @@ class _GroupScreenState extends State<GroupScreen> {
|
|||||||
],
|
],
|
||||||
currentIndex: _selectedIndex,
|
currentIndex: _selectedIndex,
|
||||||
selectedItemColor: Colors.pink[600],
|
selectedItemColor: Colors.pink[600],
|
||||||
onTap: (int index) => setState(() {
|
onTap: _onItemTapped,
|
||||||
_selectedIndex = index;
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class GroupScreen extends StatefulWidget {
|
class GroupScreen extends StatefulWidget {
|
||||||
final String id;
|
final Map<String,dynamic> group;
|
||||||
GroupScreen(this.id) { }
|
GroupScreen(this.group) { }
|
||||||
@override
|
@override
|
||||||
_GroupScreenState createState() => _GroupScreenState(id);
|
_GroupScreenState createState() => _GroupScreenState(group);
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
import 'util.dart';
|
import 'util.dart';
|
||||||
|
import 'group_noticeboard.dart';
|
||||||
import 'user.dart';
|
import 'user.dart';
|
||||||
|
|
||||||
class _GroupMembersTabState extends State<GroupMembersTab> {
|
class _GroupMembersTabState extends State<GroupMembersTab> {
|
||||||
|
final Util util = new Util();
|
||||||
final Map<String,dynamic> _group;
|
final Map<String,dynamic> _group;
|
||||||
final Api api = Api();
|
final Api api = Api();
|
||||||
List<dynamic> _members = [];
|
List<dynamic> _members = [];
|
||||||
@ -32,8 +33,15 @@ class _GroupMembersTabState extends State<GroupMembersTab> {
|
|||||||
|
|
||||||
Widget getMemberCard(member) {
|
Widget getMemberCard(member) {
|
||||||
return new ListTile(
|
return new ListTile(
|
||||||
onTap: () => context.push('/' + member['username']),
|
onTap: () {
|
||||||
leading: Util.avatarImage(Util.avatarUrl(member), size: 40),
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => UserScreen(member),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
leading: util.avatarImage(util.avatarUrl(member), size: 40),
|
||||||
trailing: Icon(Icons.keyboard_arrow_right),
|
trailing: Icon(Icons.keyboard_arrow_right),
|
||||||
title: Text(member['username'])
|
title: Text(member['username'])
|
||||||
);
|
);
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'util.dart';
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
|
import 'user.dart';
|
||||||
import 'lib.dart';
|
import 'lib.dart';
|
||||||
|
|
||||||
class _GroupNoticeBoardTabState extends State<GroupNoticeBoardTab> {
|
class _GroupNoticeBoardTabState extends State<GroupNoticeBoardTab> {
|
||||||
final TextEditingController _newEntryController = TextEditingController();
|
final TextEditingController _newEntryController = TextEditingController();
|
||||||
|
final Util utils = new Util();
|
||||||
final Api api = Api();
|
final Api api = Api();
|
||||||
Map<String,dynamic> _group;
|
Map<String,dynamic> _group;
|
||||||
List<dynamic> _entries = [];
|
List<dynamic> _entries = [];
|
||||||
@ -38,10 +42,8 @@ class _GroupNoticeBoardTabState extends State<GroupNoticeBoardTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _sendPost(context) async {
|
void _sendPost(context) async {
|
||||||
String text = _newEntryController.text;
|
|
||||||
if (text.length == 0) return;
|
|
||||||
setState(() => _posting = true);
|
setState(() => _posting = true);
|
||||||
var data = await api.request('POST', '/groups/' + _group['_id'] + '/entries', {'content': text});
|
var data = await api.request('POST', '/groups/' + _group['_id'] + '/entries', {'content': _newEntryController.text});
|
||||||
if (data['success'] == true) {
|
if (data['success'] == true) {
|
||||||
_newEntryController.value = TextEditingValue(text: '');
|
_newEntryController.value = TextEditingValue(text: '');
|
||||||
FocusScope.of(context).requestFocus(FocusNode());
|
FocusScope.of(context).requestFocus(FocusNode());
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'dart:convert';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'group.dart';
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
import 'model.dart';
|
|
||||||
import 'lib.dart';
|
|
||||||
|
|
||||||
class _GroupsTabState extends State<GroupsTab> {
|
class _GroupsTabState extends State<GroupsTab> {
|
||||||
List<dynamic> _groups = [];
|
List<dynamic> _groups = [];
|
||||||
@ -16,8 +16,6 @@ class _GroupsTabState extends State<GroupsTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void getGroups() async {
|
void getGroups() async {
|
||||||
AppModel model = Provider.of<AppModel>(context, listen: false);
|
|
||||||
if (model.user == null) return;
|
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
Api api = Api();
|
Api api = Api();
|
||||||
var data = await api.request('GET', '/groups');
|
var data = await api.request('GET', '/groups');
|
||||||
@ -30,40 +28,61 @@ class _GroupsTabState extends State<GroupsTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget buildGroupCard(Map<String,dynamic> group) {
|
Widget buildGroupCard(Map<String,dynamic> group) {
|
||||||
String? description = group['description'];
|
String description = group['description'];
|
||||||
if (description != null && description.length > 80) {
|
if (description != null && description.length > 80) {
|
||||||
description = description.substring(0, 77) + '...';
|
description = description.substring(0, 77) + '...';
|
||||||
} else if (description == null) {
|
} else {
|
||||||
description = 'This group doesn\'t have a description.';
|
description = '';
|
||||||
}
|
}
|
||||||
return Card(
|
return Card(
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => context.push('/groups/' + group['_id']),
|
onTap: () {
|
||||||
child: ListTile(
|
Navigator.push(
|
||||||
leading: Icon(Icons.people, size: 40, color: Colors.pink[300]),
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => GroupScreen(group),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: <Widget>[
|
||||||
|
new ListTile(
|
||||||
|
leading: Icon(Icons.people),
|
||||||
trailing: Icon(Icons.keyboard_arrow_right),
|
trailing: Icon(Icons.keyboard_arrow_right),
|
||||||
title: Text(group['name']),
|
title: Text(group['name']),
|
||||||
subtitle: Text(description.replaceAll("\n", " ")),
|
subtitle: Text(description.replaceAll("\n", " ")),
|
||||||
|
),
|
||||||
|
]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
)
|
||||||
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget getBody() {
|
@override
|
||||||
AppModel model = Provider.of<AppModel>(context);
|
Widget build(BuildContext context) {
|
||||||
if (model.user == null)
|
return Scaffold(
|
||||||
return LoginNeeded(text: 'Once logged in, you\'ll find your groups here.');
|
appBar: AppBar(
|
||||||
else if (_loading)
|
title: Text('Groups'),
|
||||||
return CircularProgressIndicator();
|
),
|
||||||
else if (_groups != null && _groups.length > 0)
|
body: _loading ?
|
||||||
return ListView.builder(
|
Container(
|
||||||
|
margin: const EdgeInsets.all(10.0),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: CircularProgressIndicator()
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
margin: const EdgeInsets.all(10.0),
|
||||||
|
child: (_groups != null && _groups.length > 0) ?
|
||||||
|
ListView.builder(
|
||||||
itemCount: _groups.length,
|
itemCount: _groups.length,
|
||||||
itemBuilder: (BuildContext context, int index) {
|
itemBuilder: (BuildContext context, int index) {
|
||||||
return buildGroupCard(_groups[index]);
|
return buildGroupCard(_groups[index]);
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
else
|
:
|
||||||
return Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
@ -71,20 +90,8 @@ class _GroupsTabState extends State<GroupsTab> {
|
|||||||
Image(image: AssetImage('assets/group.png'), width: 300),
|
Image(image: AssetImage('assets/group.png'), width: 300),
|
||||||
Text('Groups let you meet and keep in touch with others in the weaving community.', textAlign: TextAlign.center),
|
Text('Groups let you meet and keep in touch with others in the weaving community.', textAlign: TextAlign.center),
|
||||||
Text('Please use our website to join and leave groups.', textAlign: TextAlign.center),
|
Text('Please use our website to join and leave groups.', textAlign: TextAlign.center),
|
||||||
]);
|
])
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text('My Groups'),
|
|
||||||
),
|
),
|
||||||
body: Container(
|
|
||||||
margin: const EdgeInsets.all(10.0),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: getBody()
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'explore.dart';
|
|
||||||
import 'projects.dart';
|
import 'projects.dart';
|
||||||
import 'groups.dart';
|
import 'groups.dart';
|
||||||
|
|
||||||
@ -14,7 +13,6 @@ class HomeScreen extends StatefulWidget {
|
|||||||
class _MyStatefulWidgetState extends State<HomeScreen> {
|
class _MyStatefulWidgetState extends State<HomeScreen> {
|
||||||
int _selectedIndex = 0;
|
int _selectedIndex = 0;
|
||||||
List<Widget> _widgetOptions = <Widget> [
|
List<Widget> _widgetOptions = <Widget> [
|
||||||
ExploreTab(),
|
|
||||||
ProjectsTab(),
|
ProjectsTab(),
|
||||||
GroupsTab()
|
GroupsTab()
|
||||||
];
|
];
|
||||||
@ -33,17 +31,13 @@ class _MyStatefulWidgetState extends State<HomeScreen> {
|
|||||||
),
|
),
|
||||||
bottomNavigationBar: BottomNavigationBar(
|
bottomNavigationBar: BottomNavigationBar(
|
||||||
items: const <BottomNavigationBarItem>[
|
items: const <BottomNavigationBarItem>[
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.explore),
|
|
||||||
label: 'Explore',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.folder),
|
icon: Icon(Icons.folder),
|
||||||
label: 'My Projects',
|
label: 'Projects',
|
||||||
),
|
),
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.people),
|
icon: Icon(Icons.person),
|
||||||
label: 'My Groups',
|
label: 'Groups',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentIndex: _selectedIndex,
|
currentIndex: _selectedIndex,
|
||||||
|
@ -4,20 +4,17 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
import 'util.dart';
|
import 'util.dart';
|
||||||
import 'user.dart';
|
import 'user.dart';
|
||||||
import 'object.dart';
|
|
||||||
import 'project.dart';
|
|
||||||
|
|
||||||
class Alert extends StatelessWidget {
|
class Alert extends StatelessWidget {
|
||||||
final String type;
|
final String type;
|
||||||
final String title;
|
final String title;
|
||||||
final String description;
|
final String description;
|
||||||
final String actionText;
|
final String actionText;
|
||||||
final Widget? descriptionWidget;
|
final Widget descriptionWidget;
|
||||||
final Function? action;
|
final Function action;
|
||||||
Alert({this.type = 'info', this.title = '', this.description = '', this.descriptionWidget = null, this.actionText = 'Click here', this.action}) {}
|
Alert({this.type = 'info', this.title = '', this.description = '', this.descriptionWidget = null, this.actionText = 'Click here', this.action}) {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -42,7 +39,7 @@ class Alert extends StatelessWidget {
|
|||||||
color: accentColor,
|
color: accentColor,
|
||||||
borderRadius: new BorderRadius.all(Radius.circular(10.0)),
|
borderRadius: new BorderRadius.all(Radius.circular(10.0)),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(color: Colors.grey[50]!, spreadRadius: 5),
|
BoxShadow(color: Colors.grey[50], spreadRadius: 5),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -51,12 +48,12 @@ class Alert extends StatelessWidget {
|
|||||||
Icon(icon, color: color),
|
Icon(icon, color: color),
|
||||||
SizedBox(height: 20),
|
SizedBox(height: 20),
|
||||||
Text(description, textAlign: TextAlign.center),
|
Text(description, textAlign: TextAlign.center),
|
||||||
descriptionWidget != null ? descriptionWidget! : Text(""),
|
descriptionWidget,
|
||||||
action != null ? CupertinoButton(
|
action != null ? CupertinoButton(
|
||||||
child: Text(actionText),
|
child: Text(actionText),
|
||||||
onPressed: () => action!(),
|
onPressed: action,
|
||||||
) : Text("")
|
) : null
|
||||||
]
|
].where((o) => o != null).toList()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -64,16 +61,17 @@ class Alert extends StatelessWidget {
|
|||||||
|
|
||||||
class NoticeboardPost extends StatefulWidget {
|
class NoticeboardPost extends StatefulWidget {
|
||||||
final Map<String,dynamic> _entry;
|
final Map<String,dynamic> _entry;
|
||||||
final Function? onDelete;
|
final Function onDelete;
|
||||||
final Function? onReply;
|
final Function onReply;
|
||||||
NoticeboardPost(this._entry, {this.onDelete = null, this.onReply = null});
|
NoticeboardPost(this._entry, {this.onDelete = null, this.onReply = null});
|
||||||
_NoticeboardPostState createState() => _NoticeboardPostState(_entry, onDelete: onDelete, onReply: onReply);
|
_NoticeboardPostState createState() => _NoticeboardPostState(_entry, onDelete: onDelete, onReply: onReply);
|
||||||
}
|
}
|
||||||
class _NoticeboardPostState extends State<NoticeboardPost> {
|
class _NoticeboardPostState extends State<NoticeboardPost> {
|
||||||
final Map<String,dynamic> _entry;
|
final Map<String,dynamic> _entry;
|
||||||
|
final Util utils = new Util();
|
||||||
final Api api = new Api();
|
final Api api = new Api();
|
||||||
final Function? onDelete;
|
final Function onDelete;
|
||||||
final Function? onReply;
|
final Function onReply;
|
||||||
final TextEditingController _replyController = TextEditingController();
|
final TextEditingController _replyController = TextEditingController();
|
||||||
bool _isReplying = false;
|
bool _isReplying = false;
|
||||||
bool _replying = false;
|
bool _replying = false;
|
||||||
@ -86,9 +84,7 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
|
|||||||
if (data['success'] == true) {
|
if (data['success'] == true) {
|
||||||
_replyController.value = TextEditingValue(text: '');
|
_replyController.value = TextEditingValue(text: '');
|
||||||
FocusScope.of(context).requestFocus(FocusNode());
|
FocusScope.of(context).requestFocus(FocusNode());
|
||||||
if (onReply != null) {
|
onReply(data['payload']);
|
||||||
onReply!(data['payload']);
|
|
||||||
}
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_replying = false;
|
_replying = false;
|
||||||
_isReplying = false;
|
_isReplying = false;
|
||||||
@ -99,10 +95,8 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
|
|||||||
void _deletePost() async {
|
void _deletePost() async {
|
||||||
var data = await api.request('DELETE', '/groups/' + _entry['group'] + '/entries/' + _entry['_id']);
|
var data = await api.request('DELETE', '/groups/' + _entry['group'] + '/entries/' + _entry['_id']);
|
||||||
if (data['success'] == true) {
|
if (data['success'] == true) {
|
||||||
if (onDelete != null) {
|
onDelete(_entry);
|
||||||
onDelete!(_entry);
|
Navigator.of(context).pop();
|
||||||
}
|
|
||||||
context.pop();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,17 +104,17 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var createdAt = DateTime.parse(_entry['createdAt']);
|
var createdAt = DateTime.parse(_entry['createdAt']);
|
||||||
bool isReply = _entry['inReplyTo'] != null;
|
bool isReply = _entry['inReplyTo'] != null;
|
||||||
int replyCount = _entry['replies'] == null ? 0 : _entry['replies']!.length;
|
int replyCount = _entry['replies'] == null ? 0 : _entry['replies'].length;
|
||||||
String replyText = 'Write a reply...';
|
String replyText = 'Write a reply...';
|
||||||
if (replyCount == 1) replyText = '1 Reply';
|
if (replyCount == 1) replyText = '1 Reply';
|
||||||
if (replyCount > 1) replyText = replyCount.toString() + ' replies';
|
if (replyCount > 1) replyText = replyCount.toString() + ' replies';
|
||||||
if (_isReplying) replyText = 'Cancel reply';
|
if (_isReplying) replyText = 'Cancel reply';
|
||||||
List<Widget> replyWidgets = [];
|
List<Widget> replyWidgets = [];
|
||||||
if (_entry['replies'] != null) {
|
if (_entry['replies'] != null) {
|
||||||
for (int i = 0; i < _entry['replies']!.length; i++) {
|
for (int i = 0; i < _entry['replies'].length; i++) {
|
||||||
replyWidgets.add(new Container(
|
replyWidgets.add(new Container(
|
||||||
key: Key(_entry['replies']![i]['_id']),
|
key: Key(_entry['replies'][i]['_id']),
|
||||||
child: NoticeboardPost(_entry['replies']![i], onDelete: onDelete)
|
child: NoticeboardPost(_entry['replies'][i], onDelete: onDelete)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -133,22 +127,22 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
ElevatedButton(
|
RaisedButton(
|
||||||
//color: Colors.orange,
|
color: Colors.orange,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
launch('https://www.treadl.com');
|
launch('https://www.treadl.com');
|
||||||
},
|
},
|
||||||
child: Text('Report this post'),
|
child: Text('Report this post'),
|
||||||
),
|
),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
ElevatedButton(
|
RaisedButton(
|
||||||
//color: Colors.red,
|
color: Colors.red,
|
||||||
onPressed: _deletePost,
|
onPressed: _deletePost,
|
||||||
child: Text('Delete post'),
|
child: Text('Delete post'),
|
||||||
),
|
),
|
||||||
TextButton(
|
FlatButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
child: Text('Cancel'),
|
child: Text('Cancel'),
|
||||||
)
|
)
|
||||||
@ -167,8 +161,12 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
|
|||||||
Row(
|
Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => context.push('/' + _entry['authorUser']['username']),
|
onTap: () {
|
||||||
child: Util.avatarImage(Util.avatarUrl(_entry['authorUser']), size: isReply ? 30 : 40)
|
Navigator.push(context, MaterialPageRoute(
|
||||||
|
builder: (context) => UserScreen(_entry['authorUser']),
|
||||||
|
));
|
||||||
|
},
|
||||||
|
child: utils.avatarImage(utils.avatarUrl(_entry['authorUser']), size: isReply ? 30 : 40)
|
||||||
),
|
),
|
||||||
SizedBox(width: 5),
|
SizedBox(width: 5),
|
||||||
Text(_entry['authorUser']['username'], style: TextStyle(color: Colors.pink)),
|
Text(_entry['authorUser']['username'], style: TextStyle(color: Colors.pink)),
|
||||||
@ -178,18 +176,18 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
|
|||||||
!isReply ? GestureDetector(
|
!isReply ? GestureDetector(
|
||||||
onTap: () => setState(() => _isReplying = !_isReplying),
|
onTap: () => setState(() => _isReplying = !_isReplying),
|
||||||
child: Text(replyText, style: TextStyle(color: replyCount > 0 ? Colors.pink : Colors.black, fontSize: 10, fontWeight: FontWeight.bold)),
|
child: Text(replyText, style: TextStyle(color: replyCount > 0 ? Colors.pink : Colors.black, fontSize: 10, fontWeight: FontWeight.bold)),
|
||||||
): SizedBox(width: 0),
|
): null,
|
||||||
],
|
].where((o) => o != null).toList(),
|
||||||
),
|
),
|
||||||
Row(children: [
|
Row(children: [
|
||||||
SizedBox(width: 45),
|
SizedBox(width: 45),
|
||||||
Expanded(child: Text(_entry['content'], textAlign: TextAlign.left))
|
Expanded(child: Text(_entry['content'], textAlign: TextAlign.left))
|
||||||
]),
|
]),
|
||||||
_isReplying ? NoticeboardInput(_replyController, _sendReply, _replying, label: 'Reply to this post') : SizedBox(width: 0),
|
_isReplying ? NoticeboardInput(_replyController, _sendReply, _replying, label: 'Reply to this post') : null,
|
||||||
Column(
|
Column(
|
||||||
children: replyWidgets
|
children: replyWidgets
|
||||||
),
|
),
|
||||||
],
|
].where((o) => o != null).toList(),
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -217,7 +215,7 @@ class NoticeboardInput extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => _onPost!(),
|
onPressed: _onPost,
|
||||||
color: Colors.pink,
|
color: Colors.pink,
|
||||||
icon: _posting ? CircularProgressIndicator() : Icon(Icons.send),
|
icon: _posting ? CircularProgressIndicator() : Icon(Icons.send),
|
||||||
)
|
)
|
||||||
@ -227,173 +225,3 @@ class NoticeboardInput extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class UserChip extends StatelessWidget {
|
|
||||||
final Map<String,dynamic> user;
|
|
||||||
UserChip(this.user) {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
ImageProvider? avatar = Util.avatarUrl(user);
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => context.push('/' + user['username']),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Util.avatarImage(avatar, size: 20),
|
|
||||||
SizedBox(width: 5),
|
|
||||||
Text(user['username'], style: TextStyle(color: Colors.grey))
|
|
||||||
]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class PatternCard extends StatelessWidget {
|
|
||||||
final Map<String,dynamic> object;
|
|
||||||
PatternCard(this.object) {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Card(
|
|
||||||
elevation: 2,
|
|
||||||
clipBehavior: Clip.hardEdge,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(6.0),
|
|
||||||
),
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () {
|
|
||||||
context.push('/' + object['projectObject']['owner']['username'] + '/' + object['projectObject']['path'] + '/' + object['_id']);
|
|
||||||
},
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
height: 100,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
image: DecorationImage(
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
image: NetworkImage(object['previewUrl']),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
padding: EdgeInsets.all(10),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
UserChip(object['projectObject']['owner']),
|
|
||||||
SizedBox(height: 5),
|
|
||||||
Text(Util.ellipsis(object['name'], 35), style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProjectCard extends StatelessWidget {
|
|
||||||
final Map<String,dynamic> project;
|
|
||||||
ProjectCard(this.project) {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Card(
|
|
||||||
elevation: 2,
|
|
||||||
clipBehavior: Clip.hardEdge,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(6.0),
|
|
||||||
),
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () {
|
|
||||||
context.push('/' + this.project['owner']['username'] + '/' + this.project['path']);
|
|
||||||
},
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 200,
|
|
||||||
padding: EdgeInsets.all(10),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.folder, color: Colors.pink[200]),
|
|
||||||
SizedBox(height: 10),
|
|
||||||
UserChip(project['owner']),
|
|
||||||
SizedBox(height: 5),
|
|
||||||
Text(Util.ellipsis(project['name'], 35), style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class CustomText extends StatelessWidget {
|
|
||||||
final String text;
|
|
||||||
final String type;
|
|
||||||
final double margin;
|
|
||||||
TextStyle? style;
|
|
||||||
CustomText(this.text, this.type, {this.margin = 0}) { }
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (this.type == 'h1') {
|
|
||||||
style = Theme.of(context).textTheme.titleLarge;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
style = TextStyle();
|
|
||||||
}
|
|
||||||
return Container(
|
|
||||||
margin: EdgeInsets.all(this.margin),
|
|
||||||
child: Text(text, style: style)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class LoginNeeded extends StatelessWidget {
|
|
||||||
final String? text;
|
|
||||||
LoginNeeded({this.text}) {}
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text('You need to login to see this', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
|
|
||||||
Image(image: AssetImage('assets/login.png'), width: 300),
|
|
||||||
text != null ? Text(text!, textAlign: TextAlign.center) : SizedBox(height: 10),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
context.push('/welcome');
|
|
||||||
},
|
|
||||||
child: new Text("Login or register",
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class EmptyBox extends StatelessWidget {
|
|
||||||
final String title;
|
|
||||||
final String? description;
|
|
||||||
|
|
||||||
EmptyBox(this.title, {this.description}) {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text(title, style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
|
|
||||||
Image(image: AssetImage('assets/empty.png'), width: 300),
|
|
||||||
description != null ? Text('Add a pattern file, an image, or something else to this project using the + button below.', textAlign: TextAlign.center) : SizedBox(height: 0),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,11 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
import 'model.dart';
|
|
||||||
|
|
||||||
class _LoginScreenState extends State<LoginScreen> {
|
class _LoginScreenState extends State<LoginScreen> {
|
||||||
final TextEditingController _emailController = TextEditingController();
|
final TextEditingController _emailController = TextEditingController();
|
||||||
@ -13,14 +11,15 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
final Api api = Api();
|
final Api api = Api();
|
||||||
bool _loggingIn = false;
|
bool _loggingIn = false;
|
||||||
|
|
||||||
void _submit(BuildContext context) async {
|
void _submit(context) async {
|
||||||
setState(() => _loggingIn = true);
|
setState(() => _loggingIn = true);
|
||||||
var data = await api.request('POST', '/accounts/login', {'email': _emailController.text, 'password': _passwordController.text});
|
var data = await api.request('POST', '/accounts/login', {'email': _emailController.text, 'password': _passwordController.text});
|
||||||
setState(() => _loggingIn = false);
|
setState(() => _loggingIn = false);
|
||||||
if (data['success'] == true) {
|
if (data['success'] == true) {
|
||||||
AppModel model = Provider.of<AppModel>(context, listen: false);
|
String token = data['payload']['token'];
|
||||||
await model.setToken(data['payload']['token']);
|
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||||
context.go('/onboarding');
|
prefs.setString('apiToken', token);
|
||||||
|
Navigator.of(context).pushNamedAndRemoveUntil('/onboarding', (Route<dynamic> route) => false);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
showDialog(
|
showDialog(
|
||||||
@ -32,7 +31,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
CupertinoDialogAction(
|
CupertinoDialogAction(
|
||||||
isDefaultAction: true,
|
isDefaultAction: true,
|
||||||
child: Text('Try again'),
|
child: Text('Try again'),
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@ -47,17 +46,20 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
title: Text('Login to Treadl'),
|
title: Text('Login to Treadl'),
|
||||||
),
|
),
|
||||||
body: Container(
|
body: Container(
|
||||||
margin: const EdgeInsets.only(top: 40, left: 10, right: 10),
|
margin: const EdgeInsets.all(10.0),
|
||||||
child: ListView(
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text('Login with your Treadl account', style: Theme.of(context).textTheme.titleLarge),
|
Image(image: AssetImage('assets/logo.png'), width: 100),
|
||||||
SizedBox(height: 30),
|
SizedBox(height: 20),
|
||||||
|
Text('Login using your Treadl account.'),
|
||||||
|
SizedBox(height: 20),
|
||||||
TextField(
|
TextField(
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
controller: _emailController,
|
controller: _emailController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'sam@example.com', labelText: 'Email address or username',
|
hintText: 'sam@example.com', labelText: 'Email address or username'
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
@ -66,8 +68,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Type your password', labelText: 'Your password',
|
hintText: 'Type your password', labelText: 'Your password'
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 5),
|
SizedBox(height: 5),
|
||||||
@ -79,14 +80,17 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
)]
|
)]
|
||||||
),
|
),
|
||||||
SizedBox(height: 20),
|
SizedBox(height: 20),
|
||||||
ElevatedButton(
|
RaisedButton(
|
||||||
onPressed: () => _submit(context),
|
onPressed: () => _submit(context),
|
||||||
|
color: Colors.pink,
|
||||||
child: _loggingIn ? SizedBox(height: 20, width: 20, child:CircularProgressIndicator(backgroundColor: Colors.white)) : Text("Login",
|
child: _loggingIn ? SizedBox(height: 20, width: 20, child:CircularProgressIndicator(backgroundColor: Colors.white)) : Text("Login",
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 15)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
)
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -3,54 +3,19 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
//import 'package:fluttertoast/fluttertoast.dart';
|
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
import 'model.dart';
|
import 'store.dart';
|
||||||
import 'welcome.dart';
|
import 'welcome.dart';
|
||||||
import 'login.dart';
|
import 'login.dart';
|
||||||
import 'register.dart';
|
import 'register.dart';
|
||||||
import 'onboarding.dart';
|
import 'onboarding.dart';
|
||||||
import 'home.dart';
|
import 'home.dart';
|
||||||
import 'project.dart';
|
|
||||||
import 'object.dart';
|
|
||||||
import 'settings.dart';
|
|
||||||
import 'group.dart';
|
|
||||||
import 'user.dart';
|
|
||||||
|
|
||||||
final router = GoRouter(
|
|
||||||
routes: [
|
|
||||||
GoRoute(path: '/', builder: (context, state) => Startup()),
|
|
||||||
GoRoute(path: '/welcome', pageBuilder: (context, state) {
|
|
||||||
return CustomTransitionPage(
|
|
||||||
key: state.pageKey,
|
|
||||||
child: WelcomeScreen(),
|
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
|
||||||
// Change the opacity of the screen using a Curve based on the the animation's value
|
|
||||||
return FadeTransition(
|
|
||||||
opacity:
|
|
||||||
CurveTween(curve: Curves.easeInOutCirc).animate(animation),
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
GoRoute(path: '/login', builder: (context, state) => LoginScreen()),
|
|
||||||
GoRoute(path: '/register', builder: (context, state) => RegisterScreen()),
|
|
||||||
GoRoute(path: '/onboarding', builder: (context, state) => OnboardingScreen()),
|
|
||||||
GoRoute(path: '/home', builder: (context, state) => HomeScreen()),
|
|
||||||
GoRoute(path: '/settings', builder: (context, state) => SettingsScreen()),
|
|
||||||
GoRoute(path: '/groups/:id', builder: (context, state) => GroupScreen(state.pathParameters['id']!)),
|
|
||||||
GoRoute(path: '/:username', builder: (context, state) => UserScreen(state.pathParameters['username']!)),
|
|
||||||
GoRoute(path: '/:username/:path', builder: (context, state) => ProjectScreen(state.pathParameters['username']!, state.pathParameters['path']!)),
|
|
||||||
GoRoute(path: '/:username/:path/:id', builder: (context, state) => ObjectScreen(state.pathParameters['username']!, state.pathParameters['path']!, state.pathParameters['id']!)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
runApp(
|
runApp(
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (context) => AppModel(),
|
create: (context) => Store(),
|
||||||
child: MyApp()
|
child: MyApp()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@ -72,13 +37,21 @@ class _AppState extends State<MyApp> {
|
|||||||
// Initialize FlutterFire:
|
// Initialize FlutterFire:
|
||||||
future: _initialization,
|
future: _initialization,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
return MaterialApp.router(
|
return MaterialApp(
|
||||||
routerConfig: router,
|
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
title: 'Treadl',
|
title: 'Treadl',
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
primarySwatch: Colors.pink,
|
primarySwatch: Colors.pink,
|
||||||
|
textSelectionColor: Colors.blue,
|
||||||
),
|
),
|
||||||
|
home: Startup(),
|
||||||
|
routes: <String, WidgetBuilder>{
|
||||||
|
'/welcome': (BuildContext context) => WelcomeScreen(),
|
||||||
|
'/login': (BuildContext context) => LoginScreen(),
|
||||||
|
'/register': (BuildContext context) => RegisterScreen(),
|
||||||
|
'/onboarding': (BuildContext context) => OnboardingScreen(),
|
||||||
|
'/home': (BuildContext context) => HomeScreen(),
|
||||||
|
}
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -91,12 +64,12 @@ class Startup extends StatelessWidget {
|
|||||||
Startup() {
|
Startup() {
|
||||||
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
|
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
|
||||||
if (message.notification != null) {
|
if (message.notification != null) {
|
||||||
print(message.notification!);
|
print(message.notification.body);
|
||||||
String text = '';
|
String text = '';
|
||||||
if (message.notification != null && message.notification!.body != null) {
|
if (message.notification != null && message.notification.body != null) {
|
||||||
text = message.notification!.body!;
|
text = message.notification.body;
|
||||||
}
|
}
|
||||||
/*Fluttertoast.showToast(
|
Fluttertoast.showToast(
|
||||||
msg: text,
|
msg: text,
|
||||||
toastLength: Toast.LENGTH_LONG,
|
toastLength: Toast.LENGTH_LONG,
|
||||||
gravity: ToastGravity.TOP,
|
gravity: ToastGravity.TOP,
|
||||||
@ -104,7 +77,7 @@ class Startup extends StatelessWidget {
|
|||||||
backgroundColor: Colors.grey[100],
|
backgroundColor: Colors.grey[100],
|
||||||
textColor: Colors.black,
|
textColor: Colors.black,
|
||||||
fontSize: 16.0
|
fontSize: 16.0
|
||||||
);*/
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -113,10 +86,10 @@ class Startup extends StatelessWidget {
|
|||||||
if (_handled) return;
|
if (_handled) return;
|
||||||
_handled = true;
|
_handled = true;
|
||||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||||
String? token = prefs.getString('apiToken');
|
final String token = prefs.getString('apiToken');
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
AppModel model = Provider.of<AppModel>(context, listen: false);
|
Provider.of<Store>(context, listen: false).setToken(token);
|
||||||
await model.setToken(token!);
|
|
||||||
FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
|
FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
|
||||||
await _firebaseMessaging.requestPermission(
|
await _firebaseMessaging.requestPermission(
|
||||||
alert: true,
|
alert: true,
|
||||||
@ -127,14 +100,19 @@ class Startup extends StatelessWidget {
|
|||||||
provisional: false,
|
provisional: false,
|
||||||
sound: true,
|
sound: true,
|
||||||
);
|
);
|
||||||
String? _pushToken = await _firebaseMessaging.getToken();
|
String _pushToken = await _firebaseMessaging.getToken();
|
||||||
if (_pushToken != null) {
|
if (_pushToken != null) {
|
||||||
print("sending push");
|
print("sending push");
|
||||||
Api api = Api();
|
Api api = Api();
|
||||||
api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken!});
|
api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken});
|
||||||
}
|
}
|
||||||
|
print('111');
|
||||||
|
// Push without including current route in stack:
|
||||||
|
Navigator.of(context, rootNavigator: true).pushNamedAndRemoveUntil('/home', (Route<dynamic> route) => false);
|
||||||
|
print('222');
|
||||||
|
} else {
|
||||||
|
Navigator.of(context).pushNamedAndRemoveUntil('/welcome', (Route<dynamic> route) => false);
|
||||||
}
|
}
|
||||||
context.go('/home');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -1,65 +0,0 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import 'api.dart';
|
|
||||||
|
|
||||||
class User {
|
|
||||||
final String id;
|
|
||||||
final String username;
|
|
||||||
String? avatar;
|
|
||||||
String? avatarUrl;
|
|
||||||
|
|
||||||
User(this.id, this.username, {this.avatar, this.avatarUrl}) {}
|
|
||||||
|
|
||||||
static User loadJSON(Map<String,dynamic> input) {
|
|
||||||
return User(input['_id'], input['username'], avatar: input['avatar'], avatarUrl: input['avatarUrl']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AppModel extends ChangeNotifier {
|
|
||||||
User? user;
|
|
||||||
void setUser(User? u) {
|
|
||||||
user = u;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
String? apiToken;
|
|
||||||
Future<void> setToken(String? newToken) async {
|
|
||||||
apiToken = newToken;
|
|
||||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
|
||||||
if (apiToken != null) {
|
|
||||||
Api api = Api(token: apiToken!);
|
|
||||||
prefs.setString('apiToken', apiToken!);
|
|
||||||
var data = await api.request('GET', '/users/me');
|
|
||||||
if (data['success'] == true) {
|
|
||||||
setUser(User.loadJSON(data['payload']));
|
|
||||||
print(data);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
prefs.remove('apiToken');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
/// Internal, private state of the cart.
|
|
||||||
final List<Item> _items = [];
|
|
||||||
|
|
||||||
/// An unmodifiable view of the items in the cart.
|
|
||||||
UnmodifiableListView<Item> get items => UnmodifiableListView(_items);
|
|
||||||
|
|
||||||
/// The current total price of all items (assuming all items cost $42).
|
|
||||||
int get totalPrice => _items.length * 42;
|
|
||||||
|
|
||||||
/// Adds [item] to cart. This and [removeAll] are the only ways to modify the
|
|
||||||
/// cart from the outside.
|
|
||||||
void add(Item item) {
|
|
||||||
_items.add(item);
|
|
||||||
// This call tells the widgets that are listening to this model to rebuild.
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes all items from the cart.
|
|
||||||
void removeAll() {
|
|
||||||
_items.clear();
|
|
||||||
// This call tells the widgets that are listening to this model to rebuild.
|
|
||||||
notifyListeners();
|
|
||||||
}*/
|
|
||||||
}
|
|
@ -3,64 +3,22 @@ import 'package:flutter/cupertino.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:flutter_html/flutter_html.dart';
|
import 'package:flutter_html/flutter_html.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'dart:io';
|
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
import 'util.dart';
|
|
||||||
import 'model.dart';
|
|
||||||
import 'patterns/pattern.dart';
|
|
||||||
import 'patterns/viewer.dart';
|
|
||||||
|
|
||||||
class _ObjectScreenState extends State<ObjectScreen> {
|
class _ObjectScreenState extends State<ObjectScreen> {
|
||||||
final String username;
|
final Map<String,dynamic> _object;
|
||||||
final String projectPath;
|
final Function _onDelete;
|
||||||
final String id;
|
|
||||||
Map<String,dynamic>? object;
|
|
||||||
Map<String,dynamic>? pattern;
|
|
||||||
bool _isLoading = false;
|
|
||||||
final Api api = Api();
|
final Api api = Api();
|
||||||
|
|
||||||
_ObjectScreenState(this.username, this.projectPath, this.id) { }
|
_ObjectScreenState(this._object, this._onDelete) { }
|
||||||
|
|
||||||
@override
|
|
||||||
initState() {
|
|
||||||
super.initState();
|
|
||||||
fetchObject();
|
|
||||||
}
|
|
||||||
|
|
||||||
void fetchObject() async {
|
|
||||||
var data = await api.request('GET', '/objects/' + id);
|
|
||||||
if (data['success'] == true) {
|
|
||||||
setState(() {
|
|
||||||
object = data['payload'];
|
|
||||||
pattern = data['payload']['pattern'];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _shareObject() async {
|
|
||||||
setState(() => _isLoading = true);
|
|
||||||
File? file;
|
|
||||||
if (object!['type'] == 'pattern') {
|
|
||||||
var data = await api.request('GET', '/objects/' + id + '/wif');
|
|
||||||
if (data['success'] == true) {
|
|
||||||
file = await Util.writeFile(object!['name'] + '.wif', data['payload']['wif']);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
String fileName = Uri.file(object!['url']).pathSegments.last;
|
|
||||||
file = await api.downloadFile(object!['url'], fileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file != null) {
|
|
||||||
Util.shareFile(file!, withDelete: true);
|
|
||||||
}
|
|
||||||
setState(() => _isLoading = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _deleteObject(BuildContext context, BuildContext modalContext) async {
|
void _deleteObject(BuildContext context, BuildContext modalContext) async {
|
||||||
var data = await api.request('DELETE', '/objects/' + id);
|
var data = await api.request('DELETE', '/objects/' + _object['_id']);
|
||||||
if (data['success']) {
|
if (data['success']) {
|
||||||
context.go('/home');
|
Navigator.pop(context);
|
||||||
|
Navigator.pop(modalContext);
|
||||||
|
Navigator.pop(context);
|
||||||
|
_onDelete(_object['_id']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,13 +26,13 @@ class _ObjectScreenState extends State<ObjectScreen> {
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: modalContext,
|
context: modalContext,
|
||||||
builder: (BuildContext context) => CupertinoAlertDialog(
|
builder: (BuildContext context) => CupertinoAlertDialog(
|
||||||
title: new Text('Really delete this item?'),
|
title: new Text('Really delete this object?'),
|
||||||
content: new Text('This action cannot be undone.'),
|
content: new Text('This action cannot be undone.'),
|
||||||
actions: <Widget>[
|
actions: <Widget>[
|
||||||
CupertinoDialogAction(
|
CupertinoDialogAction(
|
||||||
isDefaultAction: true,
|
isDefaultAction: true,
|
||||||
child: Text('No'),
|
child: Text('No'),
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
CupertinoDialogAction(
|
CupertinoDialogAction(
|
||||||
isDestructiveAction: true,
|
isDestructiveAction: true,
|
||||||
@ -86,45 +44,6 @@ class _ObjectScreenState extends State<ObjectScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _renameObject(BuildContext context) {
|
|
||||||
TextEditingController renameController = TextEditingController();
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) {
|
|
||||||
return AlertDialog(
|
|
||||||
title: Text('Rename this item'),
|
|
||||||
content: TextField(
|
|
||||||
autofocus: true,
|
|
||||||
controller: renameController,
|
|
||||||
decoration: InputDecoration(hintText: "Enter a new name for the item"),
|
|
||||||
),
|
|
||||||
actions: <Widget>[
|
|
||||||
TextButton(
|
|
||||||
child: Text('CANCEL'),
|
|
||||||
onPressed: () {
|
|
||||||
context.pop();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
child: Text('OK'),
|
|
||||||
onPressed: () async {
|
|
||||||
var data = await api.request('PUT', '/objects/' + id, {'name': renameController.text});
|
|
||||||
if (data['success']) {
|
|
||||||
context.pop();
|
|
||||||
object!['name'] = data['payload']['name'];
|
|
||||||
setState(() {
|
|
||||||
object = object;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
context.pop();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showSettingsModal(context) {
|
void _showSettingsModal(context) {
|
||||||
showCupertinoModalPopup(
|
showCupertinoModalPopup(
|
||||||
context: context,
|
context: context,
|
||||||
@ -132,17 +51,13 @@ class _ObjectScreenState extends State<ObjectScreen> {
|
|||||||
return CupertinoActionSheet(
|
return CupertinoActionSheet(
|
||||||
title: Text('Manage this object'),
|
title: Text('Manage this object'),
|
||||||
cancelButton: CupertinoActionSheetAction(
|
cancelButton: CupertinoActionSheetAction(
|
||||||
onPressed: () => modalContext.pop(),
|
onPressed: () => Navigator.of(modalContext).pop(),
|
||||||
child: Text('Cancel')
|
child: Text('Cancel')
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
CupertinoActionSheetAction(
|
|
||||||
onPressed: () => _renameObject(context),
|
|
||||||
child: Text('Rename item'),
|
|
||||||
),
|
|
||||||
CupertinoActionSheetAction(
|
CupertinoActionSheetAction(
|
||||||
onPressed: () => _confirmDeleteObject(modalContext),
|
onPressed: () => _confirmDeleteObject(modalContext),
|
||||||
child: Text('Delete item'),
|
child: Text('Delete object'),
|
||||||
isDestructiveAction: true,
|
isDestructiveAction: true,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@ -152,79 +67,43 @@ class _ObjectScreenState extends State<ObjectScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget getObjectWidget() {
|
Widget getObjectWidget() {
|
||||||
if (object == null) {
|
if (_object['isImage'] == true) {
|
||||||
return Center(child: Column(
|
return Image.network(_object['url']);
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [CircularProgressIndicator()]
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
else if (object!['isImage'] == true && object!['url'] != null) {
|
else if (_object['type'] == 'pattern') {
|
||||||
print(object!['url']);
|
var dat = Uri.parse(_object['preview']).data;
|
||||||
return Image.network(object!['url']);
|
return Image.memory(dat.contentAsBytes());
|
||||||
}
|
|
||||||
else if (object!['type'] == 'pattern') {
|
|
||||||
if (pattern != null) {
|
|
||||||
return PatternViewer(pattern!, withEditor: true);
|
|
||||||
}
|
|
||||||
else if (object!['previewUrl'] != null) {
|
|
||||||
return Image.network(object!['previewUrl']!);;
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return Column(
|
return RaisedButton(child: Text('View file'), onPressed: () {
|
||||||
children: [
|
launch(_object['url']);
|
||||||
SizedBox(height: 50),
|
});
|
||||||
Icon(Icons.pattern, size: 40),
|
|
||||||
SizedBox(height: 20),
|
|
||||||
Text('A preview of this pattern is not yet available'),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return Center(child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text('Treadl cannot display this type of item.'),
|
|
||||||
SizedBox(height: 20),
|
|
||||||
ElevatedButton(child: Text('View file'), onPressed: () {
|
|
||||||
launch(object!['url']);
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
AppModel model = Provider.of<AppModel>(context);
|
|
||||||
User? user = model.user;
|
|
||||||
String description = '';
|
String description = '';
|
||||||
if (object?['description'] != null)
|
if (_object['description'] != null)
|
||||||
description = object!['description']!;
|
description = _object['description'];
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(object?['name'] ?? 'Object'),
|
title: Text(_object['name']),
|
||||||
actions: <Widget>[
|
actions: <Widget>[
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Icons.ios_share),
|
|
||||||
onPressed: () {
|
|
||||||
_shareObject();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Util.canEditProject(user, object?['projectObject']) ? IconButton(
|
|
||||||
icon: Icon(Icons.settings),
|
icon: Icon(Icons.settings),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_showSettingsModal(context);
|
_showSettingsModal(context);
|
||||||
},
|
},
|
||||||
) : SizedBox(height: 0),
|
),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
body: Container(
|
body: Container(
|
||||||
margin: const EdgeInsets.all(10.0),
|
margin: const EdgeInsets.all(10.0),
|
||||||
child: Column(
|
child: ListView(
|
||||||
children: [
|
children: <Widget>[
|
||||||
_isLoading ? LinearProgressIndicator() : SizedBox(height: 0),
|
getObjectWidget(),
|
||||||
Expanded(child: getObjectWidget()),
|
Html(data: description)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@ -233,11 +112,9 @@ class _ObjectScreenState extends State<ObjectScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ObjectScreen extends StatefulWidget {
|
class ObjectScreen extends StatefulWidget {
|
||||||
final String username;
|
final Map<String,dynamic> _object;
|
||||||
final String projectPath;
|
final Function _onDelete;
|
||||||
final String id;
|
ObjectScreen(this._object, this._onDelete) { }
|
||||||
ObjectScreen(this.username, this.projectPath, this.id, ) { }
|
|
||||||
@override
|
@override
|
||||||
_ObjectScreenState createState() => _ObjectScreenState(username, projectPath, id);
|
_ObjectScreenState createState() => _ObjectScreenState(_object, _onDelete);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
|
|
||||||
class _OnboardingScreenState extends State<OnboardingScreen> {
|
class _OnboardingScreenState extends State<OnboardingScreen> {
|
||||||
@ -11,7 +9,7 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
|||||||
);
|
);
|
||||||
final Api api = Api();
|
final Api api = Api();
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
String? _pushToken;
|
String _pushToken;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@ -20,7 +18,6 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _requestPushPermissions() async {
|
void _requestPushPermissions() async {
|
||||||
try {
|
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
|
FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
|
||||||
await _firebaseMessaging.requestPermission(
|
await _firebaseMessaging.requestPermission(
|
||||||
@ -33,11 +30,14 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
|||||||
sound: true,
|
sound: true,
|
||||||
);
|
);
|
||||||
_pushToken = await _firebaseMessaging.getToken();
|
_pushToken = await _firebaseMessaging.getToken();
|
||||||
|
/*final FirebaseMessaging _firebaseMessaging = FirebaseMessaging();
|
||||||
|
await _firebaseMessaging.requestNotificationPermissions(
|
||||||
|
const IosNotificationSettings(sound: true, badge: true, alert: true, provisional: false),
|
||||||
|
);
|
||||||
|
_pushToken = await _firebaseMessaging.getToken();*/
|
||||||
if (_pushToken != null) {
|
if (_pushToken != null) {
|
||||||
api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken!});
|
api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
on Exception { }
|
|
||||||
setState(() => _loading = false);
|
setState(() => _loading = false);
|
||||||
_controller.animateToPage(2, duration: Duration(milliseconds: 500), curve: Curves.easeInOut);
|
_controller.animateToPage(2, duration: Duration(milliseconds: 500), curve: Curves.easeInOut);
|
||||||
}
|
}
|
||||||
@ -57,14 +57,14 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text('Thanks for joining us! 🎉', style: TextStyle(color: Colors.white, fontSize: 20), textAlign: TextAlign.center),
|
Text('Thanks for joining us! 🎉', style: TextStyle(color: Colors.white, fontSize: 20), textAlign: TextAlign.center),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
Text('Treadl is a safe space for you to build your weaving projects.', style: TextStyle(color: Colors.white, fontSize: 15), textAlign: TextAlign.center),
|
Text('Treadl is a free and safe place for you to build your weaving projects.', style: TextStyle(color: Colors.white, fontSize: 15), textAlign: TextAlign.center),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
Image(image: AssetImage('assets/folder.png'), width: 300),
|
Image(image: AssetImage('assets/folder.png'), width: 300),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
Text('You can create as many projects as you like. Upload weaving draft patterns, images, and other files to your projects to store or showcase your work.', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center),
|
Text('You can create as many projects as you like. Upload weaving draft patterns, images, and other files to your projects to store and showcase your work.', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center),
|
||||||
SizedBox(height: 20),
|
SizedBox(height: 10),
|
||||||
ElevatedButton(
|
RaisedButton(
|
||||||
child: Text('OK, I know what projects are!', style: TextStyle(color: Colors.pink)),
|
child: Text('OK, I know what projects are!'),
|
||||||
onPressed: () => _controller.animateToPage(1, duration: Duration(milliseconds: 500), curve: Curves.easeInOut),
|
onPressed: () => _controller.animateToPage(1, duration: Duration(milliseconds: 500), curve: Curves.easeInOut),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@ -83,13 +83,13 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
|||||||
Text('Use groups for your classes, shared interest groups, or whatever you like!', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center),
|
Text('Use groups for your classes, shared interest groups, or whatever you like!', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
Text('We recommend enabling push notifications so you can keep up-to-date with your groups and projects.', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold), textAlign: TextAlign.center),
|
Text('We recommend enabling push notifications so you can keep up-to-date with your groups and projects.', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold), textAlign: TextAlign.center),
|
||||||
SizedBox(height: 20),
|
SizedBox(height: 10),
|
||||||
ElevatedButton(
|
RaisedButton(
|
||||||
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||||
_loading ? CircularProgressIndicator() : SizedBox(width: 0),
|
_loading ? CircularProgressIndicator() : null,
|
||||||
_loading ? SizedBox(width: 10) : SizedBox(width: 0),
|
_loading ? SizedBox(width: 5) : null,
|
||||||
Text('Continue', style: TextStyle(color: Colors.pink)),
|
Text('What\'s next?'),
|
||||||
]),
|
].where((o) => o != null).toList()),
|
||||||
onPressed: _requestPushPermissions,
|
onPressed: _requestPushPermissions,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@ -101,15 +101,15 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text('That\'s it for now!', style: TextStyle(color: Colors.white, fontSize: 25), textAlign: TextAlign.center),
|
Text('That\'s it for now!', style: TextStyle(color: Colors.white, fontSize: 15), textAlign: TextAlign.center),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
Image(image: AssetImage('assets/completed.png'), width: 300),
|
Image(image: AssetImage('assets/completed.png'), width: 300),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
Text('You\'re ready to get started. We hope you enjoy using Treadl.', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center),
|
Text('You\'re ready to get started. If you have any questions or want to get in touch then just send us a quick tweet.', style: TextStyle(color: Colors.white, fontSize: 13), textAlign: TextAlign.center),
|
||||||
SizedBox(height: 20),
|
SizedBox(height: 10),
|
||||||
ElevatedButton(
|
RaisedButton(
|
||||||
child: Text('Get started', style: TextStyle(color: Colors.pink)),
|
child: Text('Let\'s go'),
|
||||||
onPressed: () => context.go('/home'),
|
onPressed: () => Navigator.of(context).pushNamedAndRemoveUntil('/home', (Route<dynamic> route) => false),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
@ -1,75 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'dart:ui' as ui;
|
|
||||||
import '../util.dart';
|
|
||||||
|
|
||||||
class DrawdownPainter extends CustomPainter {
|
|
||||||
final Map<String,dynamic> pattern;
|
|
||||||
final double BASE_SIZE;
|
|
||||||
|
|
||||||
@override
|
|
||||||
DrawdownPainter(this.BASE_SIZE, this.pattern) {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void paint(Canvas canvas, Size size) {
|
|
||||||
var weft = pattern['weft'];
|
|
||||||
var warp = pattern['warp'];
|
|
||||||
var tieups = pattern['tieups'];
|
|
||||||
|
|
||||||
var paint = Paint()
|
|
||||||
..color = Colors.black
|
|
||||||
..strokeWidth = 1;
|
|
||||||
|
|
||||||
// Draw grid
|
|
||||||
for (double i = 0; i <= size.width; i += BASE_SIZE) {
|
|
||||||
canvas.drawLine(Offset(i.toDouble(), size.height), Offset(i.toDouble(), 0), paint);
|
|
||||||
}
|
|
||||||
for (double y = 0; y <= size.height; y += BASE_SIZE) {
|
|
||||||
canvas.drawLine(Offset(0, y.toDouble()), Offset(size.width, y.toDouble()), paint);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int tread = 0; tread < weft['treadling']?.length; tread++) {
|
|
||||||
for (int thread = 0; thread < warp['threading']?.length; thread++) {
|
|
||||||
// Ensure we only get a treadle in the allowed bounds
|
|
||||||
int treadle = weft['treadling'][tread]['treadle'] > weft['treadles'] ? 0 : weft['treadling'][tread]['treadle'];
|
|
||||||
int shaft = warp['threading'][thread]['shaft'];
|
|
||||||
Color weftColour = Util.rgb(weft['treadling'][tread]['colour'] ?? weft['defaultColour']);
|
|
||||||
Color warpColour = Util.rgb(warp['threading'][thread]['colour'] ?? warp['defaultColour']);
|
|
||||||
|
|
||||||
// Only capture valid tie-ups (e.g. in case there is data for more shafts, which are then reduced)
|
|
||||||
// Dart throws error if index < 0 so check fiest
|
|
||||||
List<dynamic> tieup = treadle > 0 ? tieups[treadle - 1] : [];
|
|
||||||
List<dynamic> filteredTieup = tieup.where((t) => t <= warp['shafts']).toList();
|
|
||||||
String threadType = filteredTieup.contains(shaft) ? 'warp' : 'weft';
|
|
||||||
|
|
||||||
Rect rect = Offset(
|
|
||||||
size.width - BASE_SIZE * (thread + 1),
|
|
||||||
tread * BASE_SIZE
|
|
||||||
) & Size(BASE_SIZE, BASE_SIZE);
|
|
||||||
canvas.drawRect(
|
|
||||||
rect,
|
|
||||||
Paint()
|
|
||||||
..color = threadType == 'warp' ? warpColour : weftColour
|
|
||||||
);
|
|
||||||
|
|
||||||
canvas.drawRect(
|
|
||||||
rect,
|
|
||||||
Paint()
|
|
||||||
..shader = ui.Gradient.linear(
|
|
||||||
threadType == 'warp' ? rect.centerLeft : rect.topCenter,
|
|
||||||
threadType == 'warp' ? rect.centerRight : rect.bottomCenter,
|
|
||||||
[
|
|
||||||
Color.fromRGBO(0,0,0,0.4),
|
|
||||||
Color.fromRGBO(0,0,0,0.0),
|
|
||||||
Color.fromRGBO(0,0,0,0.4),
|
|
||||||
],
|
|
||||||
[0.0,0.5,1.0],
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@override
|
|
||||||
bool shouldRepaint(CustomPainter oldDelegate) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,101 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'warp.dart';
|
|
||||||
import 'weft.dart';
|
|
||||||
import 'tieup.dart';
|
|
||||||
import 'drawdown.dart';
|
|
||||||
|
|
||||||
class Pattern extends StatelessWidget {
|
|
||||||
final Map<String,dynamic> pattern;
|
|
||||||
final Function? onUpdate;
|
|
||||||
final double BASE_SIZE = 5;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Pattern(this.pattern, {this.onUpdate}) {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
var warp = pattern['warp'];
|
|
||||||
var weft = pattern['weft'];
|
|
||||||
|
|
||||||
double draftWidth = warp['threading']?.length * BASE_SIZE + weft['treadles'] * BASE_SIZE + BASE_SIZE;
|
|
||||||
double draftHeight = warp['shafts'] * BASE_SIZE + weft['treadling']?.length * BASE_SIZE + BASE_SIZE;
|
|
||||||
|
|
||||||
double tieupTop = BASE_SIZE;
|
|
||||||
double tieupRight = BASE_SIZE;
|
|
||||||
double tieupWidth = weft['treadles'] * BASE_SIZE;
|
|
||||||
double tieupHeight = warp['shafts'] * BASE_SIZE;
|
|
||||||
|
|
||||||
double warpTop = 0;
|
|
||||||
double warpRight = weft['treadles'] * BASE_SIZE + BASE_SIZE * 2;
|
|
||||||
double warpWidth = warp['threading']?.length * BASE_SIZE;
|
|
||||||
double warpHeight = warp['shafts'] * BASE_SIZE + BASE_SIZE;
|
|
||||||
|
|
||||||
double weftRight = 0;
|
|
||||||
double weftTop = warp['shafts'] * BASE_SIZE + BASE_SIZE * 2;
|
|
||||||
double weftWidth = weft['treadles'] * BASE_SIZE + BASE_SIZE;
|
|
||||||
double weftHeight = weft['treadling'].length * BASE_SIZE;
|
|
||||||
|
|
||||||
double drawdownTop = warpHeight + BASE_SIZE;
|
|
||||||
double drawdownRight = weftWidth + BASE_SIZE;
|
|
||||||
double drawdownWidth = warpWidth;
|
|
||||||
double drawdownHeight = weftHeight;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
width: draftWidth,
|
|
||||||
height: draftHeight,
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Positioned(
|
|
||||||
right: tieupRight,
|
|
||||||
top: tieupTop,
|
|
||||||
child: GestureDetector(
|
|
||||||
onTapDown: (details) {
|
|
||||||
var tieups = pattern['tieups'];
|
|
||||||
double dx = details.localPosition.dx;
|
|
||||||
double dy = details.localPosition.dy;
|
|
||||||
int tie = (dx / BASE_SIZE).toInt();
|
|
||||||
int shaft = ((tieupHeight - dy) / BASE_SIZE).toInt() + 1;
|
|
||||||
if (tieups[tie].contains(shaft)) {
|
|
||||||
tieups[tie].remove(shaft);
|
|
||||||
} else {
|
|
||||||
tieups[tie].add(shaft);
|
|
||||||
}
|
|
||||||
if (onUpdate != null) {
|
|
||||||
onUpdate!({'tieups': tieups});
|
|
||||||
}
|
|
||||||
// Toggle tieups[tie][shaft]
|
|
||||||
},
|
|
||||||
child: CustomPaint(
|
|
||||||
size: Size(tieupWidth, tieupHeight),
|
|
||||||
painter: TieupPainter(BASE_SIZE, this.pattern),
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
right: warpRight,
|
|
||||||
top: warpTop,
|
|
||||||
child: CustomPaint(
|
|
||||||
size: Size(warpWidth, warpHeight),
|
|
||||||
painter: WarpPainter(BASE_SIZE, this.pattern),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
right: weftRight,
|
|
||||||
top: weftTop,
|
|
||||||
child: CustomPaint(
|
|
||||||
size: Size(weftWidth, weftHeight),
|
|
||||||
painter: WeftPainter(BASE_SIZE, this.pattern),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
right: drawdownRight,
|
|
||||||
top: drawdownTop,
|
|
||||||
child: CustomPaint(
|
|
||||||
size: Size(drawdownWidth, drawdownHeight),
|
|
||||||
painter: DrawdownPainter(BASE_SIZE, this.pattern),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,41 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class TieupPainter extends CustomPainter {
|
|
||||||
final Map<String,dynamic> pattern;
|
|
||||||
final double BASE_SIZE;
|
|
||||||
|
|
||||||
@override
|
|
||||||
TieupPainter(this.BASE_SIZE, this.pattern) {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void paint(Canvas canvas, Size size) {
|
|
||||||
var tieup = pattern['tieups'];
|
|
||||||
|
|
||||||
var paint = Paint()
|
|
||||||
..color = Colors.black..strokeWidth = 0.5;
|
|
||||||
|
|
||||||
// Draw grid
|
|
||||||
for (double i = 0; i <= size.width; i += BASE_SIZE) {
|
|
||||||
canvas.drawLine(Offset(i.toDouble(), size.height), Offset(i.toDouble(), 0), paint);
|
|
||||||
}
|
|
||||||
for (double y = 0; y <= size.height; y += BASE_SIZE) {
|
|
||||||
canvas.drawLine(Offset(0, y.toDouble()), Offset(size.width, y.toDouble()), paint);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = 0; i < tieup.length; i++) {
|
|
||||||
List<dynamic>? tie = tieup[i];
|
|
||||||
if (tie != null) {
|
|
||||||
for (var j = 0; j < tie!.length; j++) {
|
|
||||||
canvas.drawRect(
|
|
||||||
Offset(i.toDouble()*BASE_SIZE, size.height - (tie[j]*BASE_SIZE)) &
|
|
||||||
Size(BASE_SIZE.toDouble(), BASE_SIZE.toDouble()),
|
|
||||||
paint);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@override
|
|
||||||
bool shouldRepaint(CustomPainter oldDelegate) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,53 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'pattern.dart';
|
|
||||||
|
|
||||||
class PatternViewer extends StatefulWidget {
|
|
||||||
final Map<String,dynamic> pattern;
|
|
||||||
final bool withEditor;
|
|
||||||
PatternViewer(this.pattern, {this.withEditor = false}) {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<PatternViewer> createState() => _PatternViewerState(this.pattern, this.withEditor);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _PatternViewerState extends State<PatternViewer> {
|
|
||||||
Map<String,dynamic> pattern;
|
|
||||||
final bool withEditor;
|
|
||||||
bool controllerInitialised = false;
|
|
||||||
final controller = TransformationController();
|
|
||||||
final double BASE_SIZE = 5;
|
|
||||||
|
|
||||||
_PatternViewerState(this.pattern, this.withEditor) {}
|
|
||||||
|
|
||||||
void updatePattern(update) {
|
|
||||||
setState(() {
|
|
||||||
pattern!.addAll(update);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (!controllerInitialised) {
|
|
||||||
var warp = pattern['warp'];
|
|
||||||
var weft = pattern['weft'];
|
|
||||||
double draftWidth = warp['threading']?.length * BASE_SIZE + weft['treadles'] * BASE_SIZE + BASE_SIZE;
|
|
||||||
final zoomFactor = 1.0;
|
|
||||||
final xTranslate = draftWidth - MediaQuery.of(context).size.width - 0;
|
|
||||||
final yTranslate = 0.0;
|
|
||||||
controller.value.setEntry(0, 0, zoomFactor);
|
|
||||||
controller.value.setEntry(1, 1, zoomFactor);
|
|
||||||
controller.value.setEntry(2, 2, zoomFactor);
|
|
||||||
controller.value.setEntry(0, 3, -xTranslate);
|
|
||||||
controller.value.setEntry(1, 3, -yTranslate);
|
|
||||||
setState(() => controllerInitialised = true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return InteractiveViewer(
|
|
||||||
minScale: 0.5,
|
|
||||||
maxScale: 5,
|
|
||||||
constrained: false,
|
|
||||||
transformationController: controller,
|
|
||||||
child: RepaintBoundary(child: Pattern(pattern))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,65 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../util.dart';
|
|
||||||
|
|
||||||
class WarpPainter extends CustomPainter {
|
|
||||||
final Map<String,dynamic> pattern;
|
|
||||||
final double BASE_SIZE;
|
|
||||||
|
|
||||||
@override
|
|
||||||
WarpPainter(this.BASE_SIZE, this.pattern) {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void paint(Canvas canvas, Size size) {
|
|
||||||
var warp = pattern['warp'];
|
|
||||||
|
|
||||||
var paint = Paint()
|
|
||||||
..color = Colors.black
|
|
||||||
..strokeWidth = 0.5;
|
|
||||||
var thickPaint = Paint()
|
|
||||||
..color = Colors.black
|
|
||||||
..strokeWidth = 1.5;
|
|
||||||
|
|
||||||
// Draw grid
|
|
||||||
int columnsPainted = 0;
|
|
||||||
for (double i = size.width; i >= 0; i -= BASE_SIZE) {
|
|
||||||
canvas.drawLine(Offset(i.toDouble(), size.height), Offset(i.toDouble(), 0), paint);
|
|
||||||
columnsPainted += 1;
|
|
||||||
}
|
|
||||||
for (double y = 0; y <= size.height; y += BASE_SIZE) {
|
|
||||||
canvas.drawLine(Offset(0, y.toDouble()), Offset(size.width, y.toDouble()), paint);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw threads
|
|
||||||
for (var i = 0; i < warp['threading'].length; i++) {
|
|
||||||
var thread = warp['threading'][i];
|
|
||||||
int? shaft = thread?['shaft'];
|
|
||||||
String? colour = warp['defaultColour'];
|
|
||||||
double x = size.width - (i+1)*BASE_SIZE;
|
|
||||||
if (shaft != null) {
|
|
||||||
if (shaft! > 0) {
|
|
||||||
canvas.drawRect(
|
|
||||||
Offset(x, size.height - shaft!*BASE_SIZE) &
|
|
||||||
Size(BASE_SIZE.toDouble(), BASE_SIZE.toDouble()),
|
|
||||||
paint
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
if (thread?['colour'] != null) {
|
|
||||||
colour = thread!['colour'];
|
|
||||||
}
|
|
||||||
if (colour != null) {
|
|
||||||
canvas.drawRect(
|
|
||||||
Offset(x, 0) &
|
|
||||||
Size(BASE_SIZE.toDouble(), BASE_SIZE.toDouble()),
|
|
||||||
Paint()
|
|
||||||
..color = Util.rgb(colour!)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@override
|
|
||||||
bool shouldRepaint(CustomPainter oldDelegate) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,61 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../util.dart';
|
|
||||||
|
|
||||||
class WeftPainter extends CustomPainter {
|
|
||||||
final Map<String,dynamic> pattern;
|
|
||||||
final double BASE_SIZE;
|
|
||||||
|
|
||||||
@override
|
|
||||||
WeftPainter(this.BASE_SIZE, this.pattern) {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void paint(Canvas canvas, Size size) {
|
|
||||||
var weft = pattern['weft'];
|
|
||||||
|
|
||||||
var paint = Paint()
|
|
||||||
..color = Colors.black
|
|
||||||
..strokeWidth = 0.5;
|
|
||||||
var thickPaint = Paint()
|
|
||||||
..color = Colors.black
|
|
||||||
..strokeWidth = 1.5;
|
|
||||||
|
|
||||||
// Draw grid
|
|
||||||
int rowsPainted = 0;
|
|
||||||
for (double i = 0; i <= size.width; i += BASE_SIZE) {
|
|
||||||
canvas.drawLine(Offset(i.toDouble(), size.height), Offset(i.toDouble(), 0), paint);
|
|
||||||
}
|
|
||||||
for (double y = 0; y <= size.height; y += BASE_SIZE) {
|
|
||||||
canvas.drawLine(Offset(0, y.toDouble()), Offset(size.width, y.toDouble()), paint);
|
|
||||||
rowsPainted += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = 0; i < weft['treadling'].length; i++) {
|
|
||||||
var thread = weft['treadling'][i];
|
|
||||||
int? treadle = thread?['treadle'];
|
|
||||||
String? colour = weft['defaultColour'];
|
|
||||||
double y = i.toDouble()*BASE_SIZE;
|
|
||||||
if (treadle != null && treadle! > 0) {
|
|
||||||
canvas.drawRect(
|
|
||||||
Offset((treadle!.toDouble()-1)*BASE_SIZE, y) &
|
|
||||||
Size(BASE_SIZE.toDouble(), BASE_SIZE.toDouble()),
|
|
||||||
paint
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (thread?['colour'] != null) {
|
|
||||||
colour = thread!['colour'];
|
|
||||||
}
|
|
||||||
if (colour != null) {
|
|
||||||
canvas.drawRect(
|
|
||||||
Offset(size.width - BASE_SIZE, y) &
|
|
||||||
Size(BASE_SIZE.toDouble(), BASE_SIZE.toDouble()),
|
|
||||||
Paint()
|
|
||||||
..color = Util.rgb(colour!)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@override
|
|
||||||
bool shouldRepaint(CustomPainter oldDelegate) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
@ -2,432 +2,30 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
import 'util.dart';
|
import 'object.dart';
|
||||||
import 'model.dart';
|
|
||||||
import 'lib.dart';
|
|
||||||
|
|
||||||
class _ProjectScreenState extends State<ProjectScreen> {
|
|
||||||
final String username;
|
|
||||||
final String projectPath;
|
|
||||||
final String fullPath;
|
|
||||||
final Function? onUpdate;
|
|
||||||
final Function? onDelete;
|
|
||||||
final picker = ImagePicker();
|
|
||||||
final Api api = Api();
|
|
||||||
Map<String,dynamic>? project;
|
|
||||||
List<dynamic> _objects = [];
|
|
||||||
bool _loading = false;
|
|
||||||
Map<String,dynamic>? _creatingObject;
|
|
||||||
|
|
||||||
_ProjectScreenState(this.username, this.projectPath, {this.project, this.onUpdate, this.onDelete}) :
|
|
||||||
fullPath = username + '/' + projectPath;
|
|
||||||
|
|
||||||
@override
|
|
||||||
initState() {
|
|
||||||
super.initState();
|
|
||||||
getProject(fullPath);
|
|
||||||
getObjects(fullPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
void getProject(String fullName) async {
|
|
||||||
setState(() => _loading = true);
|
|
||||||
var data = await api.request('GET', '/projects/' + fullName);
|
|
||||||
if (data['success'] == true) {
|
|
||||||
setState(() {
|
|
||||||
project = data['payload'];
|
|
||||||
_loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void getObjects(String fullName) async {
|
|
||||||
setState(() => _loading = true);
|
|
||||||
var data = await api.request('GET', '/projects/' + fullName + '/objects');
|
|
||||||
if (data['success'] == true) {
|
|
||||||
setState(() {
|
|
||||||
_objects = data['payload']['objects'];
|
|
||||||
_loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _shareProject() {
|
|
||||||
Util.shareUrl('Check out my project on Treadl', Util.appUrl(fullPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onDeleteProject() {
|
|
||||||
context.pop();
|
|
||||||
onDelete!(project!['_id']);
|
|
||||||
}
|
|
||||||
void _onUpdateProject(project) {
|
|
||||||
setState(() {
|
|
||||||
project = project;
|
|
||||||
});
|
|
||||||
onUpdate!(project!['_id'], project!);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onUpdateObject(String id, Map<String,dynamic> update) {
|
|
||||||
List<dynamic> _newObjects = _objects.map((o) {
|
|
||||||
if (o['_id'] == id) {
|
|
||||||
o.addAll(update);
|
|
||||||
}
|
|
||||||
return o;
|
|
||||||
}).toList();
|
|
||||||
setState(() {
|
|
||||||
_objects = _newObjects;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
void _onDeleteObject(String id) {
|
|
||||||
List<dynamic> _newObjects = _objects.where((p) => p['_id'] != id).toList();
|
|
||||||
setState(() {
|
|
||||||
_objects = _newObjects;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _createObject(objectData) async {
|
|
||||||
var resp = await api.request('POST', '/projects/$fullPath/objects', objectData);
|
|
||||||
setState(() => _creatingObject = null);
|
|
||||||
if (resp['success']) {
|
|
||||||
List<dynamic> newObjects = _objects;
|
|
||||||
newObjects.add(resp['payload']);
|
|
||||||
setState(() {
|
|
||||||
_objects = newObjects;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _createObjectFromWif(String name, String wif) {
|
|
||||||
setState(() => _creatingObject = {
|
|
||||||
'name': name,
|
|
||||||
'type': 'pattern',
|
|
||||||
});
|
|
||||||
_createObject({
|
|
||||||
'name': name,
|
|
||||||
'type': 'pattern',
|
|
||||||
'wif': wif,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _createObjectFromFile(String name, XFile file) async {
|
|
||||||
final int size = await file.length();
|
|
||||||
final String forId = project!['_id'];
|
|
||||||
final String type = file.mimeType ?? 'text/plain';
|
|
||||||
setState(() => _creatingObject = {
|
|
||||||
'name': name,
|
|
||||||
'type': 'file',
|
|
||||||
});
|
|
||||||
var data = await api.request('GET', '/uploads/file/request?name=$name&size=$size&type=$type&forType=project&forId=$forId');
|
|
||||||
if (!data['success']) {
|
|
||||||
setState(() => _creatingObject = null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var uploadSuccess = await api.putFile(data['payload']['signedRequest'], File(file.path), type);
|
|
||||||
if (!uploadSuccess) {
|
|
||||||
setState(() => _creatingObject = null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_createObject({
|
|
||||||
'name': name,
|
|
||||||
'storedName': data['payload']['fileName'],
|
|
||||||
'type': 'file',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _chooseFile() async {
|
|
||||||
FilePickerResult? result = await FilePicker.platform.pickFiles();
|
|
||||||
if (result != null) {
|
|
||||||
PlatformFile file = result.files.single;
|
|
||||||
XFile xFile = XFile(file.path!);
|
|
||||||
String? ext = file.extension;
|
|
||||||
if (ext != null && ext!.toLowerCase() == 'wif' || xFile.name.toLowerCase().contains('.wif')) {
|
|
||||||
final String contents = await xFile.readAsString();
|
|
||||||
_createObjectFromWif(file.name, contents);
|
|
||||||
} else {
|
|
||||||
_createObjectFromFile(file.name, xFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _chooseImage() async {
|
|
||||||
File file;
|
|
||||||
try {
|
|
||||||
final XFile? imageFile = await picker.pickImage(source: ImageSource.gallery);
|
|
||||||
if (imageFile == null) return;
|
|
||||||
final f = new DateFormat('yyyy-MM-dd_hh-mm-ss');
|
|
||||||
String time = f.format(new DateTime.now());
|
|
||||||
String name = project!['name'] + ' ' + time + '.' + imageFile.name.split('.').last;
|
|
||||||
_createObjectFromFile(name, imageFile);
|
|
||||||
}
|
|
||||||
on Exception {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (BuildContext context) => CupertinoAlertDialog(
|
|
||||||
title: Text('Treadl needs access'),
|
|
||||||
content: Text('To add objects to this project you need to give Treadl access to your photos in your phone\'s settings.'),
|
|
||||||
actions: <Widget>[
|
|
||||||
CupertinoDialogAction(
|
|
||||||
isDefaultAction: true,
|
|
||||||
child: Text('OK'),
|
|
||||||
onPressed: () => context.pop(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void showSettingsModal() {
|
|
||||||
Widget settingsDialog = new _ProjectSettingsDialog(project!, _onDeleteProject, _onUpdateProject);
|
|
||||||
showCupertinoModalPopup(context: context, builder: (BuildContext context) => settingsDialog);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget getNetworkImageBox(String url) {
|
|
||||||
return new AspectRatio(
|
|
||||||
aspectRatio: 1 / 1,
|
|
||||||
child: new Container(
|
|
||||||
decoration: new BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(10.0),
|
|
||||||
image: new DecorationImage(
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
alignment: FractionalOffset.topCenter,
|
|
||||||
image: new NetworkImage(url),
|
|
||||||
)
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Widget getIconBox(Icon icon) {
|
|
||||||
return new AspectRatio(
|
|
||||||
aspectRatio: 1 / 1,
|
|
||||||
child: new Container(
|
|
||||||
decoration: new BoxDecoration(
|
|
||||||
color: Colors.grey[100],
|
|
||||||
borderRadius: BorderRadius.circular(10.0),
|
|
||||||
),
|
|
||||||
child: icon
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget getObjectCard(int index) {
|
|
||||||
Map<String,dynamic>? objectToShow;
|
|
||||||
if (index >= _objects.length) {
|
|
||||||
objectToShow = _creatingObject;
|
|
||||||
objectToShow!['creating'] = true;
|
|
||||||
} else {
|
|
||||||
objectToShow = _objects[index];
|
|
||||||
}
|
|
||||||
Map<String,dynamic> object = objectToShow!;
|
|
||||||
Widget leader;
|
|
||||||
String type;
|
|
||||||
|
|
||||||
if (object['isImage'] == true) {
|
|
||||||
type = 'Image';
|
|
||||||
if (object['url'] != null) {
|
|
||||||
leader = getNetworkImageBox(object['url']!);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
leader = getIconBox(Icon(Icons.photo));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (object['type'] == 'pattern') {
|
|
||||||
type = 'Weaving pattern';
|
|
||||||
if (object['previewUrl'] != null) {
|
|
||||||
leader = getNetworkImageBox(object['previewUrl']!);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
leader = getIconBox(Icon(Icons.pattern));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (object['type'] == 'file') {
|
|
||||||
type = 'File';
|
|
||||||
leader = getIconBox(Icon(Icons.insert_drive_file));
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
type = 'Unknown';
|
|
||||||
leader = getIconBox(Icon(Icons.file_present));
|
|
||||||
}
|
|
||||||
if (object['creating'] == true) {
|
|
||||||
leader = CircularProgressIndicator();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Card(
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () {
|
|
||||||
context.push('/' + username + '/' + projectPath + '/' + object['_id']);
|
|
||||||
},
|
|
||||||
child: ListTile(
|
|
||||||
leading: leader,
|
|
||||||
trailing: Icon(Icons.keyboard_arrow_right),
|
|
||||||
title: Text(object['name']),
|
|
||||||
subtitle: Text(type),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget getBody() {
|
|
||||||
if (_loading || project == null)
|
|
||||||
return CircularProgressIndicator();
|
|
||||||
else if ((_objects != null && _objects.length > 0) || _creatingObject != null)
|
|
||||||
return ListView.builder(
|
|
||||||
itemCount: _objects.length + (_creatingObject != null ? 1 : 0),
|
|
||||||
itemBuilder: (BuildContext context, int index) {
|
|
||||||
return getObjectCard(index);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
else
|
|
||||||
return EmptyBox('This project is currently empty', description: 'If this is your project, you can add a pattern file, an image, or something else to this project using the + button below.');
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
AppModel model = Provider.of<AppModel>(context);
|
|
||||||
User? user = model.user;
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text(project?['name'] ?? 'Project'),
|
|
||||||
actions: <Widget>[
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.ios_share),
|
|
||||||
onPressed: () {
|
|
||||||
_shareProject();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
onUpdate != null ? IconButton(
|
|
||||||
icon: Icon(Icons.settings),
|
|
||||||
onPressed: () {
|
|
||||||
showSettingsModal();
|
|
||||||
},
|
|
||||||
) : SizedBox(width: 0),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
body: Container(
|
|
||||||
margin: const EdgeInsets.all(10.0),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: getBody(),
|
|
||||||
),
|
|
||||||
floatingActionButtonLocation: ExpandableFab.location,
|
|
||||||
floatingActionButton: Util.canEditProject(user, project) ? ExpandableFab(
|
|
||||||
distance: 70,
|
|
||||||
type: ExpandableFabType.up,
|
|
||||||
openButtonBuilder: RotateFloatingActionButtonBuilder(
|
|
||||||
child: const Icon(Icons.add),
|
|
||||||
),
|
|
||||||
children: [
|
|
||||||
Row(children:[
|
|
||||||
Container(
|
|
||||||
padding: EdgeInsets.all(5),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey[800],
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
|
||||||
),
|
|
||||||
child: Text('Add an image', style: TextStyle(fontSize: 15, color: Colors.white)),
|
|
||||||
),
|
|
||||||
SizedBox(width: 10),
|
|
||||||
FloatingActionButton(
|
|
||||||
heroTag: null,
|
|
||||||
onPressed: _chooseImage,
|
|
||||||
child: Icon(Icons.image_outlined),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
Row(children:[
|
|
||||||
Container(
|
|
||||||
padding: EdgeInsets.all(5),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey[800],
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
|
||||||
),
|
|
||||||
child: Text('Add a WIF or other file', style: TextStyle(fontSize: 15, color: Colors.white)),
|
|
||||||
),
|
|
||||||
SizedBox(width: 10),
|
|
||||||
FloatingActionButton(
|
|
||||||
heroTag: null,
|
|
||||||
child: const Icon(Icons.insert_drive_file_outlined),
|
|
||||||
onPressed: _chooseFile,
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
) : null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProjectScreen extends StatefulWidget {
|
|
||||||
final String username;
|
|
||||||
final String projectPath;
|
|
||||||
final Map<String,dynamic>? project;
|
|
||||||
final Function? onUpdate;
|
|
||||||
final Function? onDelete;
|
|
||||||
ProjectScreen(this.username, this.projectPath, {this.project, this.onUpdate, this.onDelete}) { }
|
|
||||||
@override
|
|
||||||
_ProjectScreenState createState() => _ProjectScreenState(username, projectPath, project: project, onUpdate: onUpdate, onDelete: onDelete);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ProjectSettingsDialog extends StatelessWidget {
|
class _ProjectSettingsDialog extends StatelessWidget {
|
||||||
final String fullPath;
|
final Map<String,dynamic> _project;
|
||||||
final Map<String,dynamic> project;
|
|
||||||
final Function _onDelete;
|
final Function _onDelete;
|
||||||
final Function _onUpdateProject;
|
final Function _onUpdateProject;
|
||||||
final Api api = Api();
|
final Api api = Api();
|
||||||
_ProjectSettingsDialog(this.project, this._onDelete, this._onUpdateProject) :
|
_ProjectSettingsDialog(this._project, this._onDelete, this._onUpdateProject) {}
|
||||||
fullPath = project['owner']['username'] + '/' + project['path'];
|
|
||||||
|
|
||||||
void _renameProject(BuildContext context) async {
|
|
||||||
TextEditingController renameController = TextEditingController();
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) {
|
|
||||||
return AlertDialog(
|
|
||||||
title: Text('Rename your project'),
|
|
||||||
content: TextField(
|
|
||||||
autofocus: true,
|
|
||||||
controller: renameController,
|
|
||||||
decoration: InputDecoration(hintText: "Enter a new name for the project"),
|
|
||||||
),
|
|
||||||
actions: <Widget>[
|
|
||||||
TextButton(
|
|
||||||
child: Text('CANCEL'),
|
|
||||||
onPressed: () {
|
|
||||||
context.pop();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
child: Text('OK'),
|
|
||||||
onPressed: () async {
|
|
||||||
var data = await api.request('PUT', '/projects/' + fullPath, {'name': renameController.text});
|
|
||||||
if (data['success']) {
|
|
||||||
context.pop();
|
|
||||||
_onUpdateProject(data['payload']);
|
|
||||||
}
|
|
||||||
context.pop();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _toggleVisibility(BuildContext context, bool checked) async {
|
void _toggleVisibility(BuildContext context, bool checked) async {
|
||||||
var data = await api.request('PUT', '/projects/' + fullPath, {'visibility': checked ? 'private': 'public'});
|
var data = await api.request('PUT', '/projects/' + _project['owner']['username'] + '/' + _project['path'], {'visibility': checked ? 'private': 'public'});
|
||||||
if (data['success']) {
|
if (data['success']) {
|
||||||
context.pop();
|
Navigator.pop(context);
|
||||||
_onUpdateProject(data['payload']);
|
_onUpdateProject(data['payload']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _deleteProject(BuildContext context, BuildContext modalContext) async {
|
void _deleteProject(BuildContext context, BuildContext modalContext) async {
|
||||||
var data = await api.request('DELETE', '/projects/' + fullPath);
|
var data = await api.request('DELETE', '/projects/' + _project['owner']['username'] + '/' + _project['path']);
|
||||||
if (data['success']) {
|
if (data['success']) {
|
||||||
context.pop();
|
Navigator.pop(context);
|
||||||
context.pop();
|
Navigator.pop(modalContext);
|
||||||
_onDelete();
|
_onDelete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -442,7 +40,7 @@ class _ProjectSettingsDialog extends StatelessWidget {
|
|||||||
CupertinoDialogAction(
|
CupertinoDialogAction(
|
||||||
isDefaultAction: true,
|
isDefaultAction: true,
|
||||||
child: Text('No'),
|
child: Text('No'),
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
CupertinoDialogAction(
|
CupertinoDialogAction(
|
||||||
isDestructiveAction: true,
|
isDestructiveAction: true,
|
||||||
@ -459,7 +57,7 @@ class _ProjectSettingsDialog extends StatelessWidget {
|
|||||||
return CupertinoActionSheet(
|
return CupertinoActionSheet(
|
||||||
title: Text('Manage this project'),
|
title: Text('Manage this project'),
|
||||||
cancelButton: CupertinoActionSheetAction(
|
cancelButton: CupertinoActionSheetAction(
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
child: Text('Cancel')
|
child: Text('Cancel')
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
@ -469,18 +67,14 @@ class _ProjectSettingsDialog extends StatelessWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
CupertinoSwitch(
|
CupertinoSwitch(
|
||||||
value: project?['visibility'] == 'private',
|
value: _project['visibility'] == 'private',
|
||||||
onChanged: (c) => _toggleVisibility(context, c),
|
onChanged: (c) => _toggleVisibility(context, c),
|
||||||
),
|
),
|
||||||
SizedBox(width: 10),
|
SizedBox(width: 10),
|
||||||
Text('Private project', style: Theme.of(context).textTheme.bodyMedium),
|
Text('Private project', style: Theme.of(context).textTheme.bodyText1),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
CupertinoActionSheetAction(
|
|
||||||
onPressed: () { _renameProject(context); },
|
|
||||||
child: Text('Rename project'),
|
|
||||||
),
|
|
||||||
CupertinoActionSheetAction(
|
CupertinoActionSheetAction(
|
||||||
onPressed: () { _confirmDeleteProject(context); },
|
onPressed: () { _confirmDeleteProject(context); },
|
||||||
child: Text('Delete project'),
|
child: Text('Delete project'),
|
||||||
@ -490,3 +84,243 @@ class _ProjectSettingsDialog extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ProjectScreenState extends State<ProjectScreen> {
|
||||||
|
final Function _onDelete;
|
||||||
|
final picker = ImagePicker();
|
||||||
|
final Api api = Api();
|
||||||
|
Map<String,dynamic> _project;
|
||||||
|
List<dynamic> _objects = [];
|
||||||
|
bool _loading = false;
|
||||||
|
bool _creating = false;
|
||||||
|
|
||||||
|
_ProjectScreenState(this._project, this._onDelete) { }
|
||||||
|
|
||||||
|
@override
|
||||||
|
initState() {
|
||||||
|
super.initState();
|
||||||
|
getObjects(_project['fullName']);
|
||||||
|
}
|
||||||
|
|
||||||
|
void getObjects(String fullName) async {
|
||||||
|
setState(() => _loading = true);
|
||||||
|
print(fullName);
|
||||||
|
var data = await api.request('GET', '/projects/' + fullName + '/objects');
|
||||||
|
if (data['success'] == true) {
|
||||||
|
setState(() {
|
||||||
|
_objects = data['payload']['objects'];
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDeleteProject() {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_onDelete(_project['_id']);
|
||||||
|
}
|
||||||
|
void _onUpdateProject(project) {
|
||||||
|
setState(() {
|
||||||
|
_project = project;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDeleteObject(String id) {
|
||||||
|
List<dynamic> _newObjects = _objects.where((p) => p['_id'] != id).toList();
|
||||||
|
setState(() {
|
||||||
|
_objects = _newObjects;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _chooseImage() async {
|
||||||
|
File file;
|
||||||
|
try {
|
||||||
|
final imageFile = await picker.getImage(source: ImageSource.gallery);
|
||||||
|
if (imageFile == null) return;
|
||||||
|
file = File(imageFile.path);
|
||||||
|
}
|
||||||
|
on Exception {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) => CupertinoAlertDialog(
|
||||||
|
title: Text('Treadl needs access'),
|
||||||
|
content: Text('To add objects to this project you need to give Treadl access to your photos in your phone\'s settings.'),
|
||||||
|
actions: <Widget>[
|
||||||
|
CupertinoDialogAction(
|
||||||
|
isDefaultAction: true,
|
||||||
|
child: Text('OK'),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final int size = await file.length();
|
||||||
|
final String forId = _project['_id'];
|
||||||
|
final String fullPath = _project['owner']['username'] + '/' + _project['path'];
|
||||||
|
final String name = file.path.split('/').last;
|
||||||
|
final String ext = name.split('.').last;
|
||||||
|
final String type = 'image/jpeg';//$ext';
|
||||||
|
setState(() => _creating = true);
|
||||||
|
|
||||||
|
var data = await api.request('GET', '/uploads/file/request?name=$name&size=$size&type=$type&forType=project&forId=$forId');
|
||||||
|
print(data);
|
||||||
|
if (!data['success']) {
|
||||||
|
setState(() => _creating = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var uploadSuccess = await api.putFile(data['payload']['signedRequest'], file, type);
|
||||||
|
print(uploadSuccess);
|
||||||
|
if (!uploadSuccess) {
|
||||||
|
setState(() => _creating = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var newObjectData = {
|
||||||
|
'name': name,
|
||||||
|
'storedName': data['payload']['fileName'],
|
||||||
|
'type': 'file',
|
||||||
|
};
|
||||||
|
var objectData = await api.request('POST', '/projects/$fullPath/objects', newObjectData);
|
||||||
|
setState(() => _creating = false);
|
||||||
|
if (objectData['success']) {
|
||||||
|
List<dynamic> newObjects = _objects;
|
||||||
|
newObjects.add(objectData['payload']);
|
||||||
|
setState(() {
|
||||||
|
_objects = newObjects;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void showSettingsModal() {
|
||||||
|
Widget settingsDialog = new _ProjectSettingsDialog(_project, _onDeleteProject, _onUpdateProject);
|
||||||
|
showCupertinoModalPopup(context: context, builder: (BuildContext context) => settingsDialog);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget getImageBox(data, [bool isMemory, bool isNetwork]) {
|
||||||
|
return new AspectRatio(
|
||||||
|
aspectRatio: 1 / 1,
|
||||||
|
child: new Container(
|
||||||
|
decoration: new BoxDecoration(
|
||||||
|
image: new DecorationImage(
|
||||||
|
fit: BoxFit.fitWidth,
|
||||||
|
alignment: FractionalOffset.topCenter,
|
||||||
|
image: isMemory == true ? new MemoryImage(data) : new NetworkImage(data)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Widget getIconBox(Icon icon) {
|
||||||
|
return new AspectRatio(
|
||||||
|
aspectRatio: 1 / 1,
|
||||||
|
child: icon
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget getObjectCard(int index) {
|
||||||
|
if (index >= _objects.length) {
|
||||||
|
return new Card(
|
||||||
|
child: Container(
|
||||||
|
padding: EdgeInsets.all(10),
|
||||||
|
child: Center(child:CircularProgressIndicator())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
var object = _objects[index];
|
||||||
|
Widget leader;
|
||||||
|
String type;
|
||||||
|
|
||||||
|
if (object['isImage'] == true) {
|
||||||
|
type = 'Image';
|
||||||
|
leader = getImageBox(object['url']);
|
||||||
|
}
|
||||||
|
else if (object['type'] == 'pattern' && object['preview'] != null) {
|
||||||
|
type = 'Weaving pattern';
|
||||||
|
var dat = Uri.parse(object['preview']).data;
|
||||||
|
leader = getImageBox(dat.contentAsBytes(), true);
|
||||||
|
}
|
||||||
|
else if (object['type'] == 'file') {
|
||||||
|
type = 'File';
|
||||||
|
leader = getIconBox(Icon(Icons.insert_drive_file));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Card(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => ObjectScreen(object, _onDeleteObject),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: <Widget>[
|
||||||
|
new ListTile(
|
||||||
|
leading: leader,
|
||||||
|
trailing: Icon(Icons.keyboard_arrow_right),
|
||||||
|
title: Text(object['name']),
|
||||||
|
subtitle: Text(type),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(_project['name']),
|
||||||
|
actions: <Widget>[
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.settings),
|
||||||
|
onPressed: () {
|
||||||
|
showSettingsModal();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
body: _loading ?
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.all(10.0),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: CircularProgressIndicator()
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
margin: const EdgeInsets.all(10.0),
|
||||||
|
child: ((_objects != null && _objects.length > 0) || _creating) ?
|
||||||
|
ListView.builder(
|
||||||
|
itemCount: _objects.length + (_creating ? 1 : 0),
|
||||||
|
itemBuilder: (BuildContext context, int index) {
|
||||||
|
return getObjectCard(index);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
:
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text('This project is currently empty', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
|
||||||
|
Image(image: AssetImage('assets/empty.png'), width: 300),
|
||||||
|
Text('Add something to this project using the button below.', textAlign: TextAlign.center),
|
||||||
|
])
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: _chooseImage,
|
||||||
|
child: Icon(Icons.cloud_upload),
|
||||||
|
backgroundColor: Colors.pink[500],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProjectScreen extends StatefulWidget {
|
||||||
|
final Map<String,dynamic> _project;
|
||||||
|
final Function _onDelete;
|
||||||
|
ProjectScreen(this._project, this._onDelete) { }
|
||||||
|
@override
|
||||||
|
_ProjectScreenState createState() => _ProjectScreenState(_project, _onDelete);
|
||||||
|
}
|
||||||
|
@ -1,169 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'routeArguments.dart';
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
import 'model.dart';
|
import 'project.dart';
|
||||||
import 'lib.dart';
|
import 'settings.dart';
|
||||||
|
|
||||||
class _ProjectsTabState extends State<ProjectsTab> {
|
|
||||||
List<dynamic> _projects = [];
|
|
||||||
bool _loading = false;
|
|
||||||
bool _creatingProject = false;
|
|
||||||
final Api api = Api();
|
|
||||||
|
|
||||||
@override
|
|
||||||
initState() {
|
|
||||||
super.initState();
|
|
||||||
getProjects();
|
|
||||||
}
|
|
||||||
|
|
||||||
void getProjects() async {
|
|
||||||
AppModel model = Provider.of<AppModel>(context, listen: false);
|
|
||||||
if (model.user == null) return;
|
|
||||||
setState(() {
|
|
||||||
_loading = true;
|
|
||||||
});
|
|
||||||
var data = await api.request('GET', '/users/me/projects');
|
|
||||||
if (data['success'] == true) {
|
|
||||||
setState(() {
|
|
||||||
_projects = data['payload']['projects'];
|
|
||||||
_loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onCreatingProject() {
|
|
||||||
setState(() {
|
|
||||||
_creatingProject = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
void _onCreateProject(newProject) {
|
|
||||||
List<dynamic> _newProjects = _projects;
|
|
||||||
_newProjects.insert(0, newProject);
|
|
||||||
setState(() {
|
|
||||||
_projects = _newProjects;
|
|
||||||
_creatingProject = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onUpdateProject(String id, Map<String,dynamic> update) {
|
|
||||||
List<dynamic> _newProjects = _projects.map((p) {
|
|
||||||
if (p['_id'] == id) {
|
|
||||||
p.addAll(update);
|
|
||||||
}
|
|
||||||
return p;
|
|
||||||
}).toList();
|
|
||||||
setState(() {
|
|
||||||
_projects = _newProjects;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onDeleteProject(String id) {
|
|
||||||
List<dynamic> _newProjects = _projects.where((p) => p['_id'] != id).toList();
|
|
||||||
setState(() {
|
|
||||||
_projects = _newProjects;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void showNewProjectDialog() async {
|
|
||||||
Widget simpleDialog = new _NewProjectDialog(_onCreatingProject, _onCreateProject);
|
|
||||||
showDialog(context: context, builder: (BuildContext context) => simpleDialog);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget buildProjectCard(Map<String,dynamic> project) {
|
|
||||||
String description = project['description'] != null ? project['description'].replaceAll("\n", " ") : '';
|
|
||||||
if (description != null && description.length > 80) {
|
|
||||||
description = description.substring(0, 77) + '...';
|
|
||||||
}
|
|
||||||
if (project['visibility'] == 'public') {
|
|
||||||
description = "PUBLIC PROJECT\n" + description;
|
|
||||||
}
|
|
||||||
else description = "PRIVATE PROJECT\n" + description;
|
|
||||||
return new Card(
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () {
|
|
||||||
context.push('/' + project['owner']['username'] + '/' + project['path']);
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
padding: EdgeInsets.all(5),
|
|
||||||
child: ListTile(
|
|
||||||
leading: new AspectRatio(
|
|
||||||
aspectRatio: 1 / 1,
|
|
||||||
child: new Container(
|
|
||||||
decoration: new BoxDecoration(
|
|
||||||
color: Colors.grey[100],
|
|
||||||
borderRadius: BorderRadius.circular(10.0),
|
|
||||||
),
|
|
||||||
child: Icon(Icons.folder, color: Colors.pink[300])
|
|
||||||
),
|
|
||||||
),
|
|
||||||
trailing: Icon(Icons.keyboard_arrow_right),
|
|
||||||
title: Text(project['name'] != null ? project['name'] : 'Untitled project'),
|
|
||||||
subtitle: Text(description),
|
|
||||||
),
|
|
||||||
))
|
|
||||||
)
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget getBody() {
|
|
||||||
AppModel model = Provider.of<AppModel>(context);
|
|
||||||
if (model.user == null)
|
|
||||||
return LoginNeeded(text: 'Once logged in, you\'ll find your own projects shown here.');
|
|
||||||
if (_loading)
|
|
||||||
return CircularProgressIndicator();
|
|
||||||
else if (_projects != null && _projects.length > 0)
|
|
||||||
return ListView.builder(
|
|
||||||
itemCount: _projects.length,
|
|
||||||
itemBuilder: (BuildContext context, int index) {
|
|
||||||
return buildProjectCard(_projects[index]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
else return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text('Create your first project', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
|
|
||||||
Image(image: AssetImage('assets/reading.png'), width: 300),
|
|
||||||
Text('Projects contain all the files and patterns that make up a piece of work. Create one using the + button below.', textAlign: TextAlign.center),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
AppModel model = Provider.of<AppModel>(context);
|
|
||||||
User? user = model.user;
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text('My Projects'),
|
|
||||||
actions: <Widget>[
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.info_outline),
|
|
||||||
onPressed: () {
|
|
||||||
context.push('/settings');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
body: Container(
|
|
||||||
margin: const EdgeInsets.all(10.0),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: getBody()
|
|
||||||
),
|
|
||||||
floatingActionButton: user != null ? FloatingActionButton(
|
|
||||||
onPressed: showNewProjectDialog,
|
|
||||||
child: _creatingProject ? CircularProgressIndicator(backgroundColor: Colors.white) : Icon(Icons.add),
|
|
||||||
) : null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProjectsTab extends StatefulWidget {
|
|
||||||
@override
|
|
||||||
_ProjectsTabState createState() => _ProjectsTabState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _NewProjectDialogState extends State<_NewProjectDialog> {
|
class _NewProjectDialogState extends State<_NewProjectDialog> {
|
||||||
final TextEditingController _newProjectNameController = TextEditingController();
|
final TextEditingController _newProjectNameController = TextEditingController();
|
||||||
@ -188,7 +29,7 @@ class _NewProjectDialogState extends State<_NewProjectDialog> {
|
|||||||
var data = await api.request('POST', '/projects', {'name': name, 'visibility': priv ? 'private' : 'public'});
|
var data = await api.request('POST', '/projects', {'name': name, 'visibility': priv ? 'private' : 'public'});
|
||||||
if (data['success'] == true) {
|
if (data['success'] == true) {
|
||||||
_onComplete(data['payload']);
|
_onComplete(data['payload']);
|
||||||
context.pop();
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -216,14 +57,15 @@ class _NewProjectDialogState extends State<_NewProjectDialog> {
|
|||||||
title: Text('Make this project private')
|
title: Text('Make this project private')
|
||||||
),
|
),
|
||||||
SizedBox(height: 20),
|
SizedBox(height: 20),
|
||||||
ElevatedButton(
|
CupertinoButton(
|
||||||
|
color: Colors.pink,
|
||||||
onPressed: _createProject,
|
onPressed: _createProject,
|
||||||
child: Text('Create'),
|
child: Text('Create'),
|
||||||
),
|
),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
TextButton(
|
CupertinoButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
child: Text('Cancel'),
|
child: Text('Cancel'),
|
||||||
)
|
)
|
||||||
@ -240,3 +82,155 @@ class _NewProjectDialog extends StatefulWidget {
|
|||||||
@override
|
@override
|
||||||
_NewProjectDialogState createState() => _NewProjectDialogState(_onStart, _onComplete);
|
_NewProjectDialogState createState() => _NewProjectDialogState(_onStart, _onComplete);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ProjectsTabState extends State<ProjectsTab> {
|
||||||
|
List<dynamic> _projects = [];
|
||||||
|
bool _loading = false;
|
||||||
|
bool _creatingProject = false;
|
||||||
|
final Api api = Api();
|
||||||
|
|
||||||
|
@override
|
||||||
|
initState() {
|
||||||
|
super.initState();
|
||||||
|
getProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
void getProjects() async {
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
});
|
||||||
|
var data = await api.request('GET', '/users/me/projects');
|
||||||
|
if (data['success'] == true) {
|
||||||
|
setState(() {
|
||||||
|
_projects = data['payload']['projects'];
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onCreatingProject() {
|
||||||
|
setState(() {
|
||||||
|
_creatingProject = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
void _onCreateProject(newProject) {
|
||||||
|
List<dynamic> _newProjects = _projects;
|
||||||
|
_newProjects.insert(0, newProject);
|
||||||
|
setState(() {
|
||||||
|
_projects = _newProjects;
|
||||||
|
_creatingProject = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDeleteProject(String id) {
|
||||||
|
List<dynamic> _newProjects = _projects.where((p) => p['_id'] != id).toList();
|
||||||
|
setState(() {
|
||||||
|
_projects = _newProjects;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void showNewProjectDialog() async {
|
||||||
|
Widget simpleDialog = new _NewProjectDialog(_onCreatingProject, _onCreateProject);
|
||||||
|
showDialog(context: context, builder: (BuildContext context) => simpleDialog);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildProjectCard(Map<String,dynamic> project) {
|
||||||
|
String description = project['description'] != null ? project['description'] : '';
|
||||||
|
if (description != null && description.length > 80) {
|
||||||
|
description = description.substring(0, 77) + '...';
|
||||||
|
}
|
||||||
|
return new Card(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => ProjectScreen(project, _onDeleteProject),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: <Widget>[
|
||||||
|
new ListTile(
|
||||||
|
leading: Icon(Icons.folder_open),
|
||||||
|
trailing: Icon(Icons.keyboard_arrow_right),
|
||||||
|
title: Text(project['name'] != null ? project['name'] : 'Untitled project'),
|
||||||
|
subtitle: Text(description.replaceAll("\n", " ")),
|
||||||
|
),
|
||||||
|
/*ButtonBar(
|
||||||
|
children: <Widget>[
|
||||||
|
FlatButton(
|
||||||
|
child: const Text('VIEW'),
|
||||||
|
onPressed: () {
|
||||||
|
|
||||||
|
}
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),*/
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text('Your Projects'),
|
||||||
|
actions: <Widget>[
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.info_outline),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => SettingsScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
body: _loading ?
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.all(10.0),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: CircularProgressIndicator()
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
margin: const EdgeInsets.all(10.0),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: (_projects != null && _projects.length > 0) ?
|
||||||
|
ListView.builder(
|
||||||
|
itemCount: _projects.length,
|
||||||
|
itemBuilder: (BuildContext context, int index) {
|
||||||
|
return buildProjectCard(_projects[index]);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
:
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text('Create your first project', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
|
||||||
|
Image(image: AssetImage('assets/reading.png'), width: 300),
|
||||||
|
Text('Projects contain all the files and patterns that make up a piece of work. Create one using the + button below.', textAlign: TextAlign.center),
|
||||||
|
])
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: showNewProjectDialog,
|
||||||
|
child: _creatingProject ? CircularProgressIndicator(backgroundColor: Colors.white) : Icon(Icons.add),
|
||||||
|
backgroundColor: Colors.pink[500],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProjectsTab extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
_ProjectsTabState createState() => _ProjectsTabState();
|
||||||
|
}
|
||||||
|
@ -1,11 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
import 'model.dart';
|
|
||||||
|
|
||||||
class _RegisterScreenState extends State<RegisterScreen> {
|
class _RegisterScreenState extends State<RegisterScreen> {
|
||||||
final TextEditingController _usernameController = TextEditingController();
|
final TextEditingController _usernameController = TextEditingController();
|
||||||
@ -14,14 +12,15 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
final Api api = Api();
|
final Api api = Api();
|
||||||
bool _registering = false;
|
bool _registering = false;
|
||||||
|
|
||||||
void _submit(BuildContext context) async {
|
void _submit(context) async {
|
||||||
setState(() => _registering = true);
|
setState(() => _registering = true);
|
||||||
var data = await api.request('POST', '/accounts/register', {'username': _usernameController.text, 'email': _emailController.text, 'password': _passwordController.text});
|
var data = await api.request('POST', '/accounts/register', {'username': _usernameController.text, 'email': _emailController.text, 'password': _passwordController.text});
|
||||||
setState(() => _registering = false);
|
setState(() => _registering = false);
|
||||||
if (data['success'] == true) {
|
if (data['success'] == true) {
|
||||||
AppModel model = Provider.of<AppModel>(context, listen: false);
|
String token = data['payload']['token'];
|
||||||
await model.setToken(data['payload']['token']);
|
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||||
context.go('/onboarding');
|
prefs.setString('apiToken', token);
|
||||||
|
Navigator.of(context).pushNamedAndRemoveUntil('/onboarding', (Route<dynamic> route) => false);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
showDialog(
|
showDialog(
|
||||||
@ -33,7 +32,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
CupertinoDialogAction(
|
CupertinoDialogAction(
|
||||||
isDefaultAction: true,
|
isDefaultAction: true,
|
||||||
child: Text('Try again'),
|
child: Text('Try again'),
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@ -48,9 +47,15 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
title: Text('Register with Treadl'),
|
title: Text('Register with Treadl'),
|
||||||
),
|
),
|
||||||
body: Container(
|
body: Container(
|
||||||
margin: const EdgeInsets.only(top: 40, left: 10, right: 10),
|
margin: const EdgeInsets.all(10.0),
|
||||||
child: ListView(
|
child: SingleChildScrollView(
|
||||||
|
child:Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
|
Image(image: AssetImage('assets/logo.png'), width: 100),
|
||||||
|
SizedBox(height: 20),
|
||||||
|
Text('Register a free account.'),
|
||||||
|
SizedBox(height: 20),
|
||||||
TextField(
|
TextField(
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
controller: _usernameController,
|
controller: _usernameController,
|
||||||
@ -82,7 +87,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
text: TextSpan(
|
text: TextSpan(
|
||||||
text: 'By registering you agree to Treadl\'s ',
|
text: 'By registering you agree to Treadl\'s ',
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyText1,
|
||||||
children: <TextSpan>[
|
children: <TextSpan>[
|
||||||
TextSpan(text: 'Terms of Use', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.pink), recognizer: new TapGestureRecognizer()..onTap = () => launch('https://treadl.com/terms-of-use')),
|
TextSpan(text: 'Terms of Use', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.pink), recognizer: new TapGestureRecognizer()..onTap = () => launch('https://treadl.com/terms-of-use')),
|
||||||
TextSpan(text: ' and '),
|
TextSpan(text: ' and '),
|
||||||
@ -92,14 +97,17 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 20),
|
SizedBox(height: 20),
|
||||||
ElevatedButton(
|
RaisedButton(
|
||||||
onPressed: () => _submit(context),
|
onPressed: () => _submit(context),
|
||||||
|
color: Colors.pink,
|
||||||
child: _registering ? SizedBox(height: 20, width: 20, child:CircularProgressIndicator(backgroundColor: Colors.white)) : Text("Register",
|
child: _registering ? SizedBox(height: 20, width: 20, child:CircularProgressIndicator(backgroundColor: Colors.white)) : Text("Register",
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 15)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
)
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
6
mobile/lib/routeArguments.dart
Normal file
6
mobile/lib/routeArguments.dart
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
class ProjectScreenArguments {
|
||||||
|
final String projectId;
|
||||||
|
final String projectName;
|
||||||
|
final String projectPath;
|
||||||
|
ProjectScreenArguments(this.projectId, this.projectName, this.projectPath);
|
||||||
|
}
|
@ -1,21 +1,18 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
import 'model.dart';
|
|
||||||
|
|
||||||
class SettingsScreen extends StatelessWidget {
|
class SettingsScreen extends StatelessWidget {
|
||||||
final TextEditingController _passwordController = TextEditingController();
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
|
|
||||||
void _logout(BuildContext context) async {
|
void _logout(BuildContext context) async {
|
||||||
AppModel model = Provider.of<AppModel>(context, listen: false);
|
|
||||||
Api api = Api();
|
Api api = Api();
|
||||||
api.request('POST', '/accounts/logout');
|
api.request('POST', '/accounts/logout');
|
||||||
model.setToken(null);
|
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||||
model.setUser(null);
|
prefs.remove('apiToken');
|
||||||
context.pop();
|
Navigator.of(context).pushNamedAndRemoveUntil('/welcome', (Route<dynamic> route) => false);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _deleteAccount(BuildContext context) async {
|
void _deleteAccount(BuildContext context) async {
|
||||||
@ -34,20 +31,19 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
FlatButton(
|
||||||
child: Text('Cancel'),
|
child: Text('Cancel'),
|
||||||
onPressed: () => context.pop(),
|
onPressed: () { Navigator.of(context).pop(); }
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
RaisedButton(
|
||||||
child: Text('Delete Account'),
|
child: Text('Delete Account'),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
Api api = Api();
|
Api api = Api();
|
||||||
var data = await api.request('DELETE', '/accounts', {'password': _passwordController.text});
|
var data = await api.request('DELETE', '/accounts', {'password': _passwordController.text});
|
||||||
if (data['success'] == true) {
|
if (data['success'] == true) {
|
||||||
AppModel model = Provider.of<AppModel>(context, listen: false);
|
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||||
model.setToken(null);
|
prefs.remove('apiToken');
|
||||||
model.setUser(null);
|
Navigator.of(context).pushNamedAndRemoveUntil('/welcome', (Route<dynamic> route) => false);
|
||||||
context.go('/home');
|
|
||||||
} else {
|
} else {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@ -58,7 +54,7 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
CupertinoDialogAction(
|
CupertinoDialogAction(
|
||||||
isDefaultAction: true,
|
isDefaultAction: true,
|
||||||
child: Text('OK'),
|
child: Text('OK'),
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@ -79,8 +75,6 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
AppModel model = Provider.of<AppModel>(context);
|
|
||||||
User? user = model.user;
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text('About Treadl'),
|
title: Text('About Treadl'),
|
||||||
@ -94,13 +88,11 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
Text('Thanks for using Treadl', style: Theme.of(context).textTheme.titleLarge),
|
Text('Thanks for using Treadl', style: Theme.of(context).textTheme.titleLarge),
|
||||||
),
|
),
|
||||||
Container(
|
Container(
|
||||||
child: Text("Treadl is an app for managing your projects and for keeping in touch with your weaving communities.\n\nWe're always trying to make Treadl better, so if you have any feedback please let us know!", style: Theme.of(context).textTheme.bodyMedium)
|
child: Text("Treadl is an app for managing your projects and for keeping in touch with your weaving communities.\n\nWe're always trying to make Treadl better, so if you have any feedback please let us know!", style: Theme.of(context).textTheme.bodyText1)
|
||||||
),
|
),
|
||||||
|
|
||||||
SizedBox(height: 30),
|
SizedBox(height: 30),
|
||||||
|
|
||||||
user != null ? Column(
|
|
||||||
children: [
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.exit_to_app),
|
leading: Icon(Icons.exit_to_app),
|
||||||
title: Text('Logout'),
|
title: Text('Logout'),
|
||||||
@ -111,11 +103,6 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
title: Text('Delete Account'),
|
title: Text('Delete Account'),
|
||||||
onTap: () => _deleteAccount(context),
|
onTap: () => _deleteAccount(context),
|
||||||
),
|
),
|
||||||
]
|
|
||||||
) : ElevatedButton(
|
|
||||||
child: Text('Join Treadl'),
|
|
||||||
onPressed: () => context.push('/welcome'),
|
|
||||||
),
|
|
||||||
|
|
||||||
SizedBox(height: 30),
|
SizedBox(height: 30),
|
||||||
|
|
||||||
|
9
mobile/lib/store.dart
Normal file
9
mobile/lib/store.dart
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
class Store extends ChangeNotifier {
|
||||||
|
String apiToken;
|
||||||
|
|
||||||
|
void setToken(String newToken) {
|
||||||
|
apiToken = newToken;
|
||||||
|
}
|
||||||
|
}
|
@ -5,21 +5,18 @@ import 'package:url_launcher/url_launcher.dart';
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'util.dart';
|
import 'util.dart';
|
||||||
import 'api.dart';
|
import 'api.dart';
|
||||||
import 'lib.dart';
|
|
||||||
|
|
||||||
class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateMixin {
|
class _UserScreenState extends State<UserScreen> {
|
||||||
final String username;
|
final Util util = new Util();
|
||||||
final Api api = Api();
|
final Api api = Api();
|
||||||
TabController? _tabController;
|
Map<String,dynamic> _user;
|
||||||
Map<String,dynamic>? _user;
|
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
_UserScreenState(this.username) { }
|
_UserScreenState(this._user) { }
|
||||||
|
|
||||||
@override
|
@override
|
||||||
initState() {
|
initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabController = new TabController(length: 2, vsync: this);
|
getUser(_user['username']);
|
||||||
getUser(username);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void getUser(String username) async {
|
void getUser(String username) async {
|
||||||
@ -34,137 +31,75 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget getBody() {
|
@override
|
||||||
if (_loading)
|
Widget build(BuildContext context) {
|
||||||
return CircularProgressIndicator();
|
String created;
|
||||||
else if (_user != null && _tabController != null) {
|
if (_user['createdAt'] != null) {
|
||||||
var u = _user!;
|
DateTime createdAt = DateTime.parse(_user['createdAt']);
|
||||||
String? created;
|
|
||||||
if (u['createdAt'] != null) {
|
|
||||||
DateTime createdAt = DateTime.parse(u['createdAt']!);
|
|
||||||
created = DateFormat('MMMM y').format(createdAt);
|
created = DateFormat('MMMM y').format(createdAt);
|
||||||
}
|
}
|
||||||
return Column(
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(_user['username']),
|
||||||
|
),
|
||||||
|
body: _loading ?
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.all(10.0),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: CircularProgressIndicator()
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
padding: EdgeInsets.all(10),
|
||||||
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Row(children: [
|
Row(children: [
|
||||||
Util.avatarImage(Util.avatarUrl(u), size: 120),
|
util.avatarImage(util.avatarUrl(_user), size: 120),
|
||||||
Container(
|
Expanded(child: Container(
|
||||||
padding: EdgeInsets.only(left: 10),
|
padding: EdgeInsets.only(left: 10),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(u['username'], style: Theme.of(context).textTheme.titleLarge),
|
Text(_user['username'], style: Theme.of(context).textTheme.titleMedium),
|
||||||
SizedBox(height: 5),
|
SizedBox(height: 5),
|
||||||
u['location'] != null ?
|
_user['location'] != null ?
|
||||||
Row(children: [
|
Row(children: [
|
||||||
Icon(CupertinoIcons.location),
|
Icon(CupertinoIcons.location),
|
||||||
SizedBox(width: 10),
|
Text(_user['location'])
|
||||||
Text(u['location'])
|
|
||||||
]) : SizedBox(height: 1),
|
]) : SizedBox(height: 1),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
Text('Member' + (created != null ? (' since ' + created!) : ''),
|
Text('Member' + (created != null ? (' since ' + created) : ''),
|
||||||
style: TextStyle(color: Colors.grey[500])
|
style: TextStyle(color: Colors.grey[500])
|
||||||
),
|
),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
u['website'] != null ?
|
_user['website'] != null ?
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
String url = u['website'];
|
String url = _user['website'];
|
||||||
if (!url.startsWith('http')) {
|
if (!url.startsWith('http')) {
|
||||||
url = 'http://' + url;
|
url = 'http://' + url;
|
||||||
}
|
}
|
||||||
launch(url);
|
launch(url);
|
||||||
},
|
},
|
||||||
child: Text(u['website'],
|
child: Text(_user['website'],
|
||||||
style: TextStyle(color: Colors.pink))
|
style: TextStyle(color: Colors.pink))
|
||||||
) : SizedBox(height: 1),
|
) : SizedBox(height: 1),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
)
|
))
|
||||||
]),
|
]),
|
||||||
SizedBox(height: 10),
|
|
||||||
TabBar(
|
|
||||||
unselectedLabelColor: Colors.black,
|
|
||||||
labelColor: Colors.pink,
|
|
||||||
tabs: [
|
|
||||||
Tab(
|
|
||||||
text: 'Profile',
|
|
||||||
icon: Icon(Icons.person),
|
|
||||||
),
|
|
||||||
Tab(
|
|
||||||
text: 'Projects',
|
|
||||||
icon: Icon(Icons.folder),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
controller: _tabController!,
|
|
||||||
indicatorSize: TabBarIndicatorSize.tab,
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: TabBarView(
|
|
||||||
controller: _tabController,
|
|
||||||
children: [
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
SizedBox(height: 30),
|
SizedBox(height: 30),
|
||||||
u['bio'] != null ? Text(u['bio']) :
|
Text(_user['bio'] != null ? _user['bio'] : '')
|
||||||
EmptyBox('This user doesn\'t have any more profile information.')
|
|
||||||
]
|
]
|
||||||
),
|
|
||||||
(u['projects'] != null && u['projects'].length > 0) ?
|
|
||||||
Container(
|
|
||||||
margin: EdgeInsets.only(top: 10),
|
|
||||||
child: GridView.count(
|
|
||||||
crossAxisCount: 2,
|
|
||||||
mainAxisSpacing: 5,
|
|
||||||
crossAxisSpacing: 5,
|
|
||||||
childAspectRatio: 1.3,
|
|
||||||
children: u['projects'].map<Widget>((p) =>
|
|
||||||
ProjectCard(p)
|
|
||||||
).toList()
|
|
||||||
),
|
|
||||||
) :
|
|
||||||
Container(
|
|
||||||
margin: EdgeInsets.all(10),
|
|
||||||
child: EmptyBox('This user doesn\'t have any public projects'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
]);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
return Text('User not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text(username),
|
|
||||||
actions: <Widget>[
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.person),
|
|
||||||
onPressed: () {
|
|
||||||
launch('https://www.treadl.com/' + username);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
body: Container(
|
|
||||||
margin: const EdgeInsets.all(10.0),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: getBody()
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class UserScreen extends StatefulWidget {
|
class UserScreen extends StatefulWidget {
|
||||||
final String username;
|
final Map<String,dynamic> user;
|
||||||
UserScreen(this.username) { }
|
UserScreen(this.user) { }
|
||||||
@override
|
@override
|
||||||
_UserScreenState createState() => _UserScreenState(username);
|
_UserScreenState createState() => _UserScreenState(user);
|
||||||
}
|
}
|
||||||
|
@ -1,28 +1,21 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
import 'package:share_plus/share_plus.dart';
|
|
||||||
import 'dart:io';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'model.dart';
|
|
||||||
|
|
||||||
String APP_URL = 'https://www.treadl.com';
|
|
||||||
|
|
||||||
class Util {
|
class Util {
|
||||||
|
|
||||||
static ImageProvider? avatarUrl(Map<String,dynamic> user) {
|
ImageProvider avatarUrl(Map<String,dynamic> user) {
|
||||||
|
ImageProvider a = AssetImage('assets/avatars/9.png');
|
||||||
if (user != null && user['avatar'] != null) {
|
if (user != null && user['avatar'] != null) {
|
||||||
if (user['avatar'].length < 3) {
|
if (user['avatar'].length < 3) {
|
||||||
return AssetImage('assets/avatars/${user['avatar']}.png');
|
a = AssetImage('assets/avatars/${user['avatar']}.png');
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return NetworkImage(user['avatarUrl']);
|
a =NetworkImage(user['avatarUrl']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Widget avatarImage(ImageProvider? image, {double size=30}) {
|
Widget avatarImage(ImageProvider image, {double size=30}) {
|
||||||
if (image != null) {
|
|
||||||
return new Container(
|
return new Container(
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
@ -35,64 +28,4 @@ class Util {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return new Container(
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: Colors.pink[400],
|
|
||||||
),
|
|
||||||
child: Icon(Icons.person, size: size/1.5, color: Colors.white)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Color rgb(String input) {
|
|
||||||
List<String> parts = input.split(',');
|
|
||||||
List<int> iParts = parts.map((p) => int.parse(p)).toList();
|
|
||||||
iParts = iParts.map((p) => p > 255 ? 255 : p).toList();
|
|
||||||
return Color.fromRGBO(iParts[0], iParts[1], iParts[2], 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
static String appUrl(String path) {
|
|
||||||
return APP_URL + '/' + path;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<String> storagePath() async {
|
|
||||||
final Directory directory = await getApplicationDocumentsDirectory();
|
|
||||||
return directory.path;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<File> writeFile(String fileName, String data) async {
|
|
||||||
final String dirPath = await Util.storagePath();
|
|
||||||
final file = File('$dirPath/$fileName');
|
|
||||||
String contents = data.replaceAll(RegExp(r'\\n'), '\r\n');
|
|
||||||
return await file.writeAsString(contents);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<bool> deleteFile(File file) async {
|
|
||||||
await file.delete();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void shareFile(File file, {bool? withDelete}) async {
|
|
||||||
await Share.shareXFiles([XFile(file.path)]);
|
|
||||||
if (withDelete == true) {
|
|
||||||
await Util.deleteFile(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void shareUrl(String text, String url) async {
|
|
||||||
await Share.share('$text: $url');
|
|
||||||
}
|
|
||||||
|
|
||||||
static String ellipsis(String input, int cutoff) {
|
|
||||||
return (input.length <= cutoff)
|
|
||||||
? input
|
|
||||||
: '${input.substring(0, cutoff)}...';
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool canEditProject(User? user, Map<String,dynamic>? project) {
|
|
||||||
if (user == null || project == null) return false;
|
|
||||||
return project['user'] == user.id;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'store.dart';
|
||||||
import 'login.dart';
|
import 'login.dart';
|
||||||
|
|
||||||
class WelcomeScreen extends StatelessWidget {
|
class WelcomeScreen extends StatelessWidget {
|
||||||
void _login(BuildContext context) {
|
void _login(BuildContext context) {
|
||||||
context.push('/login');
|
Navigator.of(context).pushNamed('/login');
|
||||||
}
|
}
|
||||||
void _register(BuildContext context) {
|
void _register(BuildContext context) {
|
||||||
context.push('/register');
|
Navigator.of(context).pushNamed('/register');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -25,24 +25,18 @@ class WelcomeScreen extends StatelessWidget {
|
|||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
Text('Treadl is a place for weavers to connect and manage their portfolios.', style: TextStyle(color: Colors.white), textAlign: TextAlign.center),
|
Text('Treadl is a place for weavers to connect and manage their portfolios.', style: TextStyle(color: Colors.white), textAlign: TextAlign.center),
|
||||||
SizedBox(height: 30),
|
SizedBox(height: 30),
|
||||||
ElevatedButton(
|
CupertinoButton(
|
||||||
onPressed: () => _login(context),
|
onPressed: () => _login(context),
|
||||||
|
color: Colors.white,
|
||||||
child: new Text("Login",
|
child: new Text("Login",
|
||||||
style: TextStyle(color: Colors.pink),
|
style: TextStyle(color: Colors.pink),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
SizedBox(height: 15),
|
SizedBox(height: 15),
|
||||||
ElevatedButton(
|
CupertinoButton(
|
||||||
onPressed: () => _register(context),
|
onPressed: () => _register(context),
|
||||||
child: new Text("Register",
|
child: new Text("Register",
|
||||||
textAlign: TextAlign.center,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
SizedBox(height: 35),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => context.pop(),
|
|
||||||
child: new Text("Cancel",
|
|
||||||
style: TextStyle(color: Colors.white),
|
style: TextStyle(color: Colors.white),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
)
|
)
|
||||||
|
1
mobile/linux/.gitignore
vendored
1
mobile/linux/.gitignore
vendored
@ -1 +0,0 @@
|
|||||||
flutter/ephemeral
|
|
@ -1,139 +0,0 @@
|
|||||||
# Project-level configuration.
|
|
||||||
cmake_minimum_required(VERSION 3.10)
|
|
||||||
project(runner LANGUAGES CXX)
|
|
||||||
|
|
||||||
# The name of the executable created for the application. Change this to change
|
|
||||||
# the on-disk name of your application.
|
|
||||||
set(BINARY_NAME "mobile")
|
|
||||||
# The unique GTK application identifier for this application. See:
|
|
||||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
|
||||||
set(APPLICATION_ID "com.mobile")
|
|
||||||
|
|
||||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
|
||||||
# versions of CMake.
|
|
||||||
cmake_policy(SET CMP0063 NEW)
|
|
||||||
|
|
||||||
# Load bundled libraries from the lib/ directory relative to the binary.
|
|
||||||
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
|
|
||||||
|
|
||||||
# Root filesystem for cross-building.
|
|
||||||
if(FLUTTER_TARGET_PLATFORM_SYSROOT)
|
|
||||||
set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
|
|
||||||
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
|
|
||||||
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
|
||||||
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
|
|
||||||
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
|
|
||||||
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
|
|
||||||
endif()
|
|
||||||
|
|
||||||
# Define build configuration options.
|
|
||||||
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
|
||||||
set(CMAKE_BUILD_TYPE "Debug" CACHE
|
|
||||||
STRING "Flutter build mode" FORCE)
|
|
||||||
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
|
|
||||||
"Debug" "Profile" "Release")
|
|
||||||
endif()
|
|
||||||
|
|
||||||
# Compilation settings that should be applied to most targets.
|
|
||||||
#
|
|
||||||
# Be cautious about adding new options here, as plugins use this function by
|
|
||||||
# default. In most cases, you should add new options to specific targets instead
|
|
||||||
# of modifying this function.
|
|
||||||
function(APPLY_STANDARD_SETTINGS TARGET)
|
|
||||||
target_compile_features(${TARGET} PUBLIC cxx_std_14)
|
|
||||||
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
|
|
||||||
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
|
|
||||||
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
|
|
||||||
endfunction()
|
|
||||||
|
|
||||||
# Flutter library and tool build rules.
|
|
||||||
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
|
|
||||||
add_subdirectory(${FLUTTER_MANAGED_DIR})
|
|
||||||
|
|
||||||
# System-level dependencies.
|
|
||||||
find_package(PkgConfig REQUIRED)
|
|
||||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
|
||||||
|
|
||||||
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
|
|
||||||
|
|
||||||
# Define the application target. To change its name, change BINARY_NAME above,
|
|
||||||
# not the value here, or `flutter run` will no longer work.
|
|
||||||
#
|
|
||||||
# Any new source files that you add to the application should be added here.
|
|
||||||
add_executable(${BINARY_NAME}
|
|
||||||
"main.cc"
|
|
||||||
"my_application.cc"
|
|
||||||
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply the standard set of build settings. This can be removed for applications
|
|
||||||
# that need different build settings.
|
|
||||||
apply_standard_settings(${BINARY_NAME})
|
|
||||||
|
|
||||||
# Add dependency libraries. Add any application-specific dependencies here.
|
|
||||||
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
|
|
||||||
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
|
|
||||||
|
|
||||||
# Run the Flutter tool portions of the build. This must not be removed.
|
|
||||||
add_dependencies(${BINARY_NAME} flutter_assemble)
|
|
||||||
|
|
||||||
# Only the install-generated bundle's copy of the executable will launch
|
|
||||||
# correctly, since the resources must in the right relative locations. To avoid
|
|
||||||
# people trying to run the unbundled copy, put it in a subdirectory instead of
|
|
||||||
# the default top-level location.
|
|
||||||
set_target_properties(${BINARY_NAME}
|
|
||||||
PROPERTIES
|
|
||||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Generated plugin build rules, which manage building the plugins and adding
|
|
||||||
# them to the application.
|
|
||||||
include(flutter/generated_plugins.cmake)
|
|
||||||
|
|
||||||
|
|
||||||
# === Installation ===
|
|
||||||
# By default, "installing" just makes a relocatable bundle in the build
|
|
||||||
# directory.
|
|
||||||
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
|
|
||||||
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
|
|
||||||
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
|
|
||||||
endif()
|
|
||||||
|
|
||||||
# Start with a clean build bundle directory every time.
|
|
||||||
install(CODE "
|
|
||||||
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
|
|
||||||
" COMPONENT Runtime)
|
|
||||||
|
|
||||||
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
|
|
||||||
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
|
|
||||||
|
|
||||||
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
|
|
||||||
COMPONENT Runtime)
|
|
||||||
|
|
||||||
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
|
|
||||||
COMPONENT Runtime)
|
|
||||||
|
|
||||||
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
|
||||||
COMPONENT Runtime)
|
|
||||||
|
|
||||||
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
|
|
||||||
install(FILES "${bundled_library}"
|
|
||||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
|
||||||
COMPONENT Runtime)
|
|
||||||
endforeach(bundled_library)
|
|
||||||
|
|
||||||
# Fully re-copy the assets directory on each build to avoid having stale files
|
|
||||||
# from a previous install.
|
|
||||||
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
|
|
||||||
install(CODE "
|
|
||||||
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
|
|
||||||
" COMPONENT Runtime)
|
|
||||||
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
|
|
||||||
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
|
|
||||||
|
|
||||||
# Install the AOT library on non-Debug builds only.
|
|
||||||
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
|
|
||||||
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
|
||||||
COMPONENT Runtime)
|
|
||||||
endif()
|
|
@ -1,88 +0,0 @@
|
|||||||
# This file controls Flutter-level build steps. It should not be edited.
|
|
||||||
cmake_minimum_required(VERSION 3.10)
|
|
||||||
|
|
||||||
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
|
|
||||||
|
|
||||||
# Configuration provided via flutter tool.
|
|
||||||
include(${EPHEMERAL_DIR}/generated_config.cmake)
|
|
||||||
|
|
||||||
# TODO: Move the rest of this into files in ephemeral. See
|
|
||||||
# https://github.com/flutter/flutter/issues/57146.
|
|
||||||
|
|
||||||
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
|
|
||||||
# which isn't available in 3.10.
|
|
||||||
function(list_prepend LIST_NAME PREFIX)
|
|
||||||
set(NEW_LIST "")
|
|
||||||
foreach(element ${${LIST_NAME}})
|
|
||||||
list(APPEND NEW_LIST "${PREFIX}${element}")
|
|
||||||
endforeach(element)
|
|
||||||
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
|
|
||||||
endfunction()
|
|
||||||
|
|
||||||
# === Flutter Library ===
|
|
||||||
# System-level dependencies.
|
|
||||||
find_package(PkgConfig REQUIRED)
|
|
||||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
|
||||||
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
|
|
||||||
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
|
|
||||||
|
|
||||||
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
|
|
||||||
|
|
||||||
# Published to parent scope for install step.
|
|
||||||
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
|
|
||||||
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
|
|
||||||
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
|
|
||||||
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
|
|
||||||
|
|
||||||
list(APPEND FLUTTER_LIBRARY_HEADERS
|
|
||||||
"fl_basic_message_channel.h"
|
|
||||||
"fl_binary_codec.h"
|
|
||||||
"fl_binary_messenger.h"
|
|
||||||
"fl_dart_project.h"
|
|
||||||
"fl_engine.h"
|
|
||||||
"fl_json_message_codec.h"
|
|
||||||
"fl_json_method_codec.h"
|
|
||||||
"fl_message_codec.h"
|
|
||||||
"fl_method_call.h"
|
|
||||||
"fl_method_channel.h"
|
|
||||||
"fl_method_codec.h"
|
|
||||||
"fl_method_response.h"
|
|
||||||
"fl_plugin_registrar.h"
|
|
||||||
"fl_plugin_registry.h"
|
|
||||||
"fl_standard_message_codec.h"
|
|
||||||
"fl_standard_method_codec.h"
|
|
||||||
"fl_string_codec.h"
|
|
||||||
"fl_value.h"
|
|
||||||
"fl_view.h"
|
|
||||||
"flutter_linux.h"
|
|
||||||
)
|
|
||||||
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
|
|
||||||
add_library(flutter INTERFACE)
|
|
||||||
target_include_directories(flutter INTERFACE
|
|
||||||
"${EPHEMERAL_DIR}"
|
|
||||||
)
|
|
||||||
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
|
|
||||||
target_link_libraries(flutter INTERFACE
|
|
||||||
PkgConfig::GTK
|
|
||||||
PkgConfig::GLIB
|
|
||||||
PkgConfig::GIO
|
|
||||||
)
|
|
||||||
add_dependencies(flutter flutter_assemble)
|
|
||||||
|
|
||||||
# === Flutter tool backend ===
|
|
||||||
# _phony_ is a non-existent file to force this command to run every time,
|
|
||||||
# since currently there's no way to get a full input/output list from the
|
|
||||||
# flutter tool.
|
|
||||||
add_custom_command(
|
|
||||||
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
|
|
||||||
${CMAKE_CURRENT_BINARY_DIR}/_phony_
|
|
||||||
COMMAND ${CMAKE_COMMAND} -E env
|
|
||||||
${FLUTTER_TOOL_ENVIRONMENT}
|
|
||||||
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
|
|
||||||
${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
|
|
||||||
VERBATIM
|
|
||||||
)
|
|
||||||
add_custom_target(flutter_assemble DEPENDS
|
|
||||||
"${FLUTTER_LIBRARY}"
|
|
||||||
${FLUTTER_LIBRARY_HEADERS}
|
|
||||||
)
|
|
@ -1,19 +0,0 @@
|
|||||||
//
|
|
||||||
// Generated file. Do not edit.
|
|
||||||
//
|
|
||||||
|
|
||||||
// clang-format off
|
|
||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
|
||||||
|
|
||||||
#include <file_selector_linux/file_selector_plugin.h>
|
|
||||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
|
||||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
|
||||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
|
||||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
|
||||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
|
||||||
}
|
|
@ -1,15 +0,0 @@
|
|||||||
//
|
|
||||||
// Generated file. Do not edit.
|
|
||||||
//
|
|
||||||
|
|
||||||
// clang-format off
|
|
||||||
|
|
||||||
#ifndef GENERATED_PLUGIN_REGISTRANT_
|
|
||||||
#define GENERATED_PLUGIN_REGISTRANT_
|
|
||||||
|
|
||||||
#include <flutter_linux/flutter_linux.h>
|
|
||||||
|
|
||||||
// Registers Flutter plugins.
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry);
|
|
||||||
|
|
||||||
#endif // GENERATED_PLUGIN_REGISTRANT_
|
|
@ -1,25 +0,0 @@
|
|||||||
#
|
|
||||||
# Generated file, do not edit.
|
|
||||||
#
|
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
|
||||||
file_selector_linux
|
|
||||||
url_launcher_linux
|
|
||||||
)
|
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
|
||||||
)
|
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
|
||||||
|
|
||||||
foreach(plugin ${FLUTTER_PLUGIN_LIST})
|
|
||||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
|
|
||||||
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
|
|
||||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
|
|
||||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
|
|
||||||
endforeach(plugin)
|
|
||||||
|
|
||||||
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
|
|
||||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
|
|
||||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
|
|
||||||
endforeach(ffi_plugin)
|
|
@ -1,6 +0,0 @@
|
|||||||
#include "my_application.h"
|
|
||||||
|
|
||||||
int main(int argc, char** argv) {
|
|
||||||
g_autoptr(MyApplication) app = my_application_new();
|
|
||||||
return g_application_run(G_APPLICATION(app), argc, argv);
|
|
||||||
}
|
|
@ -1,104 +0,0 @@
|
|||||||
#include "my_application.h"
|
|
||||||
|
|
||||||
#include <flutter_linux/flutter_linux.h>
|
|
||||||
#ifdef GDK_WINDOWING_X11
|
|
||||||
#include <gdk/gdkx.h>
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#include "flutter/generated_plugin_registrant.h"
|
|
||||||
|
|
||||||
struct _MyApplication {
|
|
||||||
GtkApplication parent_instance;
|
|
||||||
char** dart_entrypoint_arguments;
|
|
||||||
};
|
|
||||||
|
|
||||||
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
|
|
||||||
|
|
||||||
// Implements GApplication::activate.
|
|
||||||
static void my_application_activate(GApplication* application) {
|
|
||||||
MyApplication* self = MY_APPLICATION(application);
|
|
||||||
GtkWindow* window =
|
|
||||||
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
|
|
||||||
|
|
||||||
// Use a header bar when running in GNOME as this is the common style used
|
|
||||||
// by applications and is the setup most users will be using (e.g. Ubuntu
|
|
||||||
// desktop).
|
|
||||||
// If running on X and not using GNOME then just use a traditional title bar
|
|
||||||
// in case the window manager does more exotic layout, e.g. tiling.
|
|
||||||
// If running on Wayland assume the header bar will work (may need changing
|
|
||||||
// if future cases occur).
|
|
||||||
gboolean use_header_bar = TRUE;
|
|
||||||
#ifdef GDK_WINDOWING_X11
|
|
||||||
GdkScreen* screen = gtk_window_get_screen(window);
|
|
||||||
if (GDK_IS_X11_SCREEN(screen)) {
|
|
||||||
const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
|
|
||||||
if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
|
|
||||||
use_header_bar = FALSE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
if (use_header_bar) {
|
|
||||||
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
|
||||||
gtk_widget_show(GTK_WIDGET(header_bar));
|
|
||||||
gtk_header_bar_set_title(header_bar, "mobile");
|
|
||||||
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
|
||||||
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
|
||||||
} else {
|
|
||||||
gtk_window_set_title(window, "mobile");
|
|
||||||
}
|
|
||||||
|
|
||||||
gtk_window_set_default_size(window, 1280, 720);
|
|
||||||
gtk_widget_show(GTK_WIDGET(window));
|
|
||||||
|
|
||||||
g_autoptr(FlDartProject) project = fl_dart_project_new();
|
|
||||||
fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments);
|
|
||||||
|
|
||||||
FlView* view = fl_view_new(project);
|
|
||||||
gtk_widget_show(GTK_WIDGET(view));
|
|
||||||
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
|
|
||||||
|
|
||||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
|
||||||
|
|
||||||
gtk_widget_grab_focus(GTK_WIDGET(view));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Implements GApplication::local_command_line.
|
|
||||||
static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) {
|
|
||||||
MyApplication* self = MY_APPLICATION(application);
|
|
||||||
// Strip out the first argument as it is the binary name.
|
|
||||||
self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
|
|
||||||
|
|
||||||
g_autoptr(GError) error = nullptr;
|
|
||||||
if (!g_application_register(application, nullptr, &error)) {
|
|
||||||
g_warning("Failed to register: %s", error->message);
|
|
||||||
*exit_status = 1;
|
|
||||||
return TRUE;
|
|
||||||
}
|
|
||||||
|
|
||||||
g_application_activate(application);
|
|
||||||
*exit_status = 0;
|
|
||||||
|
|
||||||
return TRUE;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Implements GObject::dispose.
|
|
||||||
static void my_application_dispose(GObject* object) {
|
|
||||||
MyApplication* self = MY_APPLICATION(object);
|
|
||||||
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
|
|
||||||
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void my_application_class_init(MyApplicationClass* klass) {
|
|
||||||
G_APPLICATION_CLASS(klass)->activate = my_application_activate;
|
|
||||||
G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line;
|
|
||||||
G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void my_application_init(MyApplication* self) {}
|
|
||||||
|
|
||||||
MyApplication* my_application_new() {
|
|
||||||
return MY_APPLICATION(g_object_new(my_application_get_type(),
|
|
||||||
"application-id", APPLICATION_ID,
|
|
||||||
"flags", G_APPLICATION_NON_UNIQUE,
|
|
||||||
nullptr));
|
|
||||||
}
|
|
@ -1,18 +0,0 @@
|
|||||||
#ifndef FLUTTER_MY_APPLICATION_H_
|
|
||||||
#define FLUTTER_MY_APPLICATION_H_
|
|
||||||
|
|
||||||
#include <gtk/gtk.h>
|
|
||||||
|
|
||||||
G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION,
|
|
||||||
GtkApplication)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* my_application_new:
|
|
||||||
*
|
|
||||||
* Creates a new Flutter-based application.
|
|
||||||
*
|
|
||||||
* Returns: a new #MyApplication.
|
|
||||||
*/
|
|
||||||
MyApplication* my_application_new();
|
|
||||||
|
|
||||||
#endif // FLUTTER_MY_APPLICATION_H_
|
|
7
mobile/macos/.gitignore
vendored
7
mobile/macos/.gitignore
vendored
@ -1,7 +0,0 @@
|
|||||||
# Flutter-related
|
|
||||||
**/Flutter/ephemeral/
|
|
||||||
**/Pods/
|
|
||||||
|
|
||||||
# Xcode-related
|
|
||||||
**/dgph
|
|
||||||
**/xcuserdata/
|
|
@ -1,2 +0,0 @@
|
|||||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
|
||||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
|
@ -1,2 +0,0 @@
|
|||||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
|
||||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
|
@ -1,26 +0,0 @@
|
|||||||
//
|
|
||||||
// Generated file. Do not edit.
|
|
||||||
//
|
|
||||||
|
|
||||||
import FlutterMacOS
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
import file_picker
|
|
||||||
import file_selector_macos
|
|
||||||
import firebase_core
|
|
||||||
import firebase_messaging
|
|
||||||
import path_provider_foundation
|
|
||||||
import share_plus
|
|
||||||
import shared_preferences_foundation
|
|
||||||
import url_launcher_macos
|
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|
||||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
|
||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
|
||||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
|
||||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
|
||||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
|
||||||
}
|
|
@ -1,43 +0,0 @@
|
|||||||
platform :osx, '10.14'
|
|
||||||
|
|
||||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
|
||||||
|
|
||||||
project 'Runner', {
|
|
||||||
'Debug' => :debug,
|
|
||||||
'Profile' => :release,
|
|
||||||
'Release' => :release,
|
|
||||||
}
|
|
||||||
|
|
||||||
def flutter_root
|
|
||||||
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__)
|
|
||||||
unless File.exist?(generated_xcode_build_settings_path)
|
|
||||||
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first"
|
|
||||||
end
|
|
||||||
|
|
||||||
File.foreach(generated_xcode_build_settings_path) do |line|
|
|
||||||
matches = line.match(/FLUTTER_ROOT\=(.*)/)
|
|
||||||
return matches[1].strip if matches
|
|
||||||
end
|
|
||||||
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\""
|
|
||||||
end
|
|
||||||
|
|
||||||
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
|
|
||||||
|
|
||||||
flutter_macos_podfile_setup
|
|
||||||
|
|
||||||
target 'Runner' do
|
|
||||||
use_frameworks!
|
|
||||||
use_modular_headers!
|
|
||||||
|
|
||||||
flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__))
|
|
||||||
target 'RunnerTests' do
|
|
||||||
inherit! :search_paths
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
post_install do |installer|
|
|
||||||
installer.pods_project.targets.each do |target|
|
|
||||||
flutter_additional_macos_build_settings(target)
|
|
||||||
end
|
|
||||||
end
|
|
@ -1,137 +0,0 @@
|
|||||||
PODS:
|
|
||||||
- file_selector_macos (0.0.1):
|
|
||||||
- FlutterMacOS
|
|
||||||
- Firebase/CoreOnly (10.9.0):
|
|
||||||
- FirebaseCore (= 10.9.0)
|
|
||||||
- Firebase/Messaging (10.9.0):
|
|
||||||
- Firebase/CoreOnly
|
|
||||||
- FirebaseMessaging (~> 10.9.0)
|
|
||||||
- firebase_core (2.13.1):
|
|
||||||
- Firebase/CoreOnly (~> 10.9.0)
|
|
||||||
- FlutterMacOS
|
|
||||||
- firebase_messaging (14.6.2):
|
|
||||||
- Firebase/CoreOnly (~> 10.9.0)
|
|
||||||
- Firebase/Messaging (~> 10.9.0)
|
|
||||||
- firebase_core
|
|
||||||
- FlutterMacOS
|
|
||||||
- FirebaseCore (10.9.0):
|
|
||||||
- FirebaseCoreInternal (~> 10.0)
|
|
||||||
- GoogleUtilities/Environment (~> 7.8)
|
|
||||||
- GoogleUtilities/Logger (~> 7.8)
|
|
||||||
- FirebaseCoreInternal (10.17.0):
|
|
||||||
- "GoogleUtilities/NSData+zlib (~> 7.8)"
|
|
||||||
- FirebaseInstallations (10.17.0):
|
|
||||||
- FirebaseCore (~> 10.0)
|
|
||||||
- GoogleUtilities/Environment (~> 7.8)
|
|
||||||
- GoogleUtilities/UserDefaults (~> 7.8)
|
|
||||||
- PromisesObjC (~> 2.1)
|
|
||||||
- FirebaseMessaging (10.9.0):
|
|
||||||
- FirebaseCore (~> 10.0)
|
|
||||||
- FirebaseInstallations (~> 10.0)
|
|
||||||
- GoogleDataTransport (~> 9.2)
|
|
||||||
- GoogleUtilities/AppDelegateSwizzler (~> 7.8)
|
|
||||||
- GoogleUtilities/Environment (~> 7.8)
|
|
||||||
- GoogleUtilities/Reachability (~> 7.8)
|
|
||||||
- GoogleUtilities/UserDefaults (~> 7.8)
|
|
||||||
- nanopb (< 2.30910.0, >= 2.30908.0)
|
|
||||||
- FlutterMacOS (1.0.0)
|
|
||||||
- GoogleDataTransport (9.2.5):
|
|
||||||
- GoogleUtilities/Environment (~> 7.7)
|
|
||||||
- nanopb (< 2.30910.0, >= 2.30908.0)
|
|
||||||
- PromisesObjC (< 3.0, >= 1.2)
|
|
||||||
- GoogleUtilities/AppDelegateSwizzler (7.12.0):
|
|
||||||
- GoogleUtilities/Environment
|
|
||||||
- GoogleUtilities/Logger
|
|
||||||
- GoogleUtilities/Network
|
|
||||||
- GoogleUtilities/Environment (7.12.0):
|
|
||||||
- PromisesObjC (< 3.0, >= 1.2)
|
|
||||||
- GoogleUtilities/Logger (7.12.0):
|
|
||||||
- GoogleUtilities/Environment
|
|
||||||
- GoogleUtilities/Network (7.12.0):
|
|
||||||
- GoogleUtilities/Logger
|
|
||||||
- "GoogleUtilities/NSData+zlib"
|
|
||||||
- GoogleUtilities/Reachability
|
|
||||||
- "GoogleUtilities/NSData+zlib (7.12.0)"
|
|
||||||
- GoogleUtilities/Reachability (7.12.0):
|
|
||||||
- GoogleUtilities/Logger
|
|
||||||
- GoogleUtilities/UserDefaults (7.12.0):
|
|
||||||
- GoogleUtilities/Logger
|
|
||||||
- nanopb (2.30909.1):
|
|
||||||
- nanopb/decode (= 2.30909.1)
|
|
||||||
- nanopb/encode (= 2.30909.1)
|
|
||||||
- nanopb/decode (2.30909.1)
|
|
||||||
- nanopb/encode (2.30909.1)
|
|
||||||
- path_provider_foundation (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- FlutterMacOS
|
|
||||||
- PromisesObjC (2.3.1)
|
|
||||||
- share_plus (0.0.1):
|
|
||||||
- FlutterMacOS
|
|
||||||
- shared_preferences_foundation (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- FlutterMacOS
|
|
||||||
- url_launcher_macos (0.0.1):
|
|
||||||
- FlutterMacOS
|
|
||||||
|
|
||||||
DEPENDENCIES:
|
|
||||||
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
|
|
||||||
- firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`)
|
|
||||||
- firebase_messaging (from `Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos`)
|
|
||||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
|
||||||
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
|
|
||||||
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
|
|
||||||
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
|
||||||
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
|
|
||||||
|
|
||||||
SPEC REPOS:
|
|
||||||
trunk:
|
|
||||||
- Firebase
|
|
||||||
- FirebaseCore
|
|
||||||
- FirebaseCoreInternal
|
|
||||||
- FirebaseInstallations
|
|
||||||
- FirebaseMessaging
|
|
||||||
- GoogleDataTransport
|
|
||||||
- GoogleUtilities
|
|
||||||
- nanopb
|
|
||||||
- PromisesObjC
|
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
|
||||||
file_selector_macos:
|
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
|
|
||||||
firebase_core:
|
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/firebase_core/macos
|
|
||||||
firebase_messaging:
|
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos
|
|
||||||
FlutterMacOS:
|
|
||||||
:path: Flutter/ephemeral
|
|
||||||
path_provider_foundation:
|
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
|
|
||||||
share_plus:
|
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
|
|
||||||
shared_preferences_foundation:
|
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
|
|
||||||
url_launcher_macos:
|
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
|
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
|
||||||
file_selector_macos: 0f85c1108e2fd597b58246bc0b0c1cb483d7593b
|
|
||||||
Firebase: bd152f0f3d278c4060c5c71359db08ebcfd5a3e2
|
|
||||||
firebase_core: bef54c6955ffe824bb73ec34090f4013b6921bc1
|
|
||||||
firebase_messaging: 5fb518ebbce926b8828c283509d68da4cf238eac
|
|
||||||
FirebaseCore: b68d3616526ec02e4d155166bbafb8eca64af557
|
|
||||||
FirebaseCoreInternal: 2cf9202e226e3f78d2bf6d56c472686b935bfb7f
|
|
||||||
FirebaseInstallations: 9387bf15abfc69a714f54e54f74a251264fdb79b
|
|
||||||
FirebaseMessaging: 6b7052cc3da7bc8e5f72bef871243e8f04a14eed
|
|
||||||
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
|
|
||||||
GoogleDataTransport: 54dee9d48d14580407f8f5fbf2f496e92437a2f2
|
|
||||||
GoogleUtilities: 0759d1a57ebb953965c2dfe0ba4c82e95ccc2e34
|
|
||||||
nanopb: d4d75c12cd1316f4a64e3c6963f879ecd4b5e0d5
|
|
||||||
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
|
|
||||||
PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4
|
|
||||||
share_plus: 76dd39142738f7a68dd57b05093b5e8193f220f7
|
|
||||||
shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c
|
|
||||||
url_launcher_macos: 5335912b679c073563f29d89d33d10d459f95451
|
|
||||||
|
|
||||||
PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367
|
|
||||||
|
|
||||||
COCOAPODS: 1.14.2
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user