initial commit

This commit is contained in:
Sebastian Dine
2022-12-02 14:37:07 +00:00
commit 28579efc80
74 changed files with 8978 additions and 0 deletions
+34
View File
@@ -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
+27
View File
@@ -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}"
}
+117
View File
@@ -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
+37
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
**/.next
**/.vscode
**/node_modules
**/target
+68
View File
@@ -0,0 +1,68 @@
# Card Collection Manager 2
![img1](img1.PNG)
![img2](img2.PNG)
## 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.
+7
View File
@@ -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)
+5
View File
@@ -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.
+11
View File
@@ -0,0 +1,11 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
images: {
unoptimized: true,
},
};
module.exports = nextConfig;
+28
View File
@@ -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/
File diff suppressed because it is too large Load Diff
@@ -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"}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

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, &copy_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;
+22
View File
@@ -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: [],
}
+20
View File
@@ -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"]
}
+1
View File
@@ -0,0 +1 @@
1.0.0
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
cp ./pre-commit ../.git/hooks
+26
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

+1
View File
@@ -0,0 +1 @@
1.0.0