diff --git a/README.md b/README.md index c773152..a107f46 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,125 @@ -# Obsidian Sample Plugin +# Obsidian Clipper Catalog -This is a sample plugin for Obsidian (https://obsidian.md). +An Obsidian plugin that provides a powerful catalog interface for all your clipped web articles and content. Easily organize, search, and manage your web clippings within your Obsidian vault. -This project uses TypeScript to provide type checking and documentation. -The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does. +## Features -This sample plugin demonstrates some of the basic functionality the plugin API can do. -- Adds a ribbon icon, which shows a Notice when clicked. -- Adds a command "Open Sample Modal" which opens a Modal. -- Adds a plugin setting tab to the settings page. -- Registers a global click event and output 'click' to the console. -- Registers a global interval which logs 'setInterval' to the console. +🔍 Catalog all your clipped articles in one view +📱 Fully responsive design that works on desktop and mobile +🏷️ Tag-based organization and filtering +🔄 Real-time search and sorting capabilities +⚡ Command palette integration +🎨 Clean, modern UI with Obsidian theme integration +📂 Advanced directory filtering options -## First time developing plugins? +## Installation -Quick starting guide for new plugin devs: +### From Obsidian Community Plugins +1. Open Obsidian Settings +2. Go to Community Plugins and turn off Safe Mode +3. Click Browse and search for "Clipper Catalog" +4. Install the plugin and enable it -- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with. -- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it). -- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder. -- Install NodeJS, then run `npm i` in the command line under your repo folder. -- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`. -- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`. -- Reload Obsidian to load the new version of your plugin. -- Enable plugin in settings window. -- For updates to the Obsidian API run `npm update` in the command line under your repo folder. +### Manual Installation +1. Download the latest release from GitHub +2. Extract the zip archive into your vault's `.obsidian/plugins` directory +3. Enable the plugin in Obsidian's settings -## Releasing new releases +## Configuration -- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release. -- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible. -- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases -- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release. -- Publish the release. +1. Open the plugin settings in Obsidian +2. Configure the property name that identifies clipped content (default: 'source') +3. The catalog will display all notes that contain the specified property with a URL value -> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`. -> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json` +## Usage -## Adding your plugin to the community plugin list +1. Open the Clipper Catalog view using: + - Command palette: Search for "Open Clipper Catalog" + - Or click the Clipper Catalog icon in the sidebar +2. Browse, search, and filter your clipped articles +3. Use advanced search options to exclude specific directories +4. Click on article titles to open them in your vault +5. Click on tags to filter by specific tags +6. Sort by date, title, or path -- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines). -- Publish an initial version. -- Make sure you have a `README.md` file in the root of your repo. -- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin. +## Features -## How to use +### Search and Filter +- Full-text search across titles and tags +- Tag-based filtering +- Directory exclusion options +- Real-time updates -- Clone this repo. -- Make sure your NodeJS is at least v16 (`node --version`). -- `npm i` or `yarn` to install dependencies. -- `npm run dev` to start compilation in watch mode. +### Organization +- Sort by multiple columns +- Advanced directory filtering +- Tag management +- Path-based organization -## Manually installing the plugin +### Interface +- Clean, modern design +- Responsive layout +- Theme-aware styling +- Keyboard navigation -- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`. +## Support -## Improve code quality with eslint (optional) -- [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code. -- To use eslint with this project, make sure to install eslint from terminal: - - `npm install -g eslint` -- To use eslint to analyze this project use this command: - - `eslint main.ts` - - eslint will then create a report with suggestions for code improvement by file and line number. -- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder: - - `eslint .\src\` +- For bugs or feature requests, please [open an issue](https://github.com/soundslikeinfo/obsidian-clipper-catalog/issues) -## Funding URL +## Privacy -You can include funding URLs where people who use your plugin can financially support it. +This plugin only reads and organizes notes within your local Obsidian vault. No data is sent to external servers. -The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file: +## Contributing -```json -{ - "fundingUrl": "https://buymeacoffee.com" -} +1. Fork the repository +2. Create a feature branch +3. Commit your changes +4. Push to the branch +5. Create a Pull Request + +## License +This project is licensed under the MIT License - see the LICENSE file for details. + +## Frequently Asked Questions + +### How does the plugin find my clipped articles? +The plugin looks for notes in your vault that contain a specified property (default: 'source') with a URL value in the frontmatter. This can be changed in the plugin's settings. + +### Does it work on mobile? +Yes! The plugin is fully compatible with both desktop and mobile versions of Obsidian. + +### Can I exclude certain folders from the catalog? +Yes! Use the advanced search options to exclude specific directories from your catalog view. + +## Support + +- For bugs or feature requests, please [open an issue](https://github.com/soundslikeinfo/obsidian-clipper-catalog/issues) + +## Privacy + +This plugin sends article content to OpenAI's API for processing. Please review OpenAI's privacy policy and ensure you're comfortable with their data handling practices. + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Commit your changes +4. Push to the branch +5. Create a Pull Request + +### 🧠 Crafted with AI & Human Creativity +``` +🎨 Design & Development +Greg K. (@soundslikeinfo) + +🤖 AI Pair Programming +Claude 3.5 Sonnet by Anthropic ``` -If you have multiple URLs, you can also do: +### 💝 Support the Project -```json -{ - "fundingUrl": { - "Buy Me a Coffee": "https://buymeacoffee.com", - "GitHub Sponsor": "https://github.com/sponsors", - "Patreon": "https://www.patreon.com/" - } -} -``` +[![GitHub Stars](https://img.shields.io/github/stars/soundslikeinfo/obsidian-clipper-catalog?style=social)](https://github.com/soundslikeinfo/obsidian-clipper-catalog) +[![Buy Me A Coffee](https://img.shields.io/badge/-buy_me_a%C2%A0coffee-gray?logo=buy-me-a-coffee)](https://www.buymeacoffee.com/soundslikeinfo) -## API Documentation - -See https://github.com/obsidianmd/obsidian-api +Made with ❤️ for the Obsidian Community diff --git a/esbuild.config.mjs b/esbuild.config.mjs index a5de8b8..7c0e9f1 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -1,6 +1,8 @@ import esbuild from "esbuild"; import process from "process"; -import builtins from "builtin-modules"; +import { builtinModules } from 'module'; +import fs from 'fs'; +import path from 'path'; const banner = `/* @@ -11,39 +13,64 @@ if you want to view the source, please visit the github repository of this plugi const prod = (process.argv[2] === "production"); +// Ensure the src/styles.css exists with Tailwind imports +const initStylesFile = () => { + const stylesDir = path.join(process.cwd(), 'src'); + const stylesFile = path.join(stylesDir, 'styles.css'); + + if (!fs.existsSync(stylesDir)) { + fs.mkdirSync(stylesDir, { recursive: true }); + } + + if (!fs.existsSync(stylesFile)) { + const tailwindImports = ` +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Your custom styles here */ + `.trim(); + + fs.writeFileSync(stylesFile, tailwindImports); + } +}; + +initStylesFile(); + const context = await esbuild.context({ - banner: { - js: banner, - }, - entryPoints: ["main.ts"], - bundle: true, - external: [ - "obsidian", - "electron", - "@codemirror/autocomplete", - "@codemirror/collab", - "@codemirror/commands", - "@codemirror/language", - "@codemirror/lint", - "@codemirror/search", - "@codemirror/state", - "@codemirror/view", - "@lezer/common", - "@lezer/highlight", - "@lezer/lr", - ...builtins], - format: "cjs", - target: "es2018", - logLevel: "info", - sourcemap: prod ? false : "inline", - treeShaking: true, - outfile: "main.js", - minify: prod, + banner: { + js: banner, + }, + entryPoints: ["src/main.tsx"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtinModules, + ], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", + jsx: "automatic", }); if (prod) { - await context.rebuild(); - process.exit(0); + await context.rebuild(); + context.dispose(); } else { - await context.watch(); -} + await context.watch(); +} \ No newline at end of file diff --git a/main.ts b/main.ts deleted file mode 100644 index 2d07212..0000000 --- a/main.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian'; - -// Remember to rename these classes and interfaces! - -interface MyPluginSettings { - mySetting: string; -} - -const DEFAULT_SETTINGS: MyPluginSettings = { - mySetting: 'default' -} - -export default class MyPlugin extends Plugin { - settings: MyPluginSettings; - - async onload() { - await this.loadSettings(); - - // This creates an icon in the left ribbon. - const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => { - // Called when the user clicks the icon. - new Notice('This is a notice!'); - }); - // Perform additional things with the ribbon - ribbonIconEl.addClass('my-plugin-ribbon-class'); - - // This adds a status bar item to the bottom of the app. Does not work on mobile apps. - const statusBarItemEl = this.addStatusBarItem(); - statusBarItemEl.setText('Status Bar Text'); - - // This adds a simple command that can be triggered anywhere - this.addCommand({ - id: 'open-sample-modal-simple', - name: 'Open sample modal (simple)', - callback: () => { - new SampleModal(this.app).open(); - } - }); - // This adds an editor command that can perform some operation on the current editor instance - this.addCommand({ - id: 'sample-editor-command', - name: 'Sample editor command', - editorCallback: (editor: Editor, view: MarkdownView) => { - console.log(editor.getSelection()); - editor.replaceSelection('Sample Editor Command'); - } - }); - // This adds a complex command that can check whether the current state of the app allows execution of the command - this.addCommand({ - id: 'open-sample-modal-complex', - name: 'Open sample modal (complex)', - checkCallback: (checking: boolean) => { - // Conditions to check - const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); - if (markdownView) { - // If checking is true, we're simply "checking" if the command can be run. - // If checking is false, then we want to actually perform the operation. - if (!checking) { - new SampleModal(this.app).open(); - } - - // This command will only show up in Command Palette when the check function returns true - return true; - } - } - }); - - // This adds a settings tab so the user can configure various aspects of the plugin - this.addSettingTab(new SampleSettingTab(this.app, this)); - - // If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin) - // Using this function will automatically remove the event listener when this plugin is disabled. - this.registerDomEvent(document, 'click', (evt: MouseEvent) => { - console.log('click', evt); - }); - - // When registering intervals, this function will automatically clear the interval when the plugin is disabled. - this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000)); - } - - onunload() { - - } - - async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); - } - - async saveSettings() { - await this.saveData(this.settings); - } -} - -class SampleModal extends Modal { - constructor(app: App) { - super(app); - } - - onOpen() { - const {contentEl} = this; - contentEl.setText('Woah!'); - } - - onClose() { - const {contentEl} = this; - contentEl.empty(); - } -} - -class SampleSettingTab extends PluginSettingTab { - plugin: MyPlugin; - - constructor(app: App, plugin: MyPlugin) { - super(app, plugin); - this.plugin = plugin; - } - - display(): void { - const {containerEl} = this; - - containerEl.empty(); - - new Setting(containerEl) - .setName('Setting #1') - .setDesc('It\'s a secret') - .addText(text => text - .setPlaceholder('Enter your secret') - .setValue(this.plugin.settings.mySetting) - .onChange(async (value) => { - this.plugin.settings.mySetting = value; - await this.plugin.saveSettings(); - })); - } -} diff --git a/manifest.json b/manifest.json index dfa940e..fd275a6 100644 --- a/manifest.json +++ b/manifest.json @@ -1,11 +1,13 @@ { - "id": "sample-plugin", - "name": "Sample Plugin", - "version": "1.0.0", + "id": "Clipper Catalog", + "name": "Clipper Catalog", + "version": "0.10.1", "minAppVersion": "0.15.0", - "description": "Demonstrates some of the capabilities of the Obsidian API.", + "description": "This provides any Obsidian vault with a catalog of all the clippings gathered with a common source property.", "author": "Obsidian", - "authorUrl": "https://obsidian.md", - "fundingUrl": "https://obsidian.md/pricing", + "authorUrl": "https://github.com/soundslikeinfo", + "fundingUrl": { + "Buy Me a Coffee": "https://www.buymeacoffee.com/soundslikeinfo" + }, "isDesktopOnly": false } diff --git a/package.json b/package.json index 6a00766..b52e405 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,37 @@ { - "name": "obsidian-sample-plugin", + "name": "obsidian-clipper-catalog", "version": "1.0.0", - "description": "This is a sample plugin for Obsidian (https://obsidian.md)", + "description": "This provides any Obsidian vault with a catalog of all the clippings gathered with a common source property.", "main": "main.js", "scripts": { "dev": "node esbuild.config.mjs", - "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "build": "tailwindcss -i ./src/styles.css -o ./styles.css --minify && tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "css:dev": "tailwindcss -i ./src/styles.css -o ./styles.css --watch", "version": "node version-bump.mjs && git add manifest.json versions.json" - }, + }, "keywords": [], "author": "", "license": "MIT", + "bugs": { + "url": "https://github.com/soundslikeinfo/obsidian-clipper-catalog/issues" + }, + "homepage": "https://github.com/soundslikeinfo/obsidian-clipper-catalog#readme", "devDependencies": { "@types/node": "^16.11.6", - "@typescript-eslint/eslint-plugin": "5.29.0", - "@typescript-eslint/parser": "5.29.0", - "builtin-modules": "3.3.0", + "@types/react": "^18.0.27", + "@types/react-dom": "^18.0.10", + "autoprefixer": "^10.4.20", "esbuild": "0.17.3", + "lucide-react": "^0.454.0", "obsidian": "latest", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", "tslib": "2.4.0", "typescript": "4.7.4" - } + }, + "dependencies": { + "openai": "^3.2.1", + "react": "^18.2.0", + "react-dom": "^18.2.0" + } } diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..56dcb48 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,7 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, + } + \ No newline at end of file diff --git a/src/ClipperCatalog.tsx b/src/ClipperCatalog.tsx new file mode 100644 index 0000000..20d2b41 --- /dev/null +++ b/src/ClipperCatalog.tsx @@ -0,0 +1,660 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Link, Search, RefreshCw, ChevronDown, ChevronRight, X } from 'lucide-react'; +import { TFile, App } from 'obsidian'; +import type ObsidianClipperCatalog from './main'; + +interface ClipperCatalogProps { + app: App; + plugin: ObsidianClipperCatalog; +} + +interface Article { + title: string; + url: string; + path: string; + date: number; + tags: string[]; + basename: string; + content: string; +} + +interface SortConfig { + key: keyof Article; + direction: 'asc' | 'desc'; +} + +interface AdvancedSettings { + ignoredDirectories: string[]; + isExpanded: boolean; +} + +const ArticleTitle = ({ file, content, title }: { file: TFile, content: string, title: string }) => { + const isUntitled = /^Untitled( \d+)?$/.test(file.basename); + const headerMatch = content.match(/^#+ (.+)$/m); + + if (isUntitled && headerMatch) { + return ( +
+ {headerMatch[1].trim()} + ({file.basename}) +
+ ); + } + + return {file.basename}; +}; + +const ClipperCatalog: React.FC = ({ app, plugin }) => { + const [articles, setArticles] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const [sortConfig, setSortConfig] = useState({ key: 'date', direction: 'desc' }); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(null); + const [advancedSettings, setAdvancedSettings] = useState(() => { + // Try to load saved settings from localStorage + const savedSettings = localStorage.getItem('clipper-catalog-advanced-settings'); + return savedSettings ? JSON.parse(savedSettings) : { + ignoredDirectories: [], + isExpanded: false + }; + }); + const [newDirectory, setNewDirectory] = useState(''); + + + // Save advanced settings to localStorage whenever they change + useEffect(() => { + localStorage.setItem('clipper-catalog-advanced-settings', JSON.stringify(advancedSettings)); + }, [advancedSettings]); + + // Helper function to check if a path should be ignored + const isPathIgnored = (filePath: string): boolean => { + return advancedSettings.ignoredDirectories.some(dir => { + // Normalize both paths to use forward slashes and remove trailing slashes + const normalizedDir = dir.replace(/\\/g, '/').replace(/\/$/, ''); + const normalizedPath = filePath.replace(/\\/g, '/'); + + // Split the paths into segments + const dirParts = normalizedDir.split('/'); + const pathParts = normalizedPath.split('/'); + + // Check if the number of path parts is at least equal to directory parts + if (pathParts.length < dirParts.length) return false; + + // Compare each segment + for (let i = 0; i < dirParts.length; i++) { + if (dirParts[i].toLowerCase() !== pathParts[i].toLowerCase()) { + return false; + } + } + + // Only match if we've matched all segments exactly + return dirParts.length === pathParts.length - 1 || // Directory contains files + dirParts.length === pathParts.length; // Directory is exactly matched + }); + }; + + const loadArticles = useCallback(async () => { + console.log("Starting to load articles..."); + setIsRefreshing(true); + setError(null); + + try { + const articleFiles: Article[] = []; + const files = app.vault.getMarkdownFiles(); + console.log(`Found ${files.length} markdown files`); + + for (const file of files) { + try { + if (isPathIgnored(file.parent?.path || '')) { + continue; + } + + const content = await app.vault.read(file); + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + + if (frontmatterMatch) { + const frontmatter = frontmatterMatch[1]; + const sourcePropertyPattern = `^${plugin.settings.sourcePropertyName}:\\s*["']?([^"'\\s]+)["']?\\s*$` + const sourcePropertyRegex = new RegExp(sourcePropertyPattern, 'm') + const sourceMatch = frontmatter.match(sourcePropertyRegex); + + if (sourceMatch) { + console.log(`Processing file: ${file.path}`); + const content = await app.vault.read(file); + const title = file.basename; + + let tags: string[] = []; + + // Get all hashtags from the entire content (including frontmatter) + const hashtagMatches = content.match(/#[\w\d-_/]+/g) || []; + + + // Add content tags (requiring # prefix) + tags = [...new Set(hashtagMatches.map(tag => tag.slice(1)))].filter(Boolean); + + + + // Remove duplicates using Set + tags = [...new Set(tags)].filter(Boolean); + + articleFiles.push({ + title, + url: sourceMatch[1], + path: file.path, + date: file.stat.ctime, + tags, + basename: file.basename, + content: content + }); + } + } + } catch (error) { + console.error(`Error processing file ${file.path}:`, error); + } + } + + console.log(`Processed ${articleFiles.length} articles`); + setArticles(articleFiles); + } catch (error) { + console.error("Error loading articles:", error); + setError("Failed to load articles"); + } finally { + setIsRefreshing(false); + setIsLoading(false); + } + }, [app.vault, advancedSettings.ignoredDirectories]); + + + // Initial load + useEffect(() => { + loadArticles(); + }, [loadArticles, advancedSettings.ignoredDirectories]); + + + useEffect(() => { + const intervalId = setInterval(() => { + loadArticles(); + }, 60000); + + return () => clearInterval(intervalId); + }, [loadArticles]); + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (isLoading) { + return ( +
+
+ +
+ Loading articles... +
+ ); + } + + const handleRefresh = () => { + loadArticles(); + }; + + const handleSort = (key: keyof Article) => { + setSortConfig(prevConfig => ({ + key, + direction: prevConfig.key === key && prevConfig.direction === 'asc' ? 'desc' : 'asc' + })); + }; + + const sortedArticles = [...articles].sort((a, b) => { + if (sortConfig.key === 'date') { + return sortConfig.direction === 'asc' + ? a.date - b.date + : b.date - a.date; + } + + const aValue = String(a[sortConfig.key]).toLowerCase(); + const bValue = String(b[sortConfig.key]).toLowerCase(); + + if (sortConfig.direction === 'asc') { + return aValue.localeCompare(bValue); + } + return bValue.localeCompare(aValue); + }); + + const filteredArticles = sortedArticles.filter(article => + article.title.toLowerCase().includes(searchTerm.toLowerCase()) || + article.tags.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase())) || + searchTerm.startsWith('#') && article.tags.some(tag => + tag.toLowerCase() === searchTerm.slice(1).toLowerCase() + ) + ); + + const getSortIcon = (key: keyof Article) => { + if (sortConfig.key === key) { + return sortConfig.direction === 'asc' ? '↑' : '↓'; + } + return null; + }; + + const openArticle = (path: string) => { + const file = app.vault.getAbstractFileByPath(path); + if (file instanceof TFile) { + const leaf = app.workspace.getLeaf(false); + leaf.openFile(file); + } + }; + + const formatDate = (timestamp: number) => { + return new Date(timestamp).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + }; + + const handleAddDirectory = () => { + if (!newDirectory.trim()) return; + + // Split by commas and clean up each directory path + const directoriesToAdd = newDirectory + .split(',') + .map(dir => dir.trim()) + .filter(dir => dir.length > 0); + + if (directoriesToAdd.length === 0) return; + + setAdvancedSettings(prev => { + const updatedDirectories = [...prev.ignoredDirectories]; + + directoriesToAdd.forEach(dir => { + if (!updatedDirectories.includes(dir)) { + updatedDirectories.push(dir); + } + }); + + return { + ...prev, + ignoredDirectories: updatedDirectories + }; + }); + + setNewDirectory(''); + }; + + const handleRemoveDirectory = (dir: string) => { + setAdvancedSettings(prev => ({ + ...prev, + ignoredDirectories: prev.ignoredDirectories.filter(d => d !== dir) + })); + // Articles will reload automatically due to the useEffect dependency on ignoredDirectories + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && newDirectory.trim()) { + handleAddDirectory(); + } + }; + + const toggleAdvancedSettings = () => { + setAdvancedSettings(prev => ({ + ...prev, + isExpanded: !prev.isExpanded + })); + }; + + const handleClearAllDirectories = () => { + setAdvancedSettings(prev => ({ + ...prev, + ignoredDirectories: [] + })); + }; + + const renderAdvancedSettingsHeader = () => { + const excludedCount = advancedSettings.ignoredDirectories.length; + + return ( +
+ + {!advancedSettings.isExpanded && excludedCount > 0 && ( + + Note: There {excludedCount === 1 ? 'is' : 'are'} {excludedCount} path{excludedCount === 1 ? '' : 's'} excluded from showing up in the results + + )} +
+ ); + }; + + return ( +
+
+ {/* Search input container */} +
+ +
+ setSearchTerm(e.target.value)} + className="cc-w-full cc-bg-transparent cc-outline-none cc-text-sm cc-pr-16 clipper-catalog-input" + /> + {searchTerm && ( +
setSearchTerm('')} + className="cc-absolute cc-right-2 cc-top-[20%] cc-flex cc-items-center cc-gap-1 cc-cursor-pointer cc-transition-colors clipper-catalog-clear-btn" + > + + + + clear +
+ )} +
+
+ + {/* Advanced Settings Section */} +
+ {renderAdvancedSettingsHeader()} + + {advancedSettings.isExpanded && ( +
+
+
+
+ setNewDirectory(e.target.value)} + onKeyPress={handleKeyPress} + className="cc-flex-1 cc-px-2 cc-py-1 cc-text-sm cc-rounded clipper-catalog-input" + /> + +
+ + Tip: You can enter multiple paths separated by commas + +
+ + {advancedSettings.ignoredDirectories.length > 0 && ( +
+
+ Excluded Paths: + +
+
+ {advancedSettings.ignoredDirectories.map((dir) => ( + + ))} +
+
+ )} +
+
+ )} +
+ + {/* Refresh link */} +
+ + + refresh list + +
+
+ +
+ + + + + + + + + + + + + + + + + + + {filteredArticles.map((article) => ( + + + + + + + + ))} + +
handleSort('title')} + className="cc-px-4 cc-py-2 cc-text-left cc-cursor-pointer clipper-catalog-header-cell" + > + Note Title {getSortIcon('title')} + handleSort('date')} + className="cc-px-4 cc-py-2 cc-text-left cc-cursor-pointer cc-whitespace-nowrap clipper-catalog-header-cell" + > + Date {getSortIcon('date')} + handleSort('path')} + className="cc-px-4 cc-py-2 cc-text-left cc-cursor-pointer clipper-catalog-header-cell" + > + Path {getSortIcon('path')} + + #Tags + + Link +
+ openArticle(article.path)} + className="cc-flex cc-items-center cc-gap-2 cc-cursor-pointer cc-transition-colors cc-min-h-[1.5rem] clipper-catalog-title" + > + + + + + + + {formatDate(article.date)} + + {article.path.split('/').slice(0, -1).join('/')} + +
+ {article.tags.map((tag, i) => ( + setSearchTerm(`#${tag}`)} + className="cc-px-2 cc-py-1 cc-text-xs cc-rounded-full cc-cursor-pointer cc-transition-colors clipper-catalog-tag" + > + #{tag} + + ))} +
+
+ + + Original + +
+
+ + {filteredArticles.length === 0 && ( +
+
+ No articles found matching your search. +
+
+ Note: This catalog shows any markdown files containing a URL in their frontmatter under the property: "{plugin.settings.sourcePropertyName}". + You can change this property name in plugin settings to match your preferred clipping workflow. +
+
+ )} + + +
+ ); +}; + +export default ClipperCatalog; \ No newline at end of file diff --git a/src/ClipperCatalogView.tsx b/src/ClipperCatalogView.tsx new file mode 100644 index 0000000..c2162f2 --- /dev/null +++ b/src/ClipperCatalogView.tsx @@ -0,0 +1,52 @@ +import { ItemView, WorkspaceLeaf } from 'obsidian'; +import { createRoot, Root } from 'react-dom/client'; +import ClipperCatalog from './ClipperCatalog'; +import React from 'react'; +import { ICON_NAME } from './main'; +import type ObsidianClipperCatalog from './main'; + +export const VIEW_TYPE_CLIPPER_CATALOG = "clipper-catalog"; + +export class ClipperCatalogView extends ItemView { + plugin: ObsidianClipperCatalog; + root: Root | null = null; + + constructor(leaf: WorkspaceLeaf, plugin: ObsidianClipperCatalog) { + super(leaf); + this.plugin = plugin; + } + + getViewType() { + return VIEW_TYPE_CLIPPER_CATALOG; + } + + // Specify the icon here + getIcon(): string { + return ICON_NAME || 'document'; + } + + getDisplayText() { + return "Clipper Catalog"; + } + + async onOpen() { + const container = this.containerEl.children[1]; + container.empty(); + + const reactContainer = container.createDiv({ cls: 'clipper-catalog-container' }); + this.root = createRoot(reactContainer); + + this.root.render( + React.createElement(ClipperCatalog, { + app: this.app, + plugin: this.plugin + }) + ); + } + + async onClose() { + if (this.root) { + this.root.unmount(); + } + } +} \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..0ce933f --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,134 @@ +import * as React from 'react'; +import { App, Plugin, WorkspaceLeaf, addIcon, PluginSettingTab, Setting } from 'obsidian'; +import { createRoot, Root } from 'react-dom/client'; +import { ClipperCatalogView, VIEW_TYPE_CLIPPER_CATALOG } from './ClipperCatalogView'; + +interface ObsidianClipperCatalogSettings { + sourcePropertyName: string; +} + +// Add this before the ObsidianClipperCatalog class definition +export const ICON_NAME = 'clipper-catalog'; + +const DEFAULT_SETTINGS: ObsidianClipperCatalogSettings = { + sourcePropertyName: 'source' +} + +export default class ObsidianClipperCatalog extends Plugin { + settings: ObsidianClipperCatalogSettings; + + async onload() { + await this.loadSettings(); + + // Register the custom icon + addIcon(ICON_NAME, ` + + + + + `); + + this.registerView( + VIEW_TYPE_CLIPPER_CATALOG, + (leaf: WorkspaceLeaf) => new ClipperCatalogView(leaf, this) + ); + + // Add ribbon icon + this.addRibbonIcon(ICON_NAME, 'Clipper Catalog', (evt: MouseEvent) => { + // Get active leaf or create new one in center + const leaf = this.app.workspace.getLeaf('tab'); + if (leaf) { + leaf.setViewState({ + type: VIEW_TYPE_CLIPPER_CATALOG, + active: true + }); + this.app.workspace.revealLeaf(leaf); + } + }); + + + this.addCommand({ + id: 'open-clipper-catalog', + name: 'Open Clipper Catalog', + callback: () => { + // Get active leaf or create new one in center + const leaf = this.app.workspace.getLeaf('tab'); + if (leaf) { + leaf.setViewState({ + type: VIEW_TYPE_CLIPPER_CATALOG, + active: true, + }); + this.app.workspace.revealLeaf(leaf); + } + } + }); + + this.addSettingTab(new ClipperCatalogSettingTab(this.app, this)); + } + + async loadSettings() { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + async saveSettings() { + await this.saveData(this.settings); + } +} + +class ClipperCatalogSettingTab extends PluginSettingTab { + plugin: ObsidianClipperCatalog; + settingsHeaderEl: HTMLDivElement; + settingsHeaderContentEl: HTMLDivElement; + advancedSettingsEl: HTMLDetailsElement; + advancedContentEl: HTMLDivElement; + + constructor(app: App, plugin: ObsidianClipperCatalog) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const {containerEl} = this; + containerEl.empty(); + containerEl.addClass('clipper-catalog-plugin'); + containerEl.addClass('clipper-catalog-settings'); + + // Create Header section using
element + this.settingsHeaderEl = containerEl.createEl('div', { + cls: 'settings-title', + }); + + const settingsHeader = this.settingsHeaderEl.createEl('div', { + cls: 'settings-clipper-catalog-name', + text: 'Clipper Catalog Settings' + }); + + // Create a container for advanced settings content + this.settingsHeaderContentEl = this.settingsHeaderEl.createEl('div', { + cls: 'advanced-settings-name' + }); + + // Add some styling to the heading element + settingsHeader.style.padding = '10px 0'; + settingsHeader.style.fontWeight = 'bold'; + + new Setting(containerEl) + .setName('Property Name') + .setDesc('Specify which frontmatter property contains your clipped URLs (e.g., "source", "url", "link").') + .addText(text => text + .setValue(this.plugin.settings.sourcePropertyName) + .onChange(async (value) => { + this.plugin.settings.sourcePropertyName = value; + await this.plugin.saveSettings(); + })); + + // Add CSS + containerEl.createEl('style', { + text: ` + .text-sm{ + font-size:.75rem; + } + ` + }); + } +} \ No newline at end of file diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..0e342fd --- /dev/null +++ b/src/styles.css @@ -0,0 +1,109 @@ +/* Remove @tailwind base to prevent global style reset */ +@tailwind components; +@tailwind utilities; + +/* Define keyframes for spin animation */ +@keyframes spinner-rotate { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* Scoped plugin styles */ +.clipper-catalog-plugin { + /* Add minimal reset only for our plugin elements */ + & * { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + /* Loading spinner styles */ + .loading-spinner-container { + display: flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + } + + .loading-spinner { + width: 1rem; + height: 1rem; + animation: spinner-rotate 1s linear infinite; + } + + /* Error states */ + .error-text { + font-size: 0.75rem; + color: #ef4444; + } + + .input-error { + border-color: #ef4444; + } + + /* Settings styles */ + &.clipper-catalog-settings { + .setting-item { + border-top: none; + padding: 18px 0; + } + + .setting-item-name { + color: var(--text-normal); + font-size: 1em; + font-weight: 500; + } + + .setting-item-description { + color: var(--text-muted); + font-size: 0.85em; + line-height: 1.5; + max-width: 500px; + margin-right: 2em; + } + + textarea, + input[type="text"], + input[type="password"], + select { + background-color: var(--background-modifier-form-field); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + padding: 6px 12px; + color: var(--text-normal); + transition: all 200ms ease-in-out; + } + + textarea:focus, + input[type="text"]:focus, + input[type="password"]:focus, + select:focus { + border-color: var(--interactive-accent); + box-shadow: 0 0 0 2px var(--background-modifier-border-focus); + } + + textarea { + min-height: 100px; + width: 100%; + resize: vertical; + } + + input[type="text"], + input[type="password"] { + height: 32px; + width: 100%; + max-width: 150px; + } + + select { + height: 32px; + width: 100%; + max-width: 325px; + } + } +} diff --git a/styles.css b/styles.css index 71cc60f..3f25610 100644 --- a/styles.css +++ b/styles.css @@ -1,8 +1,558 @@ -/* +/* Remove @tailwind base to prevent global style reset */ -This CSS file will be included with your plugin, and -available in the app when your plugin is enabled. +.cc-absolute { + position: absolute; +} -If your plugin does not need CSS, delete this file. +.cc-relative { + position: relative; +} -*/ +.cc-right-2 { + right: 0.5rem; +} + +.cc-top-\[20\%\] { + top: 20%; +} + +.cc-top-full { + top: 100%; +} + +.cc-ml-2 { + margin-left: 0.5rem; +} + +.cc-mt-1 { + margin-top: 0.25rem; +} + +.cc-mt-2 { + margin-top: 0.5rem; +} + +.cc-mt-4 { + margin-top: 1rem; +} + +.cc-flex { + display: flex; +} + +.cc-inline-flex { + display: inline-flex; +} + +.cc-h-2\.5 { + height: 0.625rem; +} + +.cc-h-3 { + height: 0.75rem; +} + +.cc-h-3\.5 { + height: 0.875rem; +} + +.cc-h-4 { + height: 1rem; +} + +.cc-min-h-\[1\.5rem\] { + min-height: 1.5rem; +} + +.cc-w-2\.5 { + width: 0.625rem; +} + +.cc-w-3 { + width: 0.75rem; +} + +.cc-w-3\.5 { + width: 0.875rem; +} + +.cc-w-4 { + width: 1rem; +} + +.cc-w-\[13\%\] { + width: 13%; +} + +.cc-w-\[15\%\] { + width: 15%; +} + +.cc-w-\[20\%\] { + width: 20%; +} + +.cc-w-\[22\%\] { + width: 22%; +} + +.cc-w-\[30\%\] { + width: 30%; +} + +.cc-w-full { + width: 100%; +} + +.cc-flex-1 { + flex: 1 1 0%; +} + +.cc-flex-shrink-0 { + flex-shrink: 0; +} + +@keyframes cc-spin { + to { + transform: rotate(360deg); + } +} + +.cc-animate-spin { + animation: cc-spin 1s linear infinite; +} + +.cc-cursor-pointer { + cursor: pointer; +} + +.cc-flex-col { + flex-direction: column; +} + +.cc-flex-wrap { + flex-wrap: wrap; +} + +.cc-items-center { + align-items: center; +} + +.cc-justify-end { + justify-content: flex-end; +} + +.cc-justify-center { + justify-content: center; +} + +.cc-justify-between { + justify-content: space-between; +} + +.cc-gap-0\.5 { + gap: 0.125rem; +} + +.cc-gap-1 { + gap: 0.25rem; +} + +.cc-gap-1\.5 { + gap: 0.375rem; +} + +.cc-gap-2 { + gap: 0.5rem; +} + +.cc-gap-3 { + gap: 0.75rem; +} + +.cc-gap-4 { + gap: 1rem; +} + +.cc-overflow-x-auto { + overflow-x: auto; +} + +.cc-whitespace-nowrap { + white-space: nowrap; +} + +.cc-rounded { + border-radius: 0.25rem; +} + +.cc-rounded-full { + border-radius: 9999px; +} + +.cc-rounded-lg { + border-radius: 0.5rem; +} + +.cc-border-gray-200 { + --tw-border-opacity: 1; + border-color: rgb(229 231 235 / var(--tw-border-opacity, 1)); +} + +.cc-bg-blue-100 { + --tw-bg-opacity: 1; + background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1)); +} + +.cc-bg-gray-100 { + --tw-bg-opacity: 1; + background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1)); +} + +.cc-bg-transparent { + background-color: transparent; +} + +.cc-p-4 { + padding: 1rem; +} + +.cc-px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.cc-px-3 { + padding-left: 0.75rem; + padding-right: 0.75rem; +} + +.cc-px-4 { + padding-left: 1rem; + padding-right: 1rem; +} + +.cc-py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.cc-py-1\.5 { + padding-top: 0.375rem; + padding-bottom: 0.375rem; +} + +.cc-py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.cc-py-4 { + padding-top: 1rem; + padding-bottom: 1rem; +} + +.cc-pr-16 { + padding-right: 4rem; +} + +.cc-text-left { + text-align: left; +} + +.cc-text-center { + text-align: center; +} + +.cc-text-right { + text-align: right; +} + +.cc-text-\[10px\] { + font-size: 10px; +} + +.cc-text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.cc-text-xs { + font-size: 0.75rem; + line-height: 1rem; +} + +.cc-font-medium { + font-weight: 500; +} + +.cc-text-blue-600 { + --tw-text-opacity: 1; + color: rgb(37 99 235 / var(--tw-text-opacity, 1)); +} + +.cc-text-blue-700 { + --tw-text-opacity: 1; + color: rgb(29 78 216 / var(--tw-text-opacity, 1)); +} + +.cc-text-gray-600 { + --tw-text-opacity: 1; + color: rgb(75 85 99 / var(--tw-text-opacity, 1)); +} + +.cc-text-gray-800 { + --tw-text-opacity: 1; + color: rgb(31 41 55 / var(--tw-text-opacity, 1)); +} + +.cc-text-gray-900 { + --tw-text-opacity: 1; + color: rgb(17 24 39 / var(--tw-text-opacity, 1)); +} + +.cc-text-red-400 { + --tw-text-opacity: 1; + color: rgb(248 113 113 / var(--tw-text-opacity, 1)); +} + +.cc-underline { + text-decoration-line: underline; +} + +.cc-opacity-0 { + opacity: 0; +} + +.cc-opacity-100 { + opacity: 1; +} + +.cc-opacity-50 { + opacity: 0.5; +} + +.cc-opacity-60 { + opacity: 0.6; +} + +.cc-outline-none { + outline: 2px solid transparent; + outline-offset: 2px; +} + +.cc-transition-all { + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.cc-transition-colors { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.cc-transition-opacity { + transition-property: opacity; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.cc-duration-200 { + transition-duration: 200ms; +} + +.cc-duration-300 { + transition-duration: 300ms; +} + +.cc-ease-in-out { + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Define keyframes for spin animation */ + +@keyframes spinner-rotate { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +/* Scoped plugin styles */ + +.clipper-catalog-plugin { + /* Add minimal reset only for our plugin elements */ + & * { + box-sizing: border-box; + margin: 0; + padding: 0; + } + /* Loading spinner styles */ + .loading-spinner-container { + display: flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + } + .loading-spinner { + width: 1rem; + height: 1rem; + animation: spinner-rotate 1s linear infinite; + } + /* Error states */ + .error-text { + font-size: 0.75rem; + color: #ef4444; + } + .input-error { + border-color: #ef4444; + } + /* Settings styles */ + &.clipper-catalog-settings { + .setting-item { + border-top: none; + padding: 18px 0; + } + .setting-item-name { + color: var(--text-normal); + font-size: 1em; + font-weight: 500; + } + .setting-item-description { + color: var(--text-muted); + font-size: 0.85em; + line-height: 1.5; + max-width: 500px; + margin-right: 2em; + } + textarea, + input[type="text"], + input[type="password"], + select { + background-color: var(--background-modifier-form-field); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + padding: 6px 12px; + color: var(--text-normal); + transition: all 200ms ease-in-out; + } + textarea:focus, + input[type="text"]:focus, + input[type="password"]:focus, + select:focus { + border-color: var(--interactive-accent); + box-shadow: 0 0 0 2px var(--background-modifier-border-focus); + } + textarea { + min-height: 100px; + width: 100%; + resize: vertical; + } + input[type="text"], + input[type="password"] { + height: 32px; + width: 100%; + max-width: 150px; + } + select { + height: 32px; + width: 100%; + max-width: 325px; + } + } +} + +.hover\:cc-bg-blue-200:hover { + --tw-bg-opacity: 1; + background-color: rgb(191 219 254 / var(--tw-bg-opacity, 1)); +} + +.hover\:cc-bg-gray-100:hover { + --tw-bg-opacity: 1; + background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1)); +} + +.hover\:cc-text-gray-600:hover { + --tw-text-opacity: 1; + color: rgb(75 85 99 / var(--tw-text-opacity, 1)); +} + +.hover\:cc-text-gray-800:hover { + --tw-text-opacity: 1; + color: rgb(31 41 55 / var(--tw-text-opacity, 1)); +} + +.hover\:cc-underline:hover { + text-decoration-line: underline; +} + +.hover\:cc-opacity-90:hover { + opacity: 0.9; +} + +.dark\:cc-border-gray-700:is(.cc-dark *) { + --tw-border-opacity: 1; + border-color: rgb(55 65 81 / var(--tw-border-opacity, 1)); +} + +.dark\:cc-bg-blue-900\/50:is(.cc-dark *) { + background-color: rgb(30 58 138 / 0.5); +} + +.dark\:cc-bg-gray-800\/50:is(.cc-dark *) { + background-color: rgb(31 41 55 / 0.5); +} + +.dark\:cc-text-blue-200:is(.cc-dark *) { + --tw-text-opacity: 1; + color: rgb(191 219 254 / var(--tw-text-opacity, 1)); +} + +.dark\:cc-text-blue-400:is(.cc-dark *) { + --tw-text-opacity: 1; + color: rgb(96 165 250 / var(--tw-text-opacity, 1)); +} + +.dark\:cc-text-gray-300:is(.cc-dark *) { + --tw-text-opacity: 1; + color: rgb(209 213 219 / var(--tw-text-opacity, 1)); +} + +.dark\:cc-text-gray-400:is(.cc-dark *) { + --tw-text-opacity: 1; + color: rgb(156 163 175 / var(--tw-text-opacity, 1)); +} + +.dark\:cc-text-gray-500:is(.cc-dark *) { + --tw-text-opacity: 1; + color: rgb(107 114 128 / var(--tw-text-opacity, 1)); +} + +.dark\:cc-text-white:is(.cc-dark *) { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity, 1)); +} + +.dark\:hover\:cc-bg-blue-800\/50:hover:is(.cc-dark *) { + background-color: rgb(30 64 175 / 0.5); +} + +.dark\:hover\:cc-bg-gray-800\/50:hover:is(.cc-dark *) { + background-color: rgb(31 41 55 / 0.5); +} + +.dark\:hover\:cc-text-blue-300:hover:is(.cc-dark *) { + --tw-text-opacity: 1; + color: rgb(147 197 253 / var(--tw-text-opacity, 1)); +} + +.dark\:hover\:cc-text-gray-300:hover:is(.cc-dark *) { + --tw-text-opacity: 1; + color: rgb(209 213 219 / var(--tw-text-opacity, 1)); +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..a99ec62 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,89 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ["./src/**/*.{js,jsx,ts,tsx}"], + darkMode: 'class', + theme: { + extend: { + keyframes: { + 'cc-spin': { + 'from': { transform: 'rotate(0deg)' }, + 'to': { transform: 'rotate(360deg)' } + } + }, + animation: { + 'cc-spin': 'cc-spin 1s linear infinite' + } + }, + }, + plugins: [], + prefix: 'cc-', + corePlugins: { + preflight: false, + }, + safelist: [ + // Basic utility classes + 'cc-opacity-0', + 'cc-opacity-50', + 'cc-opacity-100', + 'cc-transition-opacity', + 'cc-duration-200', + 'cc-duration-300', + 'cc-ease-in-out', + 'cc-animate-spin', + 'cc-w-4', + 'cc-h-4', + 'cc-gap-2', + 'cc-gap-3', + 'cc-items-center', + 'cc-justify-end', + 'cc-flex', + 'cc-flex-col', + 'cc-mt-4', + 'cc-text-xs', + 'cc-text-sm', + 'cc-font-medium', + 'cc-px-4', + 'cc-py-2', + 'cc-rounded-lg', + + // Dark mode classes + 'dark:cc-bg-gray-800/50', + 'dark:cc-text-white', + 'dark:cc-text-gray-300', + 'dark:cc-text-gray-400', + 'dark:cc-text-gray-500', + 'dark:hover:cc-bg-gray-800/50', + 'dark:hover:cc-text-gray-300', + 'dark:cc-border-gray-700', + + // Background colors + 'cc-bg-gray-100', + 'cc-bg-blue-100', + 'dark:cc-bg-blue-900/50', + + // Text colors + 'cc-text-gray-800', + 'cc-text-gray-900', + 'cc-text-gray-600', + 'cc-text-blue-600', + 'cc-text-blue-700', + 'dark:cc-text-blue-200', + 'dark:cc-text-blue-400', + + // Hover states + 'hover:cc-bg-gray-100', + 'hover:cc-text-gray-600', + 'hover:cc-text-gray-800', + 'hover:cc-bg-blue-200', + 'dark:hover:cc-bg-blue-800/50', + 'dark:hover:cc-text-blue-300', + + // Border colors + 'cc-border-gray-200', + + // Component specific classes + 'loading-spinner', + 'loading-spinner-container', + 'cc-animate-spin' + ] + }; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index c44b729..bfdf9ba 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "baseUrl": ".", + "baseUrl": "src", "inlineSourceMap": true, "inlineSources": true, "module": "ESNext", @@ -16,9 +16,13 @@ "ES5", "ES6", "ES7" - ] + ], + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true }, "include": [ - "**/*.ts" + "**/*.ts", + "**/*.tsx" ] -} +} \ No newline at end of file