Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
509b8a6e01 | ||
|
|
4ede0f58d2 |
@@ -28,6 +28,17 @@ module.exports = {
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
// The stories harness is dev-only tooling, not user-facing UI.
|
||||
files: ['app/javascript/stories/**/*.{js,vue}'],
|
||||
rules: {
|
||||
'vue/no-bare-strings-in-template': 'off',
|
||||
'@intlify/vue-i18n/no-raw-text': 'off',
|
||||
// `group` is declared on <Story> only to accept Histoire's API.
|
||||
'vue/no-unused-properties': 'off',
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
plugins: ['html', 'prettier'],
|
||||
parserOptions: {
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ CLAUDE.local.md
|
||||
|
||||
# Histoire deployment
|
||||
.netlify
|
||||
.histoire
|
||||
.stories
|
||||
.pnpm-store/*
|
||||
local/
|
||||
Procfile.worktree
|
||||
|
||||
+26
-1
@@ -59,13 +59,38 @@ const articles = [
|
||||
views: 400,
|
||||
},
|
||||
];
|
||||
|
||||
const categories = [
|
||||
{ id: 1, name: 'Getting started', icon: '🚀' },
|
||||
{ id: 2, name: 'Marketing', icon: '⚡️' },
|
||||
{ id: 3, name: 'Development', icon: '🛠️' },
|
||||
];
|
||||
|
||||
const allowedLocales = [{ code: 'en', name: 'English' }];
|
||||
|
||||
const portalName = 'Chatwoot Help Center';
|
||||
|
||||
const meta = {
|
||||
currentPage: 1,
|
||||
allArticlesCount: articles.length,
|
||||
articlesCount: articles.length,
|
||||
mineArticlesCount: 2,
|
||||
draftArticlesCount: 1,
|
||||
archivedArticlesCount: 2,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Pages/HelpCenter/ArticlesPage" :layout="{ type: 'single' }">
|
||||
<Variant title="All Articles">
|
||||
<div class="w-full min-h-screen bg-n-background">
|
||||
<ArticlesPage :articles="articles" />
|
||||
<ArticlesPage
|
||||
:articles="articles"
|
||||
:categories="categories"
|
||||
:allowed-locales="allowedLocales"
|
||||
:portal-name="portalName"
|
||||
:meta="meta"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
|
||||
+31
-175
@@ -3,206 +3,62 @@ import CategoriesPage from './CategoriesPage.vue';
|
||||
|
||||
const categories = [
|
||||
{
|
||||
id: 'getting-started',
|
||||
title: '🚀 Getting started',
|
||||
id: 1,
|
||||
name: 'Getting started',
|
||||
icon: '🚀',
|
||||
slug: 'getting-started',
|
||||
description:
|
||||
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
|
||||
articlesCount: '2',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
title:
|
||||
"How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
meta: { articles_count: 12 },
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
title: 'Marketing',
|
||||
id: 2,
|
||||
name: 'Marketing',
|
||||
icon: '⚡️',
|
||||
slug: 'marketing',
|
||||
description:
|
||||
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support.',
|
||||
articlesCount: '4',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
title:
|
||||
"How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Archived article',
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Customizing the appearance of your Help Center',
|
||||
status: '',
|
||||
updatedAt: '5 days ago',
|
||||
author: 'Jane',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
meta: { articles_count: 4 },
|
||||
},
|
||||
{
|
||||
id: 'development',
|
||||
title: 'Development',
|
||||
id: 3,
|
||||
name: 'Development',
|
||||
icon: '🛠️',
|
||||
slug: 'development',
|
||||
description: '',
|
||||
articlesCount: '5',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
title:
|
||||
"How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Archived article',
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Archived article',
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Customizing the appearance of your Help Center',
|
||||
status: '',
|
||||
updatedAt: '5 days ago',
|
||||
author: 'Jane',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
meta: { articles_count: 5 },
|
||||
},
|
||||
{
|
||||
id: 'roadmap',
|
||||
title: '🛣️ Roadmap',
|
||||
id: 4,
|
||||
name: 'Roadmap',
|
||||
icon: '🛣️',
|
||||
slug: 'roadmap',
|
||||
description:
|
||||
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
|
||||
articlesCount: '3',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
title:
|
||||
"How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
meta: { articles_count: 3 },
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
title: '💰 Finance',
|
||||
id: 5,
|
||||
name: 'Finance',
|
||||
icon: '💰',
|
||||
slug: 'finance',
|
||||
description:
|
||||
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
|
||||
articlesCount: '2',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
title:
|
||||
"How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
meta: { articles_count: 2 },
|
||||
},
|
||||
];
|
||||
|
||||
const allowedLocales = [{ code: 'en', name: 'English' }];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Pages/HelpCenter/CategoryPage" :layout="{ type: 'single' }">
|
||||
<Variant title="All Categories">
|
||||
<div class="w-full min-h-screen bg-n-background">
|
||||
<CategoriesPage :categories="categories" />
|
||||
<CategoriesPage
|
||||
:categories="categories"
|
||||
:allowed-locales="allowedLocales"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
|
||||
+17
-1
@@ -1,12 +1,28 @@
|
||||
<script setup>
|
||||
import PortalSettings from './PortalSettings.vue';
|
||||
|
||||
// `slug` matches the route the stories harness seeds (portals/chatwoot), so
|
||||
// PortalSettings resolves an active portal and renders the settings forms.
|
||||
const portals = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Chatwoot Help Center',
|
||||
slug: 'chatwoot',
|
||||
header_text: 'How can we help you today?',
|
||||
page_title: 'Chatwoot Help Center',
|
||||
homepage_link: 'https://www.chatwoot.com',
|
||||
custom_domain: '',
|
||||
config: {},
|
||||
ssl_settings: {},
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Pages/HelpCenter/PortalSettings" :layout="{ type: 'single' }">
|
||||
<Variant title="Default">
|
||||
<div class="w-[1000px] min-h-screen bg-n-background">
|
||||
<PortalSettings />
|
||||
<PortalSettings :portals="portals" :is-fetching="false" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
|
||||
@@ -2,44 +2,51 @@
|
||||
import { ref } from 'vue';
|
||||
import Copilot from './Copilot.vue';
|
||||
|
||||
const supportAgent = {
|
||||
available_name: 'Pranav Raj',
|
||||
avatar_url:
|
||||
'https://app.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBd3FodGc9PSIsImV4cCI6bnVsbCwicHVyIjoiYmxvYl9pZCJ9fQ==--d218a325af0ef45061eefd352f8efb9ac84275e8/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lKYW5CbFp3WTZCa1ZVT2hOeVpYTnBlbVZmZEc5ZlptbHNiRnNIYVFINk1BPT0iLCJleHAiOm51bGwsInB1ciI6InZhcmlhdGlvbiJ9fQ==--533c3ad7218e24c4b0e8f8959dc1953ce1d279b9/1707423736896.jpeg',
|
||||
};
|
||||
|
||||
const messages = ref([
|
||||
{
|
||||
id: 1,
|
||||
role: 'user',
|
||||
content: 'Hi there! How can I help you today?',
|
||||
message_type: 'user',
|
||||
message: { content: 'Hi there! How can I help you today?' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
role: 'assistant',
|
||||
content:
|
||||
"Hello! I'm the AI assistant. I'll be helping the support team today.",
|
||||
message_type: 'assistant_thinking',
|
||||
message: {
|
||||
content: 'Analyzing the conversation',
|
||||
reasoning: 'Breaking down the request into actionable steps',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
message_type: 'assistant_thinking',
|
||||
message: {
|
||||
content: 'Searching the knowledge base',
|
||||
reasoning: 'Looking for relevant articles and past replies',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
message_type: 'assistant',
|
||||
message: {
|
||||
content:
|
||||
"Hello! I'm the AI assistant. I'll be helping the support team today.",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const isCaptainTyping = ref(false);
|
||||
|
||||
const sendMessage = message => {
|
||||
// Add user message
|
||||
messages.value.push({
|
||||
id: messages.value.length + 1,
|
||||
role: 'user',
|
||||
content: message,
|
||||
message_type: 'user',
|
||||
message: { content: message },
|
||||
});
|
||||
|
||||
// Simulate AI response
|
||||
isCaptainTyping.value = true;
|
||||
setTimeout(() => {
|
||||
isCaptainTyping.value = false;
|
||||
messages.value.push({
|
||||
id: messages.value.length + 1,
|
||||
role: 'assistant',
|
||||
content: 'This is a simulated AI response.',
|
||||
message_type: 'assistant',
|
||||
message: { content: 'This is a simulated AI response.' },
|
||||
});
|
||||
}, 2000);
|
||||
};
|
||||
@@ -51,9 +58,8 @@ const sendMessage = message => {
|
||||
:layout="{ type: 'grid', width: '400px', height: '800px' }"
|
||||
>
|
||||
<Copilot
|
||||
:support-agent="supportAgent"
|
||||
:messages="messages"
|
||||
:is-captain-typing="isCaptainTyping"
|
||||
conversation-inbox-type="Channel::WebWidget"
|
||||
@send-message="sendMessage"
|
||||
/>
|
||||
</Story>
|
||||
|
||||
@@ -4,18 +4,24 @@ import CopilotThinkingGroup from './CopilotThinkingGroup.vue';
|
||||
const messages = [
|
||||
{
|
||||
id: 1,
|
||||
content: 'Analyzing the user query',
|
||||
reasoning: 'Breaking down the request into actionable steps',
|
||||
message: {
|
||||
content: 'Analyzing the user query',
|
||||
reasoning: 'Breaking down the request into actionable steps',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
content: 'Searching codebase',
|
||||
reasoning: 'Looking for relevant files and functions',
|
||||
message: {
|
||||
content: 'Searching codebase',
|
||||
reasoning: 'Looking for relevant files and functions',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
content: 'Generating response',
|
||||
reasoning: 'Composing a helpful and accurate answer',
|
||||
message: {
|
||||
content: 'Generating response',
|
||||
reasoning: 'Composing a helpful and accurate answer',
|
||||
},
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
@import 'dashboard/assets/scss/app';
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
--_histoire-color-primary-50: 235 245 255;
|
||||
--_histoire-color-primary-100: 194 225 255;
|
||||
--_histoire-color-primary-200: 153 206 255;
|
||||
--_histoire-color-primary-300: 112 186 255;
|
||||
--_histoire-color-primary-400: 71 166 255;
|
||||
--_histoire-color-primary-500: 31 147 255;
|
||||
--_histoire-color-primary-600: 25 118 204;
|
||||
--_histoire-color-primary-700: 19 88 153;
|
||||
--_histoire-color-primary-800: 12 59 102;
|
||||
--_histoire-color-primary-900: 6 29 51;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
@import 'dashboard/assets/scss/app';
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup>
|
||||
import { ref, computed, defineAsyncComponent } from 'vue';
|
||||
import { stories, buildTree } from './registry';
|
||||
import TreeNode from './TreeNode.vue';
|
||||
import StoryFrame from './StoryFrame.vue';
|
||||
|
||||
const query = ref('');
|
||||
const filteredStories = computed(() => {
|
||||
const term = query.value.trim().toLowerCase();
|
||||
if (!term) return stories;
|
||||
return stories.filter(story => story.title.toLowerCase().includes(term));
|
||||
});
|
||||
const tree = computed(() => buildTree(filteredStories.value));
|
||||
const isSearching = computed(() => query.value.trim().length > 0);
|
||||
|
||||
function pathFromHash() {
|
||||
const hash = decodeURIComponent(window.location.hash.replace(/^#/, ''));
|
||||
return stories.find(story => story.path === hash)?.path;
|
||||
}
|
||||
|
||||
const selectedPath = ref(pathFromHash() || stories[0]?.path || '');
|
||||
const current = computed(() =>
|
||||
stories.find(story => story.path === selectedPath.value)
|
||||
);
|
||||
const StoryComponent = computed(() =>
|
||||
current.value ? defineAsyncComponent(current.value.loader) : null
|
||||
);
|
||||
|
||||
function select(path) {
|
||||
selectedPath.value = path;
|
||||
window.location.hash = encodeURIComponent(path);
|
||||
}
|
||||
|
||||
const isDark = ref(false);
|
||||
function toggleDark() {
|
||||
isDark.value = !isDark.value;
|
||||
document.documentElement.classList.toggle('dark', isDark.value);
|
||||
}
|
||||
|
||||
// Scoped to the preview canvas only (see the <main :dir> below), so the
|
||||
// sidebar stays LTR while stories can be inspected in RTL.
|
||||
const isRtl = ref(false);
|
||||
function toggleDir() {
|
||||
isRtl.value = !isRtl.value;
|
||||
}
|
||||
|
||||
// Remount the active story on every hot update so a fixed file clears the
|
||||
// error boundary (otherwise a caught error stays latched until a full reload).
|
||||
const hmrTick = ref(0);
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.on('vite:afterUpdate', () => {
|
||||
hmrTick.value += 1;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex w-screen h-screen overflow-hidden bg-n-background text-n-slate-12"
|
||||
>
|
||||
<aside
|
||||
dir="ltr"
|
||||
class="flex flex-col border-r w-72 shrink-0 border-n-weak bg-n-solid-1"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between h-12 gap-2 px-4 border-b shrink-0 border-n-weak"
|
||||
>
|
||||
<span class="text-sm font-semibold text-n-slate-12">
|
||||
@chatwoot/design
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-1 text-xs font-medium uppercase rounded-md text-n-slate-11 hover:bg-n-alpha-1"
|
||||
@click="toggleDir"
|
||||
>
|
||||
{{ isRtl ? 'RTL' : 'LTR' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-1 text-sm rounded-md text-n-slate-11 hover:bg-n-alpha-1"
|
||||
@click="toggleDark"
|
||||
>
|
||||
{{ isDark ? 'Light' : 'Dark' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 border-b shrink-0 border-n-weak">
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
placeholder="Search stories"
|
||||
class="w-full px-3 py-1.5 text-sm rounded-md border outline-none border-n-weak bg-n-background text-n-slate-12 placeholder:text-n-slate-10 focus:border-n-brand"
|
||||
/>
|
||||
</div>
|
||||
<nav class="flex-1 p-2 overflow-y-auto">
|
||||
<TreeNode
|
||||
:nodes="tree"
|
||||
:selected-path="selectedPath"
|
||||
:force-expand="isSearching"
|
||||
@select="select"
|
||||
/>
|
||||
<p
|
||||
v-if="!filteredStories.length"
|
||||
class="px-2 py-4 text-sm text-n-slate-10"
|
||||
>
|
||||
No stories match "{{ query }}".
|
||||
</p>
|
||||
</nav>
|
||||
</aside>
|
||||
<main :dir="isRtl ? 'rtl' : 'ltr'" class="flex-1 min-w-0 overflow-hidden">
|
||||
<StoryFrame
|
||||
v-if="StoryComponent"
|
||||
:key="`${selectedPath}:${hmrTick}`"
|
||||
:component="StoryComponent"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center h-full text-n-slate-10"
|
||||
>
|
||||
Select a story to preview.
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup>
|
||||
import { computed, provide } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, required: true },
|
||||
layout: {
|
||||
type: Object,
|
||||
default: () => ({ type: 'grid', width: '80%' }),
|
||||
},
|
||||
// Accepted for API compatibility with Histoire; not used by the harness.
|
||||
group: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const layout = computed(() => ({ type: 'grid', ...props.layout }));
|
||||
provide('storyLayout', layout);
|
||||
|
||||
const name = computed(() => props.title.split('/').pop());
|
||||
const containerClass = computed(() =>
|
||||
layout.value.type === 'single'
|
||||
? 'flex flex-col gap-6'
|
||||
: 'flex flex-wrap items-start gap-6'
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full min-h-0 bg-n-background">
|
||||
<header
|
||||
class="flex flex-col gap-0.5 px-6 py-4 border-b border-n-weak shrink-0"
|
||||
>
|
||||
<h1 class="text-lg font-semibold text-n-slate-12">{{ name }}</h1>
|
||||
<p class="text-xs text-n-slate-10">{{ title }}</p>
|
||||
</header>
|
||||
<div class="flex-1 min-h-0 overflow-auto p-6">
|
||||
<div :class="containerClass">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import { ref, onErrorCaptured } from 'vue';
|
||||
|
||||
// Per-story error boundary. Keyed by the story path in App.vue, so it remounts
|
||||
// (and resets) on navigation. Containing the error here keeps one broken story
|
||||
// from tearing down the whole harness, mirroring Histoire's per-story isolation.
|
||||
defineProps({
|
||||
component: { type: [Object, Function], default: null },
|
||||
});
|
||||
|
||||
const error = ref(null);
|
||||
onErrorCaptured(err => {
|
||||
error.value = err;
|
||||
return false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="error" class="flex flex-col h-full gap-3 p-6 overflow-auto">
|
||||
<p class="text-sm font-semibold text-n-ruby-11">
|
||||
This story failed to render.
|
||||
</p>
|
||||
<div
|
||||
class="p-3 font-mono text-xs whitespace-pre-wrap rounded-md text-n-slate-11 bg-n-alpha-1"
|
||||
>
|
||||
{{ error.stack || error.message }}
|
||||
</div>
|
||||
</div>
|
||||
<component :is="component" v-else />
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
import { reactive } from 'vue';
|
||||
|
||||
// Recursive sidebar node. Each instance owns the collapsed state of its own
|
||||
// direct child groups, so nesting just works. Plain buttons/divs (no <ul>/<li>)
|
||||
// avoid the list markers the dashboard stylesheet injects.
|
||||
const props = defineProps({
|
||||
nodes: { type: Array, required: true },
|
||||
selectedPath: { type: String, default: '' },
|
||||
forceExpand: { type: Boolean, default: false },
|
||||
depth: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
defineEmits(['select']);
|
||||
|
||||
// Default: top-level (L1) groups open, everything deeper collapsed.
|
||||
// `overrides` holds the explicit open/closed state once the user toggles a group.
|
||||
const overrides = reactive({});
|
||||
const isOpen = name => {
|
||||
if (props.forceExpand) return true;
|
||||
if (name in overrides) return overrides[name];
|
||||
return props.depth === 0;
|
||||
};
|
||||
const toggle = name => {
|
||||
overrides[name] = !isOpen(name);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-px">
|
||||
<template v-for="node in nodes" :key="node.name">
|
||||
<div v-if="node.type === 'group'" class="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center w-full gap-1.5 px-2 py-1 rounded-md group text-n-slate-11 hover:bg-n-alpha-1"
|
||||
@click="toggle(node.name)"
|
||||
>
|
||||
<span
|
||||
class="transition-transform i-lucide-chevron-right size-3.5 shrink-0 text-n-slate-10"
|
||||
:class="{ 'rotate-90': isOpen(node.name) }"
|
||||
/>
|
||||
<span
|
||||
class="text-xs font-semibold tracking-wide uppercase truncate text-n-slate-10"
|
||||
>
|
||||
{{ node.name }}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-show="isOpen(node.name)"
|
||||
class="ml-2.5 pl-2 border-l border-n-weak"
|
||||
>
|
||||
<TreeNode
|
||||
:nodes="node.children"
|
||||
:selected-path="selectedPath"
|
||||
:force-expand="forceExpand"
|
||||
:depth="depth + 1"
|
||||
@select="$emit('select', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="block w-full px-2 py-1 ml-1 text-sm text-left truncate transition-colors rounded-md"
|
||||
:class="
|
||||
node.path === selectedPath
|
||||
? 'bg-n-brand/10 text-n-brand font-medium'
|
||||
: 'text-n-slate-11 hover:bg-n-alpha-1 hover:text-n-slate-12'
|
||||
"
|
||||
@click="$emit('select', node.path)"
|
||||
>
|
||||
{{ node.name }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue';
|
||||
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const layout = inject('storyLayout', null);
|
||||
|
||||
// Normalizes layout sizes: numbers and bare numeric strings -> px,
|
||||
// everything else (e.g. '100%', '800px') passes through unchanged.
|
||||
function normalizeSize(value) {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
if (typeof value === 'number') return `${value}px`;
|
||||
return /^\d+$/.test(value) ? `${value}px` : value;
|
||||
}
|
||||
|
||||
const cellStyle = computed(() => {
|
||||
const current = layout?.value ?? {};
|
||||
return {
|
||||
width: normalizeSize(current.width),
|
||||
height: normalizeSize(current.height),
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex flex-col overflow-hidden rounded-lg border border-n-weak bg-n-solid-1"
|
||||
:style="cellStyle"
|
||||
>
|
||||
<div
|
||||
class="px-3 py-2 text-sm font-medium border-b text-n-slate-12 border-n-weak bg-n-alpha-1"
|
||||
>
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="flex-1 p-4 bg-n-background">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>@chatwoot/design</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import Story from './Story.vue';
|
||||
import Variant from './Variant.vue';
|
||||
import { setupApp } from './setup';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
// Story files reference <Story> and <Variant> as globals (same as Histoire).
|
||||
app.component('Story', Story);
|
||||
app.component('Variant', Variant);
|
||||
|
||||
setupApp(app);
|
||||
|
||||
app.mount('#app');
|
||||
@@ -0,0 +1,61 @@
|
||||
// Lazy component loaders for every *.story.vue file under app/javascript.
|
||||
const storyModules = import.meta.glob('../**/*.story.vue');
|
||||
|
||||
// Raw sources, eagerly loaded, so we can read each story's title without
|
||||
// instantiating the (potentially heavy) component just to build the sidebar.
|
||||
const storySources = import.meta.glob('../**/*.story.vue', {
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
eager: true,
|
||||
});
|
||||
|
||||
function parseTitle(source) {
|
||||
const match = source.match(/<Story\b[^>]*?\btitle\s*=\s*"([^"]+)"/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export const stories = Object.keys(storyModules)
|
||||
.map(filePath => ({
|
||||
path: filePath,
|
||||
title: parseTitle(storySources[filePath] || ''),
|
||||
loader: storyModules[filePath],
|
||||
}))
|
||||
.filter(story => story.title)
|
||||
.sort((a, b) => a.title.localeCompare(b.title));
|
||||
|
||||
function insert(nodes, parts, story) {
|
||||
const [head, ...rest] = parts;
|
||||
if (rest.length === 0) {
|
||||
nodes.push({ type: 'story', name: head, path: story.path });
|
||||
return;
|
||||
}
|
||||
let group = nodes.find(node => node.type === 'group' && node.name === head);
|
||||
if (!group) {
|
||||
group = { type: 'group', name: head, children: [] };
|
||||
nodes.push(group);
|
||||
}
|
||||
insert(group.children, rest, story);
|
||||
}
|
||||
|
||||
// Sorts each level so groups come before individual stories, both alphabetically.
|
||||
function sortNodes(nodes) {
|
||||
nodes.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'group' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
nodes.forEach(node => {
|
||||
if (node.type === 'group') sortNodes(node.children);
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// Builds a nested tree from the slash-delimited story titles
|
||||
// (e.g. "Components/Button" -> group "Components" > story "Button").
|
||||
export function buildTree(items) {
|
||||
const roots = [];
|
||||
items.forEach(story => {
|
||||
const parts = story.title.split('/').map(part => part.trim());
|
||||
insert(roots, parts, story);
|
||||
});
|
||||
return sortNodes(roots);
|
||||
}
|
||||
@@ -1,14 +1,33 @@
|
||||
import './design-system/histoire.scss';
|
||||
import { defineSetupVue3 } from '@histoire/plugin-vue';
|
||||
import dashboardI18n from 'dashboard/i18n';
|
||||
import widgetI18n from 'widget/i18n';
|
||||
import '../design-system/stories.scss';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import { vResizeObserver } from '@vueuse/components';
|
||||
import store from 'dashboard/store';
|
||||
import FloatingVue from 'floating-vue';
|
||||
import VueDOMPurifyHTML from 'vue-dompurify-html';
|
||||
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js';
|
||||
import { directive as onClickaway } from 'vue3-click-away';
|
||||
import dashboardI18n from 'dashboard/i18n';
|
||||
import widgetI18n from 'widget/i18n';
|
||||
import store from 'dashboard/store';
|
||||
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js';
|
||||
|
||||
// A throwaway router so composables like useRoute()/useAccount() resolve a real
|
||||
// route object instead of throwing. We seed realistic Help Center params
|
||||
// (accountId/portalSlug/locale) so page-level stories that key off the route
|
||||
// render instead of bailing out. Story selection itself is hash-based and
|
||||
// independent of this router.
|
||||
const noop = { render: () => null };
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/app/accounts/:accountId/portals/:portalSlug/:locale?',
|
||||
name: 'stories',
|
||||
component: noop,
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', name: 'fallback', component: noop },
|
||||
],
|
||||
});
|
||||
router.replace('/app/accounts/1/portals/chatwoot/en');
|
||||
|
||||
function mergeMessages(...sources) {
|
||||
return sources.reduce((acc, src) => {
|
||||
@@ -36,9 +55,12 @@ const i18n = createI18n({
|
||||
),
|
||||
});
|
||||
|
||||
export const setupVue3 = defineSetupVue3(({ app }) => {
|
||||
// Registers the same global plugins/directives the dashboard components expect
|
||||
// at runtime, so stories render identically to the real app.
|
||||
export function setupApp(app) {
|
||||
app.use(store);
|
||||
app.use(i18n);
|
||||
app.use(router);
|
||||
app.use(FloatingVue, {
|
||||
instantMove: true,
|
||||
arrowOverflow: false,
|
||||
@@ -48,4 +70,4 @@ export const setupVue3 = defineSetupVue3(({ app }) => {
|
||||
app.directive('resize', vResizeObserver);
|
||||
app.use(VueDOMPurifyHTML, domPurifyConfig);
|
||||
app.directive('on-clickaway', onClickaway);
|
||||
});
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { defineConfig } from 'histoire';
|
||||
import { HstVue } from '@histoire/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
setupFile: './histoire.setup.ts',
|
||||
plugins: [HstVue()],
|
||||
collectMaxThreads: 4,
|
||||
vite: {
|
||||
server: {
|
||||
port: 6179,
|
||||
},
|
||||
},
|
||||
viteIgnorePlugins: ['vite-plugin-ruby'],
|
||||
theme: {
|
||||
darkClass: 'dark',
|
||||
title: '@chatwoot/design',
|
||||
logo: {
|
||||
square: './design-system/images/logo-thumbnail.svg',
|
||||
light: './design-system/images/logo.png',
|
||||
dark: './design-system/images/logo-dark.png',
|
||||
},
|
||||
},
|
||||
defaultStoryProps: {
|
||||
icon: 'carbon:cube',
|
||||
iconColor: '#1F93FF',
|
||||
layout: {
|
||||
type: 'grid',
|
||||
width: '80%',
|
||||
},
|
||||
},
|
||||
tree: {
|
||||
groups: [
|
||||
{
|
||||
id: 'top',
|
||||
title: '',
|
||||
},
|
||||
{
|
||||
id: 'components',
|
||||
title: 'Components',
|
||||
include: () => true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
+3
-5
@@ -15,9 +15,9 @@
|
||||
"build:sdk": "vite build --config vite.lib.config.ts",
|
||||
"prepare": "husky install",
|
||||
"size": "size-limit",
|
||||
"story:dev": "histoire dev",
|
||||
"story:build": "histoire build",
|
||||
"story:preview": "histoire preview",
|
||||
"story:dev": "vite --config vite.stories.config.ts",
|
||||
"story:build": "vite build --config vite.stories.config.ts",
|
||||
"story:preview": "vite preview --config vite.stories.config.ts",
|
||||
"sync:i18n": "bin/sync_i18n_file_change"
|
||||
},
|
||||
"size-limit": [
|
||||
@@ -114,7 +114,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@egoist/tailwindcss-icons": "^1.9.2",
|
||||
"@histoire/plugin-vue": "0.17.15",
|
||||
"@iconify-json/logos": "^1.2.10",
|
||||
"@iconify-json/lucide": "^1.2.82",
|
||||
"@iconify-json/ph": "^1.2.2",
|
||||
@@ -136,7 +135,6 @@
|
||||
"eslint-plugin-vitest-globals": "^1.5.0",
|
||||
"eslint-plugin-vue": "^9.28.0",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"histoire": "0.17.15",
|
||||
"husky": "^7.0.0",
|
||||
"jsdom": "^27.2.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
|
||||
Generated
+24
-1064
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,7 @@ const tailwindConfig = {
|
||||
'./app/javascript/dashboard/composables/**/*.js',
|
||||
'./app/javascript/dashboard/components-next/**/*.js',
|
||||
'./app/javascript/dashboard/routes/dashboard/**/**/*.js',
|
||||
'./app/javascript/stories/**/*.{js,vue}',
|
||||
'./app/views/**/*.erb',
|
||||
],
|
||||
theme: {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import path from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import yaml from '@rollup/plugin-yaml';
|
||||
import { aliases, vueOptions } from './vite.shared';
|
||||
|
||||
// Standalone Vite app that renders the component stories (replaces Histoire).
|
||||
// It reuses the dashboard aliases/plugins but drops vite-plugin-ruby, which is
|
||||
// specific to the Rails asset pipeline and not needed here.
|
||||
export default defineConfig({
|
||||
root: path.resolve('app/javascript/stories'),
|
||||
plugins: [vue(vueOptions), yaml()],
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
api: 'modern-compiler',
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: { alias: aliases },
|
||||
server: {
|
||||
port: 6179,
|
||||
fs: { allow: [path.resolve('.')] },
|
||||
},
|
||||
build: {
|
||||
outDir: path.resolve('.stories'),
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user