Merge branch 'master' into update-creation-time
This commit is contained in:
commit
4b3e9f9e08
|
@ -24,6 +24,7 @@ interface Props {
|
||||||
export enum FIX_STATE {
|
export enum FIX_STATE {
|
||||||
NOT_STARTED,
|
NOT_STARTED,
|
||||||
FIX_LATER,
|
FIX_LATER,
|
||||||
|
NOOP,
|
||||||
RUNNING,
|
RUNNING,
|
||||||
COMPLETED,
|
COMPLETED,
|
||||||
COMPLETED_WITH_ERRORS,
|
COMPLETED_WITH_ERRORS,
|
||||||
|
@ -38,6 +39,9 @@ function Message(props: { fixState: FIX_STATE }) {
|
||||||
case FIX_STATE.COMPLETED:
|
case FIX_STATE.COMPLETED:
|
||||||
message = constants.REPLACE_THUMBNAIL_COMPLETED();
|
message = constants.REPLACE_THUMBNAIL_COMPLETED();
|
||||||
break;
|
break;
|
||||||
|
case FIX_STATE.NOOP:
|
||||||
|
message = constants.REPLACE_THUMBNAIL_NOOP();
|
||||||
|
break;
|
||||||
case FIX_STATE.COMPLETED_WITH_ERRORS:
|
case FIX_STATE.COMPLETED_WITH_ERRORS:
|
||||||
message = constants.REPLACE_THUMBNAIL_COMPLETED_WITH_ERROR();
|
message = constants.REPLACE_THUMBNAIL_COMPLETED_WITH_ERROR();
|
||||||
break;
|
break;
|
||||||
|
@ -64,6 +68,10 @@ export default function FixLargeThumbnails(props: Props) {
|
||||||
fixState = FIX_STATE.NOT_STARTED;
|
fixState = FIX_STATE.NOT_STARTED;
|
||||||
updateFixState(fixState);
|
updateFixState(fixState);
|
||||||
}
|
}
|
||||||
|
if (fixState === FIX_STATE.COMPLETED) {
|
||||||
|
fixState = FIX_STATE.NOOP;
|
||||||
|
updateFixState(fixState);
|
||||||
|
}
|
||||||
setFixState(fixState);
|
setFixState(fixState);
|
||||||
return fixState;
|
return fixState;
|
||||||
};
|
};
|
||||||
|
@ -83,14 +91,14 @@ export default function FixLargeThumbnails(props: Props) {
|
||||||
props.show();
|
props.show();
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
fixState === FIX_STATE.COMPLETED &&
|
(fixState === FIX_STATE.COMPLETED || fixState === FIX_STATE.NOOP) &&
|
||||||
largeThumbnailFiles.length > 0
|
largeThumbnailFiles.length > 0
|
||||||
) {
|
) {
|
||||||
updateFixState(FIX_STATE.NOT_STARTED);
|
updateFixState(FIX_STATE.NOT_STARTED);
|
||||||
logError(Error(), 'large thumbnail files left after migration');
|
logError(Error(), 'large thumbnail files left after migration');
|
||||||
}
|
}
|
||||||
if (largeThumbnailFiles.length === 0) {
|
if (largeThumbnailFiles.length === 0 && fixState !== FIX_STATE.NOOP) {
|
||||||
updateFixState(FIX_STATE.COMPLETED);
|
updateFixState(FIX_STATE.NOOP);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
@ -86,6 +86,7 @@ const getTemplateColumns = (columns: number, groups?: number[]): string => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const ListContainer = styled.div<{ columns: number; groups?: number[] }>`
|
const ListContainer = styled.div<{ columns: number; groups?: number[] }>`
|
||||||
|
user-select: none;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: ${({ columns, groups }) =>
|
grid-template-columns: ${({ columns, groups }) =>
|
||||||
getTemplateColumns(columns, groups)};
|
getTemplateColumns(columns, groups)};
|
||||||
|
@ -100,6 +101,7 @@ const ListContainer = styled.div<{ columns: number; groups?: number[] }>`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const DateContainer = styled.div<{ span: number }>`
|
const DateContainer = styled.div<{ span: number }>`
|
||||||
|
user-select: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
@ -181,6 +183,28 @@ const PhotoFrame = ({
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const galleryContext = useContext(GalleryContext);
|
const galleryContext = useContext(GalleryContext);
|
||||||
const listRef = useRef(null);
|
const listRef = useRef(null);
|
||||||
|
const [rangeStart, setRangeStart] = useState(null);
|
||||||
|
const [currentHover, setCurrentHover] = useState(null);
|
||||||
|
const [isShiftKeyPressed, setIsShiftKeyPressed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Shift') {
|
||||||
|
setIsShiftKeyPressed(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Shift') {
|
||||||
|
setIsShiftKeyPressed(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown, false);
|
||||||
|
document.addEventListener('keyup', handleKeyUp, false);
|
||||||
|
return () => {
|
||||||
|
document.addEventListener('keydown', handleKeyDown, false);
|
||||||
|
document.addEventListener('keyup', handleKeyUp, false);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isInSearchMode) {
|
if (isInSearchMode) {
|
||||||
|
@ -204,6 +228,12 @@ const PhotoFrame = ({
|
||||||
setFetching({});
|
setFetching({});
|
||||||
}, [files, search, deleted]);
|
}, [files, search, deleted]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected.count === 0) {
|
||||||
|
setRangeStart(null);
|
||||||
|
}
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
const updateUrl = (index: number) => (url: string) => {
|
const updateUrl = (index: number) => (url: string) => {
|
||||||
files[index] = {
|
files[index] = {
|
||||||
...files[index],
|
...files[index],
|
||||||
|
@ -276,10 +306,16 @@ const PhotoFrame = ({
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelect = (id: number) => (checked: boolean) => {
|
const handleSelect = (id: number, index?: number) => (checked: boolean) => {
|
||||||
if (selected.collectionID !== activeCollection) {
|
if (selected.collectionID !== activeCollection) {
|
||||||
setSelected({ count: 0, collectionID: 0 });
|
setSelected({ count: 0, collectionID: 0 });
|
||||||
}
|
}
|
||||||
|
if (rangeStart || rangeStart === 0) {
|
||||||
|
setRangeStart(null);
|
||||||
|
} else if (checked) {
|
||||||
|
setRangeStart(index);
|
||||||
|
}
|
||||||
|
|
||||||
setSelected((selected) => ({
|
setSelected((selected) => ({
|
||||||
...selected,
|
...selected,
|
||||||
[id]: checked,
|
[id]: checked,
|
||||||
|
@ -287,19 +323,50 @@ const PhotoFrame = ({
|
||||||
collectionID: activeCollection,
|
collectionID: activeCollection,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
const onHoverOver = (index: number) => () => {
|
||||||
|
setCurrentHover(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRangeSelect = (index: number) => () => {
|
||||||
|
if (rangeStart !== index) {
|
||||||
|
let leftEnd = -1;
|
||||||
|
let rightEnd = -1;
|
||||||
|
if (index < rangeStart) {
|
||||||
|
leftEnd = index;
|
||||||
|
rightEnd = rangeStart;
|
||||||
|
} else {
|
||||||
|
leftEnd = rangeStart;
|
||||||
|
rightEnd = index;
|
||||||
|
}
|
||||||
|
for (let i = leftEnd; i <= rightEnd; i++) {
|
||||||
|
handleSelect(filteredData[i].id)(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
const getThumbnail = (file: File[], index: number) => (
|
const getThumbnail = (file: File[], index: number) => (
|
||||||
<PreviewCard
|
<PreviewCard
|
||||||
key={`tile-${file[index].id}`}
|
key={`tile-${file[index].id}-selected-${
|
||||||
|
selected[file[index].id] ?? false
|
||||||
|
}`}
|
||||||
file={file[index]}
|
file={file[index]}
|
||||||
updateUrl={updateUrl(file[index].dataIndex)}
|
updateUrl={updateUrl(file[index].dataIndex)}
|
||||||
onClick={onThumbnailClick(index)}
|
onClick={onThumbnailClick(index)}
|
||||||
selectable={!isSharedCollection}
|
selectable={!isSharedCollection}
|
||||||
onSelect={handleSelect(file[index].id)}
|
onSelect={handleSelect(file[index].id, index)}
|
||||||
selected={
|
selected={
|
||||||
selected.collectionID === activeCollection &&
|
selected.collectionID === activeCollection &&
|
||||||
selected[file[index].id]
|
selected[file[index].id]
|
||||||
}
|
}
|
||||||
selectOnClick={selected.count > 0}
|
selectOnClick={selected.count > 0}
|
||||||
|
onHover={onHoverOver(index)}
|
||||||
|
onRangeSelect={handleRangeSelect(index)}
|
||||||
|
isRangeSelectActive={
|
||||||
|
isShiftKeyPressed && (rangeStart || rangeStart === 0)
|
||||||
|
}
|
||||||
|
isInsSelectRange={
|
||||||
|
(index >= rangeStart + 1 && index <= currentHover) ||
|
||||||
|
(index >= currentHover && index <= rangeStart - 1)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -15,13 +15,18 @@ interface IProps {
|
||||||
selectable?: boolean;
|
selectable?: boolean;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
onSelect?: (checked: boolean) => void;
|
onSelect?: (checked: boolean) => void;
|
||||||
|
onHover?: () => void;
|
||||||
|
onRangeSelect?: () => void;
|
||||||
|
isRangeSelectActive?: boolean;
|
||||||
selectOnClick?: boolean;
|
selectOnClick?: boolean;
|
||||||
|
isInsSelectRange?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Check = styled.input`
|
const Check = styled.input<{ active: boolean }>`
|
||||||
appearance: none;
|
appearance: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
z-index: 10;
|
||||||
|
left: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
@ -34,7 +39,7 @@ const Check = styled.input`
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
border: 2px solid #fff;
|
border: 2px solid #fff;
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
background-color: #ddd;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
vertical-align: bottom;
|
vertical-align: bottom;
|
||||||
|
@ -43,18 +48,19 @@ const Check = styled.input`
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
transition: background-color 0.3s ease;
|
transition: background-color 0.3s ease;
|
||||||
pointer-events: inherit;
|
pointer-events: inherit;
|
||||||
|
color: #aaa;
|
||||||
}
|
}
|
||||||
&::after {
|
&::after {
|
||||||
content: '';
|
content: '';
|
||||||
width: 5px;
|
width: 5px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border-right: 2px solid #fff;
|
border-right: 2px solid #333;
|
||||||
border-bottom: 2px solid #fff;
|
border-bottom: 2px solid #333;
|
||||||
transform: translate(-18px, 8px);
|
transform: translate(-18px, 8px);
|
||||||
opacity: 0;
|
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
pointer-events: inherit;
|
pointer-events: inherit;
|
||||||
|
transform: translate(-18px, 10px) rotate(45deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** checked */
|
/** checked */
|
||||||
|
@ -65,15 +71,50 @@ const Check = styled.input`
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
&:checked::after {
|
&:checked::after {
|
||||||
opacity: 1;
|
content: '';
|
||||||
transform: translate(-18px, 10px) rotate(45deg);
|
border-right: 2px solid #ddd;
|
||||||
|
border-bottom: 2px solid #ddd;
|
||||||
}
|
}
|
||||||
|
${(props) => props.active && 'opacity: 0.5 '};
|
||||||
&:checked {
|
&:checked {
|
||||||
opacity: 1;
|
opacity: 1 !important;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const HoverOverlay = styled.div<{ checked: boolean }>`
|
||||||
|
opacity: 0;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
outline: none;
|
||||||
|
height: 40%;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 900;
|
||||||
|
position: absolute;
|
||||||
|
${(props) =>
|
||||||
|
!props.checked &&
|
||||||
|
'background:linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0))'};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const InSelectRangeOverLay = styled.div<{ active: boolean }>`
|
||||||
|
opacity: ${(props) => (!props.active ? 0 : 1)});
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
outline: none;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 900;
|
||||||
|
position: absolute;
|
||||||
|
${(props) => props.active && 'background:rgba(81, 205, 124, 0.25)'};
|
||||||
|
`;
|
||||||
|
|
||||||
const Cont = styled.div<{ disabled: boolean; selected: boolean }>`
|
const Cont = styled.div<{ disabled: boolean; selected: boolean }>`
|
||||||
background: #222;
|
background: #222;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -107,6 +148,9 @@ const Cont = styled.div<{ disabled: boolean; selected: boolean }>`
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover ${Check} {
|
&:hover ${Check} {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
&:hover ${HoverOverlay} {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
@ -123,6 +167,10 @@ export default function PreviewCard(props: IProps) {
|
||||||
selected,
|
selected,
|
||||||
onSelect,
|
onSelect,
|
||||||
selectOnClick,
|
selectOnClick,
|
||||||
|
onHover,
|
||||||
|
onRangeSelect,
|
||||||
|
isRangeSelectActive,
|
||||||
|
isInsSelectRange,
|
||||||
} = props;
|
} = props;
|
||||||
const isMounted = useRef(true);
|
const isMounted = useRef(true);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
@ -167,24 +215,36 @@ export default function PreviewCard(props: IProps) {
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (selectOnClick) {
|
if (selectOnClick) {
|
||||||
onSelect?.(!selected);
|
if (isRangeSelectActive) {
|
||||||
|
onRangeSelect();
|
||||||
|
} else {
|
||||||
|
onSelect?.(!selected);
|
||||||
|
}
|
||||||
} else if (file?.msrc || imgSrc) {
|
} else if (file?.msrc || imgSrc) {
|
||||||
onClick?.();
|
onClick?.();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelect: React.ChangeEventHandler<HTMLInputElement> = (e) => {
|
const handleSelect: React.ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
if (isRangeSelectActive) {
|
||||||
|
onRangeSelect?.();
|
||||||
|
}
|
||||||
onSelect?.(e.target.checked);
|
onSelect?.(e.target.checked);
|
||||||
};
|
};
|
||||||
|
|
||||||
const longPressCallback = () => {
|
const longPressCallback = () => {
|
||||||
onSelect(!selected);
|
onSelect(!selected);
|
||||||
};
|
};
|
||||||
|
const handleHover = () => {
|
||||||
|
if (selectOnClick) {
|
||||||
|
onHover();
|
||||||
|
}
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<Cont
|
<Cont
|
||||||
id={`thumb-${file?.id}`}
|
id={`thumb-${file?.id}`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
onMouseEnter={handleHover}
|
||||||
disabled={!forcedEnable && !file?.msrc && !imgSrc}
|
disabled={!forcedEnable && !file?.msrc && !imgSrc}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
{...(selectable ? useLongPress(longPressCallback, 500) : {})}>
|
{...(selectable ? useLongPress(longPressCallback, 500) : {})}>
|
||||||
|
@ -193,11 +253,16 @@ export default function PreviewCard(props: IProps) {
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selected}
|
checked={selected}
|
||||||
onChange={handleSelect}
|
onChange={handleSelect}
|
||||||
|
active={isRangeSelectActive && isInsSelectRange}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(file?.msrc || imgSrc) && <img src={file?.msrc || imgSrc} />}
|
{(file?.msrc || imgSrc) && <img src={file?.msrc || imgSrc} />}
|
||||||
{file?.metadata.fileType === 1 && <PlayCircleOutline />}
|
{file?.metadata.fileType === 1 && <PlayCircleOutline />}
|
||||||
|
<HoverOverlay checked={selected} />
|
||||||
|
<InSelectRangeOverLay
|
||||||
|
active={isRangeSelectActive && isInsSelectRange}
|
||||||
|
/>
|
||||||
</Cont>
|
</Cont>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,6 @@ import { decryptFile, mergeMetadata, sortFiles } from 'utils/file';
|
||||||
import CryptoWorker from 'utils/crypto';
|
import CryptoWorker from 'utils/crypto';
|
||||||
|
|
||||||
const ENDPOINT = getEndpoint();
|
const ENDPOINT = getEndpoint();
|
||||||
const DIFF_LIMIT: number = 1000;
|
|
||||||
|
|
||||||
const FILES = 'files';
|
const FILES = 'files';
|
||||||
|
|
||||||
|
@ -148,13 +147,7 @@ export const syncFiles = async (
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const fetchedFiles =
|
const fetchedFiles =
|
||||||
(await getFiles(
|
(await getFiles(collection, lastSyncTime, files, setFiles)) ?? [];
|
||||||
collection,
|
|
||||||
lastSyncTime,
|
|
||||||
DIFF_LIMIT,
|
|
||||||
files,
|
|
||||||
setFiles
|
|
||||||
)) ?? [];
|
|
||||||
files.push(...fetchedFiles);
|
files.push(...fetchedFiles);
|
||||||
const latestVersionFiles = new Map<string, File>();
|
const latestVersionFiles = new Map<string, File>();
|
||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
|
@ -198,7 +191,6 @@ export const syncFiles = async (
|
||||||
export const getFiles = async (
|
export const getFiles = async (
|
||||||
collection: Collection,
|
collection: Collection,
|
||||||
sinceTime: number,
|
sinceTime: number,
|
||||||
limit: number,
|
|
||||||
files: File[],
|
files: File[],
|
||||||
setFiles: (files: File[]) => void
|
setFiles: (files: File[]) => void
|
||||||
): Promise<File[]> => {
|
): Promise<File[]> => {
|
||||||
|
@ -215,11 +207,10 @@ export const getFiles = async (
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
resp = await HTTPService.get(
|
resp = await HTTPService.get(
|
||||||
`${ENDPOINT}/collections/diff`,
|
`${ENDPOINT}/collections/v2/diff`,
|
||||||
{
|
{
|
||||||
collectionID: collection.id,
|
collectionID: collection.id,
|
||||||
sinceTime: time,
|
sinceTime: time,
|
||||||
limit,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'X-Auth-Token': token,
|
'X-Auth-Token': token,
|
||||||
|
@ -249,7 +240,7 @@ export const getFiles = async (
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} while (resp.data.diff.length === limit);
|
} while (resp.data.hasMore);
|
||||||
return decryptedFiles;
|
return decryptedFiles;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logError(e, 'Get files failed');
|
logError(e, 'Get files failed');
|
||||||
|
|
|
@ -590,6 +590,9 @@ const englishConstants = {
|
||||||
REPLACE_THUMBNAIL_COMPLETED: () => (
|
REPLACE_THUMBNAIL_COMPLETED: () => (
|
||||||
<>successfully compressed all thumbnails</>
|
<>successfully compressed all thumbnails</>
|
||||||
),
|
),
|
||||||
|
REPLACE_THUMBNAIL_NOOP: () => (
|
||||||
|
<>you have no thumbnails that can be compressed further</>
|
||||||
|
),
|
||||||
REPLACE_THUMBNAIL_COMPLETED_WITH_ERROR: () => (
|
REPLACE_THUMBNAIL_COMPLETED_WITH_ERROR: () => (
|
||||||
<>could not compress some of your thumbnails, please retry</>
|
<>could not compress some of your thumbnails, please retry</>
|
||||||
),
|
),
|
||||||
|
|
Loading…
Reference in a new issue