diff --git a/commit/api/convert_to_webp.py b/commit/api/convert_to_webp.py new file mode 100644 index 0000000..f9406f9 --- /dev/null +++ b/commit/api/convert_to_webp.py @@ -0,0 +1,101 @@ +import frappe +from frappe.core.doctype.file.utils import delete_file +from urllib.parse import unquote +import requests +from PIL import Image +from frappe.core.doctype.file.file import get_local_image +from frappe.model.document import Document +import os + +@frappe.whitelist() +def convert_to_webp(image_url: str | None = None, file_doc: Document | None = None) -> str: + """BETA: Convert image to webp format""" + + CONVERTIBLE_IMAGE_EXTENSIONS = ["png", "jpeg", "jpg"] + + def can_convert_image(extn): + return extn.lower() in CONVERTIBLE_IMAGE_EXTENSIONS + + def get_extension(filename): + return filename.split(".")[-1].lower() + + def convert_and_save_image(image, path): + image.save(path, "WEBP") + return path + + def update_file_doc_with_webp(file_doc, image, extn): + webp_path = file_doc.get_full_path().replace(extn, "webp") + convert_and_save_image(image, webp_path) + delete_file(file_doc.get_full_path()) + file_doc.file_url = f"{file_doc.file_url.replace(extn, 'webp')}" + file_doc.save() + return file_doc.file_url + + def create_new_webp_file_doc(file_url, image, extn): + files = frappe.get_all("File", filters={"file_url": file_url}, fields=["name"], limit=1) + if files: + _file = frappe.get_doc("File", files[0].name) + webp_path = _file.get_full_path().replace(extn, "webp") + convert_and_save_image(image, webp_path) + new_file = frappe.copy_doc(_file) + new_file.file_name = f"{_file.file_name.replace(extn, 'webp')}" + new_file.file_url = f"{_file.file_url.replace(extn, 'webp')}" + new_file.save() + return new_file.file_url + return file_url + + def handle_image_from_url(image_url): + image_url = unquote(image_url) + response = requests.get(image_url) + image = Image.open(io.BytesIO(response.content)) + filename = image_url.split("/")[-1] + extn = get_extension(filename) + if can_convert_image(extn): + _file = frappe.get_doc( + { + "doctype": "File", + "file_name": f"{filename.replace(extn, 'webp')}", + "file_url": f"/files/{filename.replace(extn, 'webp')}", + } + ) + webp_path = _file.get_full_path() + convert_and_save_image(image, webp_path) + _file.save() + return _file.file_url + return image_url + + if not image_url and not file_doc: + return "" + + if file_doc: + if file_doc.file_url.startswith("/files"): + image, filename, extn = get_local_image(file_doc.file_url) + if can_convert_image(extn): + return update_file_doc_with_webp(file_doc, image, extn) + if file_doc.file_url.startswith("/private"): + image, filename, extn = get_local_image(file_doc.file_url) + if can_convert_image(extn): + return update_file_doc_with_webp(file_doc, image, extn) + + return file_doc.file_url + + if image_url.startswith("/files"): + image, filename, extn = get_local_image(image_url) + if can_convert_image(extn): + return create_new_webp_file_doc(image_url, image, extn) + return image_url + if image_url.startswith("/private"): + image, filename, extn = get_local_image(image_url) + if can_convert_image(extn): + return create_new_webp_file_doc(image_url, image, extn) + return image_url + if image_url.startswith("http"): + return handle_image_from_url(image_url) + + return image_url + +def save_webp_image(doctype:str,docname:str,image_field:str): + file_url = frappe.db.get_value(doctype,docname,image_field) + if file_url: + webp_url = convert_to_webp(file_url) + frappe.db.set_value(doctype,docname,image_field,webp_url) \ No newline at end of file diff --git a/commit/api/preview.py b/commit/api/preview.py new file mode 100644 index 0000000..8fdf22d --- /dev/null +++ b/commit/api/preview.py @@ -0,0 +1,59 @@ +import asyncio +import io +from pyppeteer import launch +import frappe +from frappe.utils.file_manager import save_file +from commit.api.convert_to_webp import convert_to_webp + +async def capture_screenshot(url, width=1366, height=800, delay=3): + browser = await launch( + headless=True, + handleSIGINT=False, + handleSIGTERM=False, + handleSIGHUP=False + ) + page = await browser.newPage() + + await page.setViewport({"width": width, "height": height}) + await page.goto(url, {"waitUntil": "load"}) + await asyncio.sleep(delay) # Ensure page loads fully + + # ✅ Explicitly return bytes and ensure no file is saved + screenshot_bytes = await page.screenshot({"fullPage": False, "encoding": "binary"}) + + await browser.close() + + return screenshot_bytes # Return raw image data + +def save_preview_screenshot(url, doctype, docname, field): + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + screenshot_bytes = loop.run_until_complete(capture_screenshot(url)) + else: + screenshot_bytes = loop.run_until_complete(capture_screenshot(url)) + + # ✅ Correct way: Wrap bytes in BytesIO + screenshot_io = io.BytesIO(screenshot_bytes) + + file_doc = frappe.get_doc({ + "doctype": "File", + "file_name": docname + "_" + "preview.png", + "attached_to_doctype": doctype, + "attached_to_name": docname, + "attached_to_field": field, + "is_private": 1, + "content": screenshot_io.getvalue() + }) + file_doc.save() + + # Convert to WebP + file_url = convert_to_webp(file_doc.file_url, file_doc) + # Update the document with the preview file URL + doc = frappe.get_doc(doctype, docname) + doc.set(field, file_url) + doc.save() + + frappe.db.commit() diff --git a/commit/commit/doctype/commit_docs/commit_docs.json b/commit/commit/doctype/commit_docs/commit_docs.json index 4bb7f43..4d0c18d 100644 --- a/commit/commit/doctype/commit_docs/commit_docs.json +++ b/commit/commit/doctype/commit_docs/commit_docs.json @@ -16,6 +16,7 @@ "logo_section", "light_mode_logo", "night_mode_logo", + "preview_image", "navbar_tab", "navbar_items", "footer_tab", @@ -27,6 +28,7 @@ "github", "raven", "telegram", + "discord", "section_break_gawv", "footer" ], @@ -166,11 +168,22 @@ "fieldname": "description", "fieldtype": "Small Text", "label": "Description" + }, + { + "fieldname": "preview_image", + "fieldtype": "Attach Image", + "label": "Preview Image" + }, + { + "description": "Add Discord URl", + "fieldname": "discord", + "fieldtype": "Data", + "label": "Discord" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2024-12-07 22:34:40.239467", + "modified": "2025-03-30 12:43:40.306104", "modified_by": "Administrator", "module": "commit", "name": "Commit Docs", @@ -190,6 +203,7 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [] diff --git a/commit/commit/doctype/commit_docs/commit_docs.py b/commit/commit/doctype/commit_docs/commit_docs.py index 3616512..0168b4a 100644 --- a/commit/commit/doctype/commit_docs/commit_docs.py +++ b/commit/commit/doctype/commit_docs/commit_docs.py @@ -3,8 +3,9 @@ import frappe from frappe.model.document import Document - - +from commit.api.preview import save_preview_screenshot +from commit.api.convert_to_webp import save_webp_image +import json class CommitDocs(Document): def before_insert(self): @@ -24,6 +25,24 @@ def validate(self): if primary_button_count > 1: frappe.throw('Only One Primary Button is Allowed') break + def before_save(self): + # This is to save the preview image of the first page of the commit docs + # This is done to show the preview image in the commit docs dashboard + # This is done using the async function to capture the screenshot + # The function is called using the frappe.enqueue method + if self.sidebar: + first = self.sidebar[0].docs_page + domain = frappe.utils.get_url() + if first: + docs_url = f'{domain}/commit-docs/{self.route}/{first}' + frappe.enqueue(method=save_preview_screenshot, url=docs_url,doctype=self.doctype,docname=self.name,field='preview_image') + + old_doc = self.get_doc_before_save() + if old_doc: + if old_doc.light_mode_logo != self.light_mode_logo: + frappe.enqueue(method=save_webp_image,doctype=self.doctype,docname=self.name,image_field='light_mode_logo') + if old_doc.night_mode_logo != self.night_mode_logo: + frappe.enqueue(method=save_webp_image,doctype=self.doctype,docname=self.name,image_field='dark_mode_logo') @frappe.whitelist() @@ -79,7 +98,7 @@ def get_all_commit_docs_detail(): @frappe.whitelist(allow_guest=True) -def get_commit_docs_details(route:str): +def get_commit_docs_details(route:str,show_hidden_items:bool=False): ''' Get the Commit Docs Details # 1. Get the Commit Docs Document from the route @@ -102,22 +121,22 @@ def get_commit_docs_details(route:str): else: commit_docs = frappe.get_doc('Commit Docs',{'route':route}).as_dict() - return parse_commit_docs(commit_docs) + return parse_commit_docs(commit_docs,show_hidden_items) else: return frappe.throw('Docs Not Found') -def parse_commit_docs(commit_docs): +def parse_commit_docs(commit_docs,show_hidden_items:bool=False): # Get the Sidebar Items - sidebar_items = get_sidebar_items(commit_docs.sidebar) + sidebar_items = get_sidebar_items(commit_docs.sidebar,show_hidden_items) # Get the Footer Items - footer_items = get_footer_items(commit_docs.footer) + footer_items = get_footer_items(commit_docs.footer,show_hidden_items) # Get the Navbar Items - navbar_items = get_navbar_items(commit_docs.navbar_items) + navbar_items = get_navbar_items(commit_docs.navbar_items,show_hidden_items) # remove the sidebar from the commit_docs as it is not needed commit_docs.pop('sidebar') @@ -131,7 +150,7 @@ def parse_commit_docs(commit_docs): 'navbar_items': navbar_items, } -def get_footer_items(footer): +def get_footer_items(footer,show_hidden_items:bool=False): ''' Get the Footer Items # 1. Loop Over the Footer Items Which have Parent Label URL and Label @@ -140,25 +159,28 @@ def get_footer_items(footer): ''' footer_obj = {} for footer_item in footer: - if footer_item.hide_on_footer: + if footer_item.hide_on_footer and not show_hidden_items: + # If the footer item is hidden and show_hidden_items is False, skip it continue if footer_item.parent_label not in footer_obj: footer_obj[footer_item.parent_label] = [ { 'label': footer_item.label, - 'url': footer_item.url + 'url': footer_item.url, + 'hide_on_footer': footer_item.hide_on_footer } ] else: footer_obj[footer_item.parent_label].append({ 'label': footer_item.label, - 'url': footer_item.url + 'url': footer_item.url, + 'hide_on_footer': footer_item.hide_on_footer }) return footer_obj -def get_navbar_items(navbar): +def get_navbar_items(navbar,show_hidden_items:bool=False): ''' Get the Navbar Items # 1. Loop Over the Navbar Items Which have Label, Parent Label, URL @@ -170,7 +192,7 @@ def get_navbar_items(navbar): navbar_obj = {} parent_labels = [] for navbar_item in navbar: - if navbar_item.hide_on_navbar: + if navbar_item.hide_on_navbar and not show_hidden_items: continue @@ -187,7 +209,8 @@ def get_navbar_items(navbar): 'icon': navbar_item.icon, 'open_in_new_tab': navbar_item.open_in_new_tab }], - 'is_primary_button': navbar_item.is_primary_button + 'is_primary_button': navbar_item.is_primary_button, + 'hide_on_navbar': navbar_item.hide_on_navbar } else: navbar_obj[navbar_item.parent_label]['items'].append({ @@ -205,7 +228,8 @@ def get_navbar_items(navbar): 'icon': navbar_item.icon, 'open_in_new_tab': navbar_item.open_in_new_tab, 'url': navbar_item.url, - 'is_primary_button': navbar_item.is_primary_button + 'is_primary_button': navbar_item.is_primary_button, + 'hide_on_navbar': navbar_item.hide_on_navbar } # Remove that Object whose type is Button and Key is in Parent Labels @@ -215,7 +239,7 @@ def get_navbar_items(navbar): return navbar_obj -def get_sidebar_items(sidebar): +def get_sidebar_items(sidebar,show_hidden_items:bool=False): ''' Get the Sidebar Items with support for nested Group Pages. ''' @@ -270,8 +294,8 @@ def get_group_items(commit_docs_page): return sorted(group_items, key=lambda x: x['idx']) sidebar_obj = {} - for sidebar_item in sorted(sidebar, key=lambda x: x.idx): - if sidebar_item.hide_on_sidebar: + for sidebar_item in sidebar: # Preserve the original order of the sidebar + if sidebar_item.hide_on_sidebar and not show_hidden_items: continue commit_docs_page = frappe.get_doc('Commit Docs Page', sidebar_item.docs_page) @@ -298,7 +322,8 @@ def get_group_items(commit_docs_page): 'group_name': sidebar_item.parent_label, 'is_group_page': is_group_page, 'group_items': group_items if is_group_page else None, - 'idx': commit_docs_page.idx + 'idx': commit_docs_page.idx, + 'hide_on_sidebar': sidebar_item.hide_on_sidebar } # Add sidebar entry to the parent label @@ -307,10 +332,6 @@ def get_group_items(commit_docs_page): else: sidebar_obj[sidebar_item.parent_label].append(sidebar_entry) - # Sort each group in the sidebar_obj by idx - for key in sidebar_obj: - sidebar_obj[key] = sorted(sidebar_obj[key], key=lambda x: x['idx']) - return sidebar_obj @frappe.whitelist(allow_guest=True) @@ -345,7 +366,168 @@ def get_commit_docs_list(): commit_docs_list = frappe.get_all('Commit Docs', filters=filters, - fields=["header", "light_mode_logo", "route", "published", "description"], + fields=["header", "light_mode_logo", "route", "published", "description","name"], ) - return commit_docs_list \ No newline at end of file + return commit_docs_list + +@frappe.whitelist(methods=["POST"]) +def manage_sidebar(commit_doc:str,parent_labels,docs_page): + ''' + This is to modify the sidebar items of the commit docs + @param commit_doc: The Commit Docs ID + @param parent_labels: The Parent Labels of the Sidebar List + @param docs_page: List of Object having docs page and parent label + + # 1. Get the Commit Docs Document + # 2. Loop Over the Parent Labels + # 3. Look for the Parent Label in docs_page List of Object + # 4. for loop on that filtered list append the docs_page and parent label to the sidebar + # 5. Save the Sidebar Items + ''' + + # Get the Commit Docs Document + doc = frappe.get_doc('Commit Docs',commit_doc) + + # Loop Over the Parent Labels + if isinstance(parent_labels, str): + parent_labels = json.loads(parent_labels) + + if isinstance(docs_page, str): + docs_page = json.loads(docs_page) + + doc.sidebar = [] + for parent_label in parent_labels: + # Filter the docs_page List of Object + filtered_docs_page = [item for item in docs_page if item.get('columnId') == parent_label] + + # Check if there are any duplicate docs_page + duplicate = set() + for item in filtered_docs_page: + if item.get('id') in duplicate: + frappe.throw(f'You have Duplicate Docs Page {item.get("id")} in Same Parent Label {parent_label}') + duplicate.add(item.get('id')) + + # sort by index field + filtered_docs_page = sorted(filtered_docs_page, key=lambda x: x.get('index', 0)) + + # Loop Over the Filtered List + for item in filtered_docs_page: + # Append the docs_page and parent label to the sidebar + doc.append('sidebar',{ + 'parent_label': parent_label, + 'docs_page': item.get('id'), + }) + + doc.save() + + return doc + +@frappe.whitelist(methods=["POST"]) +def manage_navbar(commit_doc:str, navbar_items, sub_navbar_items=None): + ''' + This is to modify the navbar items of the commit docs + @param commit_doc: The Commit Docs ID + @param navbar_items: The Navbar Items List of Object having label, url, parent label, icon, open_in_new_tab + + # 1. Get the Commit Docs Document + # 2. Loop Over the Navbar Items + # 3. Append the Navbar Items to the Navbar Items Table + # 4. Save the Navbar Items + ''' + + doc = frappe.get_doc('Commit Docs',commit_doc) + + if isinstance(navbar_items, str): + navbar_items = json.loads(navbar_items) + + if isinstance(sub_navbar_items, str): + sub_navbar_items = json.loads(sub_navbar_items) + + doc.navbar_items = [] + # sort the navbar_items by index field + navbar_items = sorted(navbar_items, key=lambda x: x.get('index', 0)) + + for item in navbar_items: + if item.get('type') == "Menu": + doc.append('navbar_items',{ + 'label': item.get('label'), + 'hide_on_navbar': item.get('hide_on_navbar'), + }) + if sub_navbar_items: + # find the task in the sub_navbar_items where columnId is equal to item.get('label') + sub_items = [sub_item for sub_item in sub_navbar_items if sub_item.get('columnId') == item.get('label')] + # sort the sub_items by index field + sub_items = sorted(sub_items, key=lambda x: x.get('index', 0)) + # Loop Over the Sub Items + for sub_item in sub_items: + doc.append('navbar_items',{ + 'label': sub_item.get('label'), + 'url': sub_item.get('url'), + 'icon': sub_item.get('icon'), + 'open_in_new_tab': sub_item.get('open_in_new_tab'), + "parent_label": item.get('label'), + }) + + else: + doc.append('navbar_items',{ + 'label': item.get('label'), + 'url': item.get('url'), + 'icon': item.get('icon'), + 'open_in_new_tab': item.get('open_in_new_tab'), + 'hide_on_navbar': item.get('hide_on_navbar'), + 'is_primary_button': item.get('is_primary_button') + }) + + doc.save() + + return doc + +@frappe.whitelist(methods=["POST"]) +def manage_footer(commit_doc:str, footer_columns, footer_items): + ''' + This is to modify the footer items of the commit docs + @param commit_doc: The Commit Docs ID + @param footer_columns: The Footer Columns List of Parent Label + @param footer_items: The Footer Items List of Object having label, url, hide_on_footer, columnId,id + + # 1. Get the Commit Docs Document + # 2. Loop Over the Footer Columns + # 3. Search for the Parent Label in the Footer Items + # 4. Loop over the filtered list and append the footer items to the footer + # 5. Save the Footer Items + ''' + + doc = frappe.get_doc('Commit Docs',commit_doc) + if isinstance(footer_columns, str): + footer_columns = json.loads(footer_columns) + + if isinstance(footer_items, str): + footer_items = json.loads(footer_items) + + doc.footer = [] + for parent_label in footer_columns: + # Filter the footer_items List of Object + filtered_footer_items = [item for item in footer_items if item.get('columnId') == parent_label] + + # Check if there are any duplicate footer_items + duplicate = set() + for item in filtered_footer_items: + if item.get('id') in duplicate: + frappe.throw(f'You have Duplicate Footer Item {item.get("id")} in Same Parent Label {parent_label}') + duplicate.add(item.get('id')) + + # sort by index field + filtered_footer_items = sorted(filtered_footer_items, key=lambda x: x.get('index', 0)) + + for item in filtered_footer_items: + doc.append('footer',{ + 'label': item.get('label'), + 'url': item.get('url'), + 'hide_on_footer': item.get('hide_on_footer'), + 'parent_label': parent_label, + }) + + doc.save() + + return doc \ No newline at end of file diff --git a/commit/commit/doctype/commit_docs_page/commit_docs_page.json b/commit/commit/doctype/commit_docs_page/commit_docs_page.json index ab62734..c15b38a 100644 --- a/commit/commit/doctype/commit_docs_page/commit_docs_page.json +++ b/commit/commit/doctype/commit_docs_page/commit_docs_page.json @@ -36,7 +36,6 @@ "in_preview": 1, "in_standard_filter": 1, "label": "Route", - "reqd": 1, "unique": 1 }, { @@ -112,9 +111,10 @@ "reqd": 1 } ], + "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2025-01-10 15:10:08.749535", + "modified": "2025-03-15 17:10:43.887534", "modified_by": "Administrator", "module": "commit", "name": "Commit Docs Page", @@ -134,6 +134,7 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], diff --git a/commit/commit/doctype/commit_docs_page/commit_docs_page.py b/commit/commit/doctype/commit_docs_page/commit_docs_page.py index 2cf6997..63f3985 100644 --- a/commit/commit/doctype/commit_docs_page/commit_docs_page.py +++ b/commit/commit/doctype/commit_docs_page/commit_docs_page.py @@ -4,13 +4,24 @@ import frappe from frappe.model.document import Document import json - +from commit.api.preview import save_preview_screenshot class CommitDocsPage(Document): def before_insert(self): # Set the route for the page based on the title self.route = f'{self.commit_docs.lower().replace(" ", "-")}-{self.title.lower().replace(" ", "-")}' + + def before_save(self): + # Check if this document is first item of commit docs sidebar child table + if self.commit_docs: + commit_docs = frappe.get_cached_doc('Commit Docs', self.commit_docs) + if commit_docs.sidebar: + first = commit_docs.sidebar[0] + if first.docs_page == self.name: + domain = frappe.utils.get_url() + docs_url = f'{domain}/commit-docs/{commit_docs.route}/{self.name}' + frappe.enqueue(method=save_preview_screenshot, url=docs_url,doctype="Commit Docs",docname=commit_docs.name,field='preview_image') @frappe.whitelist(methods=['POST']) def publish_documentation(project_branch, endpoint, viewer_type, docs_name, parent_label, title, published, allow_guest, content): @@ -148,3 +159,48 @@ def add_to_toc(toc, level, heading_id, title): add_to_toc(toc, level, heading_id, title) return toc + +@frappe.whitelist() +def get_commit_docs_page_list(commit_doc): + ''' + Get the list of Commit Docs Page + ''' + user_info = {} + users = [] + page = frappe.get_all('Commit Docs Page', filters={'commit_docs': commit_doc}, fields=['*'], order_by='creation desc') + for p in page: + users.append(p.owner) + users.append(p.modified_by) + users = list(set(users)) + frappe.utils.add_user_info(users, user_info) + + return { + 'pages': page, + 'user_info': user_info + } + +@frappe.whitelist() +def create_commit_docs_page(data): + ''' + Create a new Commit Docs Page + ''' + if isinstance(data, str): + data = json.loads(data) + + # create a new Commit Docs Page + commit_docs_page = frappe.get_doc({ + 'doctype': 'Commit Docs Page', + 'title': data.get('title'), + 'commit_docs': data.get('commit_docs'), + }) + + commit_docs_page.insert() + + if data.get('sidebar_label'): + commit_doc = frappe.get_doc('Commit Docs', data.get('commit_docs')) + commit_doc.append('sidebar', { + 'parent_label': data.get('sidebar_label'), + 'docs_page': commit_docs_page.name + }) + commit_doc.save() + return commit_docs_page \ No newline at end of file diff --git a/commit/hooks.py b/commit/hooks.py index 9a8589f..e408d86 100644 --- a/commit/hooks.py +++ b/commit/hooks.py @@ -201,4 +201,4 @@ # ] fixtures = [{"doctype": "Server Script", "filters": [["module" , "in" , ("commit" )]]}] -website_route_rules = [{'from_route': '/docs/', 'to_route': 'commit-docs'}, {'from_route': '/commit/', 'to_route': 'commit'}] \ No newline at end of file +website_route_rules = [{'from_route': '/commit-docs/', 'to_route': 'commit-docs'}, {'from_route': '/commit/', 'to_route': 'commit'}] \ No newline at end of file diff --git a/commit/www/commit.py b/commit/www/commit.py index 6dcf343..58ba352 100644 --- a/commit/www/commit.py +++ b/commit/www/commit.py @@ -3,7 +3,8 @@ import frappe.sessions import re from commit.api.meta_data import get_installed_apps -from commit.commit.doctype.commit_docs.commit_docs import get_all_commit_docs_detail +from commit.commit.doctype.commit_docs.commit_docs import get_all_commit_docs_detail, get_commit_docs_list + no_cache = 1 SCRIPT_TAG_PATTERN = re.compile(r"\") @@ -40,6 +41,7 @@ def get_boot(): boot["commit_docs_header_image_url"] = commit_settings.commit_docs_header_image_url boot["get_installed_apps"] = get_installed_apps() boot["get_all_commit_docs_detail"] = get_all_commit_docs_detail() + boot["get_commit_docs_list"] = get_commit_docs_list() boot_json = frappe.as_json(boot, indent=None, separators=(",", ":")) boot_json = SCRIPT_TAG_PATTERN.sub("", boot_json) diff --git a/dashboard/package.json b/dashboard/package.json index 7569a83..dd68aee 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -12,12 +12,13 @@ }, "dependencies": { "@dagrejs/dagre": "^1.0.2", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@headlessui/react": "^1.7.15", "@heroicons/react": "^2.0.18", "@hookform/resolvers": "^3.1.1", - "@mdx-js/mdx": "^3.1.0", - "@mdx-js/react": "^3.1.0", - "@mdx-js/rollup": "^3.1.0", "@milkdown/crepe": "^7.5.9", "@milkdown/kit": "^7.5.9", "@milkdown/react": "^7.5.9", @@ -65,14 +66,9 @@ "react-hook-form": "^7.45.1", "react-router-dom": "^6.14.1", "reactflow": "^11.11.4", - "rehype-katex": "^7.0.1", - "rehype-pretty-code": "^0.14.0", "rehype-raw": "^7.0.0", - "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.0", - "remark-math": "^6.0.0", "shadcn-ui": "^0.8.0", - "shiki": "^1.24.3", "socket.io-client": "^4.5.1", "swr": "^2.1.5", "tailwind-merge": "^1.13.2", diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 02d10dd..4a303fc 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,15 +1,18 @@ import { FrappeProvider } from 'frappe-react-sdk' -import { BrowserRouter, Route, Routes } from 'react-router-dom' +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' import PageNotFound from './components/common/PageNotFound/PageNotFound' import APIViewerContainer from './pages/features/api_viewer/APIViewer' import AppAPIViewerContainer from './pages/features/api_viewer/AppAPIViewer' -import DocsPage from './pages/features/docs/DocsPage' -import DocsLandingPage from './pages/features/docs/LandingPage/DocsLandingPage' -import PageContent from './pages/features/docs/PageContent' import ViewDocs from './pages/features/docs/ViewDocs' import ERDViewer from './pages/features/erd/ERDViewer' import CreateERD from './pages/features/erd/meta/CreateERDForMeta' import Overview from './pages/overview/Overview' +import { DocsMainPage } from './components/features/documentation/DocsMainPage' +import { PageTable } from './pages/features/docs/DocsEditor/PageTable' +import { Sidebar } from './pages/features/docs/Sidebar/DashboardSidebar' +import { DocsSettings } from './pages/features/docs/Settings/DocsSettings' +import { DashboardNavbar } from './pages/features/docs/Navbar/DashboardNavbar' +import { DashboardFooter } from './pages/features/docs/Footer/DashboardFooter' function App() { @@ -28,26 +31,34 @@ function App() { {/* */} - - {/** Public Routes */} - {/* } /> */} + + {/** Public Routes */} + {/* } /> */} - {/** Private Routes */} - {/* } /> */} - {/* default route on '/' */} - } /> - {/*TODO: Need to Change below route */} - } /> - } /> - } /> - } /> - } /> - } /> - } > - } /> - } /> + {/** Private Routes */} + {/* } /> */} + {/* default route on '/' */} + } /> + {/*TODO: Need to Change below route */} + } /> + } /> + } /> + } /> + } /> + } /> + } > + } /> + } /> + }> + {/* Add nested dynamic route inside editor */} + } /> - } /> + } /> + } /> + } /> + } /> + + } /> {/* */} diff --git a/dashboard/src/components/common/AsyncDropdown/AsyncDropdown.tsx b/dashboard/src/components/common/AsyncDropdown/AsyncDropdown.tsx index 4eb76a8..dee48cd 100644 --- a/dashboard/src/components/common/AsyncDropdown/AsyncDropdown.tsx +++ b/dashboard/src/components/common/AsyncDropdown/AsyncDropdown.tsx @@ -1,5 +1,5 @@ import { FrappeError, useFrappePostCall } from 'frappe-react-sdk' -import { lazy, PropsWithChildren, Suspense, useEffect, useRef } from 'react' +import { PropsWithChildren, useEffect, useRef } from 'react' import { Filter, useFrappeGetCall } from "frappe-react-sdk"; import { useCallback, useMemo, useState } from "react"; import { RegisterOptions, useController, useFormContext } from "react-hook-form"; @@ -11,11 +11,7 @@ import { useGetDoctypeMeta } from '@/hooks/useGetDoctypeMeta'; import { useDebounce } from '@/hooks/useDebounce'; import { getLinkTitleAtom, setLinkTitleAtom } from './LinkTitles'; import { AsyncSpinnerLoader } from '../FullPageLoader/SpinnerLoader'; -import { getErrorMessages } from '../ErrorBanner/ErrorBanner'; -import { FullPageLoader } from '../FullPageLoader/FullPageLoader'; - - -const MDXRenderer = lazy(() => import('../MarkdownRenderer/MDX')) +import { ErrorBanner, } from '../ErrorBanner/ErrorBanner'; interface ResultItem { value: string, @@ -474,9 +470,7 @@ const ErrorContainer = ({ error }: { error?: FrappeError }) => {

- }> - {getErrorMessages(error).map(e => )} - +

); @@ -527,4 +521,301 @@ const DropdownItem = ({ item, index, getItemProps, highlightedIndex, selectedIte ); }; -const htmlReplaceRegex = /(<([^>]+)>)/gi; \ No newline at end of file +const htmlReplaceRegex = /(<([^>]+)>)/gi; + +export interface AsyncDropdownWithoutFormProps extends BaseDropdownProps { + selectedValue?: string, + setSelectedValue: (value: string) => void, + /** callback triggered when the dropdown is opened/closed */ + onOpenChange?: (isOpen: boolean) => void, + showTitleField?: boolean, +} +/** + * The AsyncDropdown component is used to handle Link fields in any form. + * It needs to be used inside a React Hook Form FormProvider. + * The component takes in a doctype and a fieldname and returns an input field with a dropdown list of options fetched from the server. + * @param props + * @returns + */ +export const AsyncDropdownWithoutForm = ({ + doctype, + reference_doctype, + name, + filters = [], + allowPagination = true, + customQuery, + searchfield, + searchAPIPath = "commit.api.search.search_link", + limit, + isReadOnly = false, + placeholder = doctype, + isDisabled = false, + clickOpenInNewTab = false, + defaultValuesForCreate, + autoFocus, + openMenuOnFocus = false, + filterOption, + selectedValue, + onOpenChange, + setSelectedValue, + onKeyDown, + onKeyDownCapture, + showTitleField, + ...inputProps +}: AsyncDropdownWithoutFormProps) => { + + const pageLimit = useMemo(() => limit || getSystemDefault('link_field_results_limit') || 10, [limit]) + + /** Load the Doctype meta so that we can determine the search fields + the name of the title field */ + const { data: meta, isLoading: isMetaLoading } = useGetDoctypeMeta(doctype) + + const [isOpened, setIsOpened] = useState(false) + const [searchInput, setSearchInput] = useState(selectedValue ?? '') + + const debouncedInput = useDebounce(searchInput) + + + // Maintain link titles in an Atom + const [getLinkTitle] = useAtom(getLinkTitleAtom) + + const [, setLinkTitle] = useAtom(setLinkTitleAtom) + + const { call: linkTitleCall } = useFrappePostCall('emotive_app.api.utils.link.get_link_title') + + const loadingLinkTitle = useRef(false) + + // On mount, we want to check if the link title is available in the atom + // If it is, set the search input to the link title + useEffect(() => { + if (meta) { + let fetchTitle = false + if (showTitleField !== undefined) { + fetchTitle = showTitleField + } else if (["User"].includes(doctype)) { + // Is doctype in standard title fields + fetchTitle = true + } else { + fetchTitle = meta.show_title_field_in_link ? true : false + } + if (fetchTitle && selectedValue) { + const t = getLinkTitle(doctype, selectedValue) + if (t) { + setSearchInput(t) + } else { + // The link title is not available in the atom + // We need to fetch it from the server + if (!loadingLinkTitle.current) { + loadingLinkTitle.current = true + linkTitleCall({ + doctype, + docname: selectedValue + }).then(response => { + const title = response.message + setLinkTitle(doctype, selectedValue, title) + setSearchInput(title) + loadingLinkTitle.current = false + }) + } + } + } else { + setSearchInput(selectedValue ?? '') + } + } + + }, [selectedValue, meta, showTitleField]) + + const { data, error, isLoading } = useFrappeGetCall<{ message: ResultItem[] }>(searchAPIPath, { + doctype, + txt: debouncedInput, + page_length: pageLimit, + query: customQuery?.query, + searchfield, + filters: JSON.stringify(customQuery?.filters || filters || []), + reference_doctype, + }, () => { + if (!isOpened) { + return null + } else { + let key = `${searchAPIPath}_${doctype}_${debouncedInput}` + + if (pageLimit) { + key += `_${pageLimit}` + } + + if (customQuery?.filters) { + key += `_${JSON.stringify(customQuery.filters)}` + } else if (filters) { + key += `_${JSON.stringify(filters)}` + } + + if (customQuery && customQuery.query) { + key += `_${customQuery.query}` + } + + if (reference_doctype) { + key += `_${reference_doctype}` + } + + if (searchfield && searchfield !== 'name') { + key += `_${searchfield}` + } + + return key + + } + }, { + revalidateOnFocus: false, + revalidateIfStale: false, + revalidateOnMount: true, + shouldRetryOnError: false, + revalidateOnReconnect: false, + }) + + const inputRef = useRef(null) + + const items = filterOption ? data?.message?.slice(0, 50).filter((item) => filterOption(item, searchInput)) : data?.message + + const stateReducer = useCallback((state: UseComboboxState, actionAndChanges: UseComboboxStateChangeOptions) => { + const { type, changes } = actionAndChanges + // returning an uppercased version of the item string. + switch (type) { + case useCombobox.stateChangeTypes.ItemClick: + // Set the field value to the selected item + setSelectedValue(changes.selectedItem?.value ?? '') + if (changes.selectedItem?.label) { + setLinkTitle(doctype, changes.selectedItem.value, changes.selectedItem.label) + } + + return changes + case useCombobox.stateChangeTypes.InputKeyDownEnter: + + if (changes.inputValue && state.highlightedIndex === -1) { + return { + ...changes, + inputValue: '', + selectedItem: null + } + } else { + setSelectedValue(changes.selectedItem?.value ?? '') + return changes + } + case useCombobox.stateChangeTypes.InputKeyDownEscape: + case useCombobox.stateChangeTypes.InputKeyDownHome: + case useCombobox.stateChangeTypes.FunctionCloseMenu: + case useCombobox.stateChangeTypes.InputBlur: + // When the input blurs, we want to check if the value in the input is the same as the selected item. + //If not, then we want to clear the input value + // That will in turn clear the field value as well + if (selectedValue !== changes.selectedItem?.value) { + return { + ...changes, + inputValue: '' + } + } + return changes + + default: + return changes // otherwise business as usual. + } + }, [selectedValue, items]) + + const { + isOpen, + getMenuProps, + getInputProps, + highlightedIndex, + getItemProps, + openMenu, + selectedItem, + } = useCombobox({ + onInputValueChange({ inputValue, type }) { + // @ts-expect-error apparently, on production, type is a number + if (type === "__input_change__" || type === 8) { + setSearchInput(inputValue ?? '') + } + if (inputValue === '') { + setSelectedValue('') + } + }, + onSelectedItemChange: ({ selectedItem }) => { + setSelectedValue(selectedItem?.value ?? '') + if (selectedItem?.label) { + setLinkTitle(doctype, selectedItem.value, selectedItem.label) + } + }, + items: items || [], + inputValue: searchInput, + itemToString(item) { + return item ? (item.label ?? item.value) : '' + }, + onIsOpenChange: ({ isOpen }) => { + // Set the state so that we do not fetch data when the dropdown is closed + setIsOpened(isOpen ? true : false) + onOpenChange?.(isOpen ? true : false) + }, + stateReducer + }) + return ( +
+
+
+ { + if (isReadOnly || isDisabled) { + // If the field is read only/disabled - do not fire the downshift event of opening the menu + //@ts-expect-error + event.nativeEvent.preventDownshiftDefault = true + } + }, + onFocus: () => { + if (openMenuOnFocus && !isDisabled && !isReadOnly) { + openMenu() + } + }, + onKeyDown: (event) => { + onKeyDown?.(event) + }, + onKeyDownCapture: (event) => { + onKeyDownCapture?.(event) + } + })} + {...inputProps} + /> + {isLoading ? : null} +
+
+ + {!isMetaLoading && !isLoading && items?.length === 0 && + + + + } + + + {isOpen && + items?.slice(0, 50).map((item, index) => ( + + ))} + +
+ ) +} \ No newline at end of file diff --git a/dashboard/src/components/common/Checkbox/CreatableSelect.tsx b/dashboard/src/components/common/Checkbox/CreatableSelect.tsx index 6c1b7a7..825771e 100644 --- a/dashboard/src/components/common/Checkbox/CreatableSelect.tsx +++ b/dashboard/src/components/common/Checkbox/CreatableSelect.tsx @@ -248,7 +248,7 @@ export const FormCreatableSelect = ({ {option.label} diff --git a/dashboard/src/components/common/ErrorBanner/ErrorBanner.tsx b/dashboard/src/components/common/ErrorBanner/ErrorBanner.tsx index f7cee0e..200e485 100644 --- a/dashboard/src/components/common/ErrorBanner/ErrorBanner.tsx +++ b/dashboard/src/components/common/ErrorBanner/ErrorBanner.tsx @@ -1,8 +1,7 @@ import { FrappeError } from 'frappe-react-sdk' -import { lazy, Suspense, useMemo } from 'react' -import { FullPageLoader } from '../FullPageLoader/FullPageLoader' - -const MDXRenderer = lazy(() => import('../MarkdownRenderer/MDX')) +import { useMemo } from 'react' +import { MarkdownRenderer } from '../MarkdownRenderer/MarkdownRenderer' +import { CircleAlert } from 'lucide-react' interface ErrorBannerProps extends React.HTMLAttributes { error?: FrappeError | null, @@ -16,7 +15,12 @@ interface ParsedErrorMessage { } -export const getErrorMessages = (error?: FrappeError | null): ParsedErrorMessage[] => { +export const getErrorMessage = (error?: FrappeError | null): string => { + const messages = getErrorMessages(error) + return messages.map(m => m.message).join('\n') +} + +const getErrorMessages = (error?: FrappeError | null): ParsedErrorMessage[] => { if (!error) return [] let eMessages: ParsedErrorMessage[] = error?._server_messages ? JSON.parse(error?._server_messages) : [] eMessages = eMessages.map((m: any) => { @@ -60,74 +64,42 @@ export const ErrorBanner = ({ error, overrideHeading, ...props }: ErrorBannerPro // _server_messages: Array of messages - useful for showing to user const messages = useMemo(() => { - if (!error) return [] - let eMessages: ParsedErrorMessage[] = error?._server_messages ? JSON.parse(error?._server_messages) : [] - eMessages = eMessages.map((m: any) => { - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument - return JSON.parse(m) - } catch (e) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return m - } - }) + return getErrorMessages(error) + }, [error]) - if (eMessages.length === 0) { - // Get the message from the exception by removing the exc_type - const indexOfFirstColon = error?.exception?.indexOf(':') - if (indexOfFirstColon) { - const exception = error?.exception?.slice(indexOfFirstColon + 1) - if (exception) { - eMessages = [{ - message: exception, - title: "Error" - }] - } - } + const parseMessage = (message: string): string => { - if (eMessages.length === 0) { - eMessages = [{ - message: error?.message, - title: "Error", - indicator: "red" - }] - } + let extractedMessage = "" + + if (typeof message === 'string') { + extractedMessage = message } - return eMessages - }, [error]) - const parseHeading = (message?: ParsedErrorMessage) => { - if (message?.title === 'Message' || message?.title === 'Error') return undefined - return message?.title + if (Array.isArray(message)) { + extractedMessage = message.map((m) => m).join('\n') + } + return extractedMessage.replace(//g, (match, doctype, docname) => { + const decodedDoctype = decodeURIComponent(doctype) + const decodedDocname = decodeURIComponent(docname) + const href = `/app/${decodedDoctype.toLowerCase().split(' ').join('-')}/${decodedDocname}` + const linkHref = import.meta.env.VITE_BASE_PATH ? `/${import.meta.env.VITE_BASE_PATH}${href}` : href + return `` + }) } - // TODO: Sometimes, error message has links which route to the ERPNext interface. We need to parse the link to route to the correct page in our interface - // Links are of format LEAD-00001 if (!error) return null return ( -
-
+
+
- {/* */} - +
-
+

- }> - {messages.map((m, i) => - )} - + {messages.map((m, i) => { + const parsedMessage = parseMessage(m.message) + return + })}

diff --git a/dashboard/src/components/common/ImageUploader2/DocumentUploadModal.tsx b/dashboard/src/components/common/ImageUploader2/DocumentUploadModal.tsx index 7d1920c..7590496 100644 --- a/dashboard/src/components/common/ImageUploader2/DocumentUploadModal.tsx +++ b/dashboard/src/components/common/ImageUploader2/DocumentUploadModal.tsx @@ -2,7 +2,7 @@ import { Accept } from "react-dropzone" import { useState } from "react" import { FrappeError, useFrappeFileUpload, useFrappePostCall } from "frappe-react-sdk" import { File } from "@/types/Core/File" -import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@/components/ui/dialog" +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { CustomFile } from "./ImageUploader" import { ErrorBanner } from "../ErrorBanner/ErrorBanner" import { Button } from "@/components/ui/button" @@ -20,13 +20,14 @@ export interface DocumentUploadModalProps { accept?: Accept, maxFileSize?: number maxFiles?: number + isPrivate?: boolean } -export const DocumentUploadModal = ({ open, onClose, onUpdate, doctype, docname, fieldname = 'image', accept, maxFileSize = 10000000, maxFiles = 10, ...props }: DocumentUploadModalProps) => { +export const DocumentUploadModal = ({ open, onClose, onUpdate, doctype, docname, fieldname = 'image', accept, maxFileSize = 10000000, maxFiles = 10, isPrivate = true, ...props }: DocumentUploadModalProps) => { return ( - + @@ -52,10 +54,11 @@ interface UploadModalContentProps { maxFileSize?: number, maxFiles?: number onClose: () => void, - onUpload?: (files: File[]) => void + onUpload?: (files: File[]) => void, + isPrivate?: boolean } -const UploadModalContent = ({ doctype, docname, fieldname, maxFiles, accept, maxFileSize, onClose, onUpload, ...props }: UploadModalContentProps) => { +const UploadModalContent = ({ doctype, docname, fieldname, maxFiles, accept, maxFileSize, onClose, onUpload, isPrivate = true, ...props }: UploadModalContentProps) => { const [files, setFiles] = useState([]) @@ -76,7 +79,7 @@ const UploadModalContent = ({ doctype, docname, fieldname, maxFiles, accept, max doctype: doctype, docname: docname, fieldname: fieldname, - isPrivate: true, + isPrivate: isPrivate }).then(res => res) .catch((e) => { const serverMessage = JSON.parse(JSON.parse(e._server_messages)[0]) @@ -117,7 +120,11 @@ const UploadModalContent = ({ doctype, docname, fieldname, maxFiles, accept, max return ( <> - Upload + + + Upload + +
{fileErrors.length ? fileErrors?.map((e: any, index) => ) : {fileErrors.length > 0 && } - + + {/* Delete Button */} + {file || defaultFile ? ( + + ) : null}
: updatingDoc ?
@@ -155,7 +167,7 @@ export const ImagePreview = ({ file, size, borderRadius = "50%", ...props }: Ima const previewURL = file.fileType === 'file' ? useGetFilePreviewUrl(file) : '' return ( - {file.fileType + {file.fileType ) } diff --git a/dashboard/src/components/common/MarkdownRenderer/MDX.tsx b/dashboard/src/components/common/MarkdownRenderer/MDX.tsx deleted file mode 100644 index 364fcae..0000000 --- a/dashboard/src/components/common/MarkdownRenderer/MDX.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { compile, run } from '@mdx-js/mdx'; -import * as runtime from 'react/jsx-runtime'; -import './markdown.css'; // Import your custom styles for markdown -import 'katex/dist/katex.min.css'; - -// Plugins -import remarkMdx from 'remark-mdx'; -import remarkMath from 'remark-math'; -import remarkGfm from 'remark-gfm'; -import remarkBreaks from 'remark-breaks'; -import rehypeKatex from 'rehype-katex'; -import rehypeSlug from 'rehype-slug'; -import CustomHeading from '@/components/features/custommdxcomponent/CustomHeading'; -import CustomCodeBlock from '@/components/features/custommdxcomponent/CustomCodeBlock'; -import rehypePrettyCode from "rehype-pretty-code"; - -// Custom components -const CustomParagraph = ({ children }: { children?: React.ReactNode }) => { - const hasParagraph = React.Children.toArray(children).some( - (child) => React.isValidElement(child) && child.type === 'p' - ); - - if (hasParagraph) { - return <>{children}; // Return children as is if they already contain

- } - - return

{children}

; // Otherwise, wrap with

-}; - -// Custom component mapping -const components = { - p: CustomParagraph, - pre: CustomCodeBlock, - h2: (props: any) => , - h3: (props: any) => , - h4: (props: any) => , - h5: (props: any) => , - h6: (props: any) => , -}; - -const compileMDX = async (mdxContent: string) => { - const compiled = await compile(mdxContent, { - outputFormat: 'function-body', - remarkPlugins: [remarkMdx, remarkMath, remarkBreaks, remarkGfm], - rehypePlugins: [rehypeSlug, rehypeKatex, rehypePrettyCode], - }); - return String(compiled); -}; - -const renderMDX = async (compiledMdx: string, customComponents: any) => { - try { - const { default: MDXContent } = await run(compiledMdx, { ...runtime }); - - // Wrap MDXContent with MDXProvider to use custom components - const MDXWithProvider = (props: any) => - - - return MDXWithProvider; - } catch (error) { - console.error('Error rendering MDX:', error); - return null; - } -}; - -const MDXRenderer = ({ mdxContent }: { mdxContent: string }) => { - const [MDXComponent, setMDXComponent] = useState(null); - - useEffect(() => { - const renderContent = async () => { - const compiledMdx = await compileMDX(mdxContent); - const MDXContent = await renderMDX(compiledMdx, components); - setMDXComponent(() => MDXContent); - }; - - renderContent(); - }, [mdxContent]); - - if (!MDXComponent) { - return ; - } - - return ( -

- -
- ); -}; - -const MDXRendererSkeleton: React.FC = () => { - return ( -
-
-
-
-
-
-
-
-
-
-
-
- ); -}; - -export default MDXRenderer; diff --git a/dashboard/src/components/common/MarkdownRenderer/MarkdownRenderer.tsx b/dashboard/src/components/common/MarkdownRenderer/MarkdownRenderer.tsx new file mode 100644 index 0000000..100df5a --- /dev/null +++ b/dashboard/src/components/common/MarkdownRenderer/MarkdownRenderer.tsx @@ -0,0 +1,12 @@ +import React, { Suspense, lazy } from 'react' +const MarkdownRendererSuspense = lazy(() => import('./MarkdownRendererSuspense')) +interface MarkdownRendererProps { + content: string, + className?: string +} + +export const MarkdownRenderer: React.FC = (props) => { + return + + +} \ No newline at end of file diff --git a/dashboard/src/components/common/MarkdownRenderer/MarkdownRendererSuspense.tsx b/dashboard/src/components/common/MarkdownRenderer/MarkdownRendererSuspense.tsx new file mode 100644 index 0000000..22e7cc6 --- /dev/null +++ b/dashboard/src/components/common/MarkdownRenderer/MarkdownRendererSuspense.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import rehypeRaw from 'rehype-raw' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import './markdown.css' + +interface MarkdownRendererProps { + content: string, + className?: string +} + +const MarkdownRendererSuspense: React.FC = ({ content, className }) => { + return + {content} + +} + +export default MarkdownRendererSuspense \ No newline at end of file diff --git a/dashboard/src/components/common/MarkdownRenderer/markdown.css b/dashboard/src/components/common/MarkdownRenderer/markdown.css index 6ceaff0..78a0741 100644 --- a/dashboard/src/components/common/MarkdownRenderer/markdown.css +++ b/dashboard/src/components/common/MarkdownRenderer/markdown.css @@ -1,1236 +1,27 @@ -.markdown-body { - --base-size-4: 0.25rem; - --base-size-8: 0.5rem; - --base-size-16: 1rem; - --base-size-24: 1.5rem; - --base-size-40: 2.5rem; - --base-text-weight-normal: 400; - --base-text-weight-medium: 500; - --base-text-weight-semibold: 600; - --fontStack-monospace: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; - --fgColor-accent: Highlight; - } - /* @media (prefers-color-scheme: dark) { - .markdown-body, [data-theme="dark"] { - color-scheme: dark; - --focus-outlineColor: #1f6feb; - --fgColor-default: #f0f6fc; - --fgColor-muted: #9198a1; - --fgColor-accent: #4493f8; - --fgColor-success: #3fb950; - --fgColor-attention: #d29922; - --fgColor-danger: #f85149; - --fgColor-done: #ab7df8; - --bgColor-default: #0d1117; - --bgColor-muted: #151b23; - --bgColor-neutral-muted: #656c7633; - --bgColor-attention-muted: #bb800926; - --borderColor-default: #3d444d; - --borderColor-muted: #3d444db3; - --borderColor-neutral-muted: #3d444db3; - --borderColor-accent-emphasis: #1f6feb; - --borderColor-success-emphasis: #238636; - --borderColor-attention-emphasis: #9e6a03; - --borderColor-danger-emphasis: #da3633; - --borderColor-done-emphasis: #8957e5; - --color-prettylights-syntax-comment: #9198a1; - --color-prettylights-syntax-constant: #79c0ff; - --color-prettylights-syntax-constant-other-reference-link: #a5d6ff; - --color-prettylights-syntax-entity: #d2a8ff; - --color-prettylights-syntax-storage-modifier-import: #f0f6fc; - --color-prettylights-syntax-entity-tag: #7ee787; - --color-prettylights-syntax-keyword: #ff7b72; - --color-prettylights-syntax-string: #a5d6ff; - --color-prettylights-syntax-variable: #ffa657; - --color-prettylights-syntax-brackethighlighter-unmatched: #f85149; - --color-prettylights-syntax-brackethighlighter-angle: #9198a1; - --color-prettylights-syntax-invalid-illegal-text: #f0f6fc; - --color-prettylights-syntax-invalid-illegal-bg: #8e1519; - --color-prettylights-syntax-carriage-return-text: #f0f6fc; - --color-prettylights-syntax-carriage-return-bg: #b62324; - --color-prettylights-syntax-string-regexp: #7ee787; - --color-prettylights-syntax-markup-list: #f2cc60; - --color-prettylights-syntax-markup-heading: #1f6feb; - --color-prettylights-syntax-markup-italic: #f0f6fc; - --color-prettylights-syntax-markup-bold: #f0f6fc; - --color-prettylights-syntax-markup-deleted-text: #ffdcd7; - --color-prettylights-syntax-markup-deleted-bg: #67060c; - --color-prettylights-syntax-markup-inserted-text: #aff5b4; - --color-prettylights-syntax-markup-inserted-bg: #033a16; - --color-prettylights-syntax-markup-changed-text: #ffdfb6; - --color-prettylights-syntax-markup-changed-bg: #5a1e02; - --color-prettylights-syntax-markup-ignored-text: #f0f6fc; - --color-prettylights-syntax-markup-ignored-bg: #1158c7; - --color-prettylights-syntax-meta-diff-range: #d2a8ff; - --color-prettylights-syntax-sublimelinter-gutter-mark: #3d444d; - } - } */ - @media (prefers-color-scheme: light) { - .markdown-body, [data-theme="light"] { - /* light */ - color-scheme: light; - --focus-outlineColor: #0969da; - --fgColor-default: #1f2328; - --fgColor-muted: #59636e; - --fgColor-accent: #0969da; - --fgColor-success: #1a7f37; - --fgColor-attention: #9a6700; - --fgColor-danger: #d1242f; - --fgColor-done: #8250df; - --bgColor-default: #ffffff; - --bgColor-muted: #f6f8fa; - --bgColor-neutral-muted: #818b981f; - --bgColor-attention-muted: #fff8c5; - --borderColor-default: #d1d9e0; - --borderColor-muted: #d1d9e0b3; - --borderColor-neutral-muted: #d1d9e0b3; - --borderColor-accent-emphasis: #0969da; - --borderColor-success-emphasis: #1a7f37; - --borderColor-attention-emphasis: #9a6700; - --borderColor-danger-emphasis: #cf222e; - --borderColor-done-emphasis: #8250df; - --color-prettylights-syntax-comment: #59636e; - --color-prettylights-syntax-constant: #0550ae; - --color-prettylights-syntax-constant-other-reference-link: #0a3069; - --color-prettylights-syntax-entity: #6639ba; - --color-prettylights-syntax-storage-modifier-import: #1f2328; - --color-prettylights-syntax-entity-tag: #0550ae; - --color-prettylights-syntax-keyword: #cf222e; - --color-prettylights-syntax-string: #0a3069; - --color-prettylights-syntax-variable: #953800; - --color-prettylights-syntax-brackethighlighter-unmatched: #82071e; - --color-prettylights-syntax-brackethighlighter-angle: #59636e; - --color-prettylights-syntax-invalid-illegal-text: #f6f8fa; - --color-prettylights-syntax-invalid-illegal-bg: #82071e; - --color-prettylights-syntax-carriage-return-text: #f6f8fa; - --color-prettylights-syntax-carriage-return-bg: #cf222e; - --color-prettylights-syntax-string-regexp: #116329; - --color-prettylights-syntax-markup-list: #3b2300; - --color-prettylights-syntax-markup-heading: #0550ae; - --color-prettylights-syntax-markup-italic: #1f2328; - --color-prettylights-syntax-markup-bold: #1f2328; - --color-prettylights-syntax-markup-deleted-text: #82071e; - --color-prettylights-syntax-markup-deleted-bg: #ffebe9; - --color-prettylights-syntax-markup-inserted-text: #116329; - --color-prettylights-syntax-markup-inserted-bg: #dafbe1; - --color-prettylights-syntax-markup-changed-text: #953800; - --color-prettylights-syntax-markup-changed-bg: #ffd8b5; - --color-prettylights-syntax-markup-ignored-text: #d1d9e0; - --color-prettylights-syntax-markup-ignored-bg: #0550ae; - --color-prettylights-syntax-meta-diff-range: #8250df; - --color-prettylights-syntax-sublimelinter-gutter-mark: #818b98; - } - } - - .markdown-body { - -ms-text-size-adjust: 100%; - -webkit-text-size-adjust: 100%; - margin: 0; - color: var(--fgColor-default); - background-color: var(--bgColor-default); - font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"; - font-size: 16px; - line-height: 1.5; - word-wrap: break-word; - scroll-behavior: auto !important; - } - - .markdown-body .octicon { - display: inline-block; - fill: currentColor; - vertical-align: text-bottom; - } - - .markdown-body h1:hover .anchor .octicon-link:before, - .markdown-body h2:hover .anchor .octicon-link:before, - .markdown-body h3:hover .anchor .octicon-link:before, - .markdown-body h4:hover .anchor .octicon-link:before, - .markdown-body h5:hover .anchor .octicon-link:before, - .markdown-body h6:hover .anchor .octicon-link:before { - width: 16px; - height: 16px; - content: ' '; - display: inline-block; - background-color: currentColor; - -webkit-mask-image: url("data:image/svg+xml,"); - mask-image: url("data:image/svg+xml,"); - } - - .markdown-body details, - .markdown-body figcaption, - .markdown-body figure { - display: block; - } - - .markdown-body summary { - display: list-item; - } - - .markdown-body [hidden] { - display: none !important; - } - - .markdown-body a { - background-color: transparent; - color: var(--fgColor-accent); - text-decoration: none; - } - - .markdown-body abbr[title] { - border-bottom: none; - -webkit-text-decoration: underline dotted; - text-decoration: underline dotted; - } - - .markdown-body b, - .markdown-body strong { - font-weight: var(--base-text-weight-semibold, 600); - } - - .markdown-body dfn { - font-style: italic; - } - - .markdown-body h1 { - margin: .67em 0; - font-weight: var(--base-text-weight-semibold, 600); - padding-bottom: .3em; - font-size: 2em; - } - - .markdown-body mark { - background-color: var(--bgColor-attention-muted); - color: var(--fgColor-default); - } - - .markdown-body small { - font-size: 90%; - } - - .markdown-body sub, - .markdown-body sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; - } - - .markdown-body sub { - bottom: -0.25em; - } - - .markdown-body sup { - top: -0.5em; - } - - .markdown-body img { - border-style: none; - max-width: 100%; - box-sizing: content-box; - } - - .markdown-body code, - .markdown-body kbd, - .markdown-body pre, - .markdown-body samp { - font-family: monospace; - font-size: 1em; - } - - /* .markdown-body figure { - margin: 1em var(--base-size-40); - } - */ - .markdown-body hr { - box-sizing: content-box; - overflow: hidden; - background: transparent; - height: .25em; - padding: 0; - margin: var(--base-size-24) 0; - background-color: var(--borderColor-default); - border: 0; - } - - .markdown-body input { - font: inherit; - margin: 0; - overflow: visible; - font-family: inherit; - font-size: inherit; - line-height: inherit; - } - - .markdown-body [type=button], - .markdown-body [type=reset], - .markdown-body [type=submit] { - -webkit-appearance: button; - appearance: button; - } - - .markdown-body [type=checkbox], - .markdown-body [type=radio] { - box-sizing: border-box; - padding: 0; - } - - .markdown-body [type=number]::-webkit-inner-spin-button, - .markdown-body [type=number]::-webkit-outer-spin-button { - height: auto; - } - - .markdown-body [type=search]::-webkit-search-cancel-button, - .markdown-body [type=search]::-webkit-search-decoration { - -webkit-appearance: none; - appearance: none; - } - - .markdown-body ::-webkit-input-placeholder { - color: inherit; - opacity: .54; - } - - .markdown-body ::-webkit-file-upload-button { - -webkit-appearance: button; - appearance: button; - font: inherit; - } - - .markdown-body a:hover { - text-decoration: underline; - } - - .markdown-body ::placeholder { - color: var(--fgColor-muted); - opacity: 1; - } - - .markdown-body hr::before { - display: table; - content: ""; - } - - .markdown-body hr::after { - display: table; - clear: both; - content: ""; - } - - .markdown-body table { - border-spacing: 0; - border-collapse: collapse; - display: block; - width: max-content; - max-width: 100%; - overflow: auto; - } - - .markdown-body td, - .markdown-body th { - padding: 0; - } - - .markdown-body details summary { - cursor: pointer; - } - - .markdown-body a:focus, - .markdown-body [role=button]:focus, - .markdown-body input[type=radio]:focus, - .markdown-body input[type=checkbox]:focus { - outline: 2px solid var(--focus-outlineColor); - outline-offset: -2px; - box-shadow: none; - } - - .markdown-body a:focus:not(:focus-visible), - .markdown-body [role=button]:focus:not(:focus-visible), - .markdown-body input[type=radio]:focus:not(:focus-visible), - .markdown-body input[type=checkbox]:focus:not(:focus-visible) { - outline: solid 1px transparent; - } - - .markdown-body a:focus-visible, - .markdown-body [role=button]:focus-visible, - .markdown-body input[type=radio]:focus-visible, - .markdown-body input[type=checkbox]:focus-visible { - outline: 2px solid var(--focus-outlineColor); - outline-offset: -2px; - box-shadow: none; - } - - .markdown-body a:not([class]):focus, - .markdown-body a:not([class]):focus-visible, - .markdown-body input[type=radio]:focus, - .markdown-body input[type=radio]:focus-visible, - .markdown-body input[type=checkbox]:focus, - .markdown-body input[type=checkbox]:focus-visible { - outline-offset: 0; - } - - .markdown-body kbd { - display: inline-block; - padding: var(--base-size-4); - font: 11px var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace); - line-height: 10px; - color: var(--fgColor-default); - vertical-align: middle; - background-color: var(--bgColor-muted); - border: solid 1px var(--borderColor-neutral-muted); - border-bottom-color: var(--borderColor-neutral-muted); - border-radius: 6px; - box-shadow: inset 0 -1px 0 var(--borderColor-neutral-muted); - } - - .markdown-body h1, - .markdown-body h2, - .markdown-body h3, - .markdown-body h4, - .markdown-body h5, - .markdown-body h6 { - margin-top: var(--base-size-16); - margin-bottom: var(--base-size-16); - font-weight: var(--base-text-weight-semibold, 600); - line-height: 1.25; - } - - .markdown-body h2 { - font-weight: var(--base-text-weight-semibold, 600); - padding-bottom: .3em; - font-size: 1.5em; +.markdown { + font-size: var(--chakra-fontSizes-sm); +} - } - - .markdown-body h3 { - font-weight: var(--base-text-weight-semibold, 600); - font-size: 1.25em; - } - - .markdown-body h4 { - font-weight: var(--base-text-weight-semibold, 600); - font-size: 1em; - } - - .markdown-body h5 { - font-weight: var(--base-text-weight-semibold, 600); - font-size: .875em; - } - - .markdown-body h6 { - font-weight: var(--base-text-weight-semibold, 600); - font-size: .85em; - color: var(--fgColor-muted); - } - - .markdown-body p { - margin-top: 0; - margin-bottom: 10px; - } - - .markdown-body blockquote { - margin: 0; - padding: 0 1em; - color: var(--fgColor-muted); - border-left: .25em solid var(--borderColor-default); - } - - .markdown-body ul, - .markdown-body ol { - margin-top: 0; - margin-bottom: 0; - padding-left: 2em; - } - - .markdown-body ol ol, - .markdown-body ul ol { - list-style-type: lower-roman; - } - - .markdown-body ul ul ol, - .markdown-body ul ol ol, - .markdown-body ol ul ol, - .markdown-body ol ol ol { - list-style-type: lower-alpha; - } - - .markdown-body dd { - margin-left: 0; - } - - .markdown-body tt, - .markdown-body code, - .markdown-body samp { - font-family: var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace); - font-size: 12px; - } - - .markdown-body pre { - margin-top: 0; - margin-bottom: 0; - font-family: var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace); - font-size: 12px; - word-wrap: normal; - } - - .markdown-body .octicon { - display: inline-block; - overflow: visible !important; - vertical-align: text-bottom; - fill: currentColor; - } - - .markdown-body input::-webkit-outer-spin-button, - .markdown-body input::-webkit-inner-spin-button { - margin: 0; - -webkit-appearance: none; - appearance: none; - } - - .markdown-body .mr-2 { - margin-right: var(--base-size-8, 8px) !important; - } - - .markdown-body::before { - display: table; - content: ""; - } - - .markdown-body::after { - display: table; - clear: both; - content: ""; - } - - .markdown-body>*:first-child { - margin-top: 0 !important; - } - - .markdown-body>*:last-child { - margin-bottom: 0 !important; - } - - .markdown-body a:not([href]) { - color: inherit; - text-decoration: none; - } - - .markdown-body .absent { - color: var(--fgColor-danger); - } - - .markdown-body .anchor { - float: left; - padding-right: var(--base-size-4); - margin-left: -20px; - line-height: 1; - } - - .markdown-body .anchor:focus { - outline: none; - } - - .markdown-body p, - .markdown-body blockquote, - .markdown-body ul, - .markdown-body ol, - .markdown-body dl, - .markdown-body table, - .markdown-body pre, - .markdown-body details { - margin-top: 0; - margin-bottom: var(--base-size-16); - } - - .markdown-body blockquote>:first-child { - margin-top: 0; - } - - .markdown-body blockquote>:last-child { - margin-bottom: 0; - } - - .markdown-body h1 .octicon-link, - .markdown-body h2 .octicon-link, - .markdown-body h3 .octicon-link, - .markdown-body h4 .octicon-link, - .markdown-body h5 .octicon-link, - .markdown-body h6 .octicon-link { - color: var(--fgColor-default); - vertical-align: middle; - visibility: hidden; - } - - .markdown-body h1:hover .anchor, - .markdown-body h2:hover .anchor, - .markdown-body h3:hover .anchor, - .markdown-body h4:hover .anchor, - .markdown-body h5:hover .anchor, - .markdown-body h6:hover .anchor { - text-decoration: none; - } - - .markdown-body h1:hover .anchor .octicon-link, - .markdown-body h2:hover .anchor .octicon-link, - .markdown-body h3:hover .anchor .octicon-link, - .markdown-body h4:hover .anchor .octicon-link, - .markdown-body h5:hover .anchor .octicon-link, - .markdown-body h6:hover .anchor .octicon-link { - visibility: visible; - } - - .markdown-body h1 tt, - .markdown-body h1 code, - .markdown-body h2 tt, - .markdown-body h2 code, - .markdown-body h3 tt, - .markdown-body h3 code, - .markdown-body h4 tt, - .markdown-body h4 code, - .markdown-body h5 tt, - .markdown-body h5 code, - .markdown-body h6 tt, - .markdown-body h6 code { - padding: 0 .2em; - font-size: inherit; - } - - .markdown-body summary h1, - .markdown-body summary h2, - .markdown-body summary h3, - .markdown-body summary h4, - .markdown-body summary h5, - .markdown-body summary h6 { - display: inline-block; - } - - .markdown-body summary h1 .anchor, - .markdown-body summary h2 .anchor, - .markdown-body summary h3 .anchor, - .markdown-body summary h4 .anchor, - .markdown-body summary h5 .anchor, - .markdown-body summary h6 .anchor { - margin-left: -40px; - } - - .markdown-body summary h1, - .markdown-body summary h2 { - padding-bottom: 0; - border-bottom: 0; - } - - .markdown-body ul.no-list, - .markdown-body ol.no-list { - padding: 0; - list-style-type: none; - } - - .markdown-body ol[type="a s"] { - list-style-type: lower-alpha; - } - - .markdown-body ol[type="A s"] { - list-style-type: upper-alpha; - } - - .markdown-body ol[type="i s"] { - list-style-type: lower-roman; - } - - .markdown-body ol[type="I s"] { - list-style-type: upper-roman; - } - - .markdown-body ol[type="1"] { - list-style-type: decimal; - } - - .markdown-body div>ol:not([type]) { - list-style-type: decimal; - } - - .markdown-body ul ul, - .markdown-body ul ol, - .markdown-body ol ol, - .markdown-body ol ul { - margin-top: 0; - margin-bottom: 0; - } - - .markdown-body li>p { - margin-top: var(--base-size-16); - } - - .markdown-body li+li { - margin-top: .25em; - } - - .markdown-body dl { - padding: 0; - } - - .markdown-body dl dt { - padding: 0; - margin-top: var(--base-size-16); - font-size: 1em; - font-style: italic; - font-weight: var(--base-text-weight-semibold, 600); - } - - .markdown-body dl dd { - padding: 0 var(--base-size-16); - margin-bottom: var(--base-size-16); - } - - .markdown-body table th { - font-weight: var(--base-text-weight-semibold, 600); - } - - .markdown-body table th, - .markdown-body table td { - padding: 6px 13px; - border: 1px solid var(--borderColor-default); - } - - .markdown-body table td>:last-child { - margin-bottom: 0; - } - - .markdown-body table tr { - background-color: var(--bgColor-default); - border-top: 1px solid var(--borderColor-muted); - } - - .markdown-body table tr:nth-child(2n) { - background-color: var(--bgColor-muted); - } - - .markdown-body table img { - background-color: transparent; - } - - .markdown-body img[align=right] { - padding-left: 20px; - } - - .markdown-body img[align=left] { - padding-right: 20px; - } - - .markdown-body .emoji { - max-width: none; - vertical-align: text-top; - background-color: transparent; - } - - .markdown-body span.frame { - display: block; - overflow: hidden; - } - - .markdown-body span.frame>span { - display: block; - float: left; - width: auto; - padding: 7px; - margin: 13px 0 0; - overflow: hidden; - border: 1px solid var(--borderColor-default); - } - - .markdown-body span.frame span img { - display: block; - float: left; - } - - .markdown-body span.frame span span { - display: block; - padding: 5px 0 0; - clear: both; - color: var(--fgColor-default); - } - - .markdown-body span.align-center { - display: block; - overflow: hidden; - clear: both; - } - - .markdown-body span.align-center>span { - display: block; - margin: 13px auto 0; - overflow: hidden; - text-align: center; - } - - .markdown-body span.align-center span img { - margin: 0 auto; - text-align: center; - } - - .markdown-body span.align-right { - display: block; - overflow: hidden; - clear: both; - } - - .markdown-body span.align-right>span { - display: block; - margin: 13px 0 0; - overflow: hidden; - text-align: right; - } - - .markdown-body span.align-right span img { - margin: 0; - text-align: right; - } - - .markdown-body span.float-left { - display: block; - float: left; - margin-right: 13px; - overflow: hidden; - } - - .markdown-body span.float-left span { - margin: 13px 0 0; - } - - .markdown-body span.float-right { - display: block; - float: right; - margin-left: 13px; - overflow: hidden; - } - - .markdown-body span.float-right>span { - display: block; - margin: 13px auto 0; - overflow: hidden; - text-align: right; - } - - .markdown-body code, - .markdown-body tt { - padding: .2em .4em; - margin: 0; - font-size: 85%; - white-space: break-spaces; - background-color: var(--bgColor-neutral-muted); - border-radius: 6px; - } - - .markdown-body code br, - .markdown-body tt br { - display: none; - } - - .markdown-body del code { - text-decoration: inherit; - } - - .markdown-body samp { - font-size: 85%; - } - - .markdown-body pre code { - font-size: 100%; - } - - .markdown-body pre>code { - padding: 4px; - margin: 0; - word-break: normal; - /* white-space: pre; */ - background: transparent; - border: 0; - } - - .markdown-body .highlight { - margin-bottom: var(--base-size-16); - } - - .markdown-body .highlight pre { - margin-bottom: 0; - word-break: normal; - } - - .markdown-body .highlight pre, - .markdown-body pre { - padding: var(--base-size-16); - overflow: auto; - font-size: 85%; - line-height: 1.45; - color: var(--fgColor-default); - background-color: var(--bgColor-muted); - border-radius: 6px; - } - - .markdown-body pre code, - .markdown-body pre tt { - display: inline; - max-width: auto; - padding: 2px; - margin: 2px; - overflow: visible; - line-height: inherit; - word-wrap: normal; - background-color: transparent; - border: 0; - } - - .markdown-body .csv-data td, - .markdown-body .csv-data th { - padding: 5px; - overflow: hidden; - font-size: 12px; - line-height: 1; - text-align: left; - white-space: nowrap; - } - - .markdown-body .csv-data .blob-num { - padding: 10px var(--base-size-8) 9px; - text-align: right; - background: var(--bgColor-default); - border: 0; - } - - .markdown-body .csv-data tr { - border-top: 0; - } - - .markdown-body .csv-data th { - font-weight: var(--base-text-weight-semibold, 600); - background: var(--bgColor-muted); - border-top: 0; - } - - .markdown-body [data-footnote-ref]::before { - content: "["; - } - - .markdown-body [data-footnote-ref]::after { - content: "]"; - } - - .markdown-body .footnotes { - font-size: 12px; - color: var(--fgColor-muted); - border-top: 1px solid var(--borderColor-default); - } - - .markdown-body .footnotes ol { - padding-left: var(--base-size-16); - } - - .markdown-body .footnotes ol ul { - display: inline-block; - padding-left: var(--base-size-16); - margin-top: var(--base-size-16); - } - - .markdown-body .footnotes li { - position: relative; - } - - .markdown-body .footnotes li:target::before { - position: absolute; - top: calc(var(--base-size-8)*-1); - right: calc(var(--base-size-8)*-1); - bottom: calc(var(--base-size-8)*-1); - left: calc(var(--base-size-24)*-1); - pointer-events: none; - content: ""; - border: 2px solid var(--borderColor-accent-emphasis); - border-radius: 6px; - } - - .markdown-body .footnotes li:target { - color: var(--fgColor-default); - } - - .markdown-body .footnotes .data-footnote-backref g-emoji { - font-family: monospace; - } - - .markdown-body .pl-c { - color: var(--color-prettylights-syntax-comment); - } - - .markdown-body .pl-c1, - .markdown-body .pl-s .pl-v { - color: var(--color-prettylights-syntax-constant); - } - - .markdown-body .pl-e, - .markdown-body .pl-en { - color: var(--color-prettylights-syntax-entity); - } - - .markdown-body .pl-smi, - .markdown-body .pl-s .pl-s1 { - color: var(--color-prettylights-syntax-storage-modifier-import); - } - - .markdown-body .pl-ent { - color: var(--color-prettylights-syntax-entity-tag); - } - - .markdown-body .pl-k { - color: var(--color-prettylights-syntax-keyword); - } - - .markdown-body .pl-s, - .markdown-body .pl-pds, - .markdown-body .pl-s .pl-pse .pl-s1, - .markdown-body .pl-sr, - .markdown-body .pl-sr .pl-cce, - .markdown-body .pl-sr .pl-sre, - .markdown-body .pl-sr .pl-sra { - color: var(--color-prettylights-syntax-string); - } - - .markdown-body .pl-v, - .markdown-body .pl-smw { - color: var(--color-prettylights-syntax-variable); - } - - .markdown-body .pl-bu { - color: var(--color-prettylights-syntax-brackethighlighter-unmatched); - } - - .markdown-body .pl-ii { - color: var(--color-prettylights-syntax-invalid-illegal-text); - background-color: var(--color-prettylights-syntax-invalid-illegal-bg); - } - - .markdown-body .pl-c2 { - color: var(--color-prettylights-syntax-carriage-return-text); - background-color: var(--color-prettylights-syntax-carriage-return-bg); - } - - .markdown-body .pl-sr .pl-cce { - font-weight: bold; - color: var(--color-prettylights-syntax-string-regexp); - } - - .markdown-body .pl-ml { - color: var(--color-prettylights-syntax-markup-list); - } - - .markdown-body .pl-mh, - .markdown-body .pl-mh .pl-en, - .markdown-body .pl-ms { - font-weight: bold; - color: var(--color-prettylights-syntax-markup-heading); - } - - .markdown-body .pl-mi { - font-style: italic; - color: var(--color-prettylights-syntax-markup-italic); - } - - .markdown-body .pl-mb { - font-weight: bold; - color: var(--color-prettylights-syntax-markup-bold); - } - - .markdown-body .pl-md { - color: var(--color-prettylights-syntax-markup-deleted-text); - background-color: var(--color-prettylights-syntax-markup-deleted-bg); - } - - .markdown-body .pl-mi1 { - color: var(--color-prettylights-syntax-markup-inserted-text); - background-color: var(--color-prettylights-syntax-markup-inserted-bg); - } - - .markdown-body .pl-mc { - color: var(--color-prettylights-syntax-markup-changed-text); - background-color: var(--color-prettylights-syntax-markup-changed-bg); - } - - .markdown-body .pl-mi2 { - color: var(--color-prettylights-syntax-markup-ignored-text); - background-color: var(--color-prettylights-syntax-markup-ignored-bg); - } - - .markdown-body .pl-mdr { - font-weight: bold; - color: var(--color-prettylights-syntax-meta-diff-range); - } - - .markdown-body .pl-ba { - color: var(--color-prettylights-syntax-brackethighlighter-angle); - } - - .markdown-body .pl-sg { - color: var(--color-prettylights-syntax-sublimelinter-gutter-mark); - } - - .markdown-body .pl-corl { - text-decoration: underline; - color: var(--color-prettylights-syntax-constant-other-reference-link); - } - - .markdown-body [role=button]:focus:not(:focus-visible), - .markdown-body [role=tabpanel][tabindex="0"]:focus:not(:focus-visible), - .markdown-body button:focus:not(:focus-visible), - .markdown-body summary:focus:not(:focus-visible), - .markdown-body a:focus:not(:focus-visible) { - outline: none; - box-shadow: none; - } - - .markdown-body [tabindex="0"]:focus:not(:focus-visible), - .markdown-body details-dialog:focus:not(:focus-visible) { - outline: none; - } - - .markdown-body g-emoji { - display: inline-block; - min-width: 1ch; - font-family: "Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; - font-size: 1em; - font-style: normal !important; - font-weight: var(--base-text-weight-normal, 400); - line-height: 1; - vertical-align: -0.075em; - } - - .markdown-body g-emoji img { - width: 1em; - height: 1em; - } - - .markdown-body .task-list-item { - list-style-type: none; - } - - .markdown-body .task-list-item label { - font-weight: var(--base-text-weight-normal, 400); - } - - .markdown-body .task-list-item.enabled label { - cursor: pointer; - } - - .markdown-body .task-list-item+.task-list-item { - margin-top: var(--base-size-4); - } - - .markdown-body .task-list-item .handle { - display: none; - } - - .markdown-body .task-list-item-checkbox { - margin: 0 .2em .25em -1.4em; - vertical-align: middle; - } - - .markdown-body ul:dir(rtl) .task-list-item-checkbox { - margin: 0 -1.6em .25em .2em; - } - - .markdown-body ol:dir(rtl) .task-list-item-checkbox { - margin: 0 -1.6em .25em .2em; - } - - .markdown-body .contains-task-list:hover .task-list-item-convert-container, - .markdown-body .contains-task-list:focus-within .task-list-item-convert-container { - display: block; - width: auto; - height: 24px; - overflow: visible; - clip: auto; - } - - .markdown-body ::-webkit-calendar-picker-indicator { - filter: invert(50%); - } - - .markdown-body .markdown-alert { - padding: var(--base-size-8) var(--base-size-16); - margin-bottom: var(--base-size-16); - color: inherit; - border-left: .25em solid var(--borderColor-default); - } - - .markdown-body .markdown-alert>:first-child { - margin-top: 0; - } - - .markdown-body .markdown-alert>:last-child { - margin-bottom: 0; - } - - .markdown-body .markdown-alert .markdown-alert-title { - display: flex; - font-weight: var(--base-text-weight-medium, 500); - align-items: center; - line-height: 1; - } - - .markdown-body .markdown-alert.markdown-alert-note { - border-left-color: var(--borderColor-accent-emphasis); - } - - .markdown-body .markdown-alert.markdown-alert-note .markdown-alert-title { - color: var(--fgColor-accent); - } - - .markdown-body .markdown-alert.markdown-alert-important { - border-left-color: var(--borderColor-done-emphasis); - } - - .markdown-body .markdown-alert.markdown-alert-important .markdown-alert-title { - color: var(--fgColor-done); - } - - .markdown-body .markdown-alert.markdown-alert-warning { - border-left-color: var(--borderColor-attention-emphasis); - } - - .markdown-body .markdown-alert.markdown-alert-warning .markdown-alert-title { - color: var(--fgColor-attention); - } - - .markdown-body .markdown-alert.markdown-alert-tip { - border-left-color: var(--borderColor-success-emphasis); - } - - .markdown-body .markdown-alert.markdown-alert-tip .markdown-alert-title { - color: var(--fgColor-success); - } - - .markdown-body .markdown-alert.markdown-alert-caution { - border-left-color: var(--borderColor-danger-emphasis); - } - - .markdown-body .markdown-alert.markdown-alert-caution .markdown-alert-title { - color: var(--fgColor-danger); - } - - .markdown-body>*:first-child>.heading-element:first-child { - margin-top: 0 !important; - } +.break-word { + word-break: break-all; +} -.markdown-body ol, -.markdown-body ul { - list-style: initial; /* Ensure list markers are displayed */ - padding-left: 1.5em; /* Add padding to the left for indentation */ - margin-bottom: 1em; /* Add margin to the bottom for spacing */ +.note-markdown { + font-size: 11px; + font-weight: 400; + line-height: 12px; } -.markdown-body ol { - list-style-type: decimal; /* Use numbers for ordered lists */ +.markdown .ql-editor { + padding: 0; } -.markdown-body ul { - list-style-type: disc; /* Use dots for unordered lists */ +.markdown ol, +.markdown ul { + padding-left: 1.5rem; } -.markdown-body li { - margin-bottom: 0.5em; /* Add margin to the bottom of list items */ +.ql-snow .ql-picker.ql-size .ql-picker-label::before, +.ql-snow .ql-picker.ql-size .ql-picker-item::before { + content: attr(data-value); } \ No newline at end of file diff --git a/dashboard/src/components/features/Sidebar/BoardColumn.tsx b/dashboard/src/components/features/Sidebar/BoardColumn.tsx new file mode 100644 index 0000000..a79cc39 --- /dev/null +++ b/dashboard/src/components/features/Sidebar/BoardColumn.tsx @@ -0,0 +1,242 @@ +import { SortableContext, useSortable } from "@dnd-kit/sortable"; +import { type UniqueIdentifier } from "@dnd-kit/core"; +import { CSS } from "@dnd-kit/utilities"; +import { useCallback, useContext, useMemo, useState } from "react"; +import { cva } from "class-variance-authority"; +import { GripVertical, Plus, Trash2 } from "lucide-react"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@radix-ui/react-scroll-area"; +import { Task, TaskCard } from "./TaskCard"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Label } from "@/components/ui/label"; +import { AsyncDropdownWithoutForm } from "@/components/common/AsyncDropdown/AsyncDropdown"; +import { FrappeConfig, FrappeContext } from "frappe-react-sdk"; +import { Input } from "@/components/ui/input"; + +export interface Column { + id: UniqueIdentifier; + title: string; +} + +export type ColumnType = "Column"; + +export interface ColumnDragData { + type: ColumnType; + column: Column; +} + +interface BoardColumnProps { + column: Column; + tasks: Task[]; + isOverlay?: boolean; + index: number; + handleDeleteTask: (id: UniqueIdentifier) => void; // 👈 Added delete function + handleDeleteColumn: (id: UniqueIdentifier) => void; // 👈 Added delete function + handleAddTask: (task: Task) => void; // 👈 Added add function + commitDocs: string + handleUpdateColumnTitle: (id: UniqueIdentifier, title: string) => void; // 👈 Added update function +} + +export function BoardColumn({ column, index, tasks, isOverlay, handleDeleteTask, handleDeleteColumn, handleAddTask, commitDocs, handleUpdateColumnTitle }: BoardColumnProps) { + const tasksIds = useMemo(() => tasks.map((task) => task.id), [tasks]); + + const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({ + id: column.id, + data: { type: "Column", column } satisfies ColumnDragData, + attributes: { roleDescription: `Column: ${column.title}` }, + }); + + const style = { transition, transform: CSS.Translate.toString(transform) }; + + const variants = cva( + "h-[40vh] max-h-[40vh] w-auto bg-zinc-100 dark:bg-white/[0.04] flex flex-col flex-shrink-0 rounded-lg border", + { + variants: { + dragging: { + default: "border-2 border-transparent", + over: "ring-2 opacity-30", + overlay: "ring-2 ring-primary", + }, + }, + } + ); + + const [title, setTitle] = useState(null); + + const { call } = useContext(FrappeContext) as FrappeConfig; + + const [open, setOpen] = useState(false); + + const addTask = useCallback(() => { + if (title) { + call.get('frappe.client.get_value', { + doctype: 'Commit Docs Page', + filters: title, + fieldname: ['route', 'title'] + }).then((res: any) => { + const task = { + id: title, + columnId: column.id, + title: res.message.title, + route: res.message.route + } + handleAddTask(task); + setTitle(null); + setOpen(false); + }) + } else { + setOpen(false); + setTitle(null); + } + }, [title, column.id, call, handleAddTask, setTitle, setOpen]); + + const [isEditing, setIsEditing] = useState(false); + const [newTitle, setNewTitle] = useState(column.title); + + const handleBlur = () => { + setIsEditing(false); + if (newTitle.trim() !== "" && newTitle !== column.title) { + handleUpdateColumnTitle(column.id, newTitle); + } + }; + + return ( + + {/* Sticky Header */} + + + {index + 1}. + {isEditing ? ( + setNewTitle(e.target.value)} + onBlur={handleBlur} + onKeyDown={(e) => e.key === "Enter" && handleBlur()} + autoFocus + className="border-none shadow-none h-4 bg-transparent p-1 :focus:ring-0 focus:outline-none focus-visible:ring-0 focus-visible:border-none" + /> + ) : ( +

setIsEditing(true)} + > + {column.title} +

+ )} +
+ +
+ { + setOpen(open); + if (!open) { + setTitle(null); + } + } + } + > + + {/* */} + + + +
+
+

Add new

+

+ Add a new documentation page under {column.title} +

+
+
+
+
+ + * +
+ { + setTitle(null); + setOpen(false); + }} + > + Cancel + + +
+
+ + + {/* Delete Button */} + + + {/* Drag Handle */} + +
+ + + {/* Scrollable Task List */} +
+ + + + {tasks.map((task) => ( + + ))} + + + +
+ + ); +} diff --git a/dashboard/src/components/features/Sidebar/SidebarBoard.tsx b/dashboard/src/components/features/Sidebar/SidebarBoard.tsx new file mode 100644 index 0000000..d1bbf71 --- /dev/null +++ b/dashboard/src/components/features/Sidebar/SidebarBoard.tsx @@ -0,0 +1,361 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + DndContext, + DragEndEvent, + useSensor, + useSensors, + MouseSensor, + TouchSensor, + DragOverlay, + UniqueIdentifier, + DragOverEvent, +} from '@dnd-kit/core'; +import { arrayMove, SortableContext } from '@dnd-kit/sortable'; +import { BoardColumn, Column } from './BoardColumn'; +import { Task, TaskCard } from './TaskCard'; +import { Plus, Save } from 'lucide-react'; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useFrappePostCall } from 'frappe-react-sdk'; +import { useToast } from '@/components/ui/use-toast'; +import { ErrorBanner } from '@/components/common/ErrorBanner/ErrorBanner'; +import { SpinnerLoader } from '@/components/common/FullPageLoader/SpinnerLoader'; + +export function SidebarBoard({ + defaultCols, + initialTasks, + mutate, + commitDocs +}: { + defaultCols: Column[]; + initialTasks: Task[]; + mutate: VoidFunction; + commitDocs: string; +}) { + const [parentLabels, setParentLabels] = useState(defaultCols); + const [docsPage, setDocsPage] = useState(initialTasks); + const [activeItem, setActiveItem] = useState<{ + type: 'column' | 'task'; + id: string; + } | null>(null); + + const columnIds = useMemo(() => parentLabels.map((col) => col.id), [parentLabels]); + + const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor)); + + const [isModified, setIsModified] = useState(false); + + // Synchronize state with props when defaultCols or initialTasks change + useEffect(() => { + setParentLabels(defaultCols); + }, [defaultCols]); + + useEffect(() => { + setDocsPage(initialTasks); + }, [initialTasks]); + + function onDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (!over) return; + + const activeType = activeItem?.type; + + setIsModified(true); + if (activeType === 'task') { + const activeTaskIndex = docsPage.findIndex((t) => t.id === active.id); + if (activeTaskIndex === -1) return; + + const activeTask = docsPage[activeTaskIndex]; + + // Dropped on another task + const overTaskIndex = docsPage.findIndex((t) => t.id === over.id); + if (overTaskIndex !== -1) { + const overTask = docsPage[overTaskIndex]; + + setDocsPage((prevTasks) => { + const updatedTasks = [...prevTasks.filter((t) => t.id !== active.id)]; + updatedTasks.splice(overTaskIndex, 0, { + ...activeTask, + columnId: overTask.columnId, + }); + return updatedTasks; + }); + + return; + } + + // Dropped on a column + const overColumn = parentLabels.find((col) => col.id === over.id); + if (overColumn) { + setDocsPage((prevTasks) => + prevTasks.map((t) => + t.id === active.id ? { ...t, columnId: overColumn.id } : t + ) + ); + } + } else if (activeType === 'column') { + const oldIndex = parentLabels.findIndex((col) => col.id === active.id); + const newIndex = parentLabels.findIndex((col) => col.id === over.id); + + if (oldIndex !== -1 && newIndex !== -1) { + setParentLabels((prev) => arrayMove(prev, oldIndex, newIndex)); + } + } + + setActiveItem(null); + } + + const handleDeleteTask = (taskId: UniqueIdentifier) => { + // Remove the task from the tasks state + setDocsPage((prevTasks) => prevTasks.filter((task) => task.id !== taskId)); + // set modified to true + setIsModified(true); + // Optionally, you can also remove the task from the column it belongs to + } + + const handleDeleteColumn = (id: UniqueIdentifier) => { + // Remove the column from the columns state + setParentLabels((prev) => prev.filter((col) => col.id !== id)); + // Remove tasks associated with the column + setDocsPage((prevTasks) => prevTasks.filter((task) => task.columnId !== id)); + // set modified to true + setIsModified(true); + }; + + const handleAddTask = (task: Task) => { + // Add the new task to the tasks state + setDocsPage((prevTasks) => [...prevTasks, task]); + // set modified to true + setIsModified(true); + } + + const [open, setOpen] = useState(false); + + const handleColumnCreate = (label: string) => { + // Create a new column with the provided label + const newColumn: Column = { + id: label, + title: label, + }; + setParentLabels((prev) => [...prev, newColumn]); + // set modified to true + setIsModified(true); + } + const handleClose = () => { + setOpen(false); + } + + const handleUpdateColumnTitle = (id: UniqueIdentifier, newTitle: string) => { + setParentLabels((prev) => + prev.map((col) => (col.id === id ? { ...col, title: newTitle, id: newTitle } : col)) + ); + + // Update the tasks associated with the column + setDocsPage((prevTasks) => + prevTasks.map((task) => + task.columnId === id ? { ...task, columnId: newTitle } : task + ) + ); + // set modified to true + setIsModified(true); + }; + + const { call, error, loading } = useFrappePostCall('commit.commit.doctype.commit_docs.commit_docs.manage_sidebar') + + const { toast } = useToast(); + + const handleSave = () => { + // Save the columns and tasks to the database or perform any other action + + const sequenceParentLabels = parentLabels.map((col) => col.id); + const pagesWithIndex = docsPage.map((task, index) => ({ + ...task, + index: index, + })); + + call({ + commit_doc: commitDocs, + parent_labels: JSON.stringify(sequenceParentLabels), + docs_page: JSON.stringify(pagesWithIndex), + }).then((res: any) => { + if (res.message) { + mutate(); + setIsModified(false); + toast({ + description: "Sidebar structure updated successfully", + duration: 1500, + }); + + } + }) + } + + function onDragOver(event: DragOverEvent) { + const { active, over } = event; + if (!over) return; + + const activeType = activeItem?.type; + + if (activeType === 'task') { + const activeTaskIndex = docsPage.findIndex((t) => t.id === active.id); + if (activeTaskIndex === -1) return; + + const activeTask = docsPage[activeTaskIndex]; + + // Check if the task is hovering over a different board + const overColumn = parentLabels.find((col) => col.id === over.id); + if (overColumn && activeTask.columnId !== overColumn.id) { + setDocsPage((prevTasks) => { + // Temporarily add the active task to the target board + const updatedTasks = prevTasks.filter((t) => t.id !== active.id); + return [ + ...updatedTasks, + { ...activeTask, columnId: overColumn.id }, + ]; + }); + } + } + } + + return ( +
+
+
+
+

+ Sidebar Structure +

+ {/* Helper Text Below Heading */} +

+ The order of the board cards matches the sidebar sequence in the documentation. +

+ +
+
+ + +
+
+ { + if (parentLabels.find((c) => c.id === event.active.id)) { + // @ts-expect-error + setActiveItem({ type: 'column', id: event.active.id }); + } else { + // @ts-expect-error + setActiveItem({ type: 'task', id: event.active.id }); + } + }} + onDragEnd={onDragEnd} + onDragOver={onDragOver} + onDragCancel={() => setActiveItem(null)} + > +
+ + {parentLabels.map((col, index) => ( + task.columnId === col.id)} + handleDeleteTask={handleDeleteTask} + handleDeleteColumn={handleDeleteColumn} + handleAddTask={handleAddTask} + commitDocs={commitDocs} + handleUpdateColumnTitle={handleUpdateColumnTitle} + /> + ))} + +
+ + + {activeItem ? ( + activeItem.type === 'task' ? ( + t.id === activeItem.id)!} onDelete={handleDeleteTask} /> + ) : ( + // Show the column along with its tasks in DragOverlay + c.id === activeItem.id)!} + index={parentLabels.findIndex((c) => c.id === activeItem.id)} + tasks={docsPage.filter((task) => task.columnId === activeItem.id)} + handleDeleteTask={handleDeleteTask} + handleDeleteColumn={handleDeleteColumn} + handleAddTask={handleAddTask} + commitDocs={commitDocs} + handleUpdateColumnTitle={handleUpdateColumnTitle} + /> + ) + ) : null} + +
+
+ + + +
+ ); +} + +interface CreateParentLabel { + onColumnCreate: (label: string) => void + onClose: () => void + open: boolean +} + + +const CreateNewParentLabel = ({ onColumnCreate, onClose, open }: CreateParentLabel) => { + + const [label, setLabel] = useState('') + + useEffect(() => { + setLabel('') + }, [open]) + + const onSubmit = () => { + if (label) { + onColumnCreate(label) + onClose() + } + } + + return ( + + + Add Parent Label + +
+
+ + * +
+ setLabel(e.target.value)} placeholder="Enter a label" /> +
+ + + +
+ ) +} \ No newline at end of file diff --git a/dashboard/src/components/features/Sidebar/TaskCard.tsx b/dashboard/src/components/features/Sidebar/TaskCard.tsx new file mode 100644 index 0000000..78a2703 --- /dev/null +++ b/dashboard/src/components/features/Sidebar/TaskCard.tsx @@ -0,0 +1,85 @@ +import { useState } from "react"; +import type { UniqueIdentifier } from "@dnd-kit/core"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { Card, CardHeader } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { GripVertical, Trash2, ChevronLeft, ChevronRight } from "lucide-react"; + +export interface Task { + id: UniqueIdentifier; + columnId: any; + title: string; + route: string; +} + +export function TaskCard({ task, onDelete }: { task: Task; onDelete: (id: UniqueIdentifier) => void }) { + const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({ + id: task.id, + data: { type: "Task", task }, + attributes: { roleDescription: "Task" }, + }); + + const style = { + transition, + transform: transform ? CSS.Translate.toString(transform) : undefined, // Apply transform only if it exists + opacity: isDragging ? 0.5 : 1, // Ensure only the dragged task has reduced opacity + zIndex: isDragging ? 10 : "auto", // Bring the dragged task to the front + }; + + const [isRevealed, setIsRevealed] = useState(false); + + return ( +
+ {/* Delete Button (Hidden until revealed) */} + {isRevealed && ( + + )} + + {/* Task Card */} + + + {task.title} +
+ {/* Drag Handle */} +
+ + {/* Left Arrow Button (Reveals Delete Button) */} + +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/dashboard/src/components/features/Sidebar/multipleContainerKeyboardPreset.ts b/dashboard/src/components/features/Sidebar/multipleContainerKeyboardPreset.ts new file mode 100644 index 0000000..058fd86 --- /dev/null +++ b/dashboard/src/components/features/Sidebar/multipleContainerKeyboardPreset.ts @@ -0,0 +1,109 @@ +import { + closestCorners, + getFirstCollision, + KeyboardCode, + DroppableContainer, + KeyboardCoordinateGetter, + } from "@dnd-kit/core"; + + const directions: string[] = [ + KeyboardCode.Down, + KeyboardCode.Right, + KeyboardCode.Up, + KeyboardCode.Left, + ]; + + export const coordinateGetter: KeyboardCoordinateGetter = ( + event, + { context: { active, droppableRects, droppableContainers, collisionRect } } + ) => { + if (directions.includes(event.code)) { + event.preventDefault(); + + if (!active || !collisionRect) { + return; + } + + const filteredContainers: DroppableContainer[] = []; + + droppableContainers.getEnabled().forEach((entry) => { + if (!entry || entry?.disabled) { + return; + } + + const rect = droppableRects.get(entry.id); + + if (!rect) { + return; + } + + const data = entry.data.current; + + if (data) { + const { type, children } = data; + + if (type === "Column" && children?.length > 0) { + if (active.data.current?.type !== "Column") { + return; + } + } + } + + switch (event.code) { + case KeyboardCode.Down: + if (active.data.current?.type === "Column") { + return; + } + if (collisionRect.top < rect.top) { + // find all droppable areas below + filteredContainers.push(entry); + } + break; + case KeyboardCode.Up: + if (active.data.current?.type === "Column") { + return; + } + if (collisionRect.top > rect.top) { + // find all droppable areas above + filteredContainers.push(entry); + } + break; + case KeyboardCode.Left: + if (collisionRect.left >= rect.left + rect.width) { + // find all droppable areas to left + filteredContainers.push(entry); + } + break; + case KeyboardCode.Right: + // find all droppable areas to right + if (collisionRect.left + collisionRect.width <= rect.left) { + filteredContainers.push(entry); + } + break; + } + }); + const collisions = closestCorners({ + active, + collisionRect: collisionRect, + droppableRects, + droppableContainers: filteredContainers, + pointerCoordinates: null, + }); + const closestId = getFirstCollision(collisions, "id"); + + if (closestId != null) { + const newDroppable = droppableContainers.get(closestId); + const newNode = newDroppable?.node.current; + const newRect = newDroppable?.rect.current; + + if (newNode && newRect) { + return { + x: newRect.left, + y: newRect.top, + }; + } + } + } + + return undefined; + }; \ No newline at end of file diff --git a/dashboard/src/components/features/Sidebar/utils.ts b/dashboard/src/components/features/Sidebar/utils.ts new file mode 100644 index 0000000..44db552 --- /dev/null +++ b/dashboard/src/components/features/Sidebar/utils.ts @@ -0,0 +1,32 @@ +import { Active, DataRef, Over } from "@dnd-kit/core"; +import { ColumnDragData } from "./BoardColumn"; +import { UniqueIdentifier } from "@dnd-kit/core"; + +export type TaskDragData = { + type: "Task"; // Identifies the draggable type as a task + task: { + id: UniqueIdentifier; // Unique identifier for the task + columnId: UniqueIdentifier; // ID of the column the task belongs to + title: string; // Title of the task + }; +}; + +type DraggableData = ColumnDragData | TaskDragData; + +export function hasDraggableData( + entry: T | null | undefined +): entry is T & { + data: DataRef; +} { + if (!entry) { + return false; + } + + const data = entry.data.current; + + if (data?.type === "Column" || data?.type === "Task") { + return true; + } + + return false; +} \ No newline at end of file diff --git a/dashboard/src/components/features/documentation/DocsMainPage.tsx b/dashboard/src/components/features/documentation/DocsMainPage.tsx new file mode 100644 index 0000000..b002444 --- /dev/null +++ b/dashboard/src/components/features/documentation/DocsMainPage.tsx @@ -0,0 +1,152 @@ +import { Link, useParams } from "react-router-dom"; +import { useGetCommitDocsDetails } from "../meta_apps/useGetCommitDocsDetails"; +import { web_url } from "@/config/socket"; +import { Button } from "@/components/ui/button"; +import { Earth } from "lucide-react"; +import { convertFrappeDateStringToTimeAgo } from "@/lib/utils"; +import { ErrorBanner } from "@/components/common/ErrorBanner/ErrorBanner"; +import { Skeleton } from "@/components/ui/skeleton"; + +export const DocsMainPage = () => { + const { ID } = useParams(); + + if (ID) { + return ; + } + + return null; +}; + +const MainPage = ({ ID }: { ID: string }) => { + const { data, error, isLoading } = useGetCommitDocsDetails(ID, true); + + const greeting = () => { + const hours = new Date().getHours(); + + if (hours >= 0 && hours < 12) { + return "Good Morning"; + } else if (hours >= 12 && hours < 18) { + return "Good Afternoon"; + } else { + return "Good Evening"; + } + }; + + const currentUser = () => { + // @ts-ignore + return `${window?.frappe?.boot?.user?.first_name || ''} ${window?.frappe?.boot?.user?.last_name || ''}`; + }; + + if (data) { + return ( +
+
+
+
+

+ {`${greeting()}, ${currentUser()}`} +

+
+ Welcome back to your documentation dashboard +
+
+
+ +
+
+ docs image + docs image +
+ +
+
+
+
+
+
+
+

{data.commit_docs.published ? 'Live' : 'Draft'}

+
+
+
+ + + +
+
+
+
+ + Created + +
+ {convertFrappeDateStringToTimeAgo(data.commit_docs.creation)} +
+
+
+ +
{data.commit_docs.company_name}
+
+
+ +
{data.commit_docs.description}
+
+
+
+
+
+ ); + } + if (isLoading) { + return + } + if (error) { + return + } + + return null; +}; + +const MainPageSkeleton = () => { + return ( +
+
+
+ + +
+
+
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ + +
+
+ + +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/dashboard/src/components/features/documentation/DocsPage/DocsPageForm.tsx b/dashboard/src/components/features/documentation/DocsPage/DocsPageForm.tsx index 802243a..e7bf6b7 100644 --- a/dashboard/src/components/features/documentation/DocsPage/DocsPageForm.tsx +++ b/dashboard/src/components/features/documentation/DocsPage/DocsPageForm.tsx @@ -2,89 +2,73 @@ import { Check } from "@/components/common/Checkbox/Check"; import { FormElement, FormHelperText } from "@/components/common/Forms/FormControl/FormElement"; import { Input } from "@/components/ui/input"; import { CommitDocsPage } from "@/types/commit/CommitDocsPage"; -import { ChevronDown, ChevronRight } from "lucide-react"; -import { useState } from "react"; import { useFormContext } from "react-hook-form"; export const DocsPageForm = () => { const { register } = useFormContext(); - const [isExpanded, setIsExpanded] = useState(false); return ( -
+
{/* Title Field */} -
- - - - -
+ + + - {/* Hidden Fields */} - {isExpanded && ( -
-
- - - - Badge is a short text that appears on the left side of the title in the sidebar. - - - - - - Add Tailwind colors like red, green, blue, yellow, etc. - - - - - - Icon is the Lucide icon that appears on the left side of the title in the sidebar. - - -
-
- - - - - - - - - -
+
+
+ + + + Badge is a short text that appears on the left side of the title in the sidebar. + + + + + + Add Tailwind colors like red, green, blue, yellow, etc. + + + + + + Icon is the Lucide icon that appears on the left side of the title in the sidebar. + + +
+
+ + + + + + + + +
- )} +
+
); }; diff --git a/dashboard/src/components/features/documentation/OnThisPage.tsx b/dashboard/src/components/features/documentation/OnThisPage.tsx deleted file mode 100644 index 84bb521..0000000 --- a/dashboard/src/components/features/documentation/OnThisPage.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { cn } from "@/lib/utils"; -import { TocItem, TocObj } from "@/pages/features/docs/PageContent"; -import { Menu } from "lucide-react"; - -export const OnThisPage = ({ toc_obj }: { toc_obj: TocObj }) => { - - if (Object.keys(toc_obj).length > 0) { - - const hash = window.location.hash; - return ( -
-
- - On This Page -
-
    - {Object.keys(toc_obj).map((key, index) => ( - - ))} -
-
- ); - } - return null; -}; - -const TocItemComponent = ({ item, hash, index }: { item: TocItem, hash: string, index: number }) => { - // fetch the hash from url and compare with the item name - // if it matches, add a class to the anchor tag - - const sanitizeName = (name: string) => { - return name.replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').toLowerCase(); - }; - - const isActive = hash === `#${sanitizeName(item.name)}` || (index === 0 && hash === ""); - - return ( -
  • - - {item.name.replace(/[^\w\s-]/g, '')} - - {Object.keys(item.children).length > 0 && ( -
      - {Object.keys(item.children).map((key) => ( - - ))} -
    - )} -
  • - ); -}; diff --git a/dashboard/src/components/features/meta_apps/useGetCommitDocsDetails.ts b/dashboard/src/components/features/meta_apps/useGetCommitDocsDetails.ts index cb79be9..a71b5e1 100644 --- a/dashboard/src/components/features/meta_apps/useGetCommitDocsDetails.ts +++ b/dashboard/src/components/features/meta_apps/useGetCommitDocsDetails.ts @@ -1,23 +1,27 @@ import { useMemo } from 'react'; import { useFrappeGetCall } from 'frappe-react-sdk'; import { Docs } from '@/pages/features/docs/docs'; +import { CommitDocs } from '@/types/commit/CommitDocs'; -export const useGetCommitDocsDetails = (ID: string) => { +export const useGetCommitDocsDetails = (ID: string, dashboard: boolean = false) => { // First, check if the data is already available in window.frappe.boot const bootCommitDocsDetails: Docs = useMemo(() => { + // Dashboard True ,means will display latest data on User Docs Dashboard // @ts-expect-error - return window?.frappe?.boot?.get_all_commit_docs_detail?.[ID] || null; - }, [ID]); + return dashboard ? null : window?.frappe?.boot?.get_all_commit_docs_detail?.[ID] || null; + }, [ID, dashboard]); // Use the API call hook with conditional fetching const { data: apiCommitDocsDetails, error, - isLoading + isLoading, + mutate } = useFrappeGetCall<{ message: Docs }>( 'commit.commit.doctype.commit_docs.commit_docs.get_commit_docs_details', { route: ID, + show_hidden_items: dashboard }, bootCommitDocsDetails === null ? 'get_commit_docs_details' : null, { @@ -31,5 +35,37 @@ export const useGetCommitDocsDetails = (ID: string) => { // Return the data from boot if available, otherwise from the API call const data = bootCommitDocsDetails || apiCommitDocsDetails?.message; - return { data, error, isLoading }; + return { data, error, isLoading, mutate }; +}; + +export const useGetCommitDocsList = () => { + + // First check if the data is already available in window.frappe.boot + const bootCommitDocsList = useMemo(() => { + // @ts-expect-error + return window?.frappe?.boot?.get_commit_docs_list || null; + }, []); + + // Use the API call hook with conditional fetching + const { + data: apiCommitDocsList, + error, + isLoading, + mutate + } = useFrappeGetCall<{ message: CommitDocs[] }>( + 'commit.commit.doctype.commit_docs.commit_docs.get_commit_docs_list', + {}, + bootCommitDocsList === null ? 'get_commit_docs_list' : null, + { + // Only fetch if boot data is not available + revalidateOnFocus: false, + revalidateIfStale: false, + revalidateOnReconnect: false, + } + ); + + // Return the data from boot if available, otherwise from the API call + const data: CommitDocs[] = bootCommitDocsList || apiCommitDocsList?.message; + + return { data, error, isLoading, mutate }; }; \ No newline at end of file diff --git a/dashboard/src/components/ui/card.tsx b/dashboard/src/components/ui/card.tsx index 9f7e608..cabfbfc 100644 --- a/dashboard/src/components/ui/card.tsx +++ b/dashboard/src/components/ui/card.tsx @@ -30,10 +30,10 @@ const CardHeader = React.forwardRef< CardHeader.displayName = "CardHeader" const CardTitle = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes + HTMLDivElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -

    + HTMLDivElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -

    (({ className, ...props }, ref) => (

    )) diff --git a/dashboard/src/lib/utils.ts b/dashboard/src/lib/utils.ts index f72364b..153dac8 100644 --- a/dashboard/src/lib/utils.ts +++ b/dashboard/src/lib/utils.ts @@ -1,6 +1,6 @@ import { type ClassValue, clsx } from "clsx" import { twMerge } from "tailwind-merge" -import moment from "moment-timezone" +import moment, { Moment } from "moment-timezone" export function cn(...inputs: ClassValue[]) { @@ -19,4 +19,31 @@ export const convertFrappeTimestampToReadableDate = (timestamp?: string, format return moment(timestamp, 'YYYY-MM-DD HH:mm:ss').format(format) } return '' -} \ No newline at end of file +} + +/** + * Converts a Frappe date to a readable time ago string + * @param date A frappe date string in the format YYYY-MM-DD + * @param withoutSuffix remove the suffix from the time ago string + * @returns + */ +export const convertFrappeDateStringToTimeAgo = (date?: string, withoutSuffix?: boolean) => { + if (date) { + const userDate = convertFrappeTimestampToUserTimezone(date) + return userDate.fromNow(withoutSuffix) + } + return '' +} + +export const convertFrappeTimestampToUserTimezone = (timestamp: string): Moment => { + // @ts-ignore + const systemTimezone = window.frappe?.boot?.time_zone?.system + // @ts-ignore + const userTimezone = window.frappe?.boot?.time_zone?.user + + if (systemTimezone && userTimezone) { + return moment.tz(timestamp, systemTimezone).clone().tz(userTimezone) + } else { + return moment(timestamp) + } +} diff --git a/dashboard/src/pages/features/docs/CommitDocs/CreateCommitDocModal.tsx b/dashboard/src/pages/features/docs/CommitDocs/CreateCommitDocModal.tsx new file mode 100644 index 0000000..5f9bb3e --- /dev/null +++ b/dashboard/src/pages/features/docs/CommitDocs/CreateCommitDocModal.tsx @@ -0,0 +1,220 @@ +import { DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { useFrappeCreateDoc } from "frappe-react-sdk" +import { FormProvider, SubmitHandler, useForm } from "react-hook-form" +import { Input } from "@/components/ui/input" +import { Button } from '@/components/ui/button' +import { useToast } from "@/components/ui/use-toast" +import { FormElement } from "@/components/common/Forms/FormControl" +import { useEffect, useState } from "react" +import { CommitDocs } from "@/types/commit/CommitDocs" +import { Check } from "@/components/common/Checkbox/Check" +import { DocumentUploadModal } from "@/components/common/ImageUploader2/DocumentUploadModal" +import { File } from "@/types/Core/File" + +interface CreateCommitDocsProps { + onClose: VoidFunction + open: boolean + mutate: () => void +} + +const CreateCommitDocs = ({ onClose, open ,mutate}: CreateCommitDocsProps) => { + const { toast } = useToast() + const methods = useForm({ + defaultValues:{ + published:1, + } + }) + + const { createDoc, reset } = useFrappeCreateDoc() + + const onSubmit: SubmitHandler = (data) => { + console.log(data) + createDoc('Commit Docs', data) + .then(() => { + reset() + }).then(() => { + mutate() + onClose() + return toast({ + description: "Documentation Added Successfully", + duration: 1500 + }) + }) + } + + useEffect(() => { + methods.reset() + }, [open]) + + const lightModeLogo = methods.watch('light_mode_logo') + const nightModeLogo = methods.watch('night_mode_logo') + + const [fieldName,setFieldName] = useState(null) + const [isOpen,setOpen] = useState(false) + + const onOpen = (field: 'light_mode_logo' | 'night_mode_logo') => { + setFieldName(field) + setOpen(true) + } + + const onCloseModal = () => { + setFieldName(null) + setOpen(false) + methods.clearErrors(); // Clear errors when modal is closed + } + + const name = methods.watch('name') + + const onUpload = (files: File[]) => { + if(fieldName){ + methods.setValue(fieldName,files[0].file_url,{ + shouldValidate:false, + shouldDirty:true + }) + } + } + + const clearField = (field: 'light_mode_logo' | 'night_mode_logo') => { + methods.setValue(field,'',{ + shouldValidate:false, + shouldDirty:true + }) + } + + return ( + + + Add New Documentation + + +
    +
    +
    + + + + + + + + + + + + +
    + +