feat(publish): Deck direkt aus der Detail-Seite veröffentlichen
- PublishDeckModal: Author-Check, Slug-Eingabe mit Live-Exists-Check, Titel/Beschreibung/Lizenz (nur für neue Decks), Semver auto-gebumpt, Karten automatisch aus privatem Deck übernommen (kein JSON-Paste) - Deck-Detail-Seite: "↑ Veröffentlichen"-Button im Header, öffnet Modal, leitet nach Erfolg auf /d/:slug (Marketplace-Seite) weiter Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b761cd52c9
commit
c1a87a4f88
2 changed files with 605 additions and 0 deletions
586
apps/web/src/lib/components/marketplace/PublishDeckModal.svelte
Normal file
586
apps/web/src/lib/components/marketplace/PublishDeckModal.svelte
Normal file
|
|
@ -0,0 +1,586 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { Card, Deck } from '@cards/domain';
|
||||
import {
|
||||
getMyAuthorProfile,
|
||||
getMarketplaceDeck,
|
||||
initMarketplaceDeck,
|
||||
publishMarketplaceVersion,
|
||||
type MarketplaceAuthor,
|
||||
} from '$lib/api/marketplace.ts';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
|
||||
interface Props {
|
||||
deck: Deck;
|
||||
cards: Card[];
|
||||
onClose: () => void;
|
||||
onPublished: (slug: string) => void;
|
||||
}
|
||||
|
||||
let { deck, cards, onClose, onPublished }: Props = $props();
|
||||
|
||||
// Author + slug-check state
|
||||
let author = $state<MarketplaceAuthor | null>(null);
|
||||
let loadingAuthor = $state(true);
|
||||
let slugExists = $state<boolean | null>(null); // null = unchecked
|
||||
let existingVersion = $state<string | null>(null);
|
||||
let checkingSlug = $state(false);
|
||||
|
||||
// Modal wird pro Klick frisch gemountet — Init-Capture aus Props ist gewollt.
|
||||
// svelte-ignore state_referenced_locally
|
||||
let slug = $state(slugify(deck.name));
|
||||
// svelte-ignore state_referenced_locally
|
||||
let title = $state(deck.name);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let description = $state(deck.description ?? '');
|
||||
let license = $state('CC BY 4.0');
|
||||
let semver = $state('1.0.0');
|
||||
let changelog = $state('');
|
||||
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const marketplaceCards = $derived(
|
||||
cards.map((c) => ({ type: c.type, fields: c.fields as Record<string, string> }))
|
||||
);
|
||||
|
||||
const suggestedSemver = $derived.by(() => {
|
||||
if (!existingVersion) return '1.0.0';
|
||||
const m = existingVersion.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (!m) return '1.0.0';
|
||||
return `${m[1]}.${Number(m[2]) + 1}.0`;
|
||||
});
|
||||
|
||||
// Update semver suggestion when slug check completes
|
||||
$effect(() => {
|
||||
semver = suggestedSemver;
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
author = await getMyAuthorProfile();
|
||||
} catch {
|
||||
// no profile yet
|
||||
}
|
||||
loadingAuthor = false;
|
||||
if (author) await checkSlug();
|
||||
});
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
let slugCheckTimer: ReturnType<typeof setTimeout>;
|
||||
function onSlugInput() {
|
||||
slugExists = null;
|
||||
existingVersion = null;
|
||||
clearTimeout(slugCheckTimer);
|
||||
slugCheckTimer = setTimeout(checkSlug, 500);
|
||||
}
|
||||
|
||||
async function checkSlug() {
|
||||
const s = slug.trim();
|
||||
if (!s) return;
|
||||
checkingSlug = true;
|
||||
try {
|
||||
const data = await getMarketplaceDeck(s);
|
||||
slugExists = true;
|
||||
existingVersion = data.latest_version?.semver ?? null;
|
||||
} catch {
|
||||
slugExists = false;
|
||||
existingVersion = null;
|
||||
}
|
||||
checkingSlug = false;
|
||||
}
|
||||
|
||||
async function onSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (busy || !author) return;
|
||||
busy = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
if (!slugExists) {
|
||||
await initMarketplaceDeck({
|
||||
slug: slug.trim(),
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
license,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await publishMarketplaceVersion(slug.trim(), {
|
||||
semver: semver.trim(),
|
||||
changelog: changelog.trim() || undefined,
|
||||
cards: marketplaceCards,
|
||||
});
|
||||
|
||||
toasts.success(
|
||||
`${result.version.card_count} Karten als v${result.version.semver} veröffentlicht`
|
||||
);
|
||||
onPublished(slug.trim());
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = $derived(
|
||||
!busy &&
|
||||
!!author &&
|
||||
slug.trim().length > 0 &&
|
||||
title.trim().length > 0 &&
|
||||
/^\d+\.\d+\.\d+$/.test(semver.trim()) &&
|
||||
marketplaceCards.length > 0 &&
|
||||
slugExists !== null
|
||||
);
|
||||
|
||||
const LICENSES = [
|
||||
'CC BY 4.0',
|
||||
'CC BY-SA 4.0',
|
||||
'CC BY-NC 4.0',
|
||||
'CC0 1.0',
|
||||
'Alle Rechte vorbehalten',
|
||||
];
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Deck veröffentlichen"
|
||||
>
|
||||
<div class="modal">
|
||||
<!-- Header -->
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<h2 class="modal-title">Zum Marketplace veröffentlichen</h2>
|
||||
<p class="modal-subtitle">
|
||||
{cards.length} Karte{cards.length !== 1 ? 'n' : ''} aus „{deck.name}"
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="close-btn" onclick={onClose} aria-label="Schließen">×</button>
|
||||
</div>
|
||||
|
||||
{#if loadingAuthor}
|
||||
<p class="loading-hint">Lade Author-Profil…</p>
|
||||
|
||||
{:else if !author}
|
||||
<!-- No author profile -->
|
||||
<div class="no-author">
|
||||
<div class="no-author-icon">✍️</div>
|
||||
<p class="no-author-text">Du brauchst zuerst ein Author-Profil um Decks zu veröffentlichen.</p>
|
||||
<a href="/me/published" class="btn-primary" onclick={onClose}>
|
||||
Author-Profil anlegen →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- Author confirmed -->
|
||||
<div class="author-badge">
|
||||
<span class="author-dot"></span>
|
||||
<span>Author: <strong>{author.display_name}</strong> (@{author.slug})</span>
|
||||
</div>
|
||||
|
||||
<form class="pub-form" onsubmit={onSubmit}>
|
||||
<!-- Slug -->
|
||||
<div class="field">
|
||||
<label class="field-label" for="pub-slug">Marketplace-Slug</label>
|
||||
<div class="slug-row">
|
||||
<span class="slug-prefix">cardecky.mana.how/d/</span>
|
||||
<input
|
||||
id="pub-slug"
|
||||
type="text"
|
||||
bind:value={slug}
|
||||
oninput={onSlugInput}
|
||||
required
|
||||
pattern="[a-z0-9](?:[a-z0-9-]*[a-z0-9])?"
|
||||
maxlength="60"
|
||||
class="input mono slug-input"
|
||||
/>
|
||||
{#if checkingSlug}
|
||||
<span class="slug-status checking">…</span>
|
||||
{:else if slugExists === true}
|
||||
<span class="slug-status exists">↻ Update</span>
|
||||
{:else if slugExists === false}
|
||||
<span class="slug-status new">+ Neu</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if slugExists === true && existingVersion}
|
||||
<span class="field-hint">Deck existiert bereits (v{existingVersion}) — neue Version wird veröffentlicht.</span>
|
||||
{:else if slugExists === false}
|
||||
<span class="field-hint">Neues Marketplace-Deck wird angelegt.</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if slugExists === false}
|
||||
<!-- Only shown for new decks -->
|
||||
<div class="field">
|
||||
<label class="field-label" for="pub-title">Titel</label>
|
||||
<input
|
||||
id="pub-title"
|
||||
type="text"
|
||||
bind:value={title}
|
||||
required
|
||||
maxlength="200"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="pub-desc">Beschreibung (optional)</label>
|
||||
<textarea
|
||||
id="pub-desc"
|
||||
bind:value={description}
|
||||
rows="2"
|
||||
maxlength="1000"
|
||||
class="input"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="pub-license">Lizenz</label>
|
||||
<select id="pub-license" bind:value={license} class="input">
|
||||
{#each LICENSES as l}
|
||||
<option value={l}>{l}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Version info — always shown -->
|
||||
<div class="version-row">
|
||||
<div class="field">
|
||||
<label class="field-label" for="pub-semver">Version</label>
|
||||
<input
|
||||
id="pub-semver"
|
||||
type="text"
|
||||
bind:value={semver}
|
||||
required
|
||||
pattern="\d+\.\d+\.\d+"
|
||||
class="input mono"
|
||||
style="width: 8rem"
|
||||
/>
|
||||
</div>
|
||||
<div class="field" style="flex:1">
|
||||
<label class="field-label" for="pub-changelog">Changelog (optional)</label>
|
||||
<input
|
||||
id="pub-changelog"
|
||||
type="text"
|
||||
bind:value={changelog}
|
||||
maxlength="500"
|
||||
placeholder="Was ist neu in dieser Version?"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cards summary -->
|
||||
<div class="cards-summary">
|
||||
<span class="cards-count">{marketplaceCards.length}</span>
|
||||
<span class="cards-label">
|
||||
Karte{marketplaceCards.length !== 1 ? 'n' : ''} werden automatisch übernommen
|
||||
</span>
|
||||
<span class="cards-types">
|
||||
{[...new Set(marketplaceCards.map((c) => c.type))].join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-box" role="alert">{error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-ghost" onclick={onClose}>Abbrechen</button>
|
||||
<button type="submit" disabled={!canSubmit} class="btn-primary">
|
||||
{busy ? 'Veröffentliche…' : slugExists ? 'Neue Version veröffentlichen' : 'Veröffentlichen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(var(--color-foreground) / 0.4);
|
||||
backdrop-filter: blur(2px);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 36rem;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
background: hsl(var(--color-card));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 24px 48px hsl(var(--color-foreground) / 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.125rem;
|
||||
}
|
||||
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin: 0.125rem 0 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.close-btn:hover { color: hsl(var(--color-foreground)); }
|
||||
|
||||
.loading-hint {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* No-author state */
|
||||
.no-author {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-author-icon { font-size: 2rem; }
|
||||
|
||||
.no-author-text {
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
max-width: 22rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Author badge */
|
||||
.author-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-success) / 0.08);
|
||||
border: 1px solid hsl(var(--color-success) / 0.25);
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.author-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--color-success));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.pub-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.input {
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-surface));
|
||||
color: hsl(var(--color-foreground));
|
||||
padding: 0.5rem 0.625rem;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
width: 100%;
|
||||
transition: border-color 0.12s, box-shadow 0.12s;
|
||||
resize: vertical;
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--color-primary) / 0.6);
|
||||
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
|
||||
}
|
||||
.input.mono { font-family: ui-monospace, 'Cascadia Code', monospace; }
|
||||
|
||||
/* Slug row */
|
||||
.slug-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-surface));
|
||||
overflow: hidden;
|
||||
transition: border-color 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
.slug-row:focus-within {
|
||||
border-color: hsl(var(--color-primary) / 0.6);
|
||||
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
|
||||
}
|
||||
|
||||
.slug-prefix {
|
||||
padding: 0.5rem 0.5rem 0.5rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.slug-input {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding-left: 0;
|
||||
box-shadow: none;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.slug-input:focus { box-shadow: none; }
|
||||
|
||||
.slug-status {
|
||||
padding: 0 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.slug-status.checking { color: hsl(var(--color-muted-foreground)); }
|
||||
.slug-status.exists { color: hsl(var(--color-primary)); }
|
||||
.slug-status.new { color: hsl(var(--color-success)); }
|
||||
|
||||
/* Version row */
|
||||
.version-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Cards summary */
|
||||
.cards-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary) / 0.07);
|
||||
border: 1px solid hsl(var(--color-primary) / 0.2);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.cards-count {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.cards-label {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.cards-types {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.error-box {
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-error) / 0.1);
|
||||
border: 1px solid hsl(var(--color-error) / 0.4);
|
||||
color: hsl(var(--color-error));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1.125rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.12s;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { background: hsl(var(--color-primary) / 0.88); }
|
||||
.btn-primary:disabled { opacity: 0.45; cursor: default; }
|
||||
|
||||
.btn-ghost {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-ghost:hover { color: hsl(var(--color-foreground)); }
|
||||
</style>
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
import { t, tn } from '$lib/i18n/index.svelte.ts';
|
||||
import CardSurface from '$lib/components/CardSurface.svelte';
|
||||
import DeckCategoryIcon from '$lib/components/DeckCategoryIcon.svelte';
|
||||
import PublishDeckModal from '$lib/components/marketplace/PublishDeckModal.svelte';
|
||||
import { Image, CaretRight, DotsThree, PencilSimple, Trash } from '@mana/shared-icons';
|
||||
import { marked } from 'marked';
|
||||
|
||||
|
|
@ -26,6 +27,7 @@
|
|||
let error = $state<string | null>(null);
|
||||
let categoryOpen = $state(false);
|
||||
let openMenuId = $state<string | null>(null);
|
||||
let publishModalOpen = $state(false);
|
||||
|
||||
function toggleMenu(cardId: string, e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
|
|
@ -121,6 +123,14 @@
|
|||
<a href="/cards/new?deck={deck.id}" class="btn-outline">
|
||||
+ {t('deck_detail.new_card')}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
onclick={() => { publishModalOpen = true; }}
|
||||
title="Deck zum Marketplace veröffentlichen"
|
||||
>
|
||||
↑ Veröffentlichen
|
||||
</button>
|
||||
{#if dueCount > 0}
|
||||
<a href="/study/{deck.id}" class="btn-primary">
|
||||
{t('deck_detail.study_button')} ({t('study.due_count', { n: dueCount })})
|
||||
|
|
@ -265,6 +275,15 @@
|
|||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if publishModalOpen && deck}
|
||||
<PublishDeckModal
|
||||
{deck}
|
||||
{cards}
|
||||
onClose={() => { publishModalOpen = false; }}
|
||||
onPublished={(slug) => { publishModalOpen = false; goto(`/d/${slug}`); }}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue