Skip to content

Commit

Permalink
feat: ✨ Added Onboarding Customisation
Browse files Browse the repository at this point in the history
* feat: 🚀 Add backend for onboarding pages

* refactor: 📦 Cleanup code for darkmode

* feat: ✨ Add onboarding page configurator

* feat: 🎉 Add wizarr "theme" to markdown editor

* feat: 🎊 Cleanup toolbar for markdown editor

* refactor: 📦 Cleanup code for OnboardingSection

* feat: 🎉 Add custom onboarding markdown to help page

* refactor: 📦 Cleanup imports for md-editor types

* feat: 🎊 Add tooltips for onboarding page manage buttons

* feat: 🎉 Allow translations for markdown editor buttons, etc

* fix: 🩹 Fix error with height in Carousel

* fix: 🩹 Update message and doc for onboarding api

* feat: 🎉 Create migration file for onboarding table

* feat: 🚀 Allow uploading images for onboarding pages

* fix: 🩹 md-editor-v3 as frontend dependency

* fix: 🐛 Fix error in .gitignore

* fix: 🚑 Resolve change request for PR

* feat: 🎊 Add finish button to help page
  • Loading branch information
albinmedoc authored Jul 31, 2024
1 parent c8b8d36 commit e6e9d68
Show file tree
Hide file tree
Showing 22 changed files with 2,252 additions and 392 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -444,5 +444,5 @@ poetry.toml
testing/core
apps/wizarr-backend-next/
apps/wizarr-backend-old/
database/
/apps/wizarr-backend/database/
.sentryclirc
5 changes: 4 additions & 1 deletion apps/wizarr-backend/wizarr_backend/api/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .authentication_api import api as authentication_api # REVIEW - This is almost completed
from .backup_api import api as backup_api
from .discord_api import api as discord_api
from .image_api import api as image_api
from .invitations_api import api as invitations_api # REVIEW - This is almost completed
from .libraries_api import api as libraries_api
from .notifications_api import api as notifications_api
Expand All @@ -27,6 +28,7 @@
from .webhooks_api import api as webhooks_api
from .logging_api import api as logging_api
from .oauth_api import api as oauth_api
from .onboarding_api import api as onboarding_api
from .mfa_api import api as mfa_api
from .utilities_api import api as utilities_api
from .jellyfin_api import api as jellyfin_api
Expand Down Expand Up @@ -111,13 +113,15 @@ def handle_request_exception(error):
api.add_namespace(discord_api)
api.add_namespace(emby_api)
api.add_namespace(healthcheck_api)
api.add_namespace(image_api)
api.add_namespace(invitations_api)
api.add_namespace(jellyfin_api)
api.add_namespace(libraries_api)
api.add_namespace(logging_api)
api.add_namespace(mfa_api)
api.add_namespace(notifications_api)
api.add_namespace(oauth_api)
api.add_namespace(onboarding_api)
api.add_namespace(plex_api)
api.add_namespace(requests_api)
api.add_namespace(scan_libraries_api)
Expand All @@ -135,4 +139,3 @@ def handle_request_exception(error):

# TODO: Tasks API
# TODO: API API
# TODO: HTML API
85 changes: 85 additions & 0 deletions apps/wizarr-backend/wizarr_backend/api/routes/image_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import os
from json import dumps, loads
from uuid import uuid4
from flask import send_from_directory, current_app, request
from flask_jwt_extended import jwt_required
from flask_restx import Namespace, Resource, reqparse
from werkzeug.utils import secure_filename
from werkzeug.datastructures import FileStorage

api = Namespace("Image", description="Image related operations", path="/image")

# Define the file upload parser
file_upload_parser = reqparse.RequestParser()
file_upload_parser.add_argument('file', location='files',
type=FileStorage, required=True,
help='Image file')

@api.route("")
class ImageListApi(Resource):
"""API resource for all images"""

@jwt_required()
@api.doc(security="jwt")
@api.expect(file_upload_parser)
def post(self):
"""Upload image"""
# Check if the post request has the file part
if 'file' not in request.files:
return {"message": "No file part"}, 400
file = request.files['file']
# If the user does not select a file, the browser submits an
# empty file without a filename.
if file.filename == '':
return {"message": "No selected file"}, 400
if file:
# Extract the file extension
file_extension = os.path.splitext(secure_filename(file.filename))[1].lower()
if file_extension not in ['.png', '.jpg', '.jpeg']:
return {"message": "Unsupported file format"}, 400

upload_folder = current_app.config['UPLOAD_FOLDER']
if not os.path.exists(upload_folder):
os.makedirs(upload_folder)
# Generate a unique filename using UUID
filename = f"{uuid4()}{file_extension}"

# Check if the file exists and generate a new UUID if it does
while os.path.exists(os.path.join(upload_folder, filename)):
filename = f"{uuid4()}{file_extension}"
file_path = os.path.join(upload_folder, filename)
file.save(file_path)
return {"message": f"File {filename} uploaded successfully", "filename": filename}, 201


@api.route("/<filename>")
class ImageAPI(Resource):
"""API resource for a single image"""

@api.response(404, "Image not found")
@api.response(500, "Internal server error")
def get(self, filename):
"""Get image"""
# Assuming images are stored in a directory specified by UPLOAD_FOLDER config
upload_folder = current_app.config['UPLOAD_FOLDER']
image_path = os.path.join(upload_folder, filename)
if os.path.exists(image_path):

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
return send_from_directory(upload_folder, filename)
else:
return {"message": "Image not found"}, 404

@jwt_required()
@api.doc(description="Delete a single image")
@api.response(404, "Image not found")
@api.response(500, "Internal server error")
def delete(self, filename):
"""Delete image"""
upload_folder = current_app.config['UPLOAD_FOLDER']
image_path = os.path.join(upload_folder, filename)

# Check if the file exists
if not os.path.exists(image_path):

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
return {"message": "Image not found"}, 404

os.remove(image_path)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
return {"message": "Image deleted successfully"}, 200
106 changes: 106 additions & 0 deletions apps/wizarr-backend/wizarr_backend/api/routes/onboarding_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
from json import dumps, loads
from flask import request
from flask_jwt_extended import jwt_required
from flask_restx import Namespace, Resource
from playhouse.shortcuts import model_to_dict
from peewee import fn
from app.models.database import db

from app.models.database.onboarding import Onboarding as OnboardingDB

api = Namespace("Onboarding", description="Onboarding related operations", path="/onboarding")

@api.route("")
class OnboardingListApi(Resource):
"""API resource for all onboarding pages"""

@api.doc(security="jwt")
def get(self):
"""Get onboarding pages"""
response = list(OnboardingDB.select().order_by(OnboardingDB.order).dicts())
return loads(dumps(response, indent=4, sort_keys=True, default=str)), 200

@api.doc(security="jwt")
@jwt_required()
def post(self):
"""Create onboarding page"""
value = request.form.get("value")
enabled = request.form.get("enabled") in ["true", "True", "1"]
max_order = OnboardingDB.select(fn.MAX(OnboardingDB.order)).scalar() or 0
new_order = max_order + 1
onboarding_page = OnboardingDB.create(order=new_order, value=value, enabled=enabled)
onboarding_page.save()
return { "message": "Onboarding page created", "page": model_to_dict(onboarding_page) }, 200


@api.route("/<int:onboarding_id>")
class OnboardingAPI(Resource):
"""API resource for a single onboarding page"""

method_decorators = [jwt_required()]

@api.doc(description="Updates a single onboarding page")
@api.response(404, "Onboarding page not found")
@api.response(500, "Internal server error")
def put(self, onboarding_id: int):
value = request.form.get("value")
enabled = request.form.get("enabled")
order = request.form.get("order", type=int)

with db.atomic() as transaction:
page = OnboardingDB.get_or_none(OnboardingDB.id == onboarding_id)
if not page:
return {"error": "Onboarding page not found"}, 404

if(value is not None):
page.value = value
if(enabled is not None):
page.enabled = enabled in ["true", "True", "1"]

if order is not None and page.order != order:
step = 1 if page.order > order else -1
start, end = sorted([page.order, order])

# Update orders of affected pages
affected_pages = OnboardingDB.select().where(
OnboardingDB.id != onboarding_id,
OnboardingDB.order >= start,
OnboardingDB.order <= end,
)

for p in affected_pages:
p.order += step
p.save() # Save each affected page

# Update the target page
page.order = order
page.save() # Save the target page

try:
transaction.commit() # Commit the transaction
except Exception as e:
transaction.rollback() # Rollback in case of error
return {"error": str(e)}, 500
return loads(dumps(model_to_dict(page), indent=4, sort_keys=True, default=str)), 200

@api.doc(description="Delete a single onboarding page")
@api.response(404, "Invite not found")
@api.response(500, "Internal server error")
def delete(self, onboarding_id):
"""Delete onboarding page"""
# Select the invite from the database
onboarding_page = OnboardingDB.get_or_none(OnboardingDB.id == onboarding_id)

# Check if the invite exists
if not onboarding_page:
return {"message": "Onboarding page not found"}, 404

onboarding_page.delete_instance()

# Update order of subsequent pages
subsequent_pages = OnboardingDB.select().where(OnboardingDB.order > onboarding_page.order)
for page in subsequent_pages:
page.order -= 1
page.save()

return { "message": "Onboarding page deleted successfully" }, 200
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#
# CREATED ON VERSION: V4.1.1
# MIGRATION: 2024-07-30_11-02-04
# CREATED: Tue Jul 30 2024
#

from peewee import *
from playhouse.migrate import *

from app import db

# Do not change the name of this file,
# migrations are run in order of their filenames date and time

def run():
# Use migrator to perform actions on the database
migrator = SqliteMigrator(db)

# Create new table 'onboarding'
with db.transaction():
# Check if the table exists
cursor = db.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='onboarding';")
table_exists = cursor.fetchone()

if not table_exists:
db.execute_sql("""
CREATE TABLE "onboarding" (
"id" INTEGER NOT NULL UNIQUE,
"value" TEXT NOT NULL,
"order" INTEGER NOT NULL UNIQUE,
"enabled" INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY("id")
)
""")
print("Table 'onboarding' created successfully")
else:
print("Table 'onboarding' already exists")

print("Migration 2024-07-30_11-02-04 complete")
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from peewee import BooleanField, CharField, IntegerField
from app.models.database.base import BaseModel

class Onboarding(BaseModel):
id = IntegerField(primary_key=True, unique=True)
value = CharField(null=False)
order = IntegerField(null=False, unique=True)
enabled = BooleanField(default=False)
1 change: 1 addition & 0 deletions apps/wizarr-frontend/src/assets/scss/main.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

// Internal libraries
@import "./tailwind.scss";
@import "./md-editor-v3.scss";
@import "./extend.scss";
@import "./animations.scss";
@import "./xterm.scss";
Expand Down
30 changes: 30 additions & 0 deletions apps/wizarr-frontend/src/assets/scss/md-editor-v3.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
.md-editor-preview {
--md-theme-quote-border: 5px solid rgb(208, 49, 67) !important;
--md-theme-link-color: rgb(208, 49, 67) !important;
--md-theme-link-hover-color: rgb(208, 49, 67) !important;
}

.md-editor-preview {
word-break: normal !important;

h1, h2, h3, h4, h5, h6 {
word-break: normal !important;
}
h1, h2, h3, h4, h5, h6 {
&:first-child {
margin-top: 0;
}
}
}

.md-editor.md-editor-dark, .md-editor-modal-container[data-theme='dark'] {
--md-color: #fff;
}

.md-editor.md-editor-previewOnly {
background-color: inherit !important;
}

.md-editor.md-editor-previewOnly .md-editor-preview-wrapper {
padding: inherit !important;
}
7 changes: 5 additions & 2 deletions apps/wizarr-frontend/src/components/Carousel.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<div ref="carousel" class="relative overflow-hidden rounded h-screen" style="transition: max-height 0.5s ease-in-out" :style="{ maxHeight: carouselHeight }">
<div ref="carousel" class="relative rounded overflow-hidden" style="transition: height 0.5s ease-in-out" :style="{ height: carouselHeight }">
<template v-for="(view, index) in views" :key="index + 1">
<div v-if="index == 0" :id="`carousel-item-${index}`"></div>
<div :id="`carousel-item-${index + 1}`" class="hidden duration-700 ease-in-out">
Expand Down Expand Up @@ -132,13 +132,16 @@ export default defineComponent({
const carouselTransition = carouselRef.style.transition;
carouselRef.style.transition = "none";
carouselRef.style.maxHeight = this.getCarouselHeight();
carouselRef.style.height = this.getCarouselHeight();
setTimeout(() => (carouselRef.style.transition = carouselTransition), 500);
},
onChange(carousel: CarouselInterface) {
// Update current view index
this._currentView = carousel._activeItem.position ?? 1;
// Scroll to top of the page
window.scrollTo({ top: 0, behavior: "smooth" });
// Update URL to match current view
const currentView = this._views[this._currentView - 1];
if (currentView.url) this.$router.push(currentView.url);
Expand Down
2 changes: 2 additions & 0 deletions apps/wizarr-frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import i18n from "./i18n";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
import router from "./router";

import './md-editor'; // Initialize the markdown editor

const app = createApp(App);
const pinia = createPinia();

Expand Down
Loading

0 comments on commit e6e9d68

Please sign in to comment.