initial commit
@@ -0,0 +1,34 @@
|
||||
FROM debian:bullseye
|
||||
|
||||
# disable interactive ui
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
# enable React Hot Reload in Container
|
||||
ENV WATCHPACK_POLLING=true
|
||||
|
||||
# install dependencies from package manager
|
||||
RUN apt update && apt install -y \
|
||||
sudo \
|
||||
git \
|
||||
libwebkit2gtk-4.0-dev \
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
jq \
|
||||
libssl-dev \
|
||||
libgtk-3-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev
|
||||
|
||||
# install nodejs 16, npm & yarn
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_16.x | bash - && apt-get install -y nodejs
|
||||
RUN npm install -g yarn
|
||||
|
||||
# create 'dev' user, add it to sudo group and set password
|
||||
RUN mkdir /home/dev
|
||||
RUN useradd -u 1000 dev && chown -R dev /home/dev
|
||||
RUN adduser dev sudo
|
||||
RUN echo "dev:dev"|chpasswd
|
||||
|
||||
# install Rust toolchain for user 'dev'
|
||||
USER dev
|
||||
RUN curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh -s -- -y
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "Tauri Dev Environment",
|
||||
"dockerFile": "Dockerfile",
|
||||
|
||||
"settings": {
|
||||
"terminal.integrated.shell.linux": "/bin/bash",
|
||||
"rust-analyzer.linkedProjects": [
|
||||
"card-collection-manager-2/src-tauri/Cargo.toml"
|
||||
]
|
||||
},
|
||||
|
||||
"extensions": [
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"rust-lang.rust-analyzer",
|
||||
"be5invis.toml"
|
||||
],
|
||||
|
||||
"remoteEnv": {
|
||||
"DISPLAY": "host.docker.internal:0"
|
||||
},
|
||||
|
||||
"remoteUser": "dev",
|
||||
"workspaceMount": "source=${localWorkspaceFolder},target=/home/dev/workspace/${localWorkspaceFolderBasename},type=bind",
|
||||
"workspaceFolder": "/home/dev/workspace/${localWorkspaceFolderBasename}"
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
name: Tauri Release Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
|
||||
jobs:
|
||||
|
||||
create-release:
|
||||
runs-on: ubuntu-20.04
|
||||
outputs:
|
||||
version: ${{ steps.metadata.outputs.version }}
|
||||
release_upload_url: ${{ steps.create-release.outputs.upload_url }}
|
||||
|
||||
steps:
|
||||
- name: Checkout the branch
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Fecht metadata
|
||||
id: metadata
|
||||
run: |
|
||||
echo "version=$(cat version)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Release
|
||||
id: create-release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: v${{ steps.metadata.outputs.version }}
|
||||
release_name: ${{ steps.metadata.outputs.version }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
- name: test
|
||||
run: |
|
||||
echo ${{ steps.metadata.outputs.version }}
|
||||
echo ${{ steps.create-release.outputs.upload_url }}
|
||||
|
||||
build:
|
||||
needs: create-release
|
||||
strategy: # see https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs
|
||||
fail-fast: true
|
||||
matrix:
|
||||
platform: [ubuntu-20.04, windows-latest, macos-latest]
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- name: Checkout the branch
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup NodeJS 16
|
||||
uses: actions/setup-node@v3.5.1
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- name: Setup Rust Stable Toolchain
|
||||
uses: actions-rs/toolchain@v1.0.6
|
||||
with:
|
||||
toolchain: stable
|
||||
node-version: 16
|
||||
|
||||
- name: Install Linux Build Dependencies (Linux Only!)
|
||||
if: matrix.platform == 'ubuntu-20.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: build app
|
||||
run: |
|
||||
cd card-collection-manager-2
|
||||
yarn && yarn tauri build
|
||||
|
||||
- name: upload release asset (Windows)
|
||||
if: matrix.platform == 'windows-latest'
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ needs.create-release.outputs.release_upload_url }}
|
||||
asset_path: card-collection-manager-2/src-tauri/target/release/bundle/msi/card-collection-manager-2_${{ needs.create-release.outputs.version }}_x64_en-US.msi
|
||||
asset_name: card-collection-manager-2_${{ needs.create-release.outputs.version }}_x64_en-US.msi
|
||||
asset_content_type: application/x-msi
|
||||
|
||||
- name: upload release asset (Linux/Debian)
|
||||
if: matrix.platform == 'ubuntu-20.04'
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ needs.create-release.outputs.release_upload_url }}
|
||||
asset_path: card-collection-manager-2/src-tauri/target/release/bundle/deb/card-collection-manager-2_${{ needs.create-release.outputs.version }}_amd64.deb
|
||||
asset_name: card-collection-manager-2_${{ needs.create-release.outputs.version }}_amd64.deb
|
||||
asset_content_type: application/x-debian-package
|
||||
|
||||
- name: upload release asset (Linux/AppImage)
|
||||
if: matrix.platform == 'ubuntu-20.04'
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ needs.create-release.outputs.release_upload_url }}
|
||||
asset_path: card-collection-manager-2/src-tauri/target/release/bundle/appimage/card-collection-manager-2_${{ needs.create-release.outputs.version }}_amd64.AppImage
|
||||
asset_name: card-collection-manager-2_${{ needs.create-release.outputs.version }}_amd64.AppImage
|
||||
asset_content_type: application/x-executable
|
||||
|
||||
- name: upload release asset (MacOS)
|
||||
if: matrix.platform == 'macos-latest'
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ needs.create-release.outputs.release_upload_url }}
|
||||
asset_path: card-collection-manager-2/src-tauri/target/release/bundle/dmg/card-collection-manager-2_${{ needs.create-release.outputs.version }}_x64.dmg
|
||||
asset_name: card-collection-manager-2_${{ needs.create-release.outputs.version }}_x64.dmg
|
||||
asset_content_type: application/x-apple-diskimage
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Tauri Snapshot Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "*", "!master" ]
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout the branch
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup NodeJS 16
|
||||
uses: actions/setup-node@v3.5.1
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- name: Setup Rust Stable Toolchain
|
||||
uses: actions-rs/toolchain@v1.0.6
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: build app
|
||||
run: |
|
||||
cd card-collection-manager-2
|
||||
yarn && yarn tauri build
|
||||
ls src-tauri/target
|
||||
|
||||
- name: upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: target
|
||||
path: card-collection-manager-2/src-tauri/target/release/bundle
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
**/.next
|
||||
**/.vscode
|
||||
**/node_modules
|
||||
**/target
|
||||
@@ -0,0 +1,68 @@
|
||||
# Card Collection Manager 2
|
||||

|
||||

|
||||
|
||||
## Project Description
|
||||
This projects provides a management application for trading card game collections. Its aim is to be a simple solution to keep track of collections. The main reason why I wrote this application is because I required a tool, which can manage single card images of a large collection, besides common card collection management features.
|
||||
|
||||
This project is the successor of my previous project [Card Collection Manager](https://github.com/sebastiandine/Card-Collection-Manager). The main benefit over the first version of the project is that this version can support multiple card games at once. Right now, it supports [Magic the Gathering](https://magic.wizards.com/) and the [Pokemon TCG](https://tcg.pokemon.com). The project was designed with extensibility in mind, so adding support for additional games is quite easy (about 2 hours of work per game).
|
||||
|
||||
In addition to these functional enhancements, the application was completely rewritten from scratch. While the previous project was Java-based, this version is based on the [Tauri framework](https://tauri.app/), using Rust and Typescript. Therefore, the application is now available as native binary for all three common platform: Windows, Linux and MacOS.
|
||||
|
||||
## Migrate from Card Collection Manager 1
|
||||
You can migrate your Magic the Gathering collection from CCM1 to CCM2 by following these steps:
|
||||
1. Create a JSON export of your collection in CCM1 (`Export -> to JSON (.json)`)
|
||||
2. Copy this file to `<cc2_collection_dir>/magic/collection.json`, where `<cc2_collection_dir>` is the root collection directory of your CCM2 instance. By default, this will be the directory where CCM2 is installed, but you can also specify an arbitrary directory via the CCM2 settings dialog (`File -> Settings`).
|
||||
3. Copy all image files of your collection from `<cc1_collection_dir>/images/` to `<cc2_collection_dir>/magic/images/`, where `<cc1_collection_dir>` is the root collection directory of your CCM1 instance.
|
||||
|
||||
After performing these steps, the next time you open CCM2, you will see your collection in CCM2. Now, you can delete CCM1.
|
||||
|
||||
|
||||
## How to build/ contribute
|
||||
### Contribute
|
||||
The project includes configuration for [VSCode development containers](https://code.visualstudio.com/docs/remote/containers) which should be the preffered environment to develop new features of the app. The container automatically sets up a whole Tauri development environment including Typescript & Rust plugins for VSCode.
|
||||
|
||||
Also, in directory `hooks/` you find some helpful git hooks that automate/standardize some work. You can activate these hooks by executing the script `activate_hooks.sh` from within the `hooks/` directory.
|
||||
|
||||
Additionally, if you want to run the GUI out of the container, you need to use a X11 tool. I will briefly explain how to run them in order to display the GUI from the container:
|
||||
|
||||
**Windows**<br>
|
||||
I recommend [Xming](https://sourceforge.net/projects/xming/) if your host system is Windows ([VcXSrv](https://sourceforge.net/projects/vcxsrv/), which I recommended for the Java-based CCM1 did not work here). Once you have it installed, start it via `Xming.exe` and enable the option `Disable access control` before you start the server. Now, you can start a GUI app in your container that will be displayed via the X-server on the host system.
|
||||
|
||||
**MacOS**<br>
|
||||
Install [XQuartz](https://www.xquartz.org/) and run it via the following command:
|
||||
```
|
||||
xhost +localhost
|
||||
```
|
||||
Now, you can start a GUI app in your container that will be displayed via the X-server on the host system.
|
||||
|
||||
### Local Run & Building
|
||||
* Execute `yarn` to install all NodeJS dependencies when you initially check out the project. Make sure you are in the Tauri project directory.
|
||||
* Execute `yarn tauri dev` to run the application in development mode. Make sure you are in the Tauri project directory. If you run this for the first time, it will quite long since it needs to fetch all Rust-based dependencies and build corresponding binaries. If you make changes to the Rust code of the project, it will also take a while (but not as long as the initial run), since it has to recompile binaries.
|
||||
* Execute `yarn tauri build` to build the application. Right now, Tauri only supports building for the local architecture. Since the development container is based on Linux, this means you will build Linux packages via this command.
|
||||
|
||||
### Remote Building
|
||||
Actual versions are automatically built via GitHub Action pipelines defined at [`.github/workflows`](./.github/workflows/).
|
||||
|
||||
## Toolkit
|
||||
### Base
|
||||
* [Tauri](https://tauri.app/) framework with [Rust](https://www.rust-lang.org/) for backend/internal logic and [Typescript](https://www.typescriptlang.org/) for the UI.
|
||||
|
||||
### Frontend (TS)
|
||||
* [Next.JS](https://nextjs.org/) as UI framework.
|
||||
* [Tailwind CSS](https://tailwindcss.com/) for UI component styling.
|
||||
* [React Icons](https://react-icons.github.io/react-icons/)
|
||||
* [React Spinner](https://mhnpd.github.io/react-loader-spinner/docs/intro)
|
||||
|
||||
### Backend (Rust)
|
||||
* [serde](https://crates.io/crates/serde) for data serialization/deserialization.
|
||||
* [serde_json](https://crates.io/crates/serde_json) for data serialization/deserialization to and from JSON.
|
||||
* [image](https://crates.io/crates/image) to work with image files.
|
||||
* [base64](https://crates.io/crates/base64) to convert image files to base64 encoded strings to send them from backend to frontend.
|
||||
* [reqwest](https://crates.io/crates/reqwest) for REST calls to game-specific APIs to fetch set and card data.
|
||||
* [strum](https://crates.io/crates/strum) for additional macros for enums and strings.
|
||||
* [nom](https://crates.io/crates/nom) for type checks.
|
||||
|
||||
### Additional Resources
|
||||
* [Scryfall API](https://scryfall.com/docs/api) for fetching card and set data of Magic the Gathering, including card preview images.
|
||||
* [Pokemon TCG API](https://docs.pokemontcg.io/) for fetching card and set data of the Pokemon TCG, including card preview images.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Tauri + Next.js + Typescript
|
||||
|
||||
This template should help get you started developing with Tauri, Next.js and Typescript.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -0,0 +1,11 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
swcMinify: true,
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "card-collection-manager-2",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 1420",
|
||||
"build": "next build && next export -o dist",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^1.1.0",
|
||||
"next": "^12.2.5",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^4.6.0",
|
||||
"react-loader-spinner": "^5.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^1.1.0",
|
||||
"@types/node": "^18.7.11",
|
||||
"@types/react": "^18.0.17",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"postcss": "^8.4.18",
|
||||
"tailwindcss": "^3.2.2",
|
||||
"typescript": "^4.7.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
[package]
|
||||
name = "card-collection-manager-2"
|
||||
version = "1.0.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
rust-version = "1.57"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "1.1", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tauri = { version = "1.1", features = ["api-all"] }
|
||||
|
||||
# b64 image encoding
|
||||
base64 = "0.13"
|
||||
image = "0.24"
|
||||
|
||||
# rest calls
|
||||
reqwest = { version = "0.11", features = ["blocking", "json"] }
|
||||
|
||||
# iterator for enums
|
||||
strum = "0.24"
|
||||
strum_macros = "0.24"
|
||||
|
||||
# data type checker, e.g. is_digit
|
||||
nom = "7.1.1"
|
||||
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
|
||||
default = [ "custom-protocol" ]
|
||||
# this feature is used used for production builds where `devPath` points to the filesystem
|
||||
# DO NOT remove this
|
||||
custom-protocol = [ "tauri/custom-protocol" ]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"dataStorage":"/home/dev/cards","defaultGame":"Magic"}
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 922 B |
@@ -0,0 +1,93 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 8.4666665 8.4666665"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
inkscape:version="1.2 (dc2aedaf03, 2022-05-15)"
|
||||
sodipodi:docname="32x32.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview7"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="false"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="4.2179991"
|
||||
inkscape:cx="22.285448"
|
||||
inkscape:cy="66.619265"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1017"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs2" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<rect
|
||||
style="fill:#0000ff;stroke:#000000;stroke-width:0.680409"
|
||||
id="rect184-5"
|
||||
width="3.2237122"
|
||||
height="4.821435"
|
||||
x="-0.41053185"
|
||||
y="2.2624261"
|
||||
inkscape:transform-center-x="-0.99973824"
|
||||
inkscape:transform-center-y="-1.3768086"
|
||||
transform="matrix(0.93880917,-0.34443772,0.35090811,0.9364099,0,0)" />
|
||||
<rect
|
||||
style="fill:#ff0000;stroke:#000000;stroke-width:0.680409"
|
||||
id="rect184-5-6"
|
||||
width="3.2237122"
|
||||
height="4.821435"
|
||||
x="-8.2546825"
|
||||
y="-0.7155177"
|
||||
inkscape:transform-center-x="0.99973788"
|
||||
inkscape:transform-center-y="-1.3768083"
|
||||
transform="matrix(-0.93880917,-0.34443772,-0.35090811,0.9364099,0,0)" />
|
||||
<ellipse
|
||||
style="fill:#ff0000;stroke:#000000;stroke-width:0.529006"
|
||||
id="path494"
|
||||
cx="1.1477517"
|
||||
cy="4.4643512"
|
||||
rx="0.47794324"
|
||||
ry="0.67525065"
|
||||
transform="matrix(0.93935269,-0.34295266,0.34940282,0.93697261,0,0)" />
|
||||
<ellipse
|
||||
style="fill:#0000ff;stroke:#000000;stroke-width:0.529006"
|
||||
id="path494-0"
|
||||
cx="-6.792244"
|
||||
cy="1.4968817"
|
||||
rx="0.47794324"
|
||||
ry="0.67525065"
|
||||
transform="matrix(-0.93935269,-0.34295266,-0.34940282,0.93697261,0,0)" />
|
||||
<rect
|
||||
style="fill:#803300;stroke:#000000;stroke-width:0.680401"
|
||||
id="rect184"
|
||||
width="3.517828"
|
||||
height="5.2026267"
|
||||
x="2.495986"
|
||||
y="1.8122036" />
|
||||
<ellipse
|
||||
style="fill:#ffff00;stroke:#000000;stroke-width:0.528999"
|
||||
id="path440"
|
||||
cx="4.2621508"
|
||||
cy="4.2843785"
|
||||
rx="0.47854421"
|
||||
ry="0.63851511" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,93 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="128"
|
||||
height="128"
|
||||
viewBox="0 0 33.866666 33.866666"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
inkscape:version="1.2 (dc2aedaf03, 2022-05-15)"
|
||||
sodipodi:docname="icon.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview7"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="false"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="4.2179991"
|
||||
inkscape:cx="22.285448"
|
||||
inkscape:cy="66.619265"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1017"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs2" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<rect
|
||||
style="fill:#0000ff;stroke:#000000;stroke-width:0.6804"
|
||||
id="rect184-5"
|
||||
width="13.962224"
|
||||
height="21.0504"
|
||||
x="-2.3585024"
|
||||
y="7.8501358"
|
||||
inkscape:transform-center-x="-4.3244808"
|
||||
inkscape:transform-center-y="-6.0189139"
|
||||
transform="rotate(-20.344379)" />
|
||||
<rect
|
||||
style="fill:#ff0000;stroke:#000000;stroke-width:0.6804"
|
||||
id="rect184-5-6"
|
||||
width="13.962224"
|
||||
height="21.0504"
|
||||
x="-33.98983"
|
||||
y="-4.2830024"
|
||||
inkscape:transform-center-x="4.3244806"
|
||||
inkscape:transform-center-y="-6.0189138"
|
||||
transform="matrix(-0.93761993,-0.347662,-0.347662,0.93761993,0,0)" />
|
||||
<ellipse
|
||||
style="fill:#ff0000;stroke:#000000;stroke-width:0.529"
|
||||
id="path494"
|
||||
cx="4.3872981"
|
||||
cy="17.464888"
|
||||
rx="2.069998"
|
||||
ry="2.948179"
|
||||
transform="rotate(-20.253039)" />
|
||||
<ellipse
|
||||
style="fill:#0000ff;stroke:#000000;stroke-width:0.529"
|
||||
id="path494-0"
|
||||
cx="-27.657389"
|
||||
cy="5.3736062"
|
||||
rx="2.069998"
|
||||
ry="2.948179"
|
||||
transform="matrix(-0.93817298,-0.34616682,-0.34616682,0.93817298,0,0)" />
|
||||
<rect
|
||||
style="fill:#803300;stroke:#000000;stroke-width:0.6804"
|
||||
id="rect184"
|
||||
width="15.216769"
|
||||
height="22.744036"
|
||||
x="9.5475092"
|
||||
y="6.2229657" />
|
||||
<ellipse
|
||||
style="fill:#ffff00;stroke:#000000;stroke-width:0.529"
|
||||
id="path440"
|
||||
cx="17.187256"
|
||||
cy="17.030437"
|
||||
rx="2.069998"
|
||||
ry="2.7913611" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,139 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
use crate::util::enums::{Language, Condition};
|
||||
use crate::util::collection::MapEntryWithId;
|
||||
use crate::util::fs::parse_index_from_filename;
|
||||
|
||||
use super::set_services::Set;
|
||||
|
||||
use crate::templates;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Card {
|
||||
pub id: u32,
|
||||
pub amount: u8,
|
||||
pub name: String,
|
||||
pub set: Set,
|
||||
pub note: String,
|
||||
pub images: Vec<String>,
|
||||
pub language: Language,
|
||||
pub condition: Condition,
|
||||
pub foil: bool,
|
||||
pub signed: bool,
|
||||
pub altered: bool
|
||||
}
|
||||
|
||||
/// implementation of the Card struct that is used by templates
|
||||
/// to enable generic handling.
|
||||
impl MapEntryWithId for Card {
|
||||
fn set_id(&mut self, id: u32) {
|
||||
self.id = id;
|
||||
}
|
||||
fn get_id(&self) -> u32 {
|
||||
self.id
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the provided JSON object into an instance of `Card`, store it into
|
||||
/// the Magic collection hashmap and return the id of the new entry.
|
||||
///
|
||||
/// # Argument
|
||||
/// obj - Magic card instance as JSON object.
|
||||
///
|
||||
/// # Returns
|
||||
/// New id of the entry withing the collection
|
||||
///
|
||||
pub fn add_card<'a>(obj: &str) -> Result<u32, &'a str> {
|
||||
templates::card_service_templates::add_entry_to_collection::<Card>("magic", obj)
|
||||
}
|
||||
|
||||
/// Update the entry within the Magic collection hashmap with the same id as the
|
||||
/// povided `Card` instance as JSON object. The old entry will be completely overwritten
|
||||
/// with the data from the provided object.
|
||||
///
|
||||
/// # Argument
|
||||
/// obj - Pokemon card instance as JSON object.
|
||||
///
|
||||
pub fn update_card<'a>(obj: &str) -> Result<(), &'a str> {
|
||||
templates::card_service_templates::update_entry_in_collection::<Card>("magic", obj)
|
||||
}
|
||||
|
||||
/// Delete the entry within the Magic collection hashmap with the provided id.
|
||||
///
|
||||
/// # Argument
|
||||
/// id - Id of the card within the collection that should be deleted.
|
||||
///
|
||||
pub fn delete_card<'a>(id: &u32) -> Result<(), &'a str> {
|
||||
let card: Card = templates::card_service_templates::get_entry_by_id::<Card>("magic", id).unwrap();
|
||||
for image in card.images.iter() {
|
||||
delete_image(image);
|
||||
}
|
||||
templates::card_service_templates::delete_entry_by_id::<Card>("magic", id)
|
||||
}
|
||||
|
||||
/// Get the Magic collection hashmap as JSON encoded string.
|
||||
///
|
||||
pub fn get_collection_json<'a>() -> Result<String, &'a str> {
|
||||
templates::card_service_templates::get_collection_json::<Card>("magic")
|
||||
}
|
||||
|
||||
/// Copy the image specfied by the image location parameter as an image that is
|
||||
/// related to the provided Card object to the collection's image directory.
|
||||
/// The flag `new_entry` indicates if the provided Card object is an existing object
|
||||
/// within the collection or a new one. If it is a new one, this function will use
|
||||
/// the next free id wihtin the collection for the name of the image that is copied.
|
||||
///
|
||||
/// # Arguments
|
||||
/// obj - JSON encoded Card instance of the card entry that the image should belong to.
|
||||
/// img_location - Ansolute path to the image that should be copied.
|
||||
/// new_entry - Indicator whether the related card entry is already stored in the collection
|
||||
/// or if it is a new entry that is not stored in the collection.
|
||||
///
|
||||
/// # Returns
|
||||
/// New name of copied image within the card collection directory
|
||||
///
|
||||
pub fn copy_image<'a>(obj: &str, img_location: &str, new_entry: bool) -> Result<String, &'a str> {
|
||||
let card: Card = serde_json::from_str(obj).expect("Unable to deserialize card object.");
|
||||
|
||||
// get next image index
|
||||
let mut index: u8 = 0;
|
||||
if card.images.len() > 0 {
|
||||
// support of file names of card collection manager v1. In this case, we start with index 0.
|
||||
let last_element = card.images.get(card.images.len() - 1).unwrap();
|
||||
if !(last_element.contains("IMG_FRONT") || last_element.contains("IMG_BACK")) {
|
||||
index = parse_index_from_filename(last_element.as_str()) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// image name, if its a new entry, the template function will request the next id automatically
|
||||
let img_target_name: String = match new_entry {
|
||||
true => format!("{}+{}+{}", card.set.name, &card.name, index),
|
||||
false => format!("{}+{}+{}+{}", card.id, card.set.name, &card.name, index)
|
||||
};
|
||||
|
||||
// todo- fallback support old card image names from card collection manager 1
|
||||
|
||||
templates::card_service_templates::copy_image::<Card>(img_location, &img_target_name, "magic", new_entry)
|
||||
}
|
||||
|
||||
/// Delete the image with the specified name from the card collection directory.
|
||||
///
|
||||
/// # Arguments
|
||||
/// image - Name of image that should be deleted.
|
||||
///
|
||||
pub fn delete_image<'a>(image: &str) -> Result<(), &'a str> {
|
||||
templates::card_service_templates::delete_image("magic", image)
|
||||
}
|
||||
|
||||
/// Get the image with the specified name from the card collection directory
|
||||
/// as base-64 encoded string.
|
||||
///
|
||||
/// # Arguments
|
||||
/// image - Name of image that should be returned.
|
||||
///
|
||||
/// # Returns
|
||||
/// Image as base-64 encoded string
|
||||
///
|
||||
pub fn get_image_b64<'a>(image: &str) -> Result<String, &'a str> {
|
||||
templates::card_service_templates::get_entry_image_b64("magic", image)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod card_services;
|
||||
pub mod set_services;
|
||||
@@ -0,0 +1,97 @@
|
||||
use crate::templates;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// Magic set information
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Set {
|
||||
/// set id accoring to API at `https://api.magicthegathering.io`
|
||||
pub id: String,
|
||||
/// actual set name
|
||||
pub name: String,
|
||||
/// release date in format YYYY/MM/DD
|
||||
#[serde(rename = "releaseDate")]
|
||||
pub release_date: String,
|
||||
}
|
||||
|
||||
/// Get all MTG sets as vector of `Set` instances.
|
||||
/// Implicitly, this function also stores the resulting data in JSON format
|
||||
/// at `<storage_location>/magic/sets.json`.
|
||||
///
|
||||
/// This function call the REST-API at `https://api.magicthegathering.io` to retrieve the data.
|
||||
///
|
||||
pub fn update_sets<'a>() -> Result<Vec<Set>, &'a str> {
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
struct TmpSet {
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
pub released_at: String,
|
||||
pub digital: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
struct Response {
|
||||
data: Vec<TmpSet>,
|
||||
}
|
||||
|
||||
let resp = reqwest::blocking::get("https://api.scryfall.com/sets")
|
||||
.unwrap()
|
||||
.json::<Response>()
|
||||
.unwrap();
|
||||
|
||||
// filter out 'onlyOnline', map to final struct and sort by release date
|
||||
let mut prepared_sets = resp
|
||||
.data
|
||||
.iter()
|
||||
.filter(|&set| !set.digital)
|
||||
.map(|set| Set {
|
||||
id: set.code.clone(),
|
||||
name: set.name.clone(),
|
||||
release_date: set.released_at.clone().replace("-", "/"),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
prepared_sets.sort_by_key(|set| set.release_date.clone());
|
||||
store_sets(&prepared_sets).unwrap();
|
||||
|
||||
Ok(prepared_sets)
|
||||
}
|
||||
|
||||
/// Get all available MTG sets from either the locally stored file at `<storage_location>/magic/sets.json`
|
||||
/// or a fresh fetch from the corresponding API, in JSON format as a string.
|
||||
/// If the file does not exist, it will automatically fetch the data from the
|
||||
/// REST-API, store the result in the `sets.json` file and return the data as JSON.
|
||||
///
|
||||
/// # Arguments
|
||||
/// `from_local` - If `true`, the function will try to access the local `set.json` file and only
|
||||
/// fetch the API, if it cannot find this file. If `false`, it will fetch the API
|
||||
/// for set data, store it in the local `set.json` file and then provide its content
|
||||
/// as a JSON string.
|
||||
///
|
||||
pub fn get_sets_json<'a>(from_local: bool) -> Result<String, &'a str> {
|
||||
|
||||
let game = "magic";
|
||||
|
||||
// in case `from_local` is false, we perform a fresh data fetch from the API before we
|
||||
// return data.
|
||||
if !from_local {
|
||||
update_sets().unwrap();
|
||||
}
|
||||
|
||||
// in any case we will check if `set.json` already exisits. If it is not the case,
|
||||
// we will perform an API fetch before (see `Err` branch).
|
||||
match templates::set_service_templates::get_sets_json(game) {
|
||||
Ok(sets) => Ok(sets),
|
||||
Err(_) => {
|
||||
update_sets().unwrap();
|
||||
Ok(templates::set_service_templates::get_sets_json(game).unwrap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Store the provided set data in JSON format at `<storage_location>/magic/sets.json`.
|
||||
///
|
||||
/// # Argument
|
||||
/// `sets` - Vector of Set instances that should be stored as JSON.
|
||||
///
|
||||
pub fn store_sets<'a>(sets: &Vec<Set>) -> Result<(), &'a str> {
|
||||
templates::set_service_templates::store_sets::<Set>("magic", sets)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
#![cfg_attr(
|
||||
all(not(debug_assertions), target_os = "windows"),
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
mod pokemon;
|
||||
mod magic;
|
||||
mod util;
|
||||
mod templates;
|
||||
|
||||
use tauri::{CustomMenuItem, Menu, Submenu};
|
||||
use util::enums::Game;
|
||||
|
||||
#[tauri::command]
|
||||
fn get_sets<'a>(game: Game) -> Result<String, &'a str> {
|
||||
match game {
|
||||
Game::Magic => magic::set_services::get_sets_json(true),
|
||||
Game::Pokemon => pokemon::set_services::get_sets_json(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_sets<'a>(game: Game) -> Result<String, &'a str> {
|
||||
match game {
|
||||
Game::Magic => magic::set_services::get_sets_json(false),
|
||||
Game::Pokemon => pokemon::set_services::get_sets_json(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_collection<'a>(game: Game) -> Result<String, &'a str> {
|
||||
match game {
|
||||
Game::Magic => magic::card_services::get_collection_json(),
|
||||
Game::Pokemon => pokemon::card_services::get_collection_json()
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn add_card<'a>(obj: &str, game: Game) -> Result<u32, &'a str> {
|
||||
match game {
|
||||
Game::Magic => magic::card_services::add_card(obj),
|
||||
Game::Pokemon => pokemon::card_services::add_card(obj)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_card<'a>(obj: &str, game: Game) -> Result<(), &'a str> {
|
||||
match game {
|
||||
Game::Magic => magic::card_services::update_card(obj),
|
||||
Game::Pokemon => pokemon::card_services::update_card(obj)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_card<'a>(id: u32, game: Game) -> Result<(), &'a str> {
|
||||
match game {
|
||||
Game::Magic => magic::card_services::delete_card(&id),
|
||||
Game::Pokemon => pokemon::card_services::delete_card(&id)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn copy_image<'a>(obj: &str, img_location: &str, new_entry: bool, game: Game) -> Result<String, &'a str> {
|
||||
match game {
|
||||
Game::Magic => magic::card_services::copy_image(obj, img_location, new_entry),
|
||||
Game::Pokemon => pokemon::card_services::copy_image(obj, img_location, new_entry)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_image_b64<'a>(image: &str, game: Game) -> Result<String, &'a str> {
|
||||
match game {
|
||||
Game::Magic => magic::card_services::get_image_b64(image),
|
||||
Game::Pokemon => pokemon::card_services::get_image_b64(image)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_image<'a>(image: &str, game: Game) -> Result<(), &'a str> {
|
||||
match game {
|
||||
Game::Magic => magic::card_services::delete_image(image),
|
||||
Game::Pokemon => pokemon::card_services::delete_image(image)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn main() {
|
||||
// configure menu
|
||||
|
||||
// file menu
|
||||
let settings = CustomMenuItem::new("settings".to_string(), "Settings");
|
||||
let quit = CustomMenuItem::new("quit".to_string(), "Quit Application");
|
||||
let file_menu = Submenu::new("File", Menu::new().add_item(settings).add_item(quit));
|
||||
|
||||
// game menu
|
||||
let game_pokemon = CustomMenuItem::new("switch_game/pokemon".to_string(), "Pokemon");
|
||||
let game_magic = CustomMenuItem::new("switch_game/magic".to_string(), "Magic");
|
||||
let game_menu = Submenu::new("Game", Menu::new().add_item(game_pokemon).add_item(game_magic));
|
||||
|
||||
// update menu
|
||||
let update_sets_pokemon = CustomMenuItem::new("update/sets/pokemon".to_string(), "Update Pokemon");
|
||||
let update_sets_magic = CustomMenuItem::new("update/sets/magic".to_string(), "Update Magic");
|
||||
let update_menu = Submenu::new("Sets", Menu::new().add_item(update_sets_pokemon).add_item(update_sets_magic));
|
||||
|
||||
let menu = Menu::new().add_submenu(file_menu).add_submenu(game_menu).add_submenu(update_menu);
|
||||
|
||||
tauri::Builder::default()
|
||||
.menu(menu)
|
||||
.on_menu_event(|event| match event.menu_item_id() {
|
||||
"quit" => {
|
||||
std::process::exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
util::config::get_configuration_json,
|
||||
util::config::store_configuration,
|
||||
util::enums::get_condition_variants_json,
|
||||
util::enums::get_language_variants_json,
|
||||
util::enums::get_game_variants_json,
|
||||
add_card,
|
||||
get_sets,
|
||||
update_sets,
|
||||
get_collection,
|
||||
copy_image,
|
||||
get_image_b64,
|
||||
delete_image,
|
||||
delete_card,
|
||||
update_card
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
use crate::util::enums::{Language, Condition};
|
||||
use crate::util::collection::MapEntryWithId;
|
||||
use crate::util::fs::parse_index_from_filename;
|
||||
|
||||
use super::set_services::Set;
|
||||
|
||||
use crate::templates;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Card {
|
||||
pub id: u32,
|
||||
pub amount: u8,
|
||||
pub name: String,
|
||||
pub set: Set,
|
||||
#[serde(rename = "setNo")]
|
||||
pub set_no: String,
|
||||
pub note: String,
|
||||
pub images: Vec<String>,
|
||||
pub language: Language,
|
||||
pub condition: Condition,
|
||||
#[serde(rename = "firstEdition")]
|
||||
pub first_edition: bool,
|
||||
pub holo: bool,
|
||||
pub signed: bool,
|
||||
pub altered: bool
|
||||
}
|
||||
|
||||
/// implementation of the Card struct that is used by templates
|
||||
/// to enable generic handling.
|
||||
impl MapEntryWithId for Card {
|
||||
fn set_id(&mut self, id: u32) {
|
||||
self.id = id;
|
||||
}
|
||||
fn get_id(&self) -> u32 {
|
||||
self.id
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the provided JSON object into an instance of `Card`, store it into
|
||||
/// the Pokemon collection hashmap and return the id of the new entry.
|
||||
///
|
||||
/// # Argument
|
||||
/// obj - Pokemon card instance as JSON object.
|
||||
///
|
||||
/// # Returns
|
||||
/// New id of the entry withing the collection
|
||||
///
|
||||
pub fn add_card<'a>(obj: &str) -> Result<u32, &'a str> {
|
||||
templates::card_service_templates::add_entry_to_collection::<Card>("pokemon", obj)
|
||||
}
|
||||
|
||||
/// Update the entry within the Pokemon collection hashmap with the same id as the
|
||||
/// povided `Card` instance as JSON object. The old entry will be completely overwritten
|
||||
/// with the data from the provided object.
|
||||
///
|
||||
/// # Argument
|
||||
/// obj - Pokemon card instance as JSON object.
|
||||
///
|
||||
pub fn update_card<'a>(obj: &str) -> Result<(), &'a str> {
|
||||
templates::card_service_templates::update_entry_in_collection::<Card>("pokemon", obj)
|
||||
}
|
||||
|
||||
/// Delete the entry within the Pokemon collection hashmap with the provided id.
|
||||
///
|
||||
/// # Argument
|
||||
/// id - Id of the card within the collection that should be deleted.
|
||||
///
|
||||
pub fn delete_card<'a>(id: &u32) -> Result<(), &'a str> {
|
||||
let card: Card = templates::card_service_templates::get_entry_by_id::<Card>("pokemon", id).unwrap();
|
||||
for image in card.images.iter() {
|
||||
delete_image(image);
|
||||
}
|
||||
templates::card_service_templates::delete_entry_by_id::<Card>("pokemon", id)
|
||||
}
|
||||
|
||||
/// Get the Pokemon collection hashmap as JSON encoded string.
|
||||
///
|
||||
pub fn get_collection_json<'a>() -> Result<String, &'a str> {
|
||||
templates::card_service_templates::get_collection_json::<Card>("pokemon")
|
||||
}
|
||||
|
||||
/// Copy the image specfied by the image location parameter as an image that is
|
||||
/// related to the provided Card object to the collection's image directory.
|
||||
/// The flag `new_entry` indicates if the provided Card object is an existing object
|
||||
/// within the collection or a new one. If it is a new one, this function will use
|
||||
/// the next free id wihtin the collection for the name of the image that is copied.
|
||||
///
|
||||
/// # Arguments
|
||||
/// obj - JSON encoded Card instance of the card entry that the image should belong to.
|
||||
/// img_location - Ansolute path to the image that should be copied.
|
||||
/// new_entry - Indicator whether the related card entry is already stored in the collection
|
||||
/// or if it is a new entry that is not stored in the collection.
|
||||
///
|
||||
/// # Returns
|
||||
/// New name of copied image within the card collection directory
|
||||
///
|
||||
pub fn copy_image<'a>(obj: &str, img_location: &str, new_entry: bool) -> Result<String, &'a str> {
|
||||
let card: Card = serde_json::from_str(obj).expect("Unable to deserialize card object.");
|
||||
|
||||
// get next image index
|
||||
let mut index: u8 = 0;
|
||||
if card.images.len() > 0 {
|
||||
index = parse_index_from_filename(card.images[card.images.len() - 1].as_str()) + 1;
|
||||
}
|
||||
|
||||
// image name, if its a new entry, the template function will request the next id automatically
|
||||
let img_target_name: String = match new_entry {
|
||||
true => format!("{}+{}+{}", card.set.name, &card.name, index),
|
||||
false => format!("{}+{}+{}+{}", card.id, card.set.name, &card.name, index)
|
||||
};
|
||||
|
||||
templates::card_service_templates::copy_image::<Card>(img_location, &img_target_name, "pokemon", new_entry)
|
||||
}
|
||||
|
||||
/// Delete the image with the specified name from the card collection directory.
|
||||
///
|
||||
/// # Arguments
|
||||
/// image - Name of image that should be deleted.
|
||||
///
|
||||
pub fn delete_image<'a>(image: &str) -> Result<(), &'a str> {
|
||||
templates::card_service_templates::delete_image("pokemon", image)
|
||||
}
|
||||
|
||||
/// Get the image with the specified name from the card collection directory
|
||||
/// as base-64 encoded string.
|
||||
///
|
||||
/// # Arguments
|
||||
/// image - Name of image that should be returned.
|
||||
///
|
||||
/// # Returns
|
||||
/// Image as base-64 encoded string
|
||||
///
|
||||
pub fn get_image_b64<'a>(image: &str) -> Result<String, &'a str> {
|
||||
templates::card_service_templates::get_entry_image_b64("pokemon", image)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod card_services;
|
||||
pub mod set_services;
|
||||
@@ -0,0 +1,73 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use crate::templates;
|
||||
|
||||
// Pokemon TCG set information
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Set {
|
||||
/// set id accoring to API at `https://api.pokemontcg.io`
|
||||
pub id: String,
|
||||
/// actual set name
|
||||
pub name: String,
|
||||
/// release date in format YYYY/MM/DD
|
||||
#[serde(rename = "releaseDate")]
|
||||
pub release_date: String
|
||||
}
|
||||
|
||||
/// Get all Pokemon TCG sets as vector of `Set` instances.
|
||||
/// Implicitly, this function also stores the resulting data in JSON format
|
||||
/// at `<storage_location>/pokemon/sets.json`.
|
||||
///
|
||||
/// This function call the REST-API at `https://api.pokemontcg.io` to retrieve the data.
|
||||
///
|
||||
pub fn update_sets<'a>() -> Result<Vec<Set>, &'a str> {
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
struct Response {
|
||||
data: Vec<Set>
|
||||
}
|
||||
|
||||
let resp = reqwest::blocking::get("https://api.pokemontcg.io/v2/sets").unwrap().json::<Response>().unwrap();
|
||||
store_sets(&resp.data).unwrap();
|
||||
Ok(resp.data)
|
||||
}
|
||||
|
||||
/// Get all Pokemon TCG from either the locally stored file at `<storage_location>/pokemon/sets.json`
|
||||
/// or a fresh fetch from the corresponding API, in JSON format as a string.
|
||||
/// If the file does not exist, it will automatically fetch the data from the
|
||||
/// REST-API, store the result in the `sets.json` file and return the data as JSON.
|
||||
///
|
||||
/// # Arguments
|
||||
/// `from_local` - If `true`, the function will try to access the local `set.json` file and only
|
||||
/// fetch the API, if it cannot find this file. If `false`, it will fetch the API
|
||||
/// for set data, store it in the local `set.json` file and then provide its content
|
||||
/// as a JSON string.
|
||||
///
|
||||
pub fn get_sets_json<'a>(from_local: bool) -> Result<String, &'a str> {
|
||||
|
||||
let game = "pokemon";
|
||||
|
||||
// in case `from_local` is false, we perform a fresh data fetch from the API before we
|
||||
// return data.
|
||||
if !from_local {
|
||||
update_sets().unwrap();
|
||||
}
|
||||
|
||||
// in any case we will check if `set.json` already exisits. If it is not the case,
|
||||
// we will perform an API fetch before (see `Err` branch).
|
||||
match templates::set_service_templates::get_sets_json(game) {
|
||||
Ok(sets) => Ok(sets),
|
||||
Err(_) => {
|
||||
update_sets().unwrap();
|
||||
Ok(templates::set_service_templates::get_sets_json(game).unwrap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Store the provided set data in JSON format at `<storage_location>/pokemon/sets.json`.
|
||||
///
|
||||
/// # Argument
|
||||
/// `sets` - Vector of Set instances that should be stored as JSON.
|
||||
///
|
||||
pub fn store_sets<'a>(sets: &Vec<Set>) -> Result<(), &'a str> {
|
||||
templates::set_service_templates::store_sets::<Set>("pokemon", sets)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
use std::io::{Read, Write};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs::{File, create_dir, copy, remove_file};
|
||||
use serde::{Serialize};
|
||||
use serde::de::DeserializeOwned;
|
||||
use image::{DynamicImage, ImageOutputFormat};
|
||||
use std::io::Cursor;
|
||||
|
||||
use crate::util::config::{Configuration, load_configuration};
|
||||
use crate::util::collection::{MapEntryWithId, add_map_entry, get_next_id};
|
||||
use crate::util::fs::format_text_for_fs;
|
||||
|
||||
/// Add the povided object as a new entry to the collection of the provided game, store the new update
|
||||
/// to the collection file of the game and return the new id of the entry in the collection.
|
||||
///
|
||||
/// # Arguments
|
||||
/// `game` - Game name to specifiy to which collection the entry should be added
|
||||
/// `obj` - New entry as JSON string
|
||||
///
|
||||
/// # Returns
|
||||
/// New id of the entry withing the collection
|
||||
///
|
||||
pub fn add_entry_to_collection<'a, T: Serialize + DeserializeOwned + MapEntryWithId>(game: &str, obj: &str) -> Result<u32, &'a str> {
|
||||
let entry: T = serde_json::from_str(obj).expect("Unable to deserialize entry object.");
|
||||
let mut collection: HashMap<u32, T> = load_collection::<T>(game).unwrap();
|
||||
let id: u32 = add_map_entry::<T>(&mut collection, entry);
|
||||
store_collection::<T>(game, &collection).expect("Unable to store updated collection.");
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Update an existing entry in the collection of the provided game, with the provided object.
|
||||
/// The id within the provided object specficies, which existing entry should be updated.
|
||||
///
|
||||
/// # Arguments
|
||||
/// `game` - Game name to specifiy in which collection the entry should be updated
|
||||
/// `obj` - entry as JSON string
|
||||
///
|
||||
pub fn update_entry_in_collection<'a, T: Serialize + DeserializeOwned + MapEntryWithId>(game: &str, obj: &str) -> Result<(), &'a str> {
|
||||
let entry: T = serde_json::from_str(obj).expect("Unable to deserialize entry object.");
|
||||
let mut collection: HashMap<u32, T> = load_collection::<T>(game).unwrap();
|
||||
let id: u32 = entry.get_id();
|
||||
collection.insert(id, entry).expect("Unable to update collection");
|
||||
store_collection::<T>(game, &collection).expect("Unable to store updated collection.");
|
||||
Ok(())
|
||||
}
|
||||
/// Return the entry with the specfied `id` from the collection corresponding to the specified `game`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// `game` - Game name to specifiy from which collection the entry should be retrieved
|
||||
/// `id` - Id of the entry that should be retrieved
|
||||
///
|
||||
/// # Returns
|
||||
/// Entry record with the specified id from the specified collection
|
||||
///
|
||||
pub fn get_entry_by_id<'a, T: Serialize + DeserializeOwned + Clone>(game: &str, id: &u32) -> Result<T, &'a str> {
|
||||
let collection: HashMap<u32, T> = load_collection::<T>(game).unwrap();
|
||||
match collection.get(id) {
|
||||
Some(entry) => Ok(entry.clone()),
|
||||
None => Err("Unable to get entry")
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the entry with the specfied `id` from the collection corresponding to the specified `game`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// `game` - Game name to specifiy from which collection the entry should be deleted
|
||||
/// `id` - Id of the entry that should be deleted
|
||||
///
|
||||
pub fn delete_entry_by_id<'a, T: Serialize + DeserializeOwned + Clone>(game: &str, id: &u32) -> Result<(), &'a str> {
|
||||
let mut collection: HashMap<u32, T> = load_collection::<T>(game).unwrap();
|
||||
collection.remove(id).unwrap();
|
||||
store_collection::<T>(game, &collection)
|
||||
}
|
||||
|
||||
/// Get the collection as a JSON map corresponding to the provided game from the local collection.json file that
|
||||
/// belongs to this game. If this file does not exist, it will be generated.
|
||||
///
|
||||
/// # Arguments
|
||||
/// `game` - Game name for which the collection should be returned
|
||||
///
|
||||
/// # Returns
|
||||
/// string with a map of the collection data
|
||||
///
|
||||
pub fn get_collection_json<'a, T: Serialize + DeserializeOwned>(game: &'a str) -> Result<String, &'a str> {
|
||||
let collection = load_collection::<T>(game).unwrap();
|
||||
match serde_json::to_string(&collection) {
|
||||
Ok(json) => Ok(json),
|
||||
Err(_) => Err("Unable to serialize collection to JSON.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the collection related to the provided game from the corresponding collection file and
|
||||
/// return it as a hash map.
|
||||
///
|
||||
/// # Arguments
|
||||
/// `game` - Game name to specify which collection should be loaded
|
||||
///
|
||||
/// # Returns
|
||||
/// The collection corresponding to the game name as a hash map
|
||||
///
|
||||
fn load_collection<'a, T: Serialize + DeserializeOwned>(game: &str) -> Result<HashMap<u32, T>, &'a str> {
|
||||
|
||||
let config: Configuration = load_configuration().unwrap();
|
||||
|
||||
let collection_file = format!("{}/{}/collection.json", &config.data_storage, game);
|
||||
let collection_file_path = Path::new(&collection_file);
|
||||
|
||||
if collection_file_path.exists() {
|
||||
let mut data = String::new();
|
||||
let mut f = File::open(&collection_file_path).expect("Unable to open file stream.");
|
||||
f.read_to_string(&mut data).expect("Unable to read file to string.");
|
||||
let collection: HashMap<u32, T> = serde_json::from_str(&data).expect("Unable to deserialize collection.");
|
||||
Ok(collection)
|
||||
}
|
||||
else {
|
||||
let collection: HashMap<u32, T> = HashMap::new();
|
||||
store_collection::<T>(game, &collection).unwrap();
|
||||
Ok(collection)
|
||||
}
|
||||
}
|
||||
|
||||
/// Store the provided collection to the corresponding file specified by the provided game name.
|
||||
///
|
||||
/// # Arguments
|
||||
/// `game` - Game name to specify to which game the provided collection belongs
|
||||
/// `collection` - Collection that should be stored to the collection file corresponding to the provided game
|
||||
///
|
||||
fn store_collection<'a, T: Serialize>(game: &str, collection: &HashMap<u32, T>) -> Result<(), &'a str> {
|
||||
let config: Configuration = load_configuration().unwrap();
|
||||
|
||||
let game_dir = format!("{}/{}", &config.data_storage, game);
|
||||
let collection_file = format!("{}/{}/collection.json", &config.data_storage, game);
|
||||
let game_dir_path = Path::new(&game_dir);
|
||||
let collection_file_path = Path::new(&collection_file);
|
||||
|
||||
// check if game subdir exists
|
||||
if !game_dir_path.exists() {
|
||||
create_dir(game_dir_path).unwrap();
|
||||
}
|
||||
|
||||
let collection_json = serde_json::to_string(collection).expect("Unable to serialize collection to JSON.");
|
||||
let mut collection_file = File::create(&collection_file_path).expect("Unable to create 'collection.json' file.");
|
||||
collection_file.write_all(&collection_json.as_bytes()).expect("Unable to write JSON-serialized collection to 'collection.json'.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy the image from the location specified via `img_location`. The path of the copied file depends on the specified `game`,
|
||||
/// the string provided via `img_target_name` and the flag `new_entry`. `game` basically translates into the corresponding
|
||||
/// game sub-directory in the apps storage directory. `img_target_name` is an arbitrary string that is used as the name of the
|
||||
/// copied file. If the flag `new_entry` is `true`, this function will fetch the next id of the game's collection and use it
|
||||
/// as a prefix of the copied file name. Otherwise, it will simply use `img_target_name` as the filename.
|
||||
///
|
||||
/// # Arguments
|
||||
/// `img_location` - Absolute path to the image that should be copied
|
||||
/// `img_target_name` - Name that should be used for the copy
|
||||
/// `game` - Game name to specify to which game the image belongs
|
||||
/// `new_entry` - Flag to indicate if the image belogns to a new entry (`true`) or an existing one (`false`).
|
||||
/// In case of a new entry, the next id of the game's collection will be used as a prefix for
|
||||
/// the new image name.
|
||||
///
|
||||
/// # Returns
|
||||
/// The name of the new image file.
|
||||
///
|
||||
pub fn copy_image<'a, T: Serialize + DeserializeOwned>(img_location: &str, img_target_name: &str, game: &str, new_entry: bool) -> Result<String, &'a str> {
|
||||
let config = load_configuration().expect("Unable to load configuration");
|
||||
let file_extension = Path::new(&img_location).extension().and_then(OsStr::to_str).unwrap();
|
||||
|
||||
let mut new_filename: String = format_text_for_fs(&format!("{}.{}",img_target_name, file_extension));
|
||||
|
||||
if new_entry {
|
||||
let collection: HashMap<u32, T> = load_collection::<T>(game).unwrap();
|
||||
let id: u32 = get_next_id::<T>(&collection);
|
||||
new_filename = format!("{}+{}", id, &new_filename);
|
||||
}
|
||||
|
||||
// check if image dir exists
|
||||
let image_dir = format!("{}/{}/images", &config.data_storage, game);
|
||||
let image_dir_path = Path::new(&image_dir);
|
||||
if !image_dir_path.exists() {
|
||||
create_dir(image_dir_path).unwrap();
|
||||
}
|
||||
|
||||
let copy_target = format!("{}/{}", &image_dir, &new_filename);
|
||||
copy(img_location, ©_target).expect("Unable to copy file.");
|
||||
Ok(new_filename)
|
||||
}
|
||||
|
||||
/// Returns the image, specified by the provided `game` and `image` name as a base-64 encoded string.
|
||||
///
|
||||
/// # Arguments
|
||||
/// `game` - Game name to specify to which game the image belongs
|
||||
/// `image` - Name of the image to returns
|
||||
///
|
||||
/// # Returns
|
||||
/// Specified image as base-64 encoded string.
|
||||
///
|
||||
pub fn get_entry_image_b64<'a>(game: &str, image: &str) -> Result<String, &'a str> {
|
||||
let config = load_configuration().expect("Unable to load configuration");
|
||||
let file_extension = Path::new(image).extension().and_then(OsStr::to_str).unwrap();
|
||||
let image_location = format!("{}/{}/images/{}", &config.data_storage, game, image);
|
||||
let img: DynamicImage = image::open(&image_location).unwrap();
|
||||
let mut img_data: Vec<u8> = Vec::new();
|
||||
|
||||
let format = match file_extension {
|
||||
"png" => ImageOutputFormat::Png,
|
||||
"jpg" => ImageOutputFormat::Jpeg(255),
|
||||
"jpeg" => ImageOutputFormat::Jpeg(255),
|
||||
_ => ImageOutputFormat::Png,
|
||||
};
|
||||
|
||||
img.write_to(&mut Cursor::new(&mut img_data), format).unwrap();
|
||||
let img_data_b64 = format!("data:image/{};base64,{}", file_extension, base64::encode(img_data));
|
||||
Ok(img_data_b64)
|
||||
}
|
||||
|
||||
/// Delete the image, specified by the provided `game` and `image` name.
|
||||
///
|
||||
/// # Arguments
|
||||
/// `game` - Game name to specify to which game the image belongs
|
||||
/// `image` - Name of the image to delete
|
||||
///
|
||||
pub fn delete_image<'a>(game: &str, image: &str) -> Result<(), &'a str> {
|
||||
let config = load_configuration().expect("Unable to load configuration");
|
||||
let image_location = format!("{}/{}/images/{}", &config.data_storage, game, image);
|
||||
let image_location_path = Path::new(&image_location);
|
||||
remove_file(image_location_path).expect("Unable to delete image.");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod set_service_templates;
|
||||
pub mod card_service_templates;
|
||||
@@ -0,0 +1,56 @@
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
use std::fs::{File, create_dir};
|
||||
use serde::{Serialize};
|
||||
use crate::util::config::{Configuration, load_configuration};
|
||||
|
||||
/// Get all sets as a JSON list corresponding to the provided game from the local sets.json file that
|
||||
/// belongs to this game. If this file does not exist, the function will return an error.
|
||||
///
|
||||
/// # Arguments
|
||||
/// `game` - Game name for which the sets should be returned
|
||||
///
|
||||
/// # Returns
|
||||
/// string with a list of set objects if the corresponding file exists
|
||||
///
|
||||
pub fn get_sets_json<'a>(game: &'a str) -> Result<String, &'a str> {
|
||||
let config: Configuration = load_configuration().unwrap();
|
||||
|
||||
let set_file = format!("{}/{}/sets.json", &config.data_storage, game);
|
||||
let set_file_path = Path::new(&set_file);
|
||||
|
||||
if set_file_path.exists() {
|
||||
let mut data = String::new();
|
||||
let mut f = File::open(&set_file_path).expect("Unable to open file stream.");
|
||||
f.read_to_string(&mut data).expect("Unable to read file to string.");
|
||||
Ok(data)
|
||||
}
|
||||
else {
|
||||
Err("File does not exist.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Store the povided vector of sets to the set file corresponding to the provided game.
|
||||
///
|
||||
/// # Arguments
|
||||
/// `game` - Game name to specify to which game the provided sets belong
|
||||
/// `sets` - Vector of sets that should be stored in the set file corresponding to the provided game
|
||||
///
|
||||
pub fn store_sets<'a, T: Serialize>(game: &'a str, sets: &Vec<T>) -> Result<(), &'a str> {
|
||||
let config: Configuration = load_configuration().unwrap();
|
||||
|
||||
let game_dir = format!("{}/{}", &config.data_storage, game);
|
||||
let set_file = format!("{}/{}/sets.json", &config.data_storage, game);
|
||||
let game_dir_path = Path::new(&game_dir);
|
||||
let set_file_path = Path::new(&set_file);
|
||||
|
||||
// check if game subdir exists
|
||||
if !game_dir_path.exists() {
|
||||
create_dir(game_dir_path).unwrap();
|
||||
}
|
||||
|
||||
let set_json = serde_json::to_string(sets).expect("Unable to serialize set data to JSON.");
|
||||
let mut set_file = File::create(&set_file_path).expect("Unable to create 'sets.json' file.");
|
||||
set_file.write_all(&set_json.as_bytes()).expect("Unable to write JSON-serialized sets to 'set.json'.");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Trait that a type needs to implement in order to use function `add_map_entry`
|
||||
/// for a hash map that refers to the type.
|
||||
///
|
||||
pub trait MapEntryWithId {
|
||||
fn set_id(&mut self, id: u32);
|
||||
fn get_id(&self) -> u32;
|
||||
}
|
||||
|
||||
pub fn get_next_id<T>(map: &HashMap<u32, T>) -> u32 {
|
||||
match map.keys().max() {
|
||||
None => 0,
|
||||
Some(i) => i + 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the provided entry to the provided map, set its field `id` to the new id
|
||||
/// of the map and returns that id.
|
||||
///
|
||||
/// # Arguments
|
||||
/// `map` - HashMap to which the provided entry should be added
|
||||
/// `entry` - Entry that should be added to the provided map with a new id of the map
|
||||
///
|
||||
/// # Returns
|
||||
/// Id of the new entry
|
||||
///
|
||||
pub fn add_map_entry<T: MapEntryWithId>(map: &mut HashMap<u32, T>, mut entry: T) -> u32 {
|
||||
let id: u32 = get_next_id::<T>(&map);
|
||||
entry.set_id(id);
|
||||
map.insert(id, entry);
|
||||
id
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
use std::path::Path;
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Write};
|
||||
use std::env::current_dir;
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
use super::enums::Game;
|
||||
|
||||
/// General application configuration
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Configuration {
|
||||
/// Absolute path to the location, where all collection data should be stored.
|
||||
#[serde(rename = "dataStorage")]
|
||||
pub data_storage: String,
|
||||
/// Default game to start the app with
|
||||
#[serde(rename = "defaultGame")]
|
||||
pub default_game: Game
|
||||
}
|
||||
|
||||
/// Get the application_s configuration from the config file at `config.json`
|
||||
/// as instance of struct `Configuration`. If the file does not exist, it will
|
||||
/// be generated automatically.
|
||||
///
|
||||
pub fn load_configuration<'a>() -> Result<Configuration, &'a str> {
|
||||
|
||||
if Path::new("config.json").exists() {
|
||||
let mut data = String::new();
|
||||
let mut f = File::open("config.json").expect("Unable to open file stream.");
|
||||
f.read_to_string(&mut data).expect("Unable to read config file to string.");
|
||||
Ok(serde_json::from_str(&data).expect("Unable to deserialize configuration."))
|
||||
}
|
||||
else {
|
||||
let config = Configuration { data_storage: current_dir().unwrap().to_str().unwrap().to_string(), default_game: Game::Magic };
|
||||
store_configuration(&serde_json::to_string(&config).expect("Unable to serialize configuration.")).unwrap();
|
||||
load_configuration()
|
||||
}
|
||||
}
|
||||
|
||||
/// Overwrite the current configuration file with the JSON data provided by this function.
|
||||
///
|
||||
/// # Argument
|
||||
/// * `obj` - A string that contains the new app configuration as a JSON object.
|
||||
///
|
||||
#[tauri::command]
|
||||
pub fn store_configuration<'a>(obj: &'a str) -> Result<(), &'a str> {
|
||||
let mut file = File::create("config.json").expect("Unable to create file.");
|
||||
file.write_all(obj.as_bytes()).expect("Unable to write file.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the app configuration from file `config.json` in JSON format as a string.
|
||||
#[tauri::command]
|
||||
pub fn get_configuration_json<'a>() -> Result<String, &'a str> {
|
||||
let configuration = load_configuration().expect("Unable to load configuration");
|
||||
Ok(serde_json::to_string(&configuration).expect("Unable to serialize configuration."))
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
use strum::IntoEnumIterator;
|
||||
use strum_macros::EnumIter;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, EnumIter)]
|
||||
pub enum Language {
|
||||
English,
|
||||
German,
|
||||
French,
|
||||
Spanish,
|
||||
Italian,
|
||||
Chinese,
|
||||
Japanese,
|
||||
Russian
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, EnumIter)]
|
||||
pub enum Condition {
|
||||
Mint,
|
||||
NearMint,
|
||||
Excellent,
|
||||
Good,
|
||||
LightPlayed,
|
||||
Played,
|
||||
Poor
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, EnumIter)]
|
||||
pub enum Game {
|
||||
Magic,
|
||||
Pokemon
|
||||
}
|
||||
|
||||
fn get_enum_variants<T: IntoEnumIterator>() -> Vec<T> {
|
||||
let mut variants: Vec<T> = Vec::new();
|
||||
for variant in T::iter() {
|
||||
variants.push(variant);
|
||||
}
|
||||
variants
|
||||
}
|
||||
|
||||
/// Get all possible languages that are supported by the app.
|
||||
#[tauri::command]
|
||||
pub fn get_language_variants_json() -> Result<String, String> {
|
||||
Ok(serde_json::to_string(&get_enum_variants::<Language>()).unwrap())
|
||||
}
|
||||
|
||||
/// Get all possible card conditions that are supported by the app.
|
||||
#[tauri::command]
|
||||
pub fn get_condition_variants_json() -> Result<String, String> {
|
||||
Ok(serde_json::to_string(&get_enum_variants::<Condition>()).unwrap())
|
||||
}
|
||||
|
||||
/// Get all possible games that are supported by the app.
|
||||
#[tauri::command]
|
||||
pub fn get_game_variants_json() -> Result<String, String> {
|
||||
Ok(serde_json::to_string(&get_enum_variants::<Game>()).unwrap())
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use nom::character::is_digit;
|
||||
use std::path::Path;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
/// Replace problematic characters in a text in order to
|
||||
/// avoid any conflicts when using the text to create a
|
||||
/// file.
|
||||
pub fn format_text_for_fs(text: &String) -> String {
|
||||
text
|
||||
.replace("'", "")
|
||||
.replace("`", "")
|
||||
.replace(",","")
|
||||
.replace(" ", "")
|
||||
.replace(":", "-")
|
||||
.replace("&", "And")
|
||||
.replace("|", "Or")
|
||||
.replace("é", "e")
|
||||
}
|
||||
|
||||
/// Parse an index from the end of a filename. This function supports indices with
|
||||
/// 1 and 2 digit indices.
|
||||
///
|
||||
/// Example:
|
||||
/// image1.png -> 1
|
||||
/// image22.jpeg -> 22
|
||||
///
|
||||
pub fn parse_index_from_filename(filename: &str) -> u8 {
|
||||
|
||||
// get offset according to extension
|
||||
let file_extension = Path::new(filename).extension().and_then(OsStr::to_str).unwrap();
|
||||
let offset = file_extension.len() + 1;
|
||||
|
||||
let mut index_len = 1;
|
||||
|
||||
// try if index is 2 digits long
|
||||
let two_digits_test: String = filename.chars().skip(filename.len() - offset - 2).take(1).collect();
|
||||
if is_digit(two_digits_test.as_bytes()[0]) {
|
||||
index_len = 2;
|
||||
}
|
||||
|
||||
// extract index
|
||||
let index: String = filename.chars().skip(filename.len() - offset - index_len).take(index_len).collect();
|
||||
index.parse::<u8>().unwrap()
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod enums;
|
||||
pub mod config;
|
||||
pub mod collection;
|
||||
pub mod fs;
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"build": {
|
||||
"beforeDevCommand": "yarn dev",
|
||||
"beforeBuildCommand": "yarn build",
|
||||
"devPath": "http://localhost:1420",
|
||||
"distDir": "../dist",
|
||||
"withGlobalTauri": false
|
||||
},
|
||||
"package": {
|
||||
"productName": "card-collection-manager-2",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
"all": true
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"category": "DeveloperTool",
|
||||
"copyright": "",
|
||||
"deb": {
|
||||
"depends": []
|
||||
},
|
||||
"externalBin": [],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"identifier": "com.sdine.dev",
|
||||
"longDescription": "",
|
||||
"macOS": {
|
||||
"entitlements": null,
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"providerShortName": null,
|
||||
"signingIdentity": null
|
||||
},
|
||||
"resources": [],
|
||||
"shortDescription": "",
|
||||
"targets": "all",
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": ""
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"updater": {
|
||||
"active": false
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"fullscreen": false,
|
||||
"resizable": true,
|
||||
"title": "Card Collection Manager 2",
|
||||
"minWidth": 1300,
|
||||
"minHeight": 900,
|
||||
"width": 1300,
|
||||
"height": 900
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import React, { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { CardEntry } from "../../types/magic";
|
||||
import { CreateEditModalTemplate } from "../templates";
|
||||
|
||||
// Enum to control wether the modal is in "Create" or "Edit" mode.
|
||||
export enum Mode {
|
||||
Create,
|
||||
Edit,
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal to create a new Magic card entry or edit an existing one.
|
||||
* The modal needs to be connected via a image display modal via three functions
|
||||
* that control this image modal.
|
||||
*
|
||||
* # Props:
|
||||
* * visible - Flag to indicate wheter the modal should be displayed or not.
|
||||
* * setVisible - Function to change the value of prop `visible`.
|
||||
*
|
||||
* * mode - Flag to specifiy if the modal should be in "Create new Entry" or "Edit existing Entry" node.
|
||||
*
|
||||
* * collection - Reference to the current Magic `CardEntry` collection.
|
||||
* * setCollection - Function to set/update the collection list to add new entries or update existing ones.
|
||||
*
|
||||
* * selectedEntry - Reference to the currently selected `CardEntry` entry that should be edited in case the modal is in `Edit` mode.
|
||||
* * setSelectedEntry - Function to set/update the currently selected `CardEntry` entry.
|
||||
*
|
||||
* * setImageModalImages - Function to pass a list of image names to the connected image modal.
|
||||
* * setImageModalImageIndex - Function to set the starting image index of the connected image modal.
|
||||
* * setImageModalVisible - Function to control the visiblity of the connected image modal.
|
||||
*/
|
||||
const CreateEditMtgModal: React.FC<{
|
||||
visible: boolean;
|
||||
setVisible: Dispatch<SetStateAction<boolean>>;
|
||||
collection: CardEntry[];
|
||||
mode: Mode;
|
||||
selectedEntry: CardEntry;
|
||||
setCollection: Dispatch<SetStateAction<CardEntry[]>>;
|
||||
setSelectedEntry: Dispatch<SetStateAction<CardEntry>>;
|
||||
setImageModalImages: Dispatch<SetStateAction<string[]>>;
|
||||
setImageModalImageIndex: Dispatch<SetStateAction<number>>;
|
||||
setImageModalVisible: Dispatch<SetStateAction<boolean>>;
|
||||
}> = (props) => {
|
||||
const extraAttributes = [{ label: "Foil", accessKey: "foil" }];
|
||||
|
||||
return (
|
||||
<CreateEditModalTemplate
|
||||
visible={props.visible}
|
||||
setVisible={props.setVisible}
|
||||
game="Magic"
|
||||
extraAttributes={extraAttributes}
|
||||
selectedEntry={props.selectedEntry}
|
||||
setSelectedEntry={props.setSelectedEntry}
|
||||
mode={props.mode}
|
||||
collection={props.collection}
|
||||
setCollection={props.setCollection}
|
||||
setImageModalImages={props.setImageModalImages}
|
||||
setImageModalImageIndex={props.setImageModalImageIndex}
|
||||
setImageModalVisible={props.setImageModalVisible}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateEditMtgModal;
|
||||
@@ -0,0 +1,47 @@
|
||||
import React, { Dispatch, SetStateAction } from "react";
|
||||
import { CardEntry } from "../../types/magic";
|
||||
import { TableTemplate } from "../templates";
|
||||
|
||||
import { IoSparklesSharp } from "react-icons/io5";
|
||||
import { BsPencilFill, BsPaletteFill } from "react-icons/bs";
|
||||
|
||||
/**
|
||||
* Table to display a collection of Magic cards and select entries from it.
|
||||
*
|
||||
* # Props:
|
||||
* * collection - List of `CardEntry` objects that should be displayed via the table.
|
||||
* * selectedEntry - `CardEntry` object that is currently selected by the user.
|
||||
* * setCollection - Function to set/update the collection list that should be displayed.
|
||||
* * setSelectedEntry - Function to specifiy, which entry from the table the user has currenty selected.
|
||||
*/
|
||||
const MtgTable: React.FC<{
|
||||
collection: CardEntry[];
|
||||
selectedEntry: CardEntry;
|
||||
setCollection: Dispatch<SetStateAction<CardEntry[]>>;
|
||||
setSelectedEntry: Dispatch<SetStateAction<CardEntry>>;
|
||||
}> = (props) => {
|
||||
|
||||
const tableFields = [
|
||||
{label: "Name", valueKey: "name", sortKey: "name"},
|
||||
{label: "Set", valueKey: "set.name", sortKey: "set.releaseDate"},
|
||||
{label: "Language", valueKey: "language", sortKey: "language"},
|
||||
{label: "Condition", valueKey: "condition", sortKey: "condition"},
|
||||
{label: "#", valueKey: "amount", sortKey: "amount"},
|
||||
{label: "Foil", valueKey: "foil", sortKey: "foil", icon: <IoSparklesSharp /> },
|
||||
{label: "Signed", valueKey: "signed", sortKey: "signed", icon: <BsPencilFill /> },
|
||||
{label: "Altered", valueKey: "altered", sortKey: "altered", icon: <BsPaletteFill /> },
|
||||
{label: "Note", valueKey: "note", sortKey: "note"}
|
||||
];
|
||||
|
||||
return (
|
||||
<TableTemplate
|
||||
tableFields={tableFields}
|
||||
collection={props.collection}
|
||||
selectedEntry={props.selectedEntry}
|
||||
setCollection={props.setCollection}
|
||||
setSelectedEntry={props.setSelectedEntry}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MtgTable;
|
||||
@@ -0,0 +1,61 @@
|
||||
import React, { Dispatch, SetStateAction } from "react";
|
||||
import { CardEntry } from "../../types/magic";
|
||||
import { IoSparklesSharp } from "react-icons/io5";
|
||||
import { EntryPanelTemplate } from "../templates";
|
||||
|
||||
/**
|
||||
* Function to query the card image from the Scryfall API for the specfied
|
||||
* card entry. In case a matching record could be found, the url to the image will
|
||||
* be returned, otherwise an empty string will be returned.
|
||||
*/
|
||||
const getImage = async (entry: CardEntry) => {
|
||||
|
||||
// preparing card name to be compatible with the API
|
||||
const escapedName = entry.name.replace("&", "and");
|
||||
|
||||
const requestUrl = `https://api.scryfall.com/cards/search?q=name:"${escapedName}" AND set:${entry.set.id}`;
|
||||
const resp: Response = await fetch(requestUrl);
|
||||
const json = await resp.json();
|
||||
if (json.data && json.data.length > 0) {
|
||||
const obj = json.data[0];
|
||||
return obj.image_uris.normal;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
/**
|
||||
* Panel to display the details on a selected Magic card entry.
|
||||
* The panel needs to be connected via a image display modal via three functions
|
||||
* that control this image modal.
|
||||
*
|
||||
* # Props:
|
||||
* * entry - `CardEntry` object that contains the data that should be displayed.
|
||||
* * setImageModalImages - Function to pass a list of image names to the connected image modal.
|
||||
* * setImageModalImageIndex - Function to set the starting image index of the connected image modal.
|
||||
* * setImageModalVisible - Function to control the visiblity of the connected image modal.
|
||||
**/
|
||||
const SelectedMtgPanel: React.FC<{
|
||||
entry: CardEntry;
|
||||
setImageModalImages: Dispatch<SetStateAction<string[]>>;
|
||||
setImageModalImageIndex: Dispatch<SetStateAction<number>>;
|
||||
setImageModalVisible: Dispatch<SetStateAction<boolean>>;
|
||||
}> = (props) => {
|
||||
|
||||
const extraAttributes = [
|
||||
{accessKey: "foil", icon: <IoSparklesSharp />},
|
||||
]
|
||||
|
||||
return (
|
||||
<EntryPanelTemplate
|
||||
entry={props.entry}
|
||||
defaultImageUrl="https://gamepedia.cursecdn.com/mtgsalvation_gamepedia/f/f8/Magic_card_back.jpg"
|
||||
extraAttributes={extraAttributes}
|
||||
fetchEntryPreviewImage={getImage}
|
||||
setImageModalImages={props.setImageModalImages}
|
||||
setImageModalImageIndex={props.setImageModalImageIndex}
|
||||
setImageModalVisible={props.setImageModalVisible}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectedMtgPanel;
|
||||
@@ -0,0 +1,10 @@
|
||||
import CreateEditMtgModal, {Mode} from "./CreateEditMtgModal";
|
||||
import MtgTable from "./MtgTable";
|
||||
import SelectedMtgPanel from "./SelectedMtgPanel";
|
||||
|
||||
export {
|
||||
CreateEditMtgModal,
|
||||
Mode,
|
||||
MtgTable,
|
||||
SelectedMtgPanel
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import React, { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import ModalTemplate from "../templates/ModalTemplate";
|
||||
|
||||
/**
|
||||
* A modal to ask for confirmation and trigger actions based on the decision.
|
||||
*
|
||||
* # Props:
|
||||
* * visible - Flag to indicate wheter the modal should be displayed or not.
|
||||
* * setVisible - Function to change the value of prop `visible`.
|
||||
* * confirmAction - Function that should be executed when the user confirms the action.
|
||||
* * abortAction - (Optional) function that should be executed when the user aborts the action. By default, the modal is just closed.
|
||||
* * title - (Optional) title of the modal, default is 'Confirmation'.
|
||||
* * text - (Optional) text of the modal, default is 'Do you want to proceed?'.
|
||||
*/
|
||||
const ConfirmationModal: React.FC<{
|
||||
visible: boolean;
|
||||
setVisible: Dispatch<SetStateAction<boolean>>;
|
||||
confirmAction: Function;
|
||||
abortAction?: Function;
|
||||
title?: string;
|
||||
text?: string;
|
||||
}> = (props) => {
|
||||
|
||||
const confirmAction = () => {
|
||||
props.confirmAction();
|
||||
props.setVisible(false);
|
||||
}
|
||||
|
||||
const abortAction = () => {
|
||||
if(props.abortAction) {
|
||||
props.abortAction();
|
||||
}
|
||||
props.setVisible(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.visible ? (
|
||||
<ModalTemplate
|
||||
title={props.title ? props.title : "Confirmation"}
|
||||
onClickCloseIcon={() => props.setVisible(false)}
|
||||
modalStyle="w-[60%] h-[20%] lg:w-[50%] xl:w-[35%] 2xl:w-[30%]"
|
||||
>
|
||||
<div className="relative justify-center items-center text-center text-gray-600 mx-8">
|
||||
<div>
|
||||
{props.text ? props.text : "Do you want to proceed?"}
|
||||
</div>
|
||||
<div className="my-8 flex text-center items-center justify-center">
|
||||
<button type="submit" className="mx-2" onClick={() => confirmAction()}>Confirm</button>
|
||||
<button type="submit" className="mx-2" onClick={() => abortAction()}>Abort</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalTemplate>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmationModal;
|
||||
@@ -0,0 +1,121 @@
|
||||
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/tauri";
|
||||
|
||||
import { GrNext, GrPrevious } from "react-icons/gr";
|
||||
import { RotatingLines } from "react-loader-spinner";
|
||||
|
||||
import ModalTemplate from "../templates/ModalTemplate";
|
||||
import Image from "next/image";
|
||||
|
||||
/**
|
||||
* Modal to display a set of images.
|
||||
*
|
||||
* # Props:
|
||||
* * visible - Flag to indicate wheter the modal should be displayed or not.
|
||||
* * setVisible - Function to change the value of prop `visible`.
|
||||
* * game - Name of the game for which images should be displayed.
|
||||
* * images - List of image names that should be displayed (only name, no paths).
|
||||
* * startIndex - (Optional) index that indicates at which element of the list provided via `props.images` the display should start.
|
||||
*/
|
||||
const ImageModal: React.FC<{
|
||||
visible: boolean;
|
||||
setVisible: Dispatch<SetStateAction<boolean>>;
|
||||
game: string;
|
||||
images: string[];
|
||||
startIndex?: number;
|
||||
}> = (props) => {
|
||||
|
||||
// current image as base-64 string
|
||||
const [imageB64, setImageB64] = useState<string>("");
|
||||
// index of current image in `props.images`
|
||||
const [imageIndex, setImageIndex] = useState<number>(0);
|
||||
// flag to display the loading image
|
||||
const [loaderVisible, setLoaderVisible] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.visible && props.images) {
|
||||
setLoaderVisible(true);
|
||||
setImageIndex(props.startIndex | 0);
|
||||
loadImage(props.startIndex | 0);
|
||||
}
|
||||
}, [props.visible]);
|
||||
|
||||
const loadImage = async (id: number) => {
|
||||
invoke("get_image_b64", { image: props.images[id], game: props.game }).then(
|
||||
(result) => {
|
||||
setImageB64(result as string);
|
||||
setLoaderVisible(false);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const loadNextImage = () => {
|
||||
if(imageIndex < props.images.length -1){
|
||||
setLoaderVisible(true);
|
||||
setImageIndex(imageIndex + 1);
|
||||
loadImage(imageIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const loadPreviousImage = () => {
|
||||
if(imageIndex > 0){
|
||||
setLoaderVisible(true);
|
||||
setImageIndex(imageIndex - 1);
|
||||
loadImage(imageIndex - 1);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.visible ? (
|
||||
<ModalTemplate
|
||||
title="Image"
|
||||
onClickCloseIcon={() => props.setVisible(false)}
|
||||
modalStyle=" w-[80%] h-[80%]"
|
||||
>
|
||||
<div className="relative h-[90%] mx-8">
|
||||
{loaderVisible
|
||||
?
|
||||
<div className="w-full h-[90%] flex items-center justify-center">
|
||||
<RotatingLines
|
||||
strokeColor="grey"
|
||||
strokeWidth="5"
|
||||
animationDuration="0.75"
|
||||
width="96"
|
||||
/>
|
||||
</div>
|
||||
:
|
||||
<Image src={imageB64} layout="fill" objectFit="contain" />
|
||||
}
|
||||
|
||||
{/* next/previous image icons */}
|
||||
{props.images.length > 1 ? (
|
||||
<div className="absolute flex justify-between z-20 top-[50%] w-full">
|
||||
{imageIndex > 0 ? (
|
||||
<div className="ml-4 hover:scale-110 hover:rounded-full hover:shadow-xl shadow-gray-500 cursor-pointer" onClick={() => loadPreviousImage()}>
|
||||
<GrPrevious className="test-gray-600" />
|
||||
</div>
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
{imageIndex < props.images.length - 1 ? (
|
||||
<div className="mr-4 hover:scale-110 hover:rounded-full hover:shadow-xl shadow-gray-500 cursor-pointer" onClick={() => loadNextImage()}>
|
||||
<GrNext className="test-gray-600" />
|
||||
</div>
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
</ModalTemplate>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageModal;
|
||||
@@ -0,0 +1,45 @@
|
||||
import React, { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import ModalTemplate from "../templates/ModalTemplate";
|
||||
|
||||
/**
|
||||
* A modal to notify the user.
|
||||
*
|
||||
* # Props:
|
||||
* * visible - Flag to indicate wheter the modal should be displayed or not.
|
||||
* * setVisible - Function to change the value of prop `visible`.
|
||||
* * title - (Optional) title of the modal, default is 'Notification'.
|
||||
* * text - (Optional) text of the modal, default is 'Process completed successfully.'.
|
||||
*/
|
||||
const NotificationModal: React.FC<{
|
||||
visible: boolean;
|
||||
setVisible: Dispatch<SetStateAction<boolean>>;
|
||||
title?: string;
|
||||
text?: string;
|
||||
}> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.visible ? (
|
||||
<ModalTemplate
|
||||
title={props.title ? props.title : "Notification"}
|
||||
onClickCloseIcon={() => props.setVisible(false)}
|
||||
modalStyle="w-[60%] h-[20%] lg:w-[50%] xl:w-[35%] 2xl:w-[30%]"
|
||||
>
|
||||
<div className="relative justify-center items-center text-center text-gray-600 mx-8">
|
||||
<div>
|
||||
{props.text ? props.text : "Process completed successfully."}
|
||||
</div>
|
||||
<div className="my-8 flex text-center items-center justify-center">
|
||||
<button type="submit" className="mx-2" onClick={() => props.setVisible(false)}>Ok</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalTemplate>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationModal;
|
||||
@@ -0,0 +1,118 @@
|
||||
import React, { Dispatch, SetStateAction, useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/tauri";
|
||||
import { open } from "@tauri-apps/api/dialog";
|
||||
|
||||
import { Configuration } from "../../types";
|
||||
import ModalTemplate from "../templates/ModalTemplate";
|
||||
|
||||
/**
|
||||
* Modal to overwrite the general app settings.
|
||||
*
|
||||
* # Props:
|
||||
* * visible - Flag to indicate wheter the modal should be displayed or not.
|
||||
* * setVisible - Function to change the value of prop `visible`.
|
||||
*/
|
||||
const SettingsModal: React.FC<{
|
||||
visible: boolean;
|
||||
setVisible: Dispatch<SetStateAction<boolean>>;
|
||||
}> = (props) => {
|
||||
|
||||
// Configuration object
|
||||
const [config, setConfig] = useState<Configuration>(null);
|
||||
// Supported Games
|
||||
const [games, setGames] = useState<string[]>([]);
|
||||
|
||||
const gameRef = useRef<HTMLSelectElement>();
|
||||
|
||||
// load default game options the first time this component is loaded
|
||||
useEffect(() => {
|
||||
invoke("get_game_variants_json")
|
||||
.then(result => setGames(JSON.parse(result as string)));
|
||||
}, []);
|
||||
|
||||
// reload config from backend whenever this modal becomes visible
|
||||
useEffect(() => {
|
||||
if (props.visible) {
|
||||
loadConfig();
|
||||
}
|
||||
}, [props.visible]);
|
||||
|
||||
// load configuration object from backend and store it to the state variable `config`.
|
||||
const loadConfig = async () => {
|
||||
const configObj = await invoke("get_configuration_json").then((config) =>
|
||||
JSON.parse(config as string)
|
||||
);
|
||||
setConfig(configObj);
|
||||
gameRef.current.value = configObj.defaultGame;
|
||||
};
|
||||
|
||||
// send the state variable `config` to the backend in order to overwrite the general
|
||||
// app config.
|
||||
const saveConfig = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
invoke("store_configuration", { obj: JSON.stringify(config) }).then(() => {
|
||||
props.setVisible(false);
|
||||
});
|
||||
};
|
||||
|
||||
// callback function to select the collection data store directory
|
||||
const selectStorageDir = async () => {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
directory: true,
|
||||
title: "Select Directory",
|
||||
});
|
||||
if (selected) {
|
||||
let tmpConfig = config;
|
||||
tmpConfig.dataStorage = selected as string;
|
||||
setConfig({ ...tmpConfig });
|
||||
}
|
||||
};
|
||||
|
||||
const selectDefaultGame = () => {
|
||||
let tmpConfig = config;
|
||||
tmpConfig.defaultGame = gameRef.current.value;
|
||||
setConfig({ ...tmpConfig });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.visible ? (
|
||||
<ModalTemplate
|
||||
title="Settings"
|
||||
onClickCloseIcon={() => props.setVisible(false)}
|
||||
modalStyle="w-[60%] h-[25%] lg:w-[50%] xl:w-[35%] 2xl:w-[30%]"
|
||||
>
|
||||
<div className="relative flex justify-center items-center text-gray-600 mx-8">
|
||||
<form onSubmit={(e) => saveConfig(e)}>
|
||||
<div className="grid grid-settings-8 gap-x-4 gap-y-4">
|
||||
<label className="text-sm col-span-1">Storage Directory</label>
|
||||
<input
|
||||
className="col-span-7 text-sm border-2 cursor-pointer hover:bg-gray-200 hover:underline"
|
||||
onClickCapture={() => selectStorageDir()}
|
||||
value={config ? config.dataStorage : ""}
|
||||
/>
|
||||
<label className="text-sm col-span-1">Default Game</label>
|
||||
<select
|
||||
className="col-span-7 text-sm border-2"
|
||||
ref={gameRef}
|
||||
onChange={() => selectDefaultGame()}
|
||||
>
|
||||
{games.map(game => <option value={game}>{game}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="my-4 text-center">
|
||||
<button type="submit">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</ModalTemplate>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsModal;
|
||||
@@ -0,0 +1,11 @@
|
||||
import ConfirmationModal from "./ConfirmationModal";
|
||||
import ImageModal from "./ImageModal";
|
||||
import NotificationModal from "./NotificationModal";
|
||||
import SettingsModal from "./SettingsModal";
|
||||
|
||||
export {
|
||||
ConfirmationModal,
|
||||
ImageModal,
|
||||
NotificationModal,
|
||||
SettingsModal
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { CardEntry } from "../../types/pokemon";
|
||||
import { CreateEditModalTemplate } from "../templates";
|
||||
|
||||
// Enum to control wether the modal is in "Create" or "Edit" mode.
|
||||
export enum Mode {
|
||||
Create,
|
||||
Edit,
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal to create a new Pokemon card entry or edit an existing one.
|
||||
* The modal needs to be connected via a image display modal via three functions
|
||||
* that control this image modal.
|
||||
*
|
||||
* # Props:
|
||||
* * visible - Flag to indicate wheter the modal should be displayed or not.
|
||||
* * setVisible - Function to change the value of prop `visible`.
|
||||
*
|
||||
* * mode - Flag to specifiy if the modal should be in "Create new Entry" or "Edit existing Entry" node.
|
||||
*
|
||||
* * collection - Reference to the current Pokemon `CardEntry` collection.
|
||||
* * setCollection - Function to set/update the collection list to add new entries or update existing ones.
|
||||
*
|
||||
* * selectedEntry - Reference to the currently selected `CardEntry` entry that should be edited in case the modal is in `Edit` mode.
|
||||
* * setSelectedEntry - Function to set/update the currently selected `CardEntry` entry.
|
||||
*
|
||||
* * setImageModalImages - Function to pass a list of image names to the connected image modal.
|
||||
* * setImageModalImageIndex - Function to set the starting image index of the connected image modal.
|
||||
* * setImageModalVisible - Function to control the visiblity of the connected image modal.
|
||||
*/
|
||||
const CreateEditPokemonModal: React.FC<{
|
||||
visible: boolean;
|
||||
setVisible: Dispatch<SetStateAction<boolean>>;
|
||||
collection: CardEntry[];
|
||||
mode: Mode;
|
||||
selectedEntry: CardEntry;
|
||||
setCollection: Dispatch<SetStateAction<CardEntry[]>>;
|
||||
setSelectedEntry: Dispatch<SetStateAction<CardEntry>>;
|
||||
setImageModalImages: Dispatch<SetStateAction<string[]>>;
|
||||
setImageModalImageIndex: Dispatch<SetStateAction<number>>;
|
||||
setImageModalVisible: Dispatch<SetStateAction<boolean>>;
|
||||
}> = (props) => {
|
||||
const extraAttributes = [
|
||||
{ label: "Holo", accessKey: "holo" },
|
||||
{ label: "1. Edition", accessKey: "firstEdition" },
|
||||
];
|
||||
|
||||
return (
|
||||
<CreateEditModalTemplate
|
||||
visible={props.visible}
|
||||
setVisible={props.setVisible}
|
||||
game="Pokemon"
|
||||
extraAttributes={extraAttributes}
|
||||
selectedEntry={props.selectedEntry}
|
||||
setSelectedEntry={props.setSelectedEntry}
|
||||
mode={props.mode}
|
||||
collection={props.collection}
|
||||
setCollection={props.setCollection}
|
||||
setImageModalImages={props.setImageModalImages}
|
||||
setImageModalImageIndex={props.setImageModalImageIndex}
|
||||
setImageModalVisible={props.setImageModalVisible}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateEditPokemonModal;
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Rebuilding of the 1. Edition Icon
|
||||
*/
|
||||
const IconPokemonFirstEdition: React.FC<{className?: string}> = (props) => {
|
||||
return (
|
||||
<div className={`flex items-center justify-center ${props.className ? props.className: ""}`}>
|
||||
<div className="rounded-full bg-black text-white px-1 text-xs font-bold">
|
||||
1
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IconPokemonFirstEdition;
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { Dispatch, SetStateAction } from "react";
|
||||
import { CardEntry } from "../../types/pokemon";
|
||||
import { TableTemplate } from "../templates";
|
||||
|
||||
import { IoSparklesSharp } from "react-icons/io5";
|
||||
import { BsPencilFill, BsPaletteFill } from "react-icons/bs";
|
||||
import IconPokemonFirstEdition from "./IconPokemonFirstEdition";
|
||||
|
||||
/**
|
||||
* Table to display a collection of Pokemon cards and select entries from it.
|
||||
*
|
||||
* # Props:
|
||||
* * collection - List of `CardEntry` objects that should be displayed via the table.
|
||||
* * selectedEntry - `CardEntry` object that is currently selected by the user.
|
||||
* * setCollection - Function to set/update the collection list that should be displayed.
|
||||
* * setSelectedEntry - Function to specifiy, which entry from the table the user has currenty selected.
|
||||
*/
|
||||
const PokemonTable: React.FC<{
|
||||
collection: CardEntry[];
|
||||
selectedEntry: CardEntry;
|
||||
setCollection: Dispatch<SetStateAction<CardEntry[]>>;
|
||||
setSelectedEntry: Dispatch<SetStateAction<CardEntry>>;
|
||||
}> = (props) => {
|
||||
|
||||
const tableFields = [
|
||||
{label: "Name", valueKey: "name", sortKey: "name"},
|
||||
{label: "Set", valueKey: "set.name", sortKey: "set.releaseDate"},
|
||||
{label: "Language", valueKey: "language", sortKey: "language"},
|
||||
{label: "Condition", valueKey: "condition", sortKey: "condition"},
|
||||
{label: "#", valueKey: "amount", sortKey: "amount"},
|
||||
{label: "Holo", valueKey: "holo", sortKey: "holo", icon: <IoSparklesSharp /> },
|
||||
{label: "FirstEdition", valueKey: "firstEdition", sortKey: "firstEdition", icon: <IconPokemonFirstEdition /> },
|
||||
{label: "Signed", valueKey: "signed", sortKey: "signed", icon: <BsPencilFill /> },
|
||||
{label: "Altered", valueKey: "altered", sortKey: "altered", icon: <BsPaletteFill /> },
|
||||
{label: "Note", valueKey: "note", sortKey: "note"}
|
||||
];
|
||||
|
||||
return (
|
||||
<TableTemplate
|
||||
tableFields={tableFields}
|
||||
collection={props.collection}
|
||||
selectedEntry={props.selectedEntry}
|
||||
setCollection={props.setCollection}
|
||||
setSelectedEntry={props.setSelectedEntry}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PokemonTable;
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { Dispatch, SetStateAction } from "react";
|
||||
import { CardEntry } from "../../types/pokemon";
|
||||
import IconPokemonFirstEdition from "./IconPokemonFirstEdition";
|
||||
import { IoSparklesSharp } from "react-icons/io5";
|
||||
import { EntryPanelTemplate } from "../templates";
|
||||
|
||||
/**
|
||||
* Function to query the card image from the Pokemon TCG API for the specfied
|
||||
* card entry. In case a matching record could be found, the url to the image will
|
||||
* be returned, otherwise an empty string will be returned.
|
||||
*/
|
||||
const getImage = async (entry: CardEntry) => {
|
||||
|
||||
// preparing card name to be compatible with the API
|
||||
const escapedName = entry.name
|
||||
.replace("&", "*")
|
||||
.replace(" EX", "-EX")
|
||||
.replace(" GX", "-GX");
|
||||
|
||||
// if the set number of the entry is maintainend, we will query via set-id + set-no.
|
||||
// This is especially helpfull when there are multiple artworks of the same card in the
|
||||
// same set.
|
||||
const reqViaId = `https://api.pokemontcg.io/v2/cards?q=id:"${entry.set.id}-${entry.setNo}"`;
|
||||
|
||||
// if set number is not maintainend, we will query via card name + set-id.
|
||||
const reqViaNameAndSet = `https://api.pokemontcg.io/v2/cards?q=name:"${escapedName}" AND set.id:${entry.set.id}`;
|
||||
|
||||
const resp: Response = await fetch(entry.setNo && entry.setNo != "" ? reqViaId : reqViaNameAndSet);
|
||||
const json = await resp.json();
|
||||
if (json.data && json.data.length > 0) {
|
||||
const obj = json.data[0];
|
||||
return obj.images.small;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
/**
|
||||
* Panel to display the details of a selected Pokemon card entry.
|
||||
* The panel needs to be connected via a image display modal via three functions
|
||||
* that control this image modal.
|
||||
*
|
||||
* # Props:
|
||||
* * entry - `CardEntry` object that contains the data that should be displayed.
|
||||
* * setImageModalImages - Function to pass a list of image names to the connected image modal.
|
||||
* * setImageModalImageIndex - Function to set the starting image index of the connected image modal.
|
||||
* * setImageModalVisible - Function to control the visiblity of the connected image modal.
|
||||
**/
|
||||
const SelectedPokemonPanel: React.FC<{
|
||||
entry: CardEntry;
|
||||
setImageModalImages: Dispatch<SetStateAction<string[]>>;
|
||||
setImageModalImageIndex: Dispatch<SetStateAction<number>>;
|
||||
setImageModalVisible: Dispatch<SetStateAction<boolean>>;
|
||||
}> = (props) => {
|
||||
|
||||
const extraAttributes = [
|
||||
{accessKey: "holo", icon: <IoSparklesSharp />},
|
||||
{accessKey: "firstEdition", icon: <IconPokemonFirstEdition />},
|
||||
]
|
||||
|
||||
return (
|
||||
<EntryPanelTemplate
|
||||
entry={props.entry}
|
||||
defaultImageUrl="https://archives.bulbagarden.net/media/upload/1/17/Cardback.jpg"
|
||||
extraAttributes={extraAttributes}
|
||||
fetchEntryPreviewImage={getImage}
|
||||
setImageModalImages={props.setImageModalImages}
|
||||
setImageModalImageIndex={props.setImageModalImageIndex}
|
||||
setImageModalVisible={props.setImageModalVisible}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectedPokemonPanel;
|
||||
@@ -0,0 +1,10 @@
|
||||
import CreateEditPokemonModal, {Mode} from "./CreateEditPokemonModal";
|
||||
import PokemonTable from "./PokemonTable";
|
||||
import SelectedPokemonPanel from "./SelectedPokemonPanel";
|
||||
|
||||
export {
|
||||
CreateEditPokemonModal,
|
||||
Mode,
|
||||
PokemonTable,
|
||||
SelectedPokemonPanel
|
||||
};
|
||||
@@ -0,0 +1,424 @@
|
||||
import React, {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import ModalTemplate from "../templates/ModalTemplate";
|
||||
|
||||
import { SetTemplate, EntryTemplate } from "../../types";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/tauri";
|
||||
import { open } from "@tauri-apps/api/dialog";
|
||||
|
||||
import { VscClose } from "react-icons/vsc";
|
||||
|
||||
import IntegerInput from "../templates/IntegerInput";
|
||||
import ConfirmationModal from "../modals/ConfirmationModal";
|
||||
|
||||
// Enum to control wether the modal is in "Create" or "Edit" mode.
|
||||
export enum Mode {
|
||||
Create,
|
||||
Edit,
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration object to specify additional, game-specific binary attributes of an entry.
|
||||
* The specified label will be displayed for a corresponding checkbox that represents
|
||||
* this attribute. The accessKey is used to retrieve the current value of the attribute
|
||||
* for an entry that is editied as well as to send a new/updated value of the attribute
|
||||
* to the backend.
|
||||
*/
|
||||
type ExtraAttribute = {
|
||||
label: string;
|
||||
accessKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Template modal to create a new collection entries or edit existing ones.
|
||||
* The modal needs to be connected via a image display modal via three functions
|
||||
* that control this image modal.
|
||||
*
|
||||
* # Props:
|
||||
* * visible - Flag to indicate wheter the modal should be displayed or not.
|
||||
* * setVisible - Function to change the value of prop `visible`.
|
||||
*
|
||||
* * game - Identifier of the game for which this modal should create or edit entries.
|
||||
* This is required to identify the correct collection and corresponding file system
|
||||
* directories.
|
||||
*
|
||||
* * extraAttributes - List of additional, games-specific binary attributes beyond the standard binary
|
||||
* attributes `signed` and `altered`
|
||||
*
|
||||
* * mode - Flag to specifiy if the modal should be in "Create new Entry" or "Edit existing Entry" node.
|
||||
*
|
||||
* * collection - Reference to the current entry collection.
|
||||
* * setCollection - Function to set/update the collection list to add new entries or update existing ones.
|
||||
*
|
||||
* * selectedEntry - Reference to the currently selected entry that should be edited in case the modal is in `Edit` mode.
|
||||
* * setSelectedEntry - Function to set/update the currently selected entry.
|
||||
*
|
||||
* * setImageModalImages - Function to pass a list of image names to the connected image modal.
|
||||
* * setImageModalImageIndex - Function to set the starting image index of the connected image modal.
|
||||
* * setImageModalVisible - Function to control the visiblity of the connected image modal.
|
||||
*/
|
||||
const CreateEditModalTemplate: React.FC<{
|
||||
visible: boolean;
|
||||
setVisible: Dispatch<SetStateAction<boolean>>;
|
||||
game: string;
|
||||
extraAttributes: ExtraAttribute[]
|
||||
collection: EntryTemplate[];
|
||||
mode: Mode;
|
||||
selectedEntry: EntryTemplate;
|
||||
setCollection: Dispatch<SetStateAction<EntryTemplate[]>>;
|
||||
setSelectedEntry: Dispatch<SetStateAction<EntryTemplate>>;
|
||||
setImageModalImages: Dispatch<SetStateAction<string[]>>;
|
||||
setImageModalImageIndex: Dispatch<SetStateAction<number>>;
|
||||
setImageModalVisible: Dispatch<SetStateAction<boolean>>;
|
||||
}> = (props) => {
|
||||
const [sets, setSets] = useState<SetTemplate[]>([]);
|
||||
const [languages, setLanguages] = useState<string[]>([]);
|
||||
const [conditions, setConditions] = useState<string[]>([]);
|
||||
|
||||
// confirmation modal visibilities
|
||||
const [abortConfirmationModalVisibility, setAbortConfirmationModalVisiblity] = useState<boolean>(false);
|
||||
|
||||
|
||||
// form input element refs
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
const setRef = useRef<HTMLSelectElement>(null);
|
||||
const setNoRef = useRef<HTMLInputElement>(null);
|
||||
const languageRef = useRef<HTMLSelectElement>(null);
|
||||
const conditionRef = useRef<HTMLSelectElement>(null);
|
||||
const amountRef = useRef<HTMLInputElement>(null);
|
||||
const signedRef = useRef<HTMLInputElement>(null);
|
||||
const alteredRef = useRef<HTMLInputElement>(null);
|
||||
const noteRef = useRef<HTMLInputElement>(null);
|
||||
// dynamic creation of refs for extra attributes
|
||||
let extraAttributesRefs = {};
|
||||
props.extraAttributes.map(attribute => extraAttributesRefs[attribute.accessKey] = useRef<HTMLInputElement>(null));
|
||||
|
||||
// images as state variable for better handling
|
||||
const [images, setImages] = useState<string[]>([]);
|
||||
|
||||
// The first time this modal gets rendered, it fetches language and condition
|
||||
// informations from the backened.
|
||||
useEffect(() => {
|
||||
invoke("get_language_variants_json").then((result) => {
|
||||
const obj = JSON.parse(result as string) as string[];
|
||||
setLanguages(obj);
|
||||
});
|
||||
|
||||
invoke("get_condition_variants_json").then((result) => {
|
||||
const obj = JSON.parse(result as string) as string[];
|
||||
setConditions(obj);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// everytime the modal becomes visible in "Edit" mode, it populates
|
||||
// its fields with the data from the selected entry.
|
||||
useEffect(() => {
|
||||
if (props.visible) {
|
||||
|
||||
// get set data. We need to do this each time the element
|
||||
// becomes visible, because the user might have updated the set
|
||||
// data in between.
|
||||
invoke("get_sets", {game: props.game}).then((result) => {
|
||||
const obj = JSON.parse(result as string) as SetTemplate[];
|
||||
setSets(obj);
|
||||
});
|
||||
|
||||
// populate fields with data in case of 'edit' mode
|
||||
if (props.mode == Mode.Edit) {
|
||||
nameRef.current!.value = props.selectedEntry.name;
|
||||
setRef.current!.value = props.selectedEntry.set.id.toLowerCase();
|
||||
setNoRef.current!.value = props.selectedEntry.setNo;
|
||||
languageRef.current!.value = props.selectedEntry.language;
|
||||
conditionRef.current!.value = props.selectedEntry.condition;
|
||||
amountRef.current!.value = `${props.selectedEntry.amount}`;
|
||||
noteRef.current!.value = props.selectedEntry.note;
|
||||
signedRef.current!.checked = props.selectedEntry.signed;
|
||||
alteredRef.current!.checked = props.selectedEntry.altered;
|
||||
// populate dynamic extra attribute fields
|
||||
props.extraAttributes.map(attribute => extraAttributesRefs[attribute.accessKey].current!.checked = props.selectedEntry[attribute.accessKey]);
|
||||
|
||||
setImages(props.selectedEntry.images);
|
||||
}
|
||||
} else {
|
||||
// clear image state in any case the modal gets closed
|
||||
setImages([]);
|
||||
}
|
||||
}, [props.visible]);
|
||||
|
||||
// handler when user aborts maintaining an entry by clicking the close icon
|
||||
const onClose = () => {
|
||||
// if "Create" mode, delete all temporary stored images
|
||||
if (props.mode == Mode.Create) {
|
||||
images.forEach((image) => {
|
||||
invoke("delete_image", { image: image, game: props.game });
|
||||
});
|
||||
}
|
||||
// if "Edit" mode, delete all newly added images
|
||||
if (props.mode == Mode.Edit) {
|
||||
images
|
||||
.filter((image) => !props.selectedEntry.images.includes(image))
|
||||
.forEach((image) => {
|
||||
invoke("delete_image", { image: image, game: props.game });
|
||||
});
|
||||
}
|
||||
props.setVisible(false);
|
||||
};
|
||||
|
||||
// get temporary entry from the current values stored in all input fields
|
||||
const getTempEntry = () => {
|
||||
let cardEntry: EntryTemplate = {
|
||||
id: props.mode == Mode.Create ? 0 : props.selectedEntry.id,
|
||||
name: nameRef.current!.value,
|
||||
set: sets.filter((set) => set.id === setRef.current!.value)[0],
|
||||
setNo: setNoRef.current!.value,
|
||||
language: languageRef.current!.value,
|
||||
condition: conditionRef.current!.value,
|
||||
amount: Number.parseInt(amountRef.current!.value),
|
||||
altered: alteredRef.current!.checked,
|
||||
signed: signedRef.current!.checked,
|
||||
note: noteRef.current!.value,
|
||||
images: images,
|
||||
};
|
||||
// get values of extra attributes
|
||||
props.extraAttributes.map(attribute => cardEntry[attribute.accessKey] = extraAttributesRefs[attribute.accessKey].current!.checked);
|
||||
return cardEntry;
|
||||
};
|
||||
|
||||
// submit an entry to the backend based on the current values of all input fields.
|
||||
// if "Edit" mode, the existing entry will be overwritten.
|
||||
const submitEntry = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
let cardEntry = getTempEntry();
|
||||
|
||||
if (props.mode == Mode.Create) {
|
||||
invoke("add_card", { obj: JSON.stringify(cardEntry), game: props.game })
|
||||
.then((result) => {
|
||||
cardEntry.id = Number.parseInt(JSON.parse(result as string));
|
||||
props.setCollection(props.collection.concat(cardEntry));
|
||||
props.setVisible(false);
|
||||
})
|
||||
.catch((reject) => console.log(reject));
|
||||
}
|
||||
|
||||
if (props.mode == Mode.Edit) {
|
||||
cardEntry.id = props.selectedEntry.id;
|
||||
invoke("update_card", { obj: JSON.stringify(cardEntry), game: props.game })
|
||||
.then(() => {
|
||||
props.setCollection(
|
||||
props.collection.map((entry) =>
|
||||
entry.id == cardEntry.id ? cardEntry : entry
|
||||
)
|
||||
);
|
||||
props.setSelectedEntry(cardEntry);
|
||||
props.setVisible(false);
|
||||
})
|
||||
.catch((reject) => console.log(reject));
|
||||
}
|
||||
};
|
||||
|
||||
// trigger the image selection dialog, copy the selected image via the backend service and
|
||||
// store the returned image name in the temporary list of image names.
|
||||
const addImage = async () => {
|
||||
// only add image if name field has a value
|
||||
if (nameRef.current && nameRef.current.reportValidity()) {
|
||||
const selected = await open({
|
||||
multiple: true,
|
||||
title: "Select image",
|
||||
filters: [
|
||||
{
|
||||
name: "Image",
|
||||
extensions: ["png", "PNG", "jpg", "JPG", "jpeg", "JPEG"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected && Array.isArray(selected)) {
|
||||
let cardEntry = getTempEntry();
|
||||
for(let i = 0; i < selected.length; i++) {
|
||||
const imgId = await invoke("copy_image", {
|
||||
obj: JSON.stringify(cardEntry),
|
||||
imgLocation: selected[i],
|
||||
newEntry: props.mode == Mode.Create,
|
||||
game: props.game
|
||||
});
|
||||
cardEntry.images.push(imgId as string);
|
||||
}
|
||||
setImages([].concat(cardEntry.images)); // using the concat with an empty array is a trick to trigger the rerender, otherwise the state does recognize to rerender
|
||||
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Enable the connected image modal and start the display by the provided image index.
|
||||
const displayImage = async (index: number) => {
|
||||
props.setImageModalImages(images);
|
||||
props.setImageModalImageIndex(index);
|
||||
props.setImageModalVisible(true);
|
||||
};
|
||||
|
||||
// Delete the image with the specified image name by calling the corresponding backend service
|
||||
// and remove the image name from the temporary list.
|
||||
const deleteImage = async (image: string) => {
|
||||
invoke("delete_image", { image: image, game: props.game })
|
||||
.then(() => {
|
||||
setImages(images.filter((img) => img != image));
|
||||
})
|
||||
.catch((reject) => console.log(reject));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.visible ? (
|
||||
<ModalTemplate
|
||||
title={props.mode == Mode.Create ? "Create Entry" : "Edit Entry"}
|
||||
onClickCloseIcon={() => setAbortConfirmationModalVisiblity(true)}
|
||||
modalStyle="w-[60%] h-[70%] lg:w-[50%] xl:w-[35%] 2xl:w-[30%]"
|
||||
>
|
||||
<div className="relative flex justify-center items-center text-gray-600 mx-8">
|
||||
<form onSubmit={(e) => submitEntry(e)}>
|
||||
<div className="grid grid-settings-8 gap-x-4 gap-y-4">
|
||||
<label className="text-sm col-span-1">Name</label>
|
||||
<input
|
||||
className="text-sm col-span-7 border-2"
|
||||
type="text"
|
||||
required={true}
|
||||
autoFocus={true}
|
||||
ref={nameRef}
|
||||
/>
|
||||
|
||||
<label className="text-sm col-span-1">Set</label>
|
||||
<select className="text-sm col-span-7 border-2" ref={setRef}>
|
||||
{sets.map((set) => (
|
||||
<option value={`${set.id}`}>{set.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="text-sm col-span-1">Set No.</label>
|
||||
<input type="number"
|
||||
className="text-sm col-span-2 border-2"
|
||||
defaultValue={""}
|
||||
ref={setNoRef}
|
||||
/>
|
||||
<div className="col-span-5" />
|
||||
|
||||
<label className="text-sm col-span-1">Language</label>
|
||||
<select
|
||||
className="text-sm col-span-3 border-2"
|
||||
ref={languageRef}
|
||||
>
|
||||
{languages.map((language) => (
|
||||
<option value={language}>{language}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="col-span-4" />
|
||||
|
||||
<label className="text-sm col-span-1">Condition</label>
|
||||
<select
|
||||
className="text-sm col-span-3 border-2"
|
||||
ref={conditionRef}
|
||||
>
|
||||
{conditions.map((condition) => (
|
||||
<option value={condition}>{condition}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="col-span-4" />
|
||||
|
||||
<label className="text-sm col-span-1">Amount</label>
|
||||
<IntegerInput
|
||||
className="text-sm col-span-2 border-2"
|
||||
required={true}
|
||||
ref={amountRef}
|
||||
/>
|
||||
<div className="col-span-5" />
|
||||
|
||||
<div className="text-sm col-span-1" />
|
||||
<div className="text-sm col-span-7 flex justify-between">
|
||||
{/* fields for dynamic extra attributes */}
|
||||
{props.extraAttributes.map(attribute =>
|
||||
<div>
|
||||
<input type="checkbox" ref={extraAttributesRefs[attribute.accessKey]} />
|
||||
<label className="mx-2">{attribute.label}</label>
|
||||
</div>
|
||||
)}
|
||||
{/* default attributes */}
|
||||
<div>
|
||||
<input type="checkbox" ref={signedRef} />
|
||||
<label className="mx-2">Signed</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="checkbox" ref={alteredRef} />
|
||||
<label className="mx-2">Altered</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="text-sm col-span-1">Note</label>
|
||||
<input
|
||||
className="text-sm col-span-7 border-2"
|
||||
type="text"
|
||||
ref={noteRef}
|
||||
/>
|
||||
|
||||
<label className="text-sm col-span-1">Images</label>
|
||||
<div className="text-sm col-span-7">
|
||||
{images.map((value, index) => (
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="hover:bg-blue-50 cursor-pointer"
|
||||
id={index.toString()}
|
||||
onClick={() => displayImage(index)}
|
||||
>
|
||||
Image {index + 1}
|
||||
</div>
|
||||
<div
|
||||
className="mx-4 cursor-pointer hover:scale-125"
|
||||
onClick={() => deleteImage(value)}
|
||||
>
|
||||
<VscClose className="text-red-600 text-lg" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="my-4"
|
||||
onClick={() => addImage()}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-4 text-center">
|
||||
<button type="submit">Submit</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</ModalTemplate>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<ConfirmationModal
|
||||
visible={abortConfirmationModalVisibility}
|
||||
setVisible={setAbortConfirmationModalVisiblity}
|
||||
title={
|
||||
props.mode == Mode.Create
|
||||
? "Abort Creating"
|
||||
: "Abort Editing"
|
||||
}
|
||||
text={
|
||||
props.mode == Mode.Create
|
||||
? "Do you reall want to abort the current entry creation?"
|
||||
: "Do you really want to abort the current entry editing?"
|
||||
}
|
||||
confirmAction={() => onClose()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateEditModalTemplate;
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { BsPencilFill, BsPaletteFill } from "react-icons/bs";
|
||||
import { EntryTemplate } from "../../types";
|
||||
|
||||
/**
|
||||
* Configuration object to specify additional, game-specific binary attributes of an entry.
|
||||
* The specified icon will be displayed alongside the default binary attributes
|
||||
* `signed` and `altered`, if the value of the entry, that is retrieved by using
|
||||
* the access key of this configuration object is `true`.
|
||||
*/
|
||||
type ExtraAttribute = {
|
||||
accessKey: string;
|
||||
icon?: React.FC|JSX.Element
|
||||
}
|
||||
|
||||
/**
|
||||
* Template panel to display the details on of an entry.
|
||||
* The panel needs to be connected with a image display modal via three functions
|
||||
* that control this image modal.
|
||||
*
|
||||
* # Props:
|
||||
* * entry - entry object that contains the data that should be displayed.
|
||||
* * defaultImageUrl - Url to the default image that should be displayed as a preview image of the entry
|
||||
* * extraAttributes - List of additional binary attributes beyond the standard binary attributes `signed` and `altered`
|
||||
* * fetchEntryPreviewImage - Function to fetch the specific preview image for the entry object specified by property `entry`.
|
||||
* * setImageModalImages - Function to pass a list of image names to the connected image modal.
|
||||
* * setImageModalImageIndex - Function to set the starting image index of the connected image modal.
|
||||
* * setImageModalVisible - Function to control the visiblity of the connected image modal.
|
||||
**/
|
||||
const EntryPanelTemplate: React.FC<{
|
||||
entry: EntryTemplate;
|
||||
defaultImageUrl: string;
|
||||
extraAttributes: ExtraAttribute[];
|
||||
fetchEntryPreviewImage: Function;
|
||||
setImageModalImages: Function;
|
||||
setImageModalImageIndex: Function;
|
||||
setImageModalVisible: Function;
|
||||
}> = (props) => {
|
||||
|
||||
// preview image of the card entry, default image or value that is fetched via `props.fetchEntryPreviewImage`.
|
||||
const [previewImage, setPreviewImage] = useState<string>("");
|
||||
|
||||
// if entry reference changes, the preview image of the corresponding
|
||||
// card gets fetched. If no image could be found, the default image will be displayed.
|
||||
useEffect(() => {
|
||||
if (props.entry) {
|
||||
props.fetchEntryPreviewImage(props.entry)
|
||||
.then((result: string) => setPreviewImage(result));
|
||||
}
|
||||
else {
|
||||
// in this case the current selected entry was deleted. Therefore, we also reset the preview image
|
||||
setPreviewImage("");
|
||||
}
|
||||
}, [props.entry]);
|
||||
|
||||
// trigger the connected image modal to display the image with the specfied index from
|
||||
// the list of images of the currently selected entry.
|
||||
const displayImage = async (index: number) => {
|
||||
props.setImageModalImages(props.entry.images);
|
||||
props.setImageModalImageIndex(index);
|
||||
props.setImageModalVisible(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed mt-4 z-[1]">
|
||||
<div>
|
||||
{previewImage && previewImage != ""
|
||||
? <img src={previewImage} width={250} />
|
||||
: <img src={props.defaultImageUrl} width={250} />
|
||||
}
|
||||
</div>
|
||||
{props.entry ? (
|
||||
<div className="grid grid-settings-2 gap-x-4 gap-y-2 mt-4">
|
||||
<p>Name</p>
|
||||
<p>{props.entry.name}</p>
|
||||
<p>Set</p>
|
||||
<p>{props.entry.set.name}</p>
|
||||
{props.entry.setNo && props.entry.setNo != ""
|
||||
? <>
|
||||
<p>Set No.</p>
|
||||
<p>{props.entry.set.id.toUpperCase()}-{props.entry.setNo}</p>
|
||||
</>
|
||||
: ""
|
||||
}
|
||||
<p>Language</p>
|
||||
<p>{props.entry.language}</p>
|
||||
<p>Condition</p>
|
||||
<p>{props.entry.condition}</p>
|
||||
<p>Amount</p>
|
||||
<p>{props.entry.amount}</p>
|
||||
<p></p>
|
||||
<div className="flex">
|
||||
{props.extraAttributes.map(attribute =>
|
||||
props.entry[attribute.accessKey] ? <>{attribute.icon}<div className="mr-2"></div></> : ""
|
||||
)}
|
||||
{props.entry.signed ? <BsPencilFill className="mr-2" /> : ""}
|
||||
{props.entry.altered ? <BsPaletteFill className="mr-2" /> : ""}
|
||||
</div>
|
||||
|
||||
<p className="mt-4">Note</p>
|
||||
<p className="mt-4">{props.entry.note}</p>
|
||||
|
||||
<p>Images</p>
|
||||
<div>
|
||||
{props.entry.images.map((value, index) => (
|
||||
<div
|
||||
className="hover:bg-blue-50 cursor-pointer"
|
||||
id={index.toString()}
|
||||
onClick={() => displayImage(index)}
|
||||
>
|
||||
Image {index+1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EntryPanelTemplate;
|
||||
@@ -0,0 +1,31 @@
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
/**
|
||||
* HTMLInput Element for numeric values with additional validation so that only positive integer
|
||||
* values can be entered.
|
||||
*/
|
||||
|
||||
const IntegerInput = forwardRef((props: React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, ref: React.MutableRefObject<HTMLInputElement>) => {
|
||||
|
||||
const handleChange = (event: React.FormEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.value;
|
||||
if(value.includes('.') || value.includes('-') || value === ""){
|
||||
ref.current.value = "1";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
min={props.min ? props.min : 1}
|
||||
step={props.step ? props.step : 1}
|
||||
defaultValue={props.defaultValue ? props.defaultValue : 1}
|
||||
className={props.className ? props.className : ""}
|
||||
required={props.required ? props.required : false}
|
||||
ref={ref ? ref : null}
|
||||
onInput={(e) => handleChange(e)}
|
||||
/>
|
||||
)
|
||||
});
|
||||
|
||||
export default IntegerInput;
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
import { AiOutlineClose } from "react-icons/ai";
|
||||
|
||||
/**
|
||||
* General template to implement modals. This template grays out the background and displays a
|
||||
* centered container in which the modal specifics can be injected via prop `children`.
|
||||
*
|
||||
* # Props:
|
||||
* * title - Title of the modal, which will be displayed as the header of the modal container.
|
||||
* * children - (Optional) React node containing the specific modal TSX.
|
||||
* * modalStyle - (Optional) prop to extend/overwrite the styling of the modal container. If this prop is used,
|
||||
* it needs define at least the width and height of the modal container.
|
||||
* * onClickCloseIcon - (Optional) callback function that should be invoked when the user clicks on the 'close'
|
||||
* icon on the top right corner of the modal container.
|
||||
* * onClickBackground - (Optional) callback function that should be invoked when the user clicks on the grayed
|
||||
* out background.
|
||||
*/
|
||||
const ModalTemplate: React.FC<{
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
modalStyle?: string;
|
||||
onClickCloseIcon?: Function;
|
||||
onClickBackground?: Function;
|
||||
}> = (props) => {
|
||||
return (
|
||||
<div className="absolute z-10 w-full h-full bg-black/70 flex justify-center items-center">
|
||||
<div className={`rounded-lg bg-white shadow-xl ${props.modalStyle ? props.modalStyle : "w-[50%] h-[50%]" }`}>
|
||||
<div className="flex justify-between pt-2 px-4 mb-8">
|
||||
<div></div>
|
||||
<div className="text-gray-600">{props.title}</div>
|
||||
<div
|
||||
className="text-gray-600 hover:scale-110 cursor-pointer"
|
||||
onClick={
|
||||
props.onClickCloseIcon
|
||||
? () => props.onClickCloseIcon!()
|
||||
: () => ""
|
||||
}
|
||||
>
|
||||
<AiOutlineClose size={20} />
|
||||
</div>
|
||||
</div>
|
||||
{props.children ? props.children : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalTemplate;
|
||||
@@ -0,0 +1,197 @@
|
||||
import React, { useState } from "react";
|
||||
import { EntryTemplate } from "../../types";
|
||||
|
||||
/**
|
||||
* Table field configuration object. Each field maps to one column of the table.
|
||||
*
|
||||
* * label - used as column label if no icon is specfied
|
||||
* * icon - (Optional) used as column label and cell value for boolean values
|
||||
* * valueKey - Accessor key to the field on an data entry object that maps to the
|
||||
* field described by this config object. This value will be used for display
|
||||
* inside the table cell.
|
||||
* For nested access, use a `.` to define nested routes (see function `getValByKey`
|
||||
* for more information).
|
||||
* * sortKey - Accessor key to the field on an data entry object that maps to the
|
||||
* field described by this config object. This value will be used for sorting
|
||||
* entries by the attrbiute that this field represents.
|
||||
* For nested access, use a `.` to define nested routes (see function `getValByKey`
|
||||
* for more information).
|
||||
*/
|
||||
type TableField = {
|
||||
label: string;
|
||||
icon?: React.FC | JSX.Element;
|
||||
valueKey: string;
|
||||
sortKey: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to support access to fields of nested objects
|
||||
* with dynamic key routes as strings.
|
||||
*
|
||||
* e.g
|
||||
* obj: {a: "hello"} keyRoute: "a" => "hello"
|
||||
* obj: {a: "hello", b: {c: 42}} keyRoute: "b" => {c: 42}
|
||||
* obj: {a: "hello", b: {c: 42}} keyRoute: "b.c" => 42
|
||||
*/
|
||||
const getValByKey = (obj: any, keyRoute: string): any => {
|
||||
const keys = keyRoute.split(".");
|
||||
if (keys.length == 1) {
|
||||
return obj[keyRoute];
|
||||
} else {
|
||||
return getValByKey(obj[keys[0]], keyRoute.replace(`${keys[0]}.`, ""));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* General template to display an array of arbitrary objects as a table that
|
||||
* is filterable and sortable. Optionally, the table can also be configured
|
||||
* to support to select an entry from it (props: selectedEntry and setSelectedEntry)
|
||||
*
|
||||
* The main table configuration is done via the array of `TableField` objects,
|
||||
* that are specified via the property `tableFields`. Each element of `tableFields`
|
||||
* is mapped to one column of the table.
|
||||
*
|
||||
* # Props:
|
||||
* * tableFields - List of `TableField` objects to configure the columns of the table.
|
||||
* * collection - List of objects that should be displayed via the table.
|
||||
* * selectedEntry - (Optional) reference to the `Entry` object that is currently selected by the user.
|
||||
* * setCollection - Function to set/update the collection list that should be displayed.
|
||||
* * setSelectedEntry - (Optional) function to specifiy, which entry from the table the user has currenty selected.
|
||||
*/
|
||||
const TableTemplate: React.FC<{
|
||||
tableFields: TableField[];
|
||||
collection: EntryTemplate[];
|
||||
selectedEntry?: EntryTemplate;
|
||||
setCollection: Function;
|
||||
setSelectedEntry?: Function;
|
||||
}> = (props) => {
|
||||
|
||||
// string that is applied to each entry to filter the entries of the displayed collection
|
||||
const [filter, setFilter] = useState<string>("");
|
||||
|
||||
// object that will receive fields overtime to indicate the sort order of columns so that
|
||||
// the component knows to sort in the opposite direction every other sort request of a column
|
||||
const [sortOrderByField, setSortOrderByField] = useState<Object>({});
|
||||
|
||||
// Callback function to sort a set of `Entry` objects by the specified field of the
|
||||
// objects.
|
||||
const byField = (field: string, asc: boolean) => {
|
||||
return (a: EntryTemplate, b: EntryTemplate) => {
|
||||
// number, bool
|
||||
let x = getValByKey(a, field);
|
||||
let y = getValByKey(b, field);
|
||||
// string
|
||||
if (typeof x === "string") {
|
||||
x = x.toLowerCase();
|
||||
y = y.toLowerCase();
|
||||
}
|
||||
|
||||
// ascending
|
||||
if (asc) {
|
||||
if (x < y) {
|
||||
return -1;
|
||||
}
|
||||
if (x > y) {
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
if (x < y) {
|
||||
return 1;
|
||||
}
|
||||
if (x > y) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// descending
|
||||
return 0;
|
||||
};
|
||||
};
|
||||
|
||||
const sortByField = (field: string) => {
|
||||
let order = Object.hasOwn(sortOrderByField, field)
|
||||
? sortOrderByField[field]
|
||||
: true;
|
||||
props.setCollection([...props.collection].sort(byField(field, order)));
|
||||
|
||||
sortOrderByField[field] = !order;
|
||||
setSortOrderByField(sortOrderByField);
|
||||
};
|
||||
|
||||
const applyFilter = (entry: EntryTemplate) => {
|
||||
let result = false;
|
||||
for (let i = 0; i < props.tableFields.length; i++) {
|
||||
const val = getValByKey(entry, props.tableFields[i].valueKey);
|
||||
if (typeof val === "number" || typeof val === "string") {
|
||||
if (val.toString().toLowerCase().includes(filter)) {
|
||||
result = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div>
|
||||
<input
|
||||
className="border-2 mb-2 border-gray-300 px-2 rounded-sm focus:border-none"
|
||||
onChange={(e) => setFilter(e.currentTarget.value)}
|
||||
placeholder="Filter"
|
||||
/>
|
||||
</div>
|
||||
<div className="h-[85%] w-fit overflow-scroll">
|
||||
<table className="text-sm text-left border-2 border-slate-100 ">
|
||||
<thead className="sticky top-[0] bg-slate-200">
|
||||
<tr id="head">
|
||||
{props.tableFields.map((field) => (
|
||||
<th
|
||||
id={`head-${field.label}`}
|
||||
className="px-2 hover:border-b-2 border-black cursor-pointer"
|
||||
onClick={() => {
|
||||
sortByField(field.sortKey);
|
||||
}}
|
||||
>
|
||||
{field.icon ? <>{field.icon}</> : field.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.collection
|
||||
.filter((entry) => applyFilter(entry))
|
||||
.map((entry) => (
|
||||
<tr
|
||||
className={`cursor-pointer ${
|
||||
props.selectedEntry && props.selectedEntry.id == entry.id
|
||||
? "bg-blue-100"
|
||||
: "hover:bg-blue-50"
|
||||
}`}
|
||||
id={entry.id.toString()}
|
||||
onClick={() => props.setSelectedEntry ? props.setSelectedEntry(entry) : ""}
|
||||
>
|
||||
{props.tableFields.map((field) => (
|
||||
<td className="px-2">
|
||||
{field.icon ? (
|
||||
getValByKey(entry, field.valueKey) ? (
|
||||
<>{field.icon}</>
|
||||
) : (
|
||||
""
|
||||
)
|
||||
) : (
|
||||
getValByKey(entry, field.valueKey)
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="py-4"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableTemplate;
|
||||
@@ -0,0 +1,11 @@
|
||||
import ModalTemplate from "./ModalTemplate";
|
||||
import CreateEditModalTemplate from "./CreateEditModalTemplate";
|
||||
import EntryPanelTemplate from "./EntryPanelTemplate";
|
||||
import TableTemplate from "./TableTemplate";
|
||||
|
||||
export {
|
||||
ModalTemplate,
|
||||
CreateEditModalTemplate,
|
||||
EntryPanelTemplate,
|
||||
TableTemplate,
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { AppProps } from "next/app";
|
||||
|
||||
//import "../styles/globals.css";
|
||||
import "../style.css";
|
||||
|
||||
// This default export is required in a new `pages/_app.js` file.
|
||||
export default function MyApp({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />;
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/tauri";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
import { VscAdd, VscEdit, VscTrash } from "react-icons/vsc";
|
||||
|
||||
import { SettingsModal, ConfirmationModal, NotificationModal, ImageModal } from "../components/modals";
|
||||
import { CreateEditPokemonModal, Mode, PokemonTable, SelectedPokemonPanel } from "../components/pokemon";
|
||||
import { CreateEditMtgModal, MtgTable, SelectedMtgPanel } from "../components/magic";
|
||||
|
||||
import { CardEntry as PokemonCardEntry } from "../types/pokemon";
|
||||
import { CardEntry as MagicCardEntry } from "../types/magic";
|
||||
|
||||
|
||||
function App() {
|
||||
|
||||
const [collection, setCollection] = useState<PokemonCardEntry[] | MagicCardEntry[]>([]);
|
||||
const [selectedEntry, setSelectedEntry] = useState<PokemonCardEntry | MagicCardEntry>(null);
|
||||
|
||||
const [activeGame, setActiveGame] = useState<string>(null);
|
||||
|
||||
const [createEditMode, setCreateEditMode] = useState<Mode>(Mode.Create);
|
||||
|
||||
const [settingsModalVisible, setSettingsModalVisible] = useState<boolean>(false);
|
||||
const [createEditModalVisible, setCreateEditModalVisible] = useState<boolean>(false);
|
||||
const [deleteConfirmModalVisible, setDeleteConfirmModalVisible] = useState<boolean>(false);
|
||||
const [notificationModalVisible, setNotificationModalVisible] = useState<boolean>(false);
|
||||
|
||||
const [imageModalVisible, setImageModalVisible] = useState<boolean>(false);
|
||||
const [imageModalImageIndex, setImageModalImageIndex] = useState<number>(0);
|
||||
const [imageModalImages, setImageModalImages] = useState<string[]>([]);
|
||||
|
||||
/**
|
||||
* on initial render:
|
||||
* - get configuration from backend and set the active game to the default game from the config
|
||||
* - connect all menu bar events with their individual actions
|
||||
*/
|
||||
useEffect(() => {
|
||||
invoke("get_configuration_json").then((result) => {
|
||||
const config = JSON.parse(result as string);
|
||||
setActiveGame(config.defaultGame);
|
||||
});
|
||||
|
||||
// listen for general menu events
|
||||
listen("tauri://menu", (event) => {
|
||||
if (event.payload == "settings") {
|
||||
setSettingsModalVisible(true);
|
||||
}
|
||||
if (event.payload == "switch_game/pokemon") {
|
||||
setActiveGame("Pokemon");
|
||||
setSelectedEntry(null);
|
||||
}
|
||||
if (event.payload == "switch_game/magic") {
|
||||
setActiveGame("Magic");
|
||||
setSelectedEntry(null);
|
||||
}
|
||||
if (event.payload == "update/sets/pokemon") {
|
||||
invoke("update_sets", {game: "Pokemon"})
|
||||
.then(() => setNotificationModalVisible(true));
|
||||
}
|
||||
if (event.payload == "update/sets/magic") {
|
||||
invoke("update_sets", {game: "Magic"})
|
||||
.then(() => setNotificationModalVisible(true));
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Everytime the active game changes, fetch the corresponding collection
|
||||
* of the active game from the backend.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (activeGame) {
|
||||
invoke("get_collection", { game: activeGame }).then((result) => {
|
||||
const obj = JSON.parse(result as string);
|
||||
if (activeGame == "Pokemon")
|
||||
setCollection(Object.values(obj) as PokemonCardEntry[]);
|
||||
if (activeGame == "Magic")
|
||||
setCollection(Object.values(obj) as MagicCardEntry[]);
|
||||
});
|
||||
}
|
||||
}, [activeGame]);
|
||||
|
||||
|
||||
const deleteSelectedCard = () => {
|
||||
invoke("delete_card", { id: selectedEntry.id, game: activeGame }).then(
|
||||
(result) => {
|
||||
if (activeGame == "Pokemon")
|
||||
setCollection(
|
||||
(collection as PokemonCardEntry[]).filter(
|
||||
(entry) => entry.id != selectedEntry.id
|
||||
)
|
||||
);
|
||||
if (activeGame == "Magic")
|
||||
setCollection(
|
||||
(collection as MagicCardEntry[]).filter(
|
||||
(entry) => entry.id != selectedEntry.id
|
||||
)
|
||||
);
|
||||
setSelectedEntry(null);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="fixed flex w-full h-full z-[1] m-4">
|
||||
<div className="w-[30%] 2xl:w-[20%]">
|
||||
<div className="flex">
|
||||
<div
|
||||
className="mr-2 border-2 rounded-sm border-gray-600 p-2 shadow-lg shadow-gray-400 cursor-pointer hover:scale-105"
|
||||
onClick={() => {
|
||||
setCreateEditMode(Mode.Create);
|
||||
setCreateEditModalVisible(true);
|
||||
}}
|
||||
>
|
||||
<VscAdd />
|
||||
</div>
|
||||
<div
|
||||
className={`mr-2 border-2 rounded-sm border-gray-600 p-2 shadow-lg shadow-gray-400 cursor-pointer hover:scale-105 ${
|
||||
selectedEntry ? "" : "invisible"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setCreateEditMode(Mode.Edit);
|
||||
setCreateEditModalVisible(true);
|
||||
}}
|
||||
>
|
||||
<VscEdit />
|
||||
</div>
|
||||
<div
|
||||
className={`mr-2 border-2 rounded-sm border-gray-600 p-2 shadow-lg shadow-gray-400 cursor-pointer hover:scale-105 ${
|
||||
selectedEntry ? "" : "invisible"
|
||||
}`}
|
||||
onClick={() => setDeleteConfirmModalVisible(true)}
|
||||
>
|
||||
<VscTrash />
|
||||
</div>
|
||||
</div>
|
||||
{activeGame == "Pokemon" ? (
|
||||
<SelectedPokemonPanel
|
||||
entry={selectedEntry as PokemonCardEntry}
|
||||
setImageModalImages={setImageModalImages}
|
||||
setImageModalImageIndex={setImageModalImageIndex}
|
||||
setImageModalVisible={setImageModalVisible}
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{activeGame == "Magic" ? (
|
||||
<SelectedMtgPanel
|
||||
entry={selectedEntry as MagicCardEntry}
|
||||
setImageModalImages={setImageModalImages}
|
||||
setImageModalImageIndex={setImageModalImageIndex}
|
||||
setImageModalVisible={setImageModalVisible}
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
<div className="w-[70%] h-[95%] 2xl:w-[80%]">
|
||||
{activeGame == "Pokemon" ? (
|
||||
<PokemonTable
|
||||
collection={collection as PokemonCardEntry[]}
|
||||
selectedEntry={selectedEntry as PokemonCardEntry}
|
||||
setCollection={setCollection}
|
||||
setSelectedEntry={setSelectedEntry}
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{activeGame == "Magic" ? (
|
||||
<MtgTable
|
||||
collection={collection as MagicCardEntry[]}
|
||||
selectedEntry={selectedEntry as MagicCardEntry}
|
||||
setCollection={setCollection}
|
||||
setSelectedEntry={setSelectedEntry}
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div></div>
|
||||
<SettingsModal
|
||||
visible={settingsModalVisible}
|
||||
setVisible={setSettingsModalVisible}
|
||||
/>
|
||||
{activeGame == "Pokemon" ? (
|
||||
<CreateEditPokemonModal
|
||||
visible={createEditModalVisible}
|
||||
setVisible={setCreateEditModalVisible}
|
||||
selectedEntry={selectedEntry as PokemonCardEntry}
|
||||
setSelectedEntry={setSelectedEntry}
|
||||
mode={createEditMode}
|
||||
collection={collection as PokemonCardEntry[]}
|
||||
setCollection={setCollection}
|
||||
setImageModalImages={setImageModalImages}
|
||||
setImageModalImageIndex={setImageModalImageIndex}
|
||||
setImageModalVisible={setImageModalVisible}
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{activeGame == "Magic" ? (
|
||||
<CreateEditMtgModal
|
||||
visible={createEditModalVisible}
|
||||
setVisible={setCreateEditModalVisible}
|
||||
selectedEntry={selectedEntry as MagicCardEntry}
|
||||
setSelectedEntry={setSelectedEntry}
|
||||
mode={createEditMode}
|
||||
collection={collection as MagicCardEntry[]}
|
||||
setCollection={setCollection}
|
||||
setImageModalImages={setImageModalImages}
|
||||
setImageModalImageIndex={setImageModalImageIndex}
|
||||
setImageModalVisible={setImageModalVisible}
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<ImageModal
|
||||
visible={imageModalVisible}
|
||||
setVisible={setImageModalVisible}
|
||||
game={activeGame}
|
||||
images={imageModalImages}
|
||||
startIndex={imageModalImageIndex}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
visible={deleteConfirmModalVisible}
|
||||
setVisible={setDeleteConfirmModalVisible}
|
||||
confirmAction={deleteSelectedCard}
|
||||
title="Delete Entry"
|
||||
text="Do you really want to delete this entry?"
|
||||
/>
|
||||
<NotificationModal
|
||||
visible={notificationModalVisible}
|
||||
setVisible={setNotificationModalVisible}
|
||||
title="Sets Update"
|
||||
text="Sets were updated successfully."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,22 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
/*todo: document this class */
|
||||
.grid-settings-8 {
|
||||
grid-template-columns: max-content repeat(7, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.grid-settings-2 {
|
||||
grid-template-columns: max-content max-content
|
||||
}
|
||||
|
||||
|
||||
@layer base {
|
||||
|
||||
button, button[type=submit], button[type=button] {
|
||||
@apply bg-gradient-to-r from-blue-900 to-blue-700 text-white uppercase px-4 rounded-sm hover:shadow-lg hover:scale-105 bg-[#ffffff]
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type Configuration = {
|
||||
dataStorage: string;
|
||||
defaultGame: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplest type of a set. All set types need to match at least the
|
||||
* required fields of this template type.
|
||||
*/
|
||||
export type SetTemplate = {
|
||||
id: string;
|
||||
name: string;
|
||||
releaseDate: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplest type of an entry. All card types need to match at least the
|
||||
* required fields of this template type.
|
||||
*/
|
||||
export type EntryTemplate = {
|
||||
id?: number;
|
||||
name: string;
|
||||
language: string;
|
||||
amount: number;
|
||||
condition: string;
|
||||
set: SetTemplate;
|
||||
setNo?: string;
|
||||
images: string[];
|
||||
note: string;
|
||||
signed: boolean;
|
||||
altered: boolean;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type Set = {
|
||||
id: string;
|
||||
name: string;
|
||||
releaseDate: string;
|
||||
}
|
||||
|
||||
export type CardEntry = {
|
||||
id: number;
|
||||
|
||||
name: string;
|
||||
set: Set;
|
||||
setNo: string;
|
||||
language: string;
|
||||
condition: string;
|
||||
amount: number;
|
||||
|
||||
note: string;
|
||||
images: string[];
|
||||
|
||||
foil: boolean;
|
||||
signed: boolean;
|
||||
altered: boolean;
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export type Set = {
|
||||
id: string;
|
||||
name: string;
|
||||
releaseDate: string;
|
||||
}
|
||||
|
||||
export type CardEntry = {
|
||||
id: number;
|
||||
|
||||
name: string;
|
||||
set: Set;
|
||||
setNo: string;
|
||||
language: string;
|
||||
condition: string;
|
||||
amount: number;
|
||||
|
||||
note: string;
|
||||
images: string[];
|
||||
|
||||
firstEdition: boolean;
|
||||
holo: boolean;
|
||||
signed: boolean;
|
||||
altered: boolean;
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"moduleResolution": "node",
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
1.0.0
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
cp ./pre-commit ../.git/hooks
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
PROJECT_ROOT=$(pwd)
|
||||
|
||||
# check if version and project name are the same in frontend and backend manifest
|
||||
cd $PROJECT_ROOT && cd card-collection-manager-2/src-tauri
|
||||
CARGO_VERSION=$(cargo read-manifest | jq -r .version)
|
||||
CARGO_NAME=$(cargo read-manifest | jq -r .name)
|
||||
cd $PROJECT_ROOT && cd card-collection-manager-2
|
||||
YARN_VERSION=$(cat package.json | jq -r .version)
|
||||
YARN_NAME=$(cat package.json | jq -r .name)
|
||||
|
||||
if [ $CARGO_VERSION != $YARN_VERSION ]; then
|
||||
echo "[PRE-COMMIT] Project version in 'package.json' and 'Cargo.toml' does not match."
|
||||
exit 1
|
||||
fi
|
||||
if [ $CARGO_NAME != $YARN_NAME ]; then
|
||||
echo "[PRE-COMMIT] Project name in 'package.json' and 'Cargo.toml' does not match."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
cd $PROJECT_ROOT
|
||||
echo $CARGO_VERSION > version
|
||||
git add version
|
||||
|
||||
exit 0
|
||||