From e66a7cff2a48816ee9fb0d8dc6bc3d2e763507d6 Mon Sep 17 00:00:00 2001 From: Boxel Submission Bot Date: Wed, 25 Feb 2026 16:57:37 +0800 Subject: [PATCH] add Blog App changes [boxel-content-hash:af3c2aa2f6ab] --- .../95cbe2c7-9b60-4afd-8a3c-1382b610e316.json | 148 +++ catalog/Spec/author.json | 30 + catalog/Spec/blog-app.json | 30 + catalog/Spec/blog-category.json | 30 + catalog/Spec/blog-post.json | 30 + catalog/Spec/card-list.json | 24 + catalog/Spec/contact-link.json | 55 ++ catalog/Spec/featured-image.json | 23 + catalog/Spec/grid.json | 24 + catalog/Spec/layout.json | 24 + catalog/Spec/review-blog.json | 30 + catalog/Spec/sort.json | 24 + catalog/Spec/user.json | 24 + catalog/blog-app/Author/alice-enwunder.json | 53 ++ catalog/blog-app/Author/jane-doe.json | 53 ++ catalog/blog-app/BlogApp/ramped.json | 22 + .../blog-app/BlogCategory/city-design.json | 29 + .../blog-app/BlogCategory/future-tech.json | 29 + catalog/blog-app/BlogCategory/urban-work.json | 29 + .../BlogPost/ultimate-guide-remote-work.json | 52 ++ .../urban-living-future-sustainable.json | 57 ++ catalog/blog-app/author.gts | 785 ++++++++++++++++ catalog/blog-app/blog-app.gts | 555 +++++++++++ catalog/blog-app/blog-category.gts | 221 +++++ catalog/blog-app/blog-post.gts | 862 ++++++++++++++++++ catalog/blog-app/review-blog.gts | 46 + catalog/blog-app/user.gts | 17 + catalog/components/card-list.gts | 76 ++ catalog/components/grid.gts | 98 ++ catalog/components/layout.gts | 217 +++++ catalog/components/sort.gts | 123 +++ catalog/fields/contact-link.gts | 174 ++++ catalog/fields/featured-image.gts | 294 ++++++ 33 files changed, 4288 insertions(+) create mode 100644 catalog/AppListing/95cbe2c7-9b60-4afd-8a3c-1382b610e316.json create mode 100644 catalog/Spec/author.json create mode 100644 catalog/Spec/blog-app.json create mode 100644 catalog/Spec/blog-category.json create mode 100644 catalog/Spec/blog-post.json create mode 100644 catalog/Spec/card-list.json create mode 100644 catalog/Spec/contact-link.json create mode 100644 catalog/Spec/featured-image.json create mode 100644 catalog/Spec/grid.json create mode 100644 catalog/Spec/layout.json create mode 100644 catalog/Spec/review-blog.json create mode 100644 catalog/Spec/sort.json create mode 100644 catalog/Spec/user.json create mode 100644 catalog/blog-app/Author/alice-enwunder.json create mode 100644 catalog/blog-app/Author/jane-doe.json create mode 100644 catalog/blog-app/BlogApp/ramped.json create mode 100644 catalog/blog-app/BlogCategory/city-design.json create mode 100644 catalog/blog-app/BlogCategory/future-tech.json create mode 100644 catalog/blog-app/BlogCategory/urban-work.json create mode 100644 catalog/blog-app/BlogPost/ultimate-guide-remote-work.json create mode 100644 catalog/blog-app/BlogPost/urban-living-future-sustainable.json create mode 100644 catalog/blog-app/author.gts create mode 100644 catalog/blog-app/blog-app.gts create mode 100644 catalog/blog-app/blog-category.gts create mode 100644 catalog/blog-app/blog-post.gts create mode 100644 catalog/blog-app/review-blog.gts create mode 100644 catalog/blog-app/user.gts create mode 100644 catalog/components/card-list.gts create mode 100644 catalog/components/grid.gts create mode 100644 catalog/components/layout.gts create mode 100644 catalog/components/sort.gts create mode 100644 catalog/fields/contact-link.gts create mode 100644 catalog/fields/featured-image.gts diff --git a/catalog/AppListing/95cbe2c7-9b60-4afd-8a3c-1382b610e316.json b/catalog/AppListing/95cbe2c7-9b60-4afd-8a3c-1382b610e316.json new file mode 100644 index 0000000..0382bb4 --- /dev/null +++ b/catalog/AppListing/95cbe2c7-9b60-4afd-8a3c-1382b610e316.json @@ -0,0 +1,148 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "AppListing", + "module": "../catalog-app/listing/listing" + } + }, + "type": "card", + "attributes": { + "name": "Blog App", + "images": [ + "https://boxel-images.boxel.ai/app-assets/catalog/blog-app-listing/screenshot-01.png", + "https://boxel-images.boxel.ai/app-assets/catalog/blog-app-listing/screenshot-02.png", + "https://boxel-images.boxel.ai/app-assets/catalog/blog-app-listing/screenshot-04.png" + ], + "summary": "A modern, card-based blogging platform built with Cardstack framework.\n\n### Features\n\n- Create and manage blog posts, author profiles, and categories\n- Track publish dates, update history, word counts, and content status\n- Easy content creation with one-click \"New\" button", + "cardInfo": { + "notes": null, + "name": null, + "summary": null, + "cardThumbnailURL": "https://boxel-images.boxel.ai/app-assets/catalog/blog-app-listing/thumbnail.png" + } + }, + "relationships": { + "skills": { + "links": { + "self": null + } + }, + "tags.0": { + "links": { + "self": "../Tag/140feda8-625b-4a24-9ddb-6f4da891aef2" + } + }, + "tags.1": { + "links": { + "self": "../Tag/ed5a1a3f-0dbf-47b5-b2a6-d88b0d2a7642" + } + }, + "license": { + "links": { + "self": "../License/4c5a023b-a72c-4f90-930b-da60a1de5b2d" + } + }, + "specs.0": { + "links": { + "self": "../Spec/user" + } + }, + "specs.1": { + "links": { + "self": "../Spec/featured-image" + } + }, + "specs.2": { + "links": { + "self": "../Spec/contact-link" + } + }, + "specs.3": { + "links": { + "self": "../Spec/card-list" + } + }, + "specs.4": { + "links": { + "self": "../Spec/grid" + } + }, + "specs.5": { + "links": { + "self": "../Spec/sort" + } + }, + "specs.6": { + "links": { + "self": "../Spec/layout" + } + }, + "specs.7": { + "links": { + "self": "../Spec/author" + } + }, + "specs.8": { + "links": { + "self": "../Spec/blog-category" + } + }, + "specs.9": { + "links": { + "self": "../Spec/blog-post" + } + }, + "specs.10": { + "links": { + "self": "../Spec/review-blog" + } + }, + "specs.11": { + "links": { + "self": "../Spec/blog-app" + } + }, + "publisher": { + "links": { + "self": "../Publisher/9d3ca05b-684b-408f-8d8c-dd353d9956e0" + } + }, + "examples.0": { + "links": { + "self": "../blog-app/BlogApp/ramped" + } + }, + "examples.1": { + "links": { + "self": "../blog-app/Author/alice-enwunder" + } + }, + "examples.2": { + "links": { + "self": "../blog-app/Author/jane-doe" + } + }, + "examples.3": { + "links": { + "self": "../blog-app/BlogPost/urban-living-future-sustainable" + } + }, + "examples.4": { + "links": { + "self": "../blog-app/BlogPost/ultimate-guide-remote-work" + } + }, + "categories.0": { + "links": { + "self": "../Category/developer-tools-code" + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + } + } +} diff --git a/catalog/Spec/author.json b/catalog/Spec/author.json new file mode 100644 index 0000000..9147557 --- /dev/null +++ b/catalog/Spec/author.json @@ -0,0 +1,30 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "name": "Author", + "module": "../blog-app/author" + }, + "specType": "card", + "containedExamples": [], + "cardTitle": "Author", + "cardDescription": "Spec for Author card", + "cardThumbnailURL": null + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../blog-app/Author/alice-enwunder" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/catalog/Spec/blog-app.json b/catalog/Spec/blog-app.json new file mode 100644 index 0000000..8728bf4 --- /dev/null +++ b/catalog/Spec/blog-app.json @@ -0,0 +1,30 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "name": "BlogApp", + "module": "../blog-app/blog-app" + }, + "specType": "card", + "containedExamples": [], + "cardTitle": "Blog App", + "cardDescription": "Spec for Blog App card", + "cardThumbnailURL": null + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../blog-app/BlogApp/ramped" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/catalog/Spec/blog-category.json b/catalog/Spec/blog-category.json new file mode 100644 index 0000000..97ec774 --- /dev/null +++ b/catalog/Spec/blog-category.json @@ -0,0 +1,30 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "name": "BlogCategory", + "module": "../blog-app/blog-category" + }, + "specType": "card", + "containedExamples": [], + "cardTitle": "Blog Category", + "cardDescription": "Spec for Blog Category card", + "cardThumbnailURL": null + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../blog-app/BlogCategory/city-design" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/catalog/Spec/blog-post.json b/catalog/Spec/blog-post.json new file mode 100644 index 0000000..88492f6 --- /dev/null +++ b/catalog/Spec/blog-post.json @@ -0,0 +1,30 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "name": "BlogPost", + "module": "../blog-app/blog-post" + }, + "specType": "card", + "containedExamples": [], + "cardTitle": "Blog Post", + "cardDescription": "Spec for Blog Post card", + "cardThumbnailURL": null + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../blog-app/BlogPost/ultimate-guide-remote-work" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/catalog/Spec/card-list.json b/catalog/Spec/card-list.json new file mode 100644 index 0000000..98ebe8f --- /dev/null +++ b/catalog/Spec/card-list.json @@ -0,0 +1,24 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "name": "CardList", + "module": "../components/card-list" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "Card List", + "cardDescription": "Spec for Card List component", + "cardThumbnailURL": null + }, + "relationships": {}, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/catalog/Spec/contact-link.json b/catalog/Spec/contact-link.json new file mode 100644 index 0000000..9342943 --- /dev/null +++ b/catalog/Spec/contact-link.json @@ -0,0 +1,55 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "Spec", + "module": "https://cardstack.com/base/spec" + }, + "fields": { + "containedExamples": [ + { + "adoptsFrom": { + "module": "../fields/contact-link", + "name": "default" + } + } + ] + } + }, + "type": "card", + "attributes": { + "ref": { + "name": "default", + "module": "../fields/contact-link" + }, + "cardTitle": "ContactLinkField", + "readMe": null, + "cardInfo": { + "notes": null, + "name": null, + "summary": null, + "cardThumbnailURL": null + }, + "specType": "field", + "cardDescription": null, + "containedExamples": [ + { + "label": null, + "value": null + } + ] + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + }, + "linkedExamples": { + "links": { + "self": null + } + } + } + } +} diff --git a/catalog/Spec/featured-image.json b/catalog/Spec/featured-image.json new file mode 100644 index 0000000..5041a0b --- /dev/null +++ b/catalog/Spec/featured-image.json @@ -0,0 +1,23 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "name": "default", + "module": "../fields/featured-image" + }, + "specType": "field", + "containedExamples": [], + "cardTitle": "FeaturedImageField", + "cardDescription": null, + "cardThumbnailURL": null + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/catalog/Spec/grid.json b/catalog/Spec/grid.json new file mode 100644 index 0000000..aaf67fb --- /dev/null +++ b/catalog/Spec/grid.json @@ -0,0 +1,24 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "name": "CardsGrid", + "module": "../components/grid" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "Cards Grid", + "cardDescription": "Spec for Cards Grid component", + "cardThumbnailURL": null + }, + "relationships": {}, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/catalog/Spec/layout.json b/catalog/Spec/layout.json new file mode 100644 index 0000000..ccbfaf4 --- /dev/null +++ b/catalog/Spec/layout.json @@ -0,0 +1,24 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "name": "Layout", + "module": "../components/layout" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "Layout", + "cardDescription": "Spec for Layout component", + "cardThumbnailURL": null + }, + "relationships": {}, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/catalog/Spec/review-blog.json b/catalog/Spec/review-blog.json new file mode 100644 index 0000000..740da81 --- /dev/null +++ b/catalog/Spec/review-blog.json @@ -0,0 +1,30 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "name": "ReviewBlog", + "module": "../blog-app/review-blog" + }, + "specType": "card", + "containedExamples": [], + "cardTitle": "Review Blog", + "cardDescription": "Spec for Review Blog card", + "cardThumbnailURL": null + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../blog-app/ReviewBlog/583df6bb-5739-418a-9186-978bd72816c1" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/catalog/Spec/sort.json b/catalog/Spec/sort.json new file mode 100644 index 0000000..5e78dc3 --- /dev/null +++ b/catalog/Spec/sort.json @@ -0,0 +1,24 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "name": "Sort", + "module": "../components/sort" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "Sort", + "cardDescription": "Spec for Sort component", + "cardThumbnailURL": null + }, + "relationships": {}, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/catalog/Spec/user.json b/catalog/Spec/user.json new file mode 100644 index 0000000..14e9dcb --- /dev/null +++ b/catalog/Spec/user.json @@ -0,0 +1,24 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "name": "User", + "module": "../blog-app/user" + }, + "specType": "card", + "containedExamples": [], + "cardTitle": "User", + "cardDescription": "Spec for User card", + "cardThumbnailURL": null + }, + "relationships": {}, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/catalog/blog-app/Author/alice-enwunder.json b/catalog/blog-app/Author/alice-enwunder.json new file mode 100644 index 0000000..3a6f3d6 --- /dev/null +++ b/catalog/blog-app/Author/alice-enwunder.json @@ -0,0 +1,53 @@ +{ + "data": { + "type": "card", + "attributes": { + "firstName": "Alice", + "lastName": "Enwunder", + "bio": null, + "fullBio": null, + "quote": "Curiouser and curiouser", + "contactLinks": [ + { + "label": "X", + "value": "https://x.com/alice-boxel" + }, + { + "label": "LinkedIn", + "value": "https://linkedin.com/alice-boxel" + }, + { + "label": "Email", + "value": "alice@email.com" + } + ], + "email": null, + "featuredImage": { + "imageUrl": "https://boxel-images.boxel.ai/app-assets/portraits/photo-1509868918748-a554ad25f858.jpeg", + "credit": "Photo via Unsplash", + "caption": null, + "altText": "Alice Enwunder", + "size": "actual", + "height": null, + "width": null + }, + "cardInfo": { + "summary": null, + "cardThumbnailURL": "https://boxel-images.boxel.ai/app-assets/portraits/photo-1509868918748-a554ad25f858.jpeg" + } + }, + "relationships": { + "blog": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../author", + "name": "Author" + } + } + } +} diff --git a/catalog/blog-app/Author/jane-doe.json b/catalog/blog-app/Author/jane-doe.json new file mode 100644 index 0000000..cd63497 --- /dev/null +++ b/catalog/blog-app/Author/jane-doe.json @@ -0,0 +1,53 @@ +{ + "data": { + "type": "card", + "attributes": { + "firstName": "Jane", + "lastName": "Doe", + "bio": "Jane Doe is the Senior Managing Editor at Ramped.com, where she leads content strategy, editorial direction, and ensures the highest standards of quality across all publications. With over a decade of experience in digital media and editorial management, Jane has a proven track record of shaping impactful narratives, growing engaged audiences, and collaborating with cross-functional teams to deliver compelling content. When she's not editing, you can find her exploring new books, hiking, or indulging in her love of photography.", + "fullBio": "Jane Doe is the Senior Managing Editor at *Ramped.com*, where she leads the editorial team in crafting high-impact content that resonates with a diverse, global audience. With over 10 years of experience in digital media, Jane has built a career around her passion for storytelling, strategic content development, and editorial excellence. At *Ramped.com*, she oversees the editorial direction for both long-form features and quick-hit content, ensuring that all materials meet rigorous standards for quality, accuracy, and engagement. Her role involves not only managing content workflows but also driving the site\u2019s content strategy, optimizing for SEO, and integrating new multimedia formats to enhance user experience.\n\nBefore joining *Ramped*, Jane served in senior editorial positions at prominent media organizations, where she was instrumental in shaping editorial voice, increasing readership, and overseeing large teams of writers, editors, and designers. Her ability to adapt to evolving trends in digital media has helped these organizations expand their online presence and build strong, loyal audiences. She is particularly skilled in data-driven content strategies and has worked closely with product, marketing, and analytics teams to optimize content for growth and engagement.\n\nA firm believer in the power of mentorship, Jane is deeply committed to helping young writers develop their craft and grow professionally. She frequently leads editorial workshops and offers guidance on topics ranging from narrative structure to the nuances of digital publishing. Her approach to leadership emphasizes collaboration, creativity, and a keen attention to detail.\n\nWhen she's not editing or refining strategy, Jane enjoys exploring new books across various genres, hiking through nature trails, and experimenting with photography. She is also a dedicated advocate for promoting diversity in media and creating inclusive spaces for underrepresented voices within the industry.\n\nHer dedication to her craft and her team has made Jane an invaluable asset to *Ramped.com*, where she continues to shape the future of digital content and drive the organization\u2019s mission forward.", + "quote": "\u201cGreat content isn\u2019t just about words on a page\u2014it's about creating experiences that resonate with people, spark conversation, and drive meaningful action.\u201d", + "contactLinks": [ + { + "label": "X", + "value": "https://x.com/jane-doe-boxel" + }, + { + "label": "LinkedIn", + "value": "https://linkedin.com/jane-doe-boxel" + }, + { + "label": "Email", + "value": "jane.doe@email.com" + } + ], + "email": "jane.doe@email.com", + "featuredImage": { + "imageUrl": "https://boxel-images.boxel.ai/app-assets/portraits/photo-1481214110143-ed630356e1bb.jpeg", + "credit": "Photo via Unsplash", + "caption": null, + "altText": "Jane Doe", + "size": "actual", + "height": null, + "width": null + }, + "cardInfo": { + "summary": "Senior Managing Editor at Ramped.com", + "cardThumbnailURL": "https://boxel-images.boxel.ai/app-assets/portraits/photo-1481214110143-ed630356e1bb.jpeg" + } + }, + "relationships": { + "blog": { + "links": { + "self": "../BlogApp/ramped" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../author", + "name": "Author" + } + } + } +} diff --git a/catalog/blog-app/BlogApp/ramped.json b/catalog/blog-app/BlogApp/ramped.json new file mode 100644 index 0000000..56b9f95 --- /dev/null +++ b/catalog/blog-app/BlogApp/ramped.json @@ -0,0 +1,22 @@ +{ + "data": { + "type": "card", + "attributes": { + "website": "ramped.com", + "cardTitle": "Ramped", + "cardDescription": "Urban insights", + "cardThumbnailURL": "https://boxel-images.boxel.ai/icons/ramped.png", + "cardInfo": { + "name": "Ramped", + "summary": "Urban insights", + "cardThumbnailURL": "https://boxel-images.boxel.ai/icons/ramped.png" + } + }, + "meta": { + "adoptsFrom": { + "module": "../blog-app", + "name": "BlogApp" + } + } + } +} diff --git a/catalog/blog-app/BlogCategory/city-design.json b/catalog/blog-app/BlogCategory/city-design.json new file mode 100644 index 0000000..bf7c2a1 --- /dev/null +++ b/catalog/blog-app/BlogCategory/city-design.json @@ -0,0 +1,29 @@ +{ + "data": { + "type": "card", + "attributes": { + "longName": "City Design", + "shortName": "Design", + "slug": "city-design", + "pillColor": "#1EDF67", + "cardInfo": { + "summary": "Showcasing architecture and urban planning brilliance.", + "name": "City Design", + "cardThumbnailURL": null + } + }, + "relationships": { + "blog": { + "links": { + "self": "../BlogApp/ramped" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../blog-category", + "name": "BlogCategory" + } + } + } +} diff --git a/catalog/blog-app/BlogCategory/future-tech.json b/catalog/blog-app/BlogCategory/future-tech.json new file mode 100644 index 0000000..276f5fa --- /dev/null +++ b/catalog/blog-app/BlogCategory/future-tech.json @@ -0,0 +1,29 @@ +{ + "data": { + "type": "card", + "attributes": { + "longName": "Future Tech", + "shortName": "Tech", + "slug": "future-tech", + "pillColor": "#000000", + "cardInfo": { + "summary": "Highlighting technology shaping tomorrow\u2019s cities.", + "name": "Future Tech", + "cardThumbnailURL": null + } + }, + "relationships": { + "blog": { + "links": { + "self": "../BlogApp/ramped" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../blog-category", + "name": "BlogCategory" + } + } + } +} diff --git a/catalog/blog-app/BlogCategory/urban-work.json b/catalog/blog-app/BlogCategory/urban-work.json new file mode 100644 index 0000000..1016e46 --- /dev/null +++ b/catalog/blog-app/BlogCategory/urban-work.json @@ -0,0 +1,29 @@ +{ + "data": { + "type": "card", + "attributes": { + "longName": "Urban Work", + "shortName": "Work", + "slug": "urban-work", + "pillColor": "#A6F4CA", + "cardInfo": { + "summary": "Exploring work trends in the evolving city landscape.", + "name": "Work", + "cardThumbnailURL": null + } + }, + "relationships": { + "blog": { + "links": { + "self": "../BlogApp/ramped" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../blog-category", + "name": "BlogCategory" + } + } + } +} diff --git a/catalog/blog-app/BlogPost/ultimate-guide-remote-work.json b/catalog/blog-app/BlogPost/ultimate-guide-remote-work.json new file mode 100644 index 0000000..b634169 --- /dev/null +++ b/catalog/blog-app/BlogPost/ultimate-guide-remote-work.json @@ -0,0 +1,52 @@ +{ + "data": { + "type": "card", + "attributes": { + "headline": "The Ultimate Guide to Remote Work", + "slug": "ultimate-guide-remote-work", + "body": "| Table of Contents |\n| ----------- | \n|
  1. [Introduction](#introduction)
  2. [Benefits of Remote Work](#benefits-of-remote-work)
  3. [Challenges and Solutions](#challenges-and-solutions)
  4. [Essential Tools for Remote Workers](#essential-tools-for-remote-workers)
  5. [Best Practices for Remote Work](#best-practices-for-remote-work)
  6. [Conclusion](#conclusion)
|\n\n

Introduction

\n\nRemote work has revolutionized the traditional workplace, offering flexibility and autonomy like never before. Whether you're a freelancer, an entrepreneur, or part of a company embracing flexible work arrangements, understanding how to make the most of remote work is crucial for success.\n\n

Benefits of Remote Work

\n\n### Flexibility\n\nOne of the most significant advantages of remote work is the ability to **work from anywhere**. Whether you prefer the comfort of your home, a bustling coffee shop, or a serene beach, the choice is yours.\n\n> \"The freedom to choose your workspace can greatly enhance creativity and job satisfaction.\" \n>\n> \u2014 Jane Doe, Remote Work Expert\n\n### Increased Productivity\n\nWithout the usual office distractions, many remote workers find they can **focus better** and **get more done** in less time.\n\n### Improved Work-Life Balance\n\nEliminating the daily commute gives you more time for personal activities, helping to reduce stress and improve overall well-being.\n\n
\n\"Work-life\n
Achieving a healthy work-life balance is easier with remote work.
\n
\n\n

Challenges and Solutions

\n\nWhile remote work offers many benefits, it also comes with its own set of challenges.\n\n### Overcoming Isolation\n\n**Challenge:** Feeling isolated from colleagues can lead to decreased motivation.\n\n**Solution:**\n- **Regular Check-Ins**: Schedule daily or weekly video calls with your team.\n- **Join Online Communities**: Engage with professionals in your field through forums or social media.\n\n> \"Staying connected is essential. Virtual coffee breaks can foster team spirit even when miles apart.\" \n>\n> \u2014 Remote Work Enthusiast\n\n### Effective Communication\n\n**Challenge**: Misunderstandings can occur without face-to-face interaction.\n\n**Solution**:\n- **Use Video Conferencing**: Tools like Zoom or Skype can help mimic in-person meetings.\n- **Clarify Expectations**: Be explicit in your communications to avoid confusion.\n\n### Time Management Strategies\n\n**Challenge**: Blurring lines between work and personal life can lead to burnout.\n\n**Solution**:\n1. **Set Boundaries**: Define your working hours and stick to them.\n2. **Take Regular Breaks**: Use techniques like the Pomodoro Technique to maintain focus.\n3. **Prioritize Tasks**: Create a to-do list and rank tasks by importance.\n\nHere's an example of a simple task list:\n- [x] Define working hours\n- [ ] Schedule regular breaks\n- [ ] Create daily to-do lists\n\n

Essential Tools for Remote Workers

\n\n### Communication Tools\n\n- **[Slack](https://slack.com/)**: For real-time messaging and collaboration.\n- **[Zoom](https://zoom.us/)**: For video conferencing and virtual meetings.\n- **[Microsoft Teams](https://www.microsoft.com/en-us/microsoft-teams/group-chat-software)**: Integrates with Office 365 for seamless collaboration.\n\n#### Sample Slack Configuration\n\n```json\n{\n \"channel\": \"#remote-work\",\n \"username\": \"JaneDoe\",\n \"icon_emoji\": \"\ud83d\udcbb\"\n}\n```\n\n### Project Management Tools\n\n- **[Trello](https://trello.com/)**: Organize tasks with boards and cards.\n- **[Asana](https://asana.com/)**: Manage projects and track progress.\n- **[Basecamp](https://basecamp.com/)**: Combines messaging, task management, and file storage.\n\n#### Trello Board Example\n\n- **To Do**\n - Write blog post\n- **In Progress**\n - Research remote work tools\n- **Done**\n - Set up home office\n\n### Productivity Apps\n\n- **[Todoist](https://todoist.com/)**: Keep track of tasks and deadlines.\n- **[RescueTime](https://www.rescuetime.com/)**: Monitor how you spend your time online.\n- **[Focus@Will](https://www.focusatwill.com/)**: Music designed to improve concentration.\n\n
\n\"Project\n
Utilizing the right tools can streamline your workflow.
\n
\n\n

Best Practices for Remote Work

\n\n### Setting Up a Workspace\n\n- **Choose the Right Spot**: Find a quiet area with minimal distractions.\n- **Ergonomic Furniture**: Invest in a good chair and desk to maintain proper posture.\n- **Proper Lighting**: Ensure your workspace is well-lit to reduce eye strain.\n\n> \"Your workspace should inspire you to do your best work every day.\" \n>\n> \u2014 Productivity Coach\n\n### Establishing a Routine\n\n- **Consistent Schedule**: Start and end work at the same times each day.\n- **Morning Rituals**: Activities like exercise or reading can prepare you mentally for the day.\n- **Plan Your Day**: Outline tasks and set achievable goals.\n\nHere's a sample morning routine:\n\n1. **6:30 AM**: Wake up and meditate.\n2. **7:00 AM**: Exercise for 30 minutes.\n3. **7:30 AM**: Have a healthy breakfast.\n4. **8:00 AM**: Start work.\n\n### Staying Connected\n\n- **Regular Updates**: Keep your team informed about your progress.\n- **Virtual Social Events**: Organize online gatherings to build camaraderie.\n- **Open Communication Channels**: Encourage feedback and discussions.\n\n

Conclusion

\n\nEmbracing remote work can lead to greater satisfaction and efficiency. By understanding the benefits and proactively addressing the challenges, you can create a fulfilling remote work experience.\n\n~~Working from the office every day is mandatory.~~ Remote work offers a flexible alternative that benefits both employers and employees.\n\n---\n\n*Interested in more tips? Check out our other articles on remote work and productivity.*\n", + "publishDate": "2024-11-05T20:00:00.000Z", + "featuredImage": { + "imageUrl": "https://images.unsplash.com/photo-1614624532983-4ce03382d63d?q=80&w=3131&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", + "credit": "Image by Unsplash", + "caption": "Success in remote work is achievable with the right approach.", + "altText": "Desktop setup with laptop, monitor, keyboard and mouse, phone, headphones, and a cup of coffee", + "size": "actual", + "height": null, + "width": null + }, + "cardInfo": { + "summary": "In today's digital age, remote work has transformed from a luxury to a necessity. This comprehensive guide will help you navigate the world of remote work, offering tips, tools, and best practices for success.", + "cardThumbnailURL": "https://images.unsplash.com/photo-1614624532983-4ce03382d63d?q=80&w=3131&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" + } + }, + "relationships": { + "authors.0": { + "links": { + "self": "../Author/jane-doe" + } + }, + "blog": { + "links": { + "self": "../BlogApp/ramped" + } + }, + "categories.0": { + "links": { + "self": "../BlogCategory/urban-work" + } + }, + "editors": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../blog-post", + "name": "BlogPost" + } + } + } +} diff --git a/catalog/blog-app/BlogPost/urban-living-future-sustainable.json b/catalog/blog-app/BlogPost/urban-living-future-sustainable.json new file mode 100644 index 0000000..93a594e --- /dev/null +++ b/catalog/blog-app/BlogPost/urban-living-future-sustainable.json @@ -0,0 +1,57 @@ +{ + "data": { + "type": "card", + "attributes": { + "headline": "The Future of Urban Living: Skyscrapers or Sustainable Communities?", + "slug": "urban-living-future-sustainable", + "body": "As urban populations swell and space becomes increasingly limited, the debate between vertical development and sustainable community-building intensifies. Skyscrapers promise to accommodate more people in a smaller footprint, enhancing density and reducing urban sprawl. However, this vertical approach can strain infrastructure and may lack the sense of community that many residents crave. On the other hand, sustainable communities emphasize green spaces, local resources, and human-scale development, fostering a stronger connection between people and their environment. While both paths offer solutions to urban challenges, the future of cities may lie in blending these two concepts\u2014integrating smart skyscrapers with sustainable, community-oriented design to create harmonious, livable urban ecosystems.", + "publishDate": "2024-12-06T22:29:00.000Z", + "featuredImage": { + "imageUrl": "https://images.unsplash.com/photo-1548182880-8b7b2af2caa2?q=80&w=2970&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", + "credit": "Photo via Unsplash", + "caption": null, + "altText": "Times Square, New York", + "size": "actual", + "height": null, + "width": null + }, + "cardInfo": { + "summary": "As our cities grow ever upward, should we continue to build, or focus on creating? This article explores the pros and cons of both approaches.", + "cardThumbnailURL": "https://images.unsplash.com/photo-1548182880-8b7b2af2caa2?q=80&w=2970&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" + } + }, + "relationships": { + "authors.0": { + "links": { + "self": "../Author/jane-doe" + } + }, + "blog": { + "links": { + "self": "../BlogApp/ramped" + } + }, + "categories.0": { + "links": { + "self": "../BlogCategory/city-design" + } + }, + "categories.1": { + "links": { + "self": "../BlogCategory/future-tech" + } + }, + "editors": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../blog-post", + "name": "BlogPost" + } + } + } +} diff --git a/catalog/blog-app/author.gts b/catalog/blog-app/author.gts new file mode 100644 index 0000000..7bb7f88 --- /dev/null +++ b/catalog/blog-app/author.gts @@ -0,0 +1,785 @@ +import MarkdownField from 'https://cardstack.com/base/markdown'; +import TextAreaField from 'https://cardstack.com/base/text-area'; +import { + Component, + CardDef, + field, + contains, + containsMany, + linksTo, + StringField, +} from 'https://cardstack.com/base/card-api'; +import EmailField from 'https://cardstack.com/base/email'; + +import Email from '@cardstack/boxel-icons/mail'; +import Linkedin from '@cardstack/boxel-icons/linkedin'; +import XIcon from '@cardstack/boxel-icons/brand-x'; +import UserIcon from '@cardstack/boxel-icons/user'; +import UserRoundPen from '@cardstack/boxel-icons/user-round-pen'; + +import { cn, not } from '@cardstack/boxel-ui/helpers'; + +import { setBackgroundImage } from '../components/layout'; +import FeaturedImageField from '../fields/featured-image'; +import ContactLinkField from '../fields/contact-link'; +import { BlogApp } from './blog-app'; + +class AuthorContactLink extends ContactLinkField { + static values = [ + { + type: 'social', + label: 'X', + icon: XIcon, + cta: 'Follow', + }, + { + type: 'social', + label: 'LinkedIn', + icon: Linkedin, + cta: 'Connect', + }, + { + type: 'email', + label: 'Email', + icon: Email, + cta: 'Contact', + }, + ]; +} + +export class Author extends CardDef { + static displayName = 'Author'; + static icon = UserRoundPen; + @field firstName = contains(StringField); + @field lastName = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function (this: Author) { + let fullName = [this.firstName, this.lastName].filter(Boolean).join(' '); + return fullName.length ? fullName : 'Untitled Author'; + }, + description: 'Full name of author', + }); + @field bio = contains(TextAreaField, { + description: 'Default author bio for embedded and isolated views.', + }); + @field fullBio = contains(MarkdownField, { + description: 'Full bio for isolated view', + }); + @field quote = contains(TextAreaField); + @field contactLinks = containsMany(AuthorContactLink); + @field email = contains(EmailField); + @field featuredImage = contains(FeaturedImageField); + @field blog = linksTo(BlogApp, { isUsed: true }); + + static isolated = class Isolated extends Component { + + }; + + static embedded = class Embedded extends Component { + + }; + + static atom = class Atom extends Component { + + }; + + static fitted = class FittedTemplate extends Component { + + }; +} diff --git a/catalog/blog-app/blog-app.gts b/catalog/blog-app/blog-app.gts new file mode 100644 index 0000000..6f80d14 --- /dev/null +++ b/catalog/blog-app/blog-app.gts @@ -0,0 +1,555 @@ +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import type Owner from '@ember/owner'; +import GlimmerComponent from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { restartableTask } from 'ember-concurrency'; + +import { + CardDef, + Component, + realmURL, + field, + contains, + StringField, + type CardContext, +} from 'https://cardstack.com/base/card-api'; + +import { + type LooseSingleCardDocument, + ResolvedCodeRef, + TypedFilter, +} from '@cardstack/runtime-common'; +import { + type SortOption, + sortByCardTitleAsc, + SortMenu, +} from '../components/sort'; +import { CardList } from '../components/card-list'; +import { CardsGrid } from '../components/grid'; +import { TitleGroup, Layout, type LayoutFilter } from '../components/layout'; + +import { + BasicFitted, + BoxelButton, + FieldContainer, + Pill, + ViewSelector, +} from '@cardstack/boxel-ui/components'; +import { eq } from '@cardstack/boxel-ui/helpers'; +import { IconPlus } from '@cardstack/boxel-ui/icons'; + +import CategoriesIcon from '@cardstack/boxel-icons/hierarchy-3'; +import BlogPostIcon from '@cardstack/boxel-icons/newspaper'; +import BlogAppIcon from '@cardstack/boxel-icons/notebook'; +import AuthorIcon from '@cardstack/boxel-icons/square-user'; + +import type { BlogPost } from './blog-post'; +import type { User } from './user'; + +type ViewOption = 'card' | 'strip' | 'grid'; + +export const toISOString = (datetime: Date) => datetime.toISOString(); + +export const formatDatetime = ( + datetime: Date, + opts: Intl.DateTimeFormatOptions, +) => { + const Format = new Intl.DateTimeFormat('en-US', opts); + return Format.format(datetime); +}; + +const or = function (item1: any, item2: any) { + if (Boolean(item1)) { + return item1; + } else if (Boolean(item2)) { + return item2; + } + return; +}; + +interface CardAdminViewSignature { + Args: { + cardId: string; + context?: CardContext; + }; + Element: HTMLElement; +} +class BlogAdminData extends GlimmerComponent { + + + @tracked resource = this.args.context + ? this.args.context.getCard(this, () => this.args.cardId) + : undefined; + + formattedDate = (datetime: Date) => { + return formatDatetime(datetime, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour12: true, + hour: 'numeric', + minute: '2-digit', + }); + }; + + get editors() { + return this.resource?.card && this.resource.card.editors.length > 0 + ? this.resource.card.editors + .map((editor: User) => + editor.email ? `${editor.name} (${editor.email})` : editor.name, + ) + .join(',') + : 'N/A'; + } +} + +class BlogAppTemplate extends Component { + + + @tracked private selectedView: ViewOption = 'card'; + @tracked private activeFilter: LayoutFilter; + @tracked private filters: LayoutFilter[] = []; + + constructor(owner: Owner, args: any) { + super(owner, args); + this.setFilters(); + this.activeFilter = this.filters[0]; + } + + private get context() { + return this.args.context as CardContext; + } + + private get gridClass() { + let displayName = this.activeFilter.displayName; + let gridName = + displayName === 'Blog Posts' + ? 'blog-posts-grid' + : displayName === 'Author Bios' + ? 'author-bios-grid' + : displayName === 'Categories' + ? 'categories-grid' + : ''; + return gridName ? `bordered-items ${gridName}` : ''; + } + + private setFilters() { + let blogId = this.args.model.id; + + let makeQuery = (codeRef: ResolvedCodeRef) => { + if (!blogId) { + throw new Error('Missing blog id'); + } + + return { + filter: { + on: codeRef, + eq: { 'blog.id': blogId }, + }, + }; + }; + + this.filters = + this.args.model.filters?.map((filter) => { + if (!filter.query && filter.cardRef) { + return { + ...filter, + query: makeQuery(filter.cardRef), + }; + } + return filter; + }) ?? []; + } + + private get selectedSort() { + if (!this.activeFilter.sortOptions?.length) { + return; + } + return this.activeFilter.selectedSort ?? this.activeFilter.sortOptions[0]; + } + + private get showAdminData() { + return this.activeFilter.showAdminData && this.selectedView === 'card'; + } + + private get realms() { + return [this.args.model[realmURL]!]; + } + + private get realmHrefs() { + return this.realms.map((url) => url.href); + } + + private get query() { + return { + ...this.activeFilter.query, + sort: this.selectedSort?.sort ?? sortByCardTitleAsc, + }; + } + + @action private onChangeView(id: ViewOption) { + this.selectedView = id; + } + + @action private onSort(option: SortOption) { + this.activeFilter.selectedSort = option; + this.activeFilter = this.activeFilter; + } + + @action private onFilterChange(filter: LayoutFilter) { + this.activeFilter = filter; + } + + @action private createNew() { + this.createCard.perform(); + } + + private createCard = restartableTask(async () => { + if (!this.activeFilter?.query?.filter) { + throw new Error('Missing active filter'); + } + let ref = (this.activeFilter.query.filter as TypedFilter).on; + + if (!ref) { + throw new Error('Missing card ref'); + } + let currentRealm = this.realms[0]; + let doc: LooseSingleCardDocument = { + data: { + type: 'card', + relationships: { + blog: { + links: { + self: this.args.model.id!, + }, + }, + }, + meta: { + adoptsFrom: ref, + }, + }, + }; + await this.args.createCard?.(ref, currentRealm, { + realmURL: currentRealm, + doc, + }); + }); +} + +// TODO: BlogApp should extend AppCard +// Using type CardDef instead of AppCard from catalog because of +// the many type issues resulting from the lack types from catalog realm +export class BlogApp extends CardDef { + @field website = contains(StringField); + static displayName = 'Blog App'; + static icon = BlogAppIcon; + static prefersWideFormat = true; + static headerColor = '#fff500'; + + static sortOptionList: SortOption[] = [ + { + id: 'datePubDesc', + displayName: 'Date Published', + sort: [ + { + on: { + module: new URL('./blog-post', import.meta.url).href, + name: 'BlogPost', + }, + by: 'publishDate', + direction: 'desc', + }, + ], + }, + { + id: 'lastUpdatedDesc', + displayName: 'Last Updated', + sort: [ + { + by: 'lastModified', + direction: 'desc', + }, + ], + }, + { + id: 'cardTitleAsc', + displayName: 'A-Z', + sort: sortByCardTitleAsc, + }, + ]; + + static filterList: LayoutFilter[] = [ + { + displayName: 'Blog Posts', + icon: BlogPostIcon, + cardTypeName: 'Blog Post', + createNewButtonText: 'Post', + showAdminData: true, + sortOptions: BlogApp.sortOptionList, + cardRef: { + name: 'BlogPost', + module: new URL('./blog-post', import.meta.url).href, + }, + }, + { + displayName: 'Author Bios', + icon: AuthorIcon, + cardTypeName: 'Author', + createNewButtonText: 'Author', + cardRef: { + name: 'Author', + module: new URL('./author', import.meta.url).href, + }, + }, + { + displayName: 'Categories', + icon: CategoriesIcon, + cardTypeName: 'Category', + createNewButtonText: 'Category', + cardRef: { + name: 'BlogCategory', + module: new URL('./blog-category', import.meta.url).href, + }, + }, + ]; + + get filters(): LayoutFilter[] { + if (this.constructor && 'filterList' in this.constructor) { + return this.constructor.filterList as LayoutFilter[]; + } + return BlogApp.filterList; + } + + static isolated = BlogAppTemplate; + static fitted = class Fitted extends Component { + + }; +} diff --git a/catalog/blog-app/blog-category.gts b/catalog/blog-app/blog-category.gts new file mode 100644 index 0000000..27908b0 --- /dev/null +++ b/catalog/blog-app/blog-category.gts @@ -0,0 +1,221 @@ +import { + contains, + field, + Component, + CardDef, + linksTo, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import ColorField from 'https://cardstack.com/base/color'; +import { BlogApp as BlogAppCard } from './blog-app'; +import { htmlSafe } from '@ember/template'; +import { cssVar, getContrastColor } from '@cardstack/boxel-ui/helpers'; + +export const categoryStyle = (category: Partial) => { + if (!category) { + return; + } + const pillColor = category.pillColor ?? '#e8e8e8'; // var(--boxel-200) + const borderColor = category.pillColor ?? '#d3d3d3'; // var(--boxel-border-color) + return htmlSafe(` + background-color: ${pillColor}; + color: ${getContrastColor(pillColor, undefined, undefined, { + isSmallText: true, + })}; + border: 1px solid ${borderColor} + `); +}; + +let BlogCategoryTemplate = class Embedded extends Component< + typeof BlogCategory +> { + +}; + +export class BlogCategory extends CardDef { + static displayName = 'Blog Category'; + + @field longName = contains(StringField); + @field shortName = contains(StringField); + @field slug = contains(StringField); + @field pillColor = contains(ColorField); + @field cardDescription = contains(StringField); + @field blog = linksTo(BlogAppCard, { isUsed: true }); + + static embedded = BlogCategoryTemplate; + static isolated = BlogCategoryTemplate; + static atom = class Atom extends Component { + + }; + + static fitted = class FittedTemplate extends Component { + + }; +} diff --git a/catalog/blog-app/blog-post.gts b/catalog/blog-app/blog-post.gts new file mode 100644 index 0000000..2129aff --- /dev/null +++ b/catalog/blog-app/blog-post.gts @@ -0,0 +1,862 @@ +import DatetimeField from 'https://cardstack.com/base/datetime'; +import StringField from 'https://cardstack.com/base/string'; +import MarkdownField from 'https://cardstack.com/base/markdown'; +import NumberField from 'https://cardstack.com/base/number'; +import { + CardDef, + field, + contains, + linksTo, + Component, + getCardMeta, + linksToMany, +} from 'https://cardstack.com/base/card-api'; + +import CalendarCog from '@cardstack/boxel-icons/calendar-cog'; +import BlogIcon from '@cardstack/boxel-icons/notebook'; + +import { setBackgroundImage } from '../components/layout'; + +import { Author } from './author'; +import { formatDatetime, BlogApp as BlogAppCard } from './blog-app'; +import { BlogCategory, categoryStyle } from './blog-category'; +import { User } from './user'; +import { markdownToHtml } from '@cardstack/runtime-common'; +import FeaturedImageField from '../fields/featured-image'; + +class EmbeddedTemplate extends Component { + +} + +class FittedTemplate extends Component { + +} + +class Status extends StringField { + static displayName = 'Status'; + static icon = CalendarCog; +} + +export class BlogPost extends CardDef { + static displayName = 'Blog Post'; + static icon = BlogIcon; + @field headline = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function (this: BlogPost) { + return this.headline?.length + ? this.headline + : `Untitled ${this.constructor.displayName}`; + }, + }); + @field slug = contains(StringField); + @field body = contains(MarkdownField); + @field authors = linksToMany(Author); + @field publishDate = contains(DatetimeField); + @field status = contains(Status, { + computeVia: function (this: BlogPost) { + if (!this.publishDate) { + return 'Draft'; + } + if (Date.now() >= Date.parse(String(this.publishDate))) { + return 'Published'; + } + return 'Scheduled'; + }, + }); + @field blog = linksTo(BlogAppCard, { isUsed: true }); + @field featuredImage = contains(FeaturedImageField); + @field categories = linksToMany(BlogCategory); + @field lastUpdated = contains(DatetimeField, { + computeVia: function (this: BlogPost) { + let lastModified = getCardMeta(this, 'lastModified'); + return lastModified ? new Date(lastModified * 1000) : undefined; + }, + }); + @field wordCount = contains(NumberField, { + computeVia: function (this: BlogPost) { + if (!this.body) { + return 0; + } + const plainText = markdownToHtml(this.body).replace( + /<\/?[^>]+(>|$)/g, + '', + ); + return plainText.trim().split(/\s+/).length; + }, + }); + @field editors = linksToMany(User); + + get formattedDatePublished() { + if (this.status === 'Published' && this.publishDate) { + return formatDatetime(this.publishDate, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } + return undefined; + } + + get datePublishedIsoTimestamp() { + if (this.status === 'Published' && this.publishDate) { + return this.publishDate.toISOString(); + } + return undefined; + } + + get formattedLastUpdated() { + return this.lastUpdated + ? formatDatetime(this.lastUpdated, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + : undefined; + } + + get lastUpdatedIsoTimestamp() { + return this.lastUpdated ? this.lastUpdated.toISOString() : undefined; + } + + get formattedAuthors() { + const authors = this.authors ?? []; + if (authors.length === 0) return undefined; + + const titles = authors.map((author) => author.cardTitle); + + if (titles.length === 2) { + return `${titles[0]} and ${titles[1]}`; + } + + return titles.length > 2 + ? `${titles.slice(0, -1).join(', ')}, and ${titles.at(-1)}` + : titles[0]; + } + + static embedded = EmbeddedTemplate; + static fitted = FittedTemplate; + static isolated = class Isolated extends Component { + + }; +} diff --git a/catalog/blog-app/review-blog.gts b/catalog/blog-app/review-blog.gts new file mode 100644 index 0000000..68bf127 --- /dev/null +++ b/catalog/blog-app/review-blog.gts @@ -0,0 +1,46 @@ +import MovieIcon from '@cardstack/boxel-icons/movie'; +import BlogPostIcon from '@cardstack/boxel-icons/newspaper'; +import AuthorIcon from '@cardstack/boxel-icons/square-user'; +import CategoriesIcon from '@cardstack/boxel-icons/hierarchy-3'; +import { type LayoutFilter } from '../components/layout'; +import { BlogApp } from './blog-app'; + +export class ReviewBlog extends BlogApp { + static displayName = 'Review Blog'; + static icon = MovieIcon; + + static filterList: LayoutFilter[] = [ + { + displayName: 'Posts', + icon: BlogPostIcon, + cardTypeName: 'Review', + createNewButtonText: 'Post', + showAdminData: true, + sortOptions: BlogApp.sortOptionList, + cardRef: { + name: 'Review', + module: new URL('./review', import.meta.url).href, + }, + }, + { + displayName: 'Authors', + icon: AuthorIcon, + cardTypeName: 'Author', + createNewButtonText: 'Author', + cardRef: { + name: 'Author', + module: new URL('./author', import.meta.url).href, + }, + }, + { + displayName: 'Categories', + icon: CategoriesIcon, + cardTypeName: 'Category', + createNewButtonText: 'Category', + cardRef: { + name: 'BlogCategory', + module: new URL('./blog-category', import.meta.url).href, + }, + }, + ]; +} diff --git a/catalog/blog-app/user.gts b/catalog/blog-app/user.gts new file mode 100644 index 0000000..9131197 --- /dev/null +++ b/catalog/blog-app/user.gts @@ -0,0 +1,17 @@ +import { + CardDef, + StringField, + contains, + field, +} from 'https://cardstack.com/base/card-api'; + +export class User extends CardDef { + static displayName = 'User'; + @field name = contains(StringField); + @field email = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function (this: User) { + return this.name; + }, + }); +} diff --git a/catalog/components/card-list.gts b/catalog/components/card-list.gts new file mode 100644 index 0000000..458b38d --- /dev/null +++ b/catalog/components/card-list.gts @@ -0,0 +1,76 @@ +import GlimmerComponent from '@glimmer/component'; + +import { type CardContext } from 'https://cardstack.com/base/card-api'; + +import { + type Query, + type PrerenderedCardLike, +} from '@cardstack/runtime-common'; + +interface CardListSignature { + Args: { + query: Query; + realms: string[]; + context?: CardContext; + }; + Blocks: { + meta: [card: PrerenderedCardLike]; + }; + Element: HTMLElement; +} +export class CardList extends GlimmerComponent { + +} diff --git a/catalog/components/grid.gts b/catalog/components/grid.gts new file mode 100644 index 0000000..09c9cb0 --- /dev/null +++ b/catalog/components/grid.gts @@ -0,0 +1,98 @@ +import GlimmerComponent from '@glimmer/component'; + +import { type CardContext } from 'https://cardstack.com/base/card-api'; + +import { type Query } from '@cardstack/runtime-common'; + +interface CardsGridSignature { + Args: { + query: Query; + realms: string[]; + selectedView: string; + context?: CardContext; + }; + Element: HTMLElement; +} +export class CardsGrid extends GlimmerComponent { + +} diff --git a/catalog/components/layout.gts b/catalog/components/layout.gts new file mode 100644 index 0000000..5a46f2d --- /dev/null +++ b/catalog/components/layout.gts @@ -0,0 +1,217 @@ +import GlimmerComponent from '@glimmer/component'; +import type { TemplateOnlyComponent } from '@ember/component/template-only'; +import { htmlSafe } from '@ember/template'; +import { type CardOrFieldTypeIcon } from 'https://cardstack.com/base/card-api'; +import ImageIcon from '@cardstack/boxel-icons/image'; +import { FilterList } from '@cardstack/boxel-ui/components'; +import { element } from '@cardstack/boxel-ui/helpers'; +import type { Query, ResolvedCodeRef } from '@cardstack/runtime-common'; +import type { SortOption } from './sort'; + +export interface LayoutFilter { + displayName: string; + icon: CardOrFieldTypeIcon; + cardTypeName?: string; + createNewButtonText?: string; + isCreateNewDisabled?: boolean; + cardRef?: ResolvedCodeRef; + query?: Query; + sortOptions?: SortOption[]; + selectedSort?: SortOption; + showAdminData?: boolean; +} + +interface LayoutSignature { + Args: { + filters: LayoutFilter[]; + activeFilter?: LayoutFilter | undefined; + onFilterChange: (filter: LayoutFilter) => void; + }; + Blocks: { + default: []; + sidebar: []; + contentHeader: []; + grid: []; + }; + Element: HTMLElement; +} + +export const setBackgroundImage = ( + backgroundURL: string | null | undefined, +) => { + if (!backgroundURL) { + return; + } + return htmlSafe(`background-image: url(${backgroundURL});`); +}; + +interface TitleGroupSignature { + Args: { + title?: string; + tagline?: string; + thumbnailURL?: string; + icon?: CardOrFieldTypeIcon; + element?: keyof HTMLElementTagNameMap; + }; + Element: HTMLElement; +} +export const TitleGroup: TemplateOnlyComponent = ; + +export class Layout extends GlimmerComponent { + +} diff --git a/catalog/components/sort.gts b/catalog/components/sort.gts new file mode 100644 index 0000000..415b7ba --- /dev/null +++ b/catalog/components/sort.gts @@ -0,0 +1,123 @@ +import { get } from '@ember/object'; +import GlimmerComponent from '@glimmer/component'; + +import { type Sort, baseRealm } from '@cardstack/runtime-common'; + +import { + BoxelButton, + BoxelDropdown, + Menu as BoxelMenu, +} from '@cardstack/boxel-ui/components'; +import { eq, MenuItem } from '@cardstack/boxel-ui/helpers'; +import { DropdownArrowFilled } from '@cardstack/boxel-ui/icons'; +import ArrowDown from '@cardstack/boxel-icons/arrow-down'; +import ArrowUp from '@cardstack/boxel-icons/arrow-up'; + +export const sortByCardTitleAsc: Sort = [ + { + on: { + module: `${baseRealm.url}card-api`, + name: 'CardDef', + }, + by: 'cardTitle', + direction: 'asc', + }, +]; + +export interface SortOption { + id: string; + displayName: string; + sort: Sort; +} + +interface SortMenuSignature { + Args: { + options: SortOption[]; + onSort: (option: SortOption) => void; + selected: SortOption; + }; + Element: HTMLElement; +} +export class SortMenu extends GlimmerComponent { + + + private get sortOptions() { + return this.args.options.map((option) => { + return new MenuItem({ + label: option.displayName, + action: () => this.args.onSort(option), + icon: option.sort?.[0].direction === 'desc' ? ArrowDown : ArrowUp, + checked: + option.displayName === this.args.selected.displayName && + option.sort?.[0].direction === this.args.selected.sort?.[0].direction, + }); + }); + } +} diff --git a/catalog/fields/contact-link.gts b/catalog/fields/contact-link.gts new file mode 100644 index 0000000..d0d5523 --- /dev/null +++ b/catalog/fields/contact-link.gts @@ -0,0 +1,174 @@ +import { + Component, + field, + contains, + StringField, + FieldDef, +} from 'https://cardstack.com/base/card-api'; +import UrlField from 'https://cardstack.com/base/url'; + +import { + BoxelSelect, + FieldContainer, + Pill, +} from '@cardstack/boxel-ui/components'; + +import type IconComponent from '@cardstack/boxel-icons/captions'; +import Email from '@cardstack/boxel-icons/mail'; +import Link from '@cardstack/boxel-icons/link'; +import Phone from '@cardstack/boxel-icons/phone'; + +export interface ContactLink { + type: 'email' | 'tel' | 'link' | string; + label: string; + icon: typeof IconComponent; + cta: string; +} + +const contactValues: ContactLink[] = [ + { + type: 'email', + label: 'Email', + icon: Email, + cta: 'Email', + }, + { + type: 'tel', + label: 'Phone', + icon: Phone, + cta: 'Contact', + }, + { + type: 'link', + label: 'Other', + icon: Link, + cta: 'Connect', + }, +]; + +export default class ContactLinkField extends FieldDef { + static displayName = 'Contact Link'; + static values: ContactLink[] = contactValues; + @field label = contains(StringField); + @field value = contains(StringField); + @field url = contains(UrlField, { + computeVia: function (this: ContactLinkField) { + switch (this.item?.type) { + case 'email': + return `mailto:${this.value}`; + case 'tel': + return `tel:${this.value}`; + default: + return this.value; + } + }, + }); + get items() { + if (this.constructor && 'values' in this.constructor) { + return this.constructor.values as ContactLink[]; + } + return ContactLinkField.values; + } + get item() { + return this.items?.find((val) => val.label === this.label); + } + static edit = class Edit extends Component { + + + options = this.args.model.items; + + onSelect = (option: ContactLink) => (this.args.model.label = option.label); + + get selectedOption() { + return this.options?.find( + (option) => option.label === this.args.model.label, + ); + } + + get label() { + switch (this.selectedOption?.type) { + case 'email': + return 'Address'; + case 'tel': + return 'Number'; + default: + return 'Link'; + } + } + }; + static atom = class Atom extends Component { + + }; + static embedded = class Embedded extends Component { + + }; +} diff --git a/catalog/fields/featured-image.gts b/catalog/fields/featured-image.gts new file mode 100644 index 0000000..f2d9064 --- /dev/null +++ b/catalog/fields/featured-image.gts @@ -0,0 +1,294 @@ +import { hash } from '@ember/helper'; +import { htmlSafe } from '@ember/template'; +import { + Component, + field, + contains, + StringField, + FieldDef, +} from 'https://cardstack.com/base/card-api'; +import NumberField from 'https://cardstack.com/base/number'; +import { ImageSizeField } from 'https://cardstack.com/base/base64-image'; +import UrlField from 'https://cardstack.com/base/url'; +import { FieldContainer } from '@cardstack/boxel-ui/components'; +import { FailureBordered } from '@cardstack/boxel-ui/icons'; +import PhotoIcon from '@cardstack/boxel-icons/photo'; + +const setBackgroundImage = (backgroundURL: string | null | undefined) => { + if (!backgroundURL) { + return; + } + return htmlSafe(`background-image: url(${backgroundURL});`); +}; + +function cssForFeaturedImage({ + imageUrl, + size, + height, + width, +}: { + imageUrl: string | undefined; + size: 'actual' | 'contain' | 'cover' | undefined; + height?: number; + width?: number; +}) { + if (!imageUrl) { + return undefined; + } + + let css: string[] = []; + css.push(`background-image: url("${imageUrl}");`); + if (size && ['contain', 'cover'].includes(size)) { + css.push(`background-size: ${size};`); + } + if (height) { + css.push(`height: ${height}px;`); + } + if (width) { + css.push(`width: ${width}px`); + } else { + css.push(`width: 100%`); + } + return htmlSafe(css.join(' ')); +} + +export default class FeaturedImageField extends FieldDef { + static displayName = 'Featured Image'; + static icon = PhotoIcon; + @field imageUrl = contains(UrlField); + @field credit = contains(StringField); + @field caption = contains(StringField); + @field altText = contains(StringField); + @field size = contains(ImageSizeField); + @field height = contains(NumberField); + @field width = contains(NumberField); + static edit = class Edit extends Component { + get usesActualSize() { + return this.args.model.size === 'actual' || this.args.model.size == null; + } + + get backgroundMaskStyle() { + let css: string[] = []; + if (this.args.model.height) { + css.push(`height: ${this.args.model.height}px;`); + } + if (this.args.model.width) { + css.push(`width: ${this.args.model.width}px`); + } + return htmlSafe(css.join(' ')); + } + + get needsHeight() { + return ( + (this.args.model.size === 'contain' || + this.args.model.size === 'cover') && + !this.args.model.height + ); + } + + + }; + + static atom = class Atom extends Component { + + }; + static embedded = class Embedded extends Component { + get usesActualSize() { + return this.args.model.size === 'actual' || this.args.model.size == null; + } + + }; +}