Merge branch 'master' of https://github.com/naturalcrit/homebrewery into search-by-tags

This commit is contained in:
Víctor Losada Hernández
2026-10-30 10:53:06 +01:00
8 changed files with 147 additions and 170 deletions
+94 -104
View File
@@ -1,143 +1,133 @@
import React from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash'; import _ from 'lodash';
import './combobox.less'; import './combobox.less';
const Combobox = createReactClass({ const Combobox = ({
displayName : 'Combobox', id = '',
getDefaultProps : function() { className = '',
return { trigger = 'hover',
id : '', defaultValue = '',
className : '', placeholder = '',
trigger : 'hover', tooltip = '',
default : '', autoSuggest = {
placeholder : '', clearAutoSuggestOnClick : true,
tooltip : '', suggestMethod : 'includes',
autoSuggest : { filterOn : [] // should allow as array to filter on multiple attributes, or even custom filter
clearAutoSuggestOnClick : true,
suggestMethod : 'includes',
filterOn : [] // should allow as array to filter on multiple attributes, or even custom filter
},
valuePatterns : /.+/
};
}, },
getInitialState : function() { valuePatterns = '.+',
this.dropdownRef = React.createRef(); options = [],
return { keepValueOnClose = false,
showDropdown : false, ...props
value : '', })=>{
options : [...this.props.options], const dropdownRef = useRef(null);
inputFocused : false const [showDropdown, setShowDropdown] = useState(false);
}; const [inputFocused, setInputFocused] = useState(false);
}, const [value, setValue] = useState(defaultValue);
componentDidMount : function() {
if(this.props.trigger == 'click') useEffect(()=>{
document.addEventListener('click', this.handleClickOutside); if(trigger === 'click') {
this.setState({ document.addEventListener('click', handleClickOutside);
value : this.props.default return ()=>document.removeEventListener('click', handleClickOutside);
});
},
componentWillUnmount : function() {
if(this.props.trigger == 'click')
document.removeEventListener('click', this.handleClickOutside);
},
handleClickOutside : function(e){
// Close dropdown when clicked outside
if(this.dropdownRef.current && !this.dropdownRef.current.contains(e.target)) {
this.handleDropdown(false);
} }
}, }, []);
handleDropdown : function(show){
this.setState({ const handleClickOutside = useCallback((e)=>{
value : show ? '' : this.props.default, // Close dropdown when clicked outside
showDropdown : show, if(!dropdownRef.current?.contains(e.target)) {
inputFocused : this.props.autoSuggest.clearAutoSuggestOnClick ? show : false handleDropdown(false);
}); }
}, }, []);
handleInput : function(e){
e.persist(); const handleDropdown = (show)=>{
this.setState({ if(show) {
value : e.target.value, if(autoSuggest.clearAutoSuggestOnClick) {
inputFocused : false setValue('');
}, ()=>{ }
this.props.onEntry(e); } else if(!keepValueOnClose) {
}); setValue(defaultValue);
}, }
handleSelect : function(value, data=value){ setShowDropdown(show);
this.setState({ setInputFocused(autoSuggest.clearAutoSuggestOnClick && show);
value : value };
}, ()=>{this.props.onSelect(data);});
; const handleInput = (e)=>{
}, setValue(e.target.value);
renderTextInput : function(){ setInputFocused(false);
props.onEntry(e);
};
const handleSelect = (value, data=value)=>{
setValue(value);
props.onSelect(data);
};
const renderTextInput = ()=>{
return ( return (
<div className='dropdown-input item' <div className='dropdown-input item'
onMouseEnter={this.props.trigger == 'hover' ? ()=>{this.handleDropdown(true);} : undefined} onMouseEnter={trigger == 'hover' ? ()=>{handleDropdown(true);} : undefined}
onClick= {this.props.trigger == 'click' ? ()=>{this.handleDropdown(true);} : undefined} onClick= {trigger == 'click' ? ()=>{handleDropdown(true);} : undefined}
{...(this.props.tooltip ? { 'data-tooltip-right': this.props.tooltip } : {})}> {...(tooltip ? { 'data-tooltip-right': tooltip } : {})}>
<input <input
id={this.props.id} id={id}
type='text' type='text'
onChange={(e)=>this.handleInput(e)} onChange={(e)=>handleInput(e)}
value={this.state.value || ''} value={value || ''}
title='' title=''
pattern={this.props.valuePatterns} pattern={valuePatterns}
placeholder={this.props.placeholder} placeholder={placeholder}
onBlur={(e)=>{ onBlur={(e)=>{
if(!e.target.checkValidity()){ if(!e.target.checkValidity()){
this.setState({ setValue(defaultValue);
value : this.props.default
});
} }
}} }}
onKeyDown={(e)=>{ onKeyDown={(e)=>{
if(e.key === 'Enter') { if(e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
this.props.onEntry(e); props.onEntry(e);
} }
}} }}
/> />
<i className='fas fa-caret-down'/> <i className='fas fa-caret-down'/>
</div> </div>
); );
}, };
renderDropdown : function(dropdownChildren){
if(!this.state.showDropdown) return null; const renderDropdown = ()=>{
if(this.props.autoSuggest && !this.state.inputFocused){ if(!showDropdown) return null;
const suggestMethod = this.props.autoSuggest.suggestMethod; let dropdownChildren = options.map((child, i)=>{
const filterOn = _.isString(this.props.autoSuggest.filterOn) ? [this.props.autoSuggest.filterOn] : this.props.autoSuggest.filterOn; const clone = React.cloneElement(child, { onClick: ()=>handleSelect(child.props.value, child.props.data) });
return clone;
});
if(autoSuggest && !inputFocused){
const suggestMethod = autoSuggest.suggestMethod;
const filterOn = _.isString(autoSuggest.filterOn) ? [autoSuggest.filterOn] : autoSuggest.filterOn;
const filteredArrays = filterOn.map((attr)=>{ const filteredArrays = filterOn.map((attr)=>{
const children = dropdownChildren.filter((item)=>{ const children = dropdownChildren.filter((item)=>{
if(suggestMethod === 'includes') if(suggestMethod === 'includes')
return item.props[attr]?.toLowerCase().includes(this.state.value.toLowerCase()); return item.props[attr]?.toLowerCase().includes(value.toLowerCase());
if(suggestMethod === 'startsWith') if(suggestMethod === 'startsWith')
return item.props[attr]?.toLowerCase().startsWith(this.state.value.toLowerCase()); return item.props[attr]?.toLowerCase().startsWith(value.toLowerCase());
}); });
return children; return children;
}); });
dropdownChildren = _.uniq(filteredArrays.flat(1)); dropdownChildren = _.uniq(filteredArrays.flat(1));
} }
return ( return (
<div className='dropdown-options'> <div className='dropdown-options'>
{dropdownChildren} {dropdownChildren}
</div> </div>
); );
}, };
render : function () {
const dropdownChildren = this.state.options.map((child, i)=>{ return (
const clone = React.cloneElement(child, { onClick: ()=>this.handleSelect(child.props.value, child.props.data) }); <div className={`dropdown-container ${className}`}
return clone; ref={dropdownRef}
}); onMouseLeave={trigger == 'hover' ? ()=>{handleDropdown(false);} : undefined}>
return ( {renderTextInput()}
<div className={`dropdown-container ${this.props.className}`} {renderDropdown()}
ref={this.dropdownRef} </div>
onMouseLeave={this.props.trigger == 'hover' ? ()=>{this.handleDropdown(false);} : undefined}> );
{this.renderTextInput()}
{this.renderDropdown(dropdownChildren)} };
</div>
);
}
});
export default Combobox; export default Combobox;
@@ -219,7 +219,7 @@ const MetadataEditor = ({
<Combobox trigger='click' <Combobox trigger='click'
id='combobox-themes' id='combobox-themes'
className='themes-dropdown' className='themes-dropdown'
default={currentThemeDisplay} defaultValue={currentThemeDisplay}
placeholder='Select from below, or enter the Share URL or ID of a brew with the meta:theme tag' placeholder='Select from below, or enter the Share URL or ID of a brew with the meta:theme tag'
onSelect={(value)=>handleTheme(value)} onSelect={(value)=>handleTheme(value)}
onEntry={(e)=>{ onEntry={(e)=>{
@@ -261,8 +261,10 @@ const MetadataEditor = ({
<Combobox trigger='click' <Combobox trigger='click'
id='combobox-language' id='combobox-language'
className='language-dropdown' className='language-dropdown'
default={metadata.lang || ''} defaultValue={metadata.lang || ''}
keepValueOnClose={true}
placeholder='en' placeholder='en'
valuePatterns="^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2})?$"
onSelect={(value)=>handleLanguage(value)} onSelect={(value)=>handleLanguage(value)}
onEntry={(e)=>{ onEntry={(e)=>{
e.target.setCustomValidity(''); //Clear the validation popup while typing e.target.setCustomValidity(''); //Clear the validation popup while typing
+1 -1
View File
@@ -131,7 +131,7 @@ const TagInput = ({ id, tooltip, label, valuePatterns, values = [], unique = tru
id={id} id={id}
trigger='click' trigger='click'
className='tagInput-dropdown' className='tagInput-dropdown'
default='' defaultValue=''
placeholder={placeholder} placeholder={placeholder}
options={label === 'tags' ? suggestionOptions : []} options={label === 'tags' ? suggestionOptions : []}
tooltip={tooltip} tooltip={tooltip}
@@ -145,8 +145,8 @@
box-shadow : 0 4px 5px 0 #333333; box-shadow : 0 4px 5px 0 #333333;
.text { .text {
min-height : 54px; min-height : 54px;
padding-left : 5px;
h2 { margin-bottom : 5px; } h2 { margin-bottom : 5px; }
.description { padding-left : 5px; }
} }
.info { padding-left : 5px; } .info { padding-left : 5px; }
.links { .links {
@@ -398,7 +398,7 @@
} }
.storage { display : none; } .storage { display : none; }
} }
.brewItem { .brewItem {
h2 { h2 {
width : 200px; width : 200px;
font-size : 1em; font-size : 1em;
@@ -426,7 +426,7 @@
} }
.info { .info {
display : grid; display : grid;
grid-template-areas : grid-template-areas :
'tags pages views views' 'tags pages views views'
'authors update update storage'; 'authors update update storage';
grid-template-columns : 2fr 1fr 1fr 20px; grid-template-columns : 2fr 1fr 1fr 20px;
@@ -440,7 +440,7 @@
a, a,
button { margin : 0; } button { margin : 0; }
} }
} }
} }
} }
@container (max-width : 650px) { @container (max-width : 650px) {
@@ -1,6 +1,6 @@
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/ /*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
import './listPage.less'; import './listPage.less';
import React, { useEffect, useState, useRef, useMemo } from 'react'; import React, { useEffect, useState, useMemo } from 'react';
import moment from 'moment'; import moment from 'moment';
import _ from 'lodash'; import _ from 'lodash';
@@ -26,24 +26,7 @@ const ListPage = ({ brewCollection = [{ title: '', class: '', brews: [] }], navI
const [layoutMode, setLayoutMode] = useState(DEFAULT_LAYOUT_MODE); const [layoutMode, setLayoutMode] = useState(DEFAULT_LAYOUT_MODE);
const [themeMode, setThemeMode] = useState(DEFAULT_THEME_MODE); const [themeMode, setThemeMode] = useState(DEFAULT_THEME_MODE);
const groupVisibilityRef = useRef(groupVisibility);
const sortTypeRef = useRef(sortType);
const sortDirRef = useRef(sortDir);
useEffect(()=>{ useEffect(()=>{
groupVisibilityRef.current = groupVisibility;
}, [groupVisibility]);
useEffect(()=>{
sortTypeRef.current = sortType;
}, [sortType]);
useEffect(()=>{
sortDirRef.current = sortDir;
}, [sortDir]);
useEffect(()=>{
window.onbeforeunload = saveToLocalStorage;
if(typeof window === 'undefined') return; if(typeof window === 'undefined') return;
const newSortType = sortType ?? (localStorage.getItem(USERPAGE_SORT_TYPE) || DEFAULT_SORT_TYPE); const newSortType = sortType ?? (localStorage.getItem(USERPAGE_SORT_TYPE) || DEFAULT_SORT_TYPE);
@@ -63,20 +46,8 @@ const ListPage = ({ brewCollection = [{ title: '', class: '', brews: [] }], navI
setSortDir(newSortDir); setSortDir(newSortDir);
setLayoutMode(newLayoutMode); setLayoutMode(newLayoutMode);
setThemeMode(newThemeMode); setThemeMode(newThemeMode);
return ()=>{
window.onbeforeunload = null;
};
}, []); }, []);
const saveToLocalStorage = ()=>{
brewCollection.forEach((brewGroup)=>{
localStorage.setItem(`${USERPAGE_GROUP_VISIBILITY_PREFIX}_${brewGroup.class}`, `${groupVisibilityRef.current[brewGroup.class]}`);
});
localStorage.setItem(USERPAGE_SORT_TYPE, sortTypeRef.current);
localStorage.setItem(USERPAGE_SORT_DIR, sortDirRef.current);
};
const renderBrews = (brews)=>{ const renderBrews = (brews)=>{
if(!brews || !brews.length) return <div className='noBrews'>No Brews.</div>; if(!brews || !brews.length) return <div className='noBrews'>No Brews.</div>;
@@ -108,6 +79,8 @@ const ListPage = ({ brewCollection = [{ title: '', class: '', brews: [] }], navI
const handleSortOptionChange = (event)=>{ const handleSortOptionChange = (event)=>{
updateUrl(filterString, event.target.value, sortDir); updateUrl(filterString, event.target.value, sortDir);
setSortType(event.target.value); setSortType(event.target.value);
localStorage.setItem(USERPAGE_SORT_TYPE, event.target.value);
return;
}; };
const handleSortDirChange = (event)=>{ const handleSortDirChange = (event)=>{
@@ -115,6 +88,8 @@ const ListPage = ({ brewCollection = [{ title: '', class: '', brews: [] }], navI
updateUrl(filterString, sortType, newDir); updateUrl(filterString, sortType, newDir);
setSortDir(newDir); setSortDir(newDir);
localStorage.setItem(USERPAGE_SORT_DIR, newDir);
return;
}; };
const renderSortOption = (sortTitle, sortValue)=>{ const renderSortOption = (sortTitle, sortValue)=>{
@@ -187,7 +162,7 @@ const ListPage = ({ brewCollection = [{ title: '', class: '', brews: [] }], navI
if(filterTags?.length == 0) return; if(filterTags?.length == 0) return;
return ( return (
<div className='tags-container'> <div className='tags-container'>
Tags: Tags:
{_.map(filterTags, (tag, idx)=>{ {_.map(filterTags, (tag, idx)=>{
const matches = tag.match(/^(?:([^:]+):)?([^:]+)$/); const matches = tag.match(/^(?:([^:]+):)?([^:]+)$/);
return ( return (
@@ -198,7 +173,7 @@ const ListPage = ({ brewCollection = [{ title: '', class: '', brews: [] }], navI
updateUrl(filterString, sortType, sortDir, tag); updateUrl(filterString, sortType, sortDir, tag);
}}> }}>
{matches[2]} {matches[2]}
</span> </span>
); );
})} })}
</div> </div>
@@ -257,7 +232,13 @@ const ListPage = ({ brewCollection = [{ title: '', class: '', brews: [] }], navI
}, [brewCollection, filterString, filterTags, sortType, sortDir]); }, [brewCollection, filterString, filterTags, sortType, sortDir]);
const toggleBrewCollectionState = (brewGroupClass)=>{ const toggleBrewCollectionState = (brewGroupClass)=>{
setGroupVisibility((prevVisibility)=>({ ...prevVisibility, [brewGroupClass]: !prevVisibility[brewGroupClass] })); setGroupVisibility((prevVisibility)=>{
const visible = !prevVisibility[brewGroupClass];
localStorage.setItem(`${USERPAGE_GROUP_VISIBILITY_PREFIX}_${brewGroupClass}`, visible);
return {...prevVisibility, [brewGroupClass]: visible};
});
}; };
const renderBrewCollection = (brewCollection)=>{ const renderBrewCollection = (brewCollection)=>{
@@ -298,8 +279,8 @@ const ListPage = ({ brewCollection = [{ title: '', class: '', brews: [] }], navI
<div className='radio-toggle layout-container'> <div className='radio-toggle layout-container'>
<h6>View:</h6> <h6>View:</h6>
<label className='option' title='grid'> <label className='option' title='grid'>
<input name='layout-mode' type='radio' onChange={(e)=>handleLayoutChange(e, 'grid')} checked={layoutMode === 'grid'} /> <input name='layout-mode' type='radio' onChange={(e)=>handleLayoutChange(e, 'grid')} checked={layoutMode === 'grid'} />
<i className='fac compressed-grid'></i> <i className='fac compressed-grid'></i>
</label> </label>
<label className='option' title='list'> <label className='option' title='list'>
<input name='layout-mode' type='radio' onChange={(e)=>handleLayoutChange(e, 'list')} checked={layoutMode === 'list'} /> <input name='layout-mode' type='radio' onChange={(e)=>handleLayoutChange(e, 'list')} checked={layoutMode === 'list'} />
@@ -320,24 +301,24 @@ const ListPage = ({ brewCollection = [{ title: '', class: '', brews: [] }], navI
}; };
const renderThemePicker = ()=>{ const renderThemePicker = ()=>{
return ( return (
<div className="radio-toggle theme-container"> <div className='radio-toggle theme-container'>
<h6>Theme:</h6> <h6>Theme:</h6>
<label className="option"> <label className='option'>
<input type="radio" name="theme" onChange={(e)=>handleThemeChange(e, 'light')} checked={themeMode === 'light'}/> <input type='radio' name='theme' onChange={(e)=>handleThemeChange(e, 'light')} checked={themeMode === 'light'}/>
<i className="fas fa-sun"/> <i className='fas fa-sun'/>
</label> </label>
<label className="option"> <label className='option'>
<input type="radio" name="theme" onChange={(e)=>handleThemeChange(e, 'dark')} checked={themeMode === 'dark'}/> <input type='radio' name='theme' onChange={(e)=>handleThemeChange(e, 'dark')} checked={themeMode === 'dark'}/>
<i className="fas fa-moon"/> <i className='fas fa-moon'/>
</label> </label>
</div> </div>
) );
}; };
const renderNav = () =>{ const renderNav = ()=>{
return ( return (
<div className="listPageNav"> <div className='listPageNav'>
{renderLayoutModeOptions()} {renderLayoutModeOptions()}
{renderThemePicker()} {renderThemePicker()}
{renderSortOptions()} {renderSortOptions()}
@@ -345,8 +326,8 @@ const ListPage = ({ brewCollection = [{ title: '', class: '', brews: [] }], navI
{renderTagsOptions()} {renderTagsOptions()}
</div> </div>
); );
} };
return ( return (
<div className='sitePage listPage'> <div className='sitePage listPage'>
@@ -354,7 +335,7 @@ const ListPage = ({ brewCollection = [{ title: '', class: '', brews: [] }], navI
{renderNav()} {renderNav()}
<div className={`content V3 ${themeMode + 'Mode'}`}> <div className={`content V3 ${`${themeMode}Mode`}`}>
<div className='brewCollection'>{renderBrewCollection(brewCollection)}</div> <div className='brewCollection'>{renderBrewCollection(brewCollection)}</div>
</div> </div>
</div> </div>
+7 -7
View File
@@ -25,7 +25,7 @@
"@codemirror/search": "^6.6.0", "@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.7.6", "@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.15", "@codemirror/view": "^6.43.15",
"@googleapis/drive": "^26.0.2", "@googleapis/drive": "^27.0.0",
"@lezer/highlight": "^1.2.4", "@lezer/highlight": "^1.2.4",
"@oddbird/css-anchor-positioning": "^0.10.2", "@oddbird/css-anchor-positioning": "^0.10.2",
"@sanity/diff-match-patch": "^3.2.1", "@sanity/diff-match-patch": "^3.2.1",
@@ -2559,9 +2559,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@googleapis/drive": { "node_modules/@googleapis/drive": {
"version": "26.0.2", "version": "27.0.0",
"resolved": "https://registry.npmjs.org/@googleapis/drive/-/drive-26.0.2.tgz", "resolved": "https://registry.npmjs.org/@googleapis/drive/-/drive-27.0.0.tgz",
"integrity": "sha512-VeKVZvzgH4ioczJZrZ5sy73RJbHrYLI1XZIZ3ZqGKQhC4U03XsOOOiBHKqvH4F9vbXrxR2LawmktoIJuTJhzYg==", "integrity": "sha512-q+K1+T4Kbz3VUOww15lhV+jq+/IzuxzkfiASIXzxVG6X894QUPdhBgU9hKFNSKh+e58o0g6mX/06XoLsS1JE6Q==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"googleapis-common": "^9.0.0" "googleapis-common": "^9.0.0"
@@ -7660,9 +7660,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.8", "version": "3.1.9",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
+1 -1
View File
@@ -104,7 +104,7 @@
"@codemirror/search": "^6.6.0", "@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.7.6", "@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.15", "@codemirror/view": "^6.43.15",
"@googleapis/drive": "^26.0.2", "@googleapis/drive": "^27.0.0",
"@lezer/highlight": "^1.2.4", "@lezer/highlight": "^1.2.4",
"@oddbird/css-anchor-positioning": "^0.10.2", "@oddbird/css-anchor-positioning": "^0.10.2",
"@sanity/diff-match-patch": "^3.2.1", "@sanity/diff-match-patch": "^3.2.1",
+7 -3
View File
@@ -28,8 +28,10 @@ export function generateAssetsPlugin(isDev = false) {
// Compile Legacy themes // Compile Legacy themes
const themes = { Legacy: {}, V3: {} }; const themes = { Legacy: {}, V3: {} };
const legacyDirs = fs.readdirSync('./themes/Legacy'); const legacyDirs = fs.readdirSync('./themes/Legacy', { withFileTypes: true })
for (const dir of legacyDirs) { .filter(entry => entry.isDirectory())
.map(entry => entry.name);
for (const dir of legacyDirs) {
const themeData = JSON.parse(fs.readFileSync(`./themes/Legacy/${dir}/settings.json`, 'utf-8')); const themeData = JSON.parse(fs.readFileSync(`./themes/Legacy/${dir}/settings.json`, 'utf-8'));
themeData.path = dir; themeData.path = dir;
themes.Legacy[dir] = themeData; themes.Legacy[dir] = themeData;
@@ -41,7 +43,9 @@ export function generateAssetsPlugin(isDev = false) {
} }
// Compile V3 themes // Compile V3 themes
const v3Dirs = fs.readdirSync('./themes/V3'); const v3Dirs = fs.readdirSync('./themes/V3', { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => entry.name);
for (const dir of v3Dirs) { for (const dir of v3Dirs) {
const themeData = JSON.parse(fs.readFileSync(`./themes/V3/${dir}/settings.json`, 'utf-8')); const themeData = JSON.parse(fs.readFileSync(`./themes/V3/${dir}/settings.json`, 'utf-8'));
themeData.path = dir; themeData.path = dir;